diff --git a/.astra/project-context/architecture.md b/.astra/project-context/architecture.md
index a7d0ee3..8254d24 100644
--- a/.astra/project-context/architecture.md
+++ b/.astra/project-context/architecture.md
@@ -3,15 +3,15 @@
## Snapshot
-- **Workspace**: `connectai` `v2.2.194` _(absolute path varies by environment; resolved from the active VS Code workspace)_
+- **Workspace**: `connectai` `v2.2.200` _(absolute path varies by environment; resolved from the active VS Code workspace)_
- **Description**: The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.
- **Stack**: TypeScript, Node.js, VS Code Extension, LM Studio SDK, Test runner
-- **Stats**: 419 source files, ~70,411 lines across 5 top-level modules.
+- **Stats**: 431 source files, ~70,417 lines across 5 top-level modules.
## Last Refresh
-- **Time**: 2026-05-29T07:30:42.899Z
+- **Time**: 2026-06-01T02:30:44.120Z
- **Files newly analysed**: 3
-- **Files reused from cache**: 416
+- **Files reused from cache**: 428
## Directory Map
```mermaid
@@ -20,8 +20,8 @@ mindmap
src/
features/
sidebar/
- lib/
agent/
+ lib/
retrieval/
core/
media/
@@ -40,11 +40,11 @@ mindmap
> Arrows: which top-level module imports from which.
```mermaid
flowchart LR
- src["src/
263 files"]
+ src["src/
274 files"]
media["media/
6 files"]
tests["tests/
37 files"]
core_py["core_py/
6 files"]
- docs["docs/
107 files"]
+ docs["docs/
108 files"]
tests --> src
```
@@ -57,8 +57,8 @@ flowchart LR
## Hub Files
> Imported by many other files — touching these has wide blast radius.
- `src/utils.ts` — referenced by **87** files
+- `src/config.ts` — referenced by **35** files
- `src/agent.ts` — referenced by **34** files
-- `src/config.ts` — referenced by **33** files
- `src/core/services.ts` — referenced by **15** files
- `src/features/company/index.ts` — referenced by **14** files · Public API for 1인 기업 모드. Consumers (sidebarProvider, chatHandlers, command handlers) import from this barrel so internal layout can move around without touching every call site.
- `src/features/company/types.ts` — referenced by **14** files · Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta
@@ -67,13 +67,13 @@ flowchart LR
## Modules
-### `src/` — 263 files, ~52,640 lines
+### `src/` — 274 files, ~52,627 lines
**Sub-directories**
-- `src/features/` (92) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
+- `src/features/` (100) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
- `src/sidebar/` (35) — Brain profile lifecycle 의 pure helpers — sidebarProvider 의 add/edit/delete 흐름에서 modal UI 와 config 쓰기를 제외한 데이터 변환 만 격리. 현
-- `src/lib/` (28) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할
-- `src/agent/` (27) — Post-hoc Self-Check — 답변 완료 후 LLM 한 번 호출로 3가지 평가. 사용자 제안: "[Self-Check] 단계 — 이 답변이 사용자 질문에 직접 답하는가 / 규칙 준수 / 논리 모순 없는가".
+- `src/agent/` (29) — Post-answer hook registry — 답변 완료 후 실행되는 부가 작업 모음. 새 hook 추가 = 1 객체 push. agent.ts 는 이 배열을 iterate 만 함. 현재 등록 순서 (v2.2.1
+- `src/lib/` (29) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할
- `src/retrieval/` (16) — Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중. 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는 작업과 직접 연결 된 문서
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
- `src/memory/` (9) — Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급. 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지
@@ -86,12 +86,13 @@ flowchart LR
**Key files**
- `src/utils.ts` (471 lines)
- `src/config.ts` (557 lines)
-- `src/agent.ts` (1589 lines)
+- `src/agent.ts` (1503 lines)
- `src/features/company/types.ts` (446 lines) — Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta
- `src/core/services.ts` (176 lines)
- `src/sidebarProvider.ts` (3186 lines)
- `src/lib/contextManager.ts` (278 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계
- `src/features/company/companyConfig.ts` (896 lines) — State + config plumbing for 1인 기업 모드. Two surfaces: - CompanyState (runtime data: enabled flag, company name, which agents are active, per-agent model overrides). Persisted in VS Code's globalState so
+- `src/features/datacollect/slashRouter.ts` (1240 lines)
- `src/integrations/telegram/telegramClient.ts` (154 lines)
- `src/lib/paths.ts` (151 lines)
- `src/agent/actions/types.ts` (41 lines)
@@ -108,7 +109,6 @@ flowchart LR
- `src/integrations/telegram/telegramBot.ts` (270 lines)
- `src/lib/contextBuilders/localProjectIntent.ts` (233 lines)
- `src/lib/engine.ts` (1114 lines)
-- `src/lmstudio/streamer.ts` (252 lines)
### `media/` — 6 files, ~7,671 lines
@@ -165,17 +165,17 @@ flowchart LR
- `core_py/optimizer.py` (55 lines)
- `core_py/queue_worker.py` (82 lines)
-### `docs/` — 107 files, ~3,816 lines
+### `docs/` — 108 files, ~3,835 lines
**Sub-directories**
-- `docs/records/` (94) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
+- `docs/records/` (95) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
- `docs/docs/` (5) — Bug: Viewed integrationretrieval.test.ts:1-59 integrationretrieval.test.ts를 통해 ...
- `docs/Meeting/` (0)
**Key files**
- `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서
- `docs/AgentEngine_Architecture.md` (314 lines) — AgentEngine Architecture Document
-- `docs/records/ConnectAI/timeline.md` (233 lines) — Project Timeline
+- `docs/records/ConnectAI/timeline.md` (236 lines) — Project Timeline
- `docs/ASTRA_OFFICE_REFACTOR.md` (198 lines) — Astra Office Refactor — Design Doc
- `docs/EXPERIENCE_MEMORY_PLAN.md` (122 lines) — Experience Memory (Mistake / Lesson Loop) — Implementation Plan
- `docs/records/ConnectAI/development/2026-05-02_connectai_project_knowledge_overview.md` (121 lines) — Astra Project Knowledge Overview
@@ -341,7 +341,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한
**Designed for High-Performance Decision Making.**
Copyright (C) **g1nation**. All rights reserved.
-_Last auto-scan: 2026-05-29T07:30:42.899Z · signature `246e4864`_
+_Last auto-scan: 2026-06-01T02:30:44.120Z · signature `a95021db`_
## Purpose
diff --git a/.astra/project-context/scan-cache.json b/.astra/project-context/scan-cache.json
index 2c36c83..c495439 100644
--- a/.astra/project-context/scan-cache.json
+++ b/.astra/project-context/scan-cache.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "generatedAt": "2026-05-29T07:30:42.949Z",
+ "generatedAt": "2026-06-01T02:30:44.174Z",
"files": {
"src/agent/actions/brainOps.ts": {
"mtimeMs": 1779764602582.9768,
@@ -135,9 +135,9 @@
]
},
"src/agent/handlePrompt/buildAstraModeSystemPrompt.ts": {
- "mtimeMs": 1780033670993.9626,
- "size": 8427,
- "lines": 133,
+ "mtimeMs": 1780276266500.7705,
+ "size": 6433,
+ "lines": 100,
"role": "",
"imports": [
"src/lib/contextBuilders/localProjectIntent",
@@ -298,6 +298,26 @@
"src/agent"
]
},
+ "src/agent/postAnswerHooks/index.ts": {
+ "mtimeMs": 1780278997000.157,
+ "size": 3900,
+ "lines": 96,
+ "role": "Post-answer hook registry — 답변 완료 후 실행되는 부가 작업 모음. 새 hook 추가 = 1 객체 push. agent.ts 는 이 배열을 iterate 만 함. 현재 등록 순서 (v2.2.197): 1. devilRebuttal — Devil Agent 반박 카드 (비활성 시 silent skip) 2. postHocSelfChec",
+ "imports": [
+ "src/agent/postAnswerHooks/types",
+ "src/agent/llm/devilRebuttal",
+ "src/agent/postHocSelfCheck",
+ "src/agent/termValidator",
+ "src/config"
+ ]
+ },
+ "src/agent/postAnswerHooks/types.ts": {
+ "mtimeMs": 1780278967597.8257,
+ "size": 2001,
+ "lines": 48,
+ "role": "Post-Answer Hook 인터페이스 — 답변 streaming 완료 후 실행되는 부가 작업. 옛 구조: agent.ts 의 maybeEmitDevilRebuttal, maybePostHocSelfCheck, maybeRunTermValidator 3개 private method. 새 hook 추가 시 (1) method 정의 (2) import (3)",
+ "imports": []
+ },
"src/agent/postHocSelfCheck.ts": {
"mtimeMs": 1780033021215.5054,
"size": 8943,
@@ -317,16 +337,18 @@
]
},
"src/agent/termValidator.ts": {
- "mtimeMs": 1780039129011.7288,
- "size": 8675,
- "lines": 221,
+ "mtimeMs": 1780278698973.1787,
+ "size": 8156,
+ "lines": 202,
"role": "Post-generation Term Validator — 답변 완료 후 정규식/사전 기반 결정론적 스캔. v2.2.192 의 Terminology Dictionary 가 instructional (LLM 에게 표준 표기 사용 지시) 이면, 이건 deterministic — LLM 이 지시를 안 따랐을 때 catch. Glossary 파싱 — 두 패턴 인식",
- "imports": []
+ "imports": [
+ "src/lib/mtimeFileCache"
+ ]
},
"src/agent.ts": {
- "mtimeMs": 1780039163639.0967,
- "size": 87154,
- "lines": 1589,
+ "mtimeMs": 1780278886140.8762,
+ "size": 81193,
+ "lines": 1503,
"role": "",
"imports": [
"src/utils",
@@ -397,8 +419,7 @@
"src/agent/handlePrompt/buildAstraModeSystemPrompt",
"src/agent/handlePrompt/computeBudgetedRequest",
"src/agent/handlePrompt/processFinalAnswer",
- "src/agent/postHocSelfCheck",
- "src/agent/termValidator",
+ "src/agent/postAnswerHooks",
"src/agent/handlePrompt/applyAutoContinuation",
"src/features/approval/approvalQueue",
"src/features/providers",
@@ -704,11 +725,13 @@
]
},
"src/extension.ts": {
- "mtimeMs": 1779764602602.9807,
- "size": 17079,
- "lines": 354,
+ "mtimeMs": 1780280902153.7483,
+ "size": 17342,
+ "lines": 358,
"role": "",
"imports": [
+ "src/features/teamops/handlers",
+ "src/features/system/handlers",
"src/utils",
"src/config",
"src/agent",
@@ -1117,11 +1140,13 @@
"imports": []
},
"src/features/customers/customersStore.ts": {
- "mtimeMs": 1779952731356.5476,
- "size": 6815,
- "lines": 176,
+ "mtimeMs": 1780275713140.8125,
+ "size": 5722,
+ "lines": 149,
"role": "고객사 / MRR / 갱신 트래커. 4인 기업의 수입 쪽 — /runway 가 통장과 burn 을 본다면, 여기는 어디서 돈이 들어오나. Salesforce / HubSpot 같은 CRM 아닌 가벼운 event-sourced 로그. 저장 형식: JSON Lines (.jsonl) — append-only event log. 같은 customer 의 여러 이",
- "imports": []
+ "imports": [
+ "src/features/_shared/eventSourcedStore"
+ ]
},
"src/features/datacollect/bridgeClient.ts": {
"mtimeMs": 1779764602617.1548,
@@ -1166,9 +1191,9 @@
"imports": []
},
"src/features/datacollect/slashRouter.ts": {
- "mtimeMs": 1780039211177.6035,
- "size": 226165,
- "lines": 4096,
+ "mtimeMs": 1780280990100.2815,
+ "size": 67318,
+ "lines": 1240,
"role": "",
"imports": [
"src/utils",
@@ -1179,6 +1204,7 @@
"src/config",
"src/retrieval/terminologyBlock",
"src/agent/termValidator",
+ "src/features/teamops/handlers/_shared",
"src/features/setup/datacollectSetup",
"src/features/datacollect/prompts/synthesisPrompt",
"src/features/datacollect/prompts/youtubePrompts",
@@ -1214,18 +1240,22 @@
]
},
"src/features/feedback/feedbackStore.ts": {
- "mtimeMs": 1779931731117.0737,
- "size": 3406,
- "lines": 81,
+ "mtimeMs": 1780275889230.5403,
+ "size": 1851,
+ "lines": 44,
"role": "고객 피드백 누적 저장소. 단일 운영자(대표) 모드에서 슬랙·이메일·CS 채널에 흩어진 고객 피드백을 /feedback <텍스트> 한 줄로 모아 둔다. 패턴 분석은 /feedback summary 로 LLM 이 누적 데이터를 보고 카테고리 분포 + 반복 주제를 추출. 저장 형식: JSON Lines (.jsonl) — 한 줄 = 한 entry. 누적·app",
- "imports": []
+ "imports": [
+ "src/features/_shared/eventSourcedStore"
+ ]
},
"src/features/hire/hireStore.ts": {
- "mtimeMs": 1779964473915.006,
- "size": 5586,
- "lines": 150,
+ "mtimeMs": 1780275773463.53,
+ "size": 4567,
+ "lines": 125,
"role": "채용 파이프라인 트래커. 4인 → 5인 이상 확장 시점에 후보자가 여러 명, 여러 역할(개발/기획/디자인) 로 들어오기 시작 — 노션·스프레드시트·이메일에 흩어진 정보를 한 명령으로 본다. Event-sourced (customersStore 와 동일 패턴) — append-only 이벤트 로그를 재생해 후보자별 현재 단계 + 노트 누적 도출. 위치: /.as",
- "imports": []
+ "imports": [
+ "src/lib/mtimeFileCache"
+ ]
},
"src/retrieval/types.ts": {
"mtimeMs": 1779764602656.6587,
@@ -3356,8 +3486,15 @@
"role": "Bug: 논문 Outline Title 인간-AI 상호작용에서 의도 정렬을 높이기 위한 최소 질의 구조 연구 또는 사용자의 인지적 편향을 보완하는 구조화...",
"imports": []
},
+ "docs/records/ConnectAI/bugs/BUG-0015-짚어둘-관찰-사항-참고용-face-api-js-환경-face-api-js는-원래-브라우저용입니다-node-단.md": {
+ "mtimeMs": 1780281037883.6174,
+ "size": 2482,
+ "lines": 16,
+ "role": "Bug: 짚어둘 관찰 사항 (참고용) face-api.js 환경: face-api.js는 원래 브라우저용입니다. Node 단독 실행 시 @tensorfl...",
+ "imports": []
+ },
"docs/records/ConnectAI/chronicle.config.json": {
- "mtimeMs": 1780039836702.8801,
+ "mtimeMs": 1780281037893.3647,
"size": 371,
"lines": 11,
"role": "JSON configuration",
@@ -3910,9 +4047,9 @@
"imports": []
},
"docs/records/ConnectAI/timeline.md": {
- "mtimeMs": 1780039836694.6646,
- "size": 14881,
- "lines": 233,
+ "mtimeMs": 1780281037885.788,
+ "size": 15049,
+ "lines": 236,
"role": "Project Timeline",
"imports": []
},
diff --git a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json
index f482f2e..461e9d0 100644
--- a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json
+++ b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json
@@ -1,5 +1,5 @@
{
"result": "직답 결과 — single-pass mock 응답입니다.",
- "createdAt": 1780039528659,
+ "createdAt": 1780282136586,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json
index 34013c4..6dc29ca 100644
--- a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json
+++ b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json
@@ -1,5 +1,5 @@
{
- "result": "---\nid: wiki_on\ndate: 2026-05-29T07:25:28.661Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (22ms)\n",
- "createdAt": 1780039528661,
+ "result": "---\nid: wiki_on\ndate: 2026-06-01T02:48:56.587Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (26ms)\n",
+ "createdAt": 1780282136588,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/engine/.astra/missions/wiki_on.json b/.astra/tests/engine/.astra/missions/wiki_on.json
index 589ebb5..e64244f 100644
--- a/.astra/tests/engine/.astra/missions/wiki_on.json
+++ b/.astra/tests/engine/.astra/missions/wiki_on.json
@@ -1,8 +1,8 @@
{
"missionId": "wiki_on",
"status": "completed",
- "startTime": "2026-05-29T07:25:28.636Z",
- "totalElapsedMs": 26,
+ "startTime": "2026-06-01T02:48:56.559Z",
+ "totalElapsedMs": 29,
"results": {
"direct": "직답 결과 — single-pass mock 응답입니다."
},
@@ -12,16 +12,16 @@
{
"from": "idle",
"to": "direct",
- "durationMs": 22,
+ "durationMs": 26,
"message": "답변 작성 중... (단일 호출 fast-path)",
- "ts": "2026-05-29T07:25:28.658Z"
+ "ts": "2026-06-01T02:48:56.585Z"
},
{
"from": "direct",
"to": "completed",
- "durationMs": 4,
+ "durationMs": 3,
"message": "미션 완료",
- "ts": "2026-05-29T07:25:28.662Z"
+ "ts": "2026-06-01T02:48:56.588Z"
}
],
"resilienceMetrics": {
diff --git a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json
index 771d30f..997b7be 100644
--- a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json
+++ b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
- "createdAt": 1780039534874,
+ "createdAt": 1780282143243,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json
index 771d30f..eb4c5b0 100644
--- a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json
+++ b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
- "createdAt": 1780039534874,
+ "createdAt": 1780282143242,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json
index c218834..2c39627 100644
--- a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json
+++ b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json
@@ -1,5 +1,5 @@
{
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
- "createdAt": 1780039534870,
+ "createdAt": 1780282143239,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json
index 9f73afc..ee8df55 100644
--- a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json
+++ b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json
@@ -1,5 +1,5 @@
{
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
- "createdAt": 1780039534872,
+ "createdAt": 1780282143241,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1780039534851.json b/.astra/tests/stress/.astra/missions/stress_conflict_1780282143218.json
similarity index 75%
rename from .astra/tests/stress/.astra/missions/stress_conflict_1780039534851.json
rename to .astra/tests/stress/.astra/missions/stress_conflict_1780282143218.json
index 0a8fde6..94ca63b 100644
--- a/.astra/tests/stress/.astra/missions/stress_conflict_1780039534851.json
+++ b/.astra/tests/stress/.astra/missions/stress_conflict_1780282143218.json
@@ -1,8 +1,8 @@
{
- "missionId": "stress_conflict_1780039534851",
+ "missionId": "stress_conflict_1780282143218",
"status": "completed",
- "startTime": "2026-05-29T07:25:34.851Z",
- "totalElapsedMs": 24,
+ "startTime": "2026-06-01T02:49:03.218Z",
+ "totalElapsedMs": 26,
"results": {
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
@@ -14,30 +14,30 @@
{
"from": "idle",
"to": "outline",
- "durationMs": 18,
+ "durationMs": 20,
"message": "답변 구조 잡는 중...",
- "ts": "2026-05-29T07:25:34.869Z"
+ "ts": "2026-06-01T02:49:03.238Z"
},
{
"from": "outline",
"to": "section",
"durationMs": 2,
"message": "본문 작성 중...",
- "ts": "2026-05-29T07:25:34.871Z"
+ "ts": "2026-06-01T02:49:03.240Z"
},
{
"from": "section",
"to": "polish",
- "durationMs": 2,
+ "durationMs": 1,
"message": "최종 다듬기 중...",
- "ts": "2026-05-29T07:25:34.873Z"
+ "ts": "2026-06-01T02:49:03.241Z"
},
{
"from": "polish",
"to": "completed",
- "durationMs": 2,
+ "durationMs": 3,
"message": "미션 완료",
- "ts": "2026-05-29T07:25:34.875Z"
+ "ts": "2026-06-01T02:49:03.244Z"
}
],
"resilienceMetrics": {
diff --git a/PATCHNOTES.md b/PATCHNOTES.md
index 5805594..720925c 100644
--- a/PATCHNOTES.md
+++ b/PATCHNOTES.md
@@ -1,5 +1,346 @@
# Astra Patch Notes
+## v2.2.201 (2026-05-29) 🎯
+### 🏗️ 리팩터링 라운드 7 — datacollect cluster split (god-file 완전 해체)
+slashRouter god-file 의 마지막 클러스터 — 6 datacollect 핸들러 (research/benchmark/youtube/blog/wikify/meet) 도메인 파일로. 더불어 LLM 호출 인프라(callLmSynthesis/repairKoreanGlitches/bridgeErrorRemedy) 도 별도 모듈로.
+
+**slashRouter.ts: 1,229 → 201줄 (–1,028, –84%)**
+**누적 (v2.2.195 부터): 4,174 → 201 (–3,973, –95%) ⭐ 95% 축소**
+
+**신규 파일 2개:**
+- `src/features/datacollect/handlers.ts`:
+ - runResearch (NotebookLM Deep Research)
+ - runBenchmark (Playwright + 4-렌즈 LLM)
+ - runYoutube + helpers (`_looksLikeYoutubeChannelUrl`, `_normalizeYoutubeUrl`, YOUTUBE_BATCH_MAX)
+ - runBlog (Blog Pipeline 안내)
+ - runWikify + `wikifyOne` (P-Reinforce v3.0 위키 합성)
+ - runMeet (회의록 transcript → 합성 + Tasks/Calendar 자동 등록)
+
+- `src/features/datacollect/llm.ts`:
+ - `callLmSynthesis` (bridge /api/lm 호출 — datacollect handlers + teamops/communication 양쪽 사용)
+ - `repairKoreanGlitches` (한·영 토큰 깨짐 자동 교정)
+ - `bridgeErrorRemedy` (환경 의존성 에러 → 사용자 해결 가이드 생성, slashRouter catch 블록이 사용)
+
+**Wiring:**
+- extension.ts — `import './features/datacollect/handlers'` 추가
+- communication.ts — `callLmSynthesis` import 를 `./slashRouter` → `./llm` 로 변경
+- slashRouter.ts — 모든 import 정리, vscode/logInfo/getBridgeBaseUrl/bridgeErrorRemedy 4개만 남음
+
+**slashRouter.ts 최종 (201줄):**
+- REGISTRY Map + registerSlashCommand + listSlashCommands + isSlashCommand
+- handleSlashCommand (dispatcher + 에러 처리 + bridgeErrorRemedy 적용)
+- Webview interface + chunk helper
+- getRecentSlashCommands ring buffer
+
+**아키텍처 감사 HIGH 1번 완전 해결 ✅**
+god-file 해체 완료. 모든 슬래시 핸들러 도메인별 파일 분리, slashRouter 는 순수 dispatcher.
+
+**전체 리팩터링 시리즈 (v2.2.195~201, 7 builds):**
+- 신규 utility 5개 (eventSourcedStore, mtimeFileCache, postAnswerHooks, SystemPromptBlock Map, datacollect/llm)
+- 신규 도메인 핸들러 모듈 7개 (teamops trackers/dashboards/coordination/communication + system handlers + datacollect handlers)
+- slashRouter: 4,174 → 201 (–3,973, –95%)
+- agent.ts: 1,617 → 1,551 (–66, –4%)
+- 새 슬래시 명령 추가 = 1 파일 + 1 register call (옛 god-file 와 비교 불가능)
+- 새 verification block / event store / post-answer hook = 1곳 변경
+
+**기능 변경 없음.**
+
+**신규 패키징:** `astra-2.2.201.vsix`.
+
+---
+
+
+
+## v2.2.200 (2026-05-29) 🎉
+### 🏗️ 리팩터링 라운드 6 — system cluster split (마무리)
+slashRouter 분리 4단계 (마지막 TeamOps 영역) — system cluster (memory/glossary/help). 4인 팀 운영 모든 핸들러 도메인 분리 완료.
+
+**slashRouter.ts: 1,615 → 1,229줄 (–386, –24%)**
+**누적 (v2.2.195 부터): 4,174 → 1,229 (–2,945, –71%)**
+
+**신규 파일: `src/features/system/handlers.ts`**
+
+- runMemory + _memoryOverview + _formatDate (Temporal + Distillation 진입점)
+- runGlossary (Terminology Dictionary CRUD)
+- runHelp + HelpCategory + HELP_CATEGORIES (카테고리별 명령 브라우저 + 엔진 상태)
+
+**Wiring:**
+- `extension.ts` — `import './features/system/handlers'` side-effect (extension.ts 가 entry point)
+
+**전체 슬래시 분리 현황:**
+```
+src/features/teamops/handlers/
+├── _shared.ts (헬퍼)
+├── trackers.ts (3) v2.2.196
+├── dashboards.ts (4) v2.2.198
+├── coordination.ts (5) v2.2.199
+├── communication.ts (2) v2.2.199
+└── index.ts (barrel)
+
+src/features/system/
+└── handlers.ts (3) v2.2.200
+```
+
+**slashRouter 잔여 (~1,229줄):**
+- 인프라: REGISTRY / registerSlashCommand / chunk / Webview / isSlashCommand / handleSlashCommand / parseTaskOwner / callLmSynthesis / getRecentSlashCommands (~200줄)
+- datacollect: research / benchmark / youtube / blog / wikify / meet (~1,000줄) — 다른 도메인 영역, 추후 라운드
+
+**아키텍처 감사 5건 최종 상태:**
+| Sev | 항목 | 결과 |
+|---|---|---|
+| HIGH | slashRouter god-file | 🟢 71% 축소 (teamops + system 완료, datacollect 만 잔여) |
+| HIGH | 5-block 체인 | ✅ v2.2.195 |
+| MED | 4 store 중복 | ✅ v2.2.195 |
+| MED | 2-cache invariant | ✅ v2.2.197 |
+| LOW | `_maybe*` hook 패턴 | ✅ v2.2.197 |
+
+**전체 정리 시리즈 (v2.2.195~200, 6 builds):**
+- 신규 utility 4개 (eventSourcedStore, mtimeFileCache, postAnswerHooks, SystemPromptBlock Map)
+- 신규 도메인별 핸들러 모듈 5개 (teamops trackers/dashboards/coordination/communication + system)
+- slashRouter.ts: 4,174 → 1,229 (–2,945, –71%)
+- agent.ts: 1,617 → 1,551 (–66, –4%)
+- 향후 새 verification block / store / hook / 슬래시 명령 추가 패턴 모두 *1곳 변경* 으로 완료
+
+**기능 변경 없음 — 순수 구조 정리.**
+
+**신규 패키징:** `astra-2.2.200.vsix`. 🎉 v2.2.200 통과!
+
+---
+
+
+
+## v2.2.199 (2026-05-29)
+### 🏗️ 리팩터링 라운드 5 — coordination + communication clusters
+slashRouter 분리 3단계 — coordination (5 handlers) + communication (2 handlers) 도메인 파일로. 원래 v2.2.200 예정이던 communication 도 같이 묶음 (sed 범위에 함께 잡혀 자연스러운 통합).
+
+**slashRouter.ts: 2,537 → 1,615줄 (–922, –36%)**
+**누적 (v2.2.195 부터): 4,174 → 1,615 (–2,559, –61%)**
+
+**신규 파일 2개:**
+- `src/features/teamops/handlers/coordination.ts`:
+ - runTask + parseFlexibleDate (Google Tasks + Calendar 동시 등록)
+ - runDecisions (Chronicle ADR 검색)
+ - runOnesie (1:1 미팅 카드)
+ - runBlocked (전사 지연·블로커 뷰)
+ - runStandup (팀 스탠드업 카드)
+- `src/features/teamops/handlers/communication.ts`:
+ - runDraft + DRAFT_TYPES (6종 초안)
+ - runFeedback + feedbackSave/List/Summary/Path + 2개 LLM 프롬프트
+
+**Wiring 변경:**
+- `slashRouter.callLmSynthesis` 를 `export` 로 노출 (communication 이 LLM 호출용으로 import)
+- `parseTaskOwner` 옛 local 정의 삭제 (`_shared.ts` 사용)
+- 옛 ParsedTaskOwner interface 삭제 (도메인별로 분산)
+- 옛 orphan 주석 블록 (deletion 후 남은 `// ─── /morning` header 등) 정리
+
+**slashRouter 잔여 (~1,615줄):**
+- system: memory / glossary / help (~480줄) — v2.2.200
+- datacollect: research / benchmark / youtube / blog / wikify / meet (~1,000줄) — 다른 도메인 영역, 추후
+- 인프라: registerSlashCommand / chunk / Webview / parseTaskOwner / callLmSynthesis / handleSlashCommand 등 (~130줄)
+
+**기능 변경 없음 — 순수 구조 정리.**
+
+**신규 패키징:** `astra-2.2.199.vsix`.
+
+---
+
+
+
+## v2.2.198 (2026-05-29)
+### 🏗️ 리팩터링 라운드 4 — dashboards cluster split
+slashRouter 분리 2단계 — dashboards 클러스터 (morning/evening/cohort/weekly) 도메인 파일로.
+
+**slashRouter.ts: 3,448 → 2,537줄 (–911, –26%)**
+**누적 (v2.2.195 부터): 4,174 → 2,537 (–1,637, –39%)**
+
+**신규 파일: `src/features/teamops/handlers/dashboards.ts`**
+
+- runMorning + _morningActions (브리핑 카드 + 액션 도출)
+- runEvening (오늘 진척 + 내일 준비 + 회고)
+- runCohort + _buildMonthlyBuckets + _cohortDashboard (MoM 추세)
+- runWeekly + _isoWeek + _thisWeekWindow + _priorWeekWindow + _aggregateWeek + _deltaSymbol (주간 리뷰)
+
+**Wiring:**
+- `_shared.ts` 의 fmtKrw/daysUntil/parseTaskOwner/stageEmoji/STAGE_ORDER/TERMINAL_STAGES 재사용 (trackers 와 동일)
+- ChronicleProjectStore 직접 import (위클리 ADR 스캔)
+- `index.ts` 배럴에 한 줄 추가 (`import './dashboards'`)
+
+**slashRouter 잔여 (~2,540줄):**
+- coordination: task / decisions / onesie / blocked / standup (~700줄) — v2.2.199 예정
+- communication: draft / feedback / meet (~430줄) — v2.2.200 예정
+- system: memory / glossary / help (~480줄) — v2.2.200 예정
+- datacollect: research / benchmark / youtube / blog / wikify (~900줄) — 추후 (별 영역)
+
+**기능 변경 없음 — 순수 구조 정리.**
+
+**신규 패키징:** `astra-2.2.198.vsix`.
+
+---
+
+
+
+## v2.2.197 (2026-05-29)
+### 🏗️ 리팩터링 라운드 3 — PostAnswerHook registry + mtimeFileCache 공유 유틸
+아키텍처 감사의 MED 1건 (2 cache 동일 invariant) + LOW 1건 (`_maybeX` 3개 메서드 패턴) 마무리.
+
+**감사 5건 누적 진행도 (라운드 1~3 완료):**
+| Sev | 항목 | 결과 |
+|---|---|---|
+| HIGH | slashRouter god-file | 🟡 v2.2.196 trackers 분리 (–17%). 나머지 보류 — 패턴 입증됨 |
+| HIGH | 5-block 체인 (system prompt) | ✅ v2.2.195 |
+| MED | 4 store 중복 | ✅ v2.2.195 |
+| **MED** | **terminology+termValidator 2캐시** | **✅ v2.2.197** |
+| **LOW** | **`_maybe*` hook 패턴** | **✅ v2.2.197** |
+
+---
+
+**Part A — `src/lib/mtimeFileCache.ts` (신규 utility)**
+
+배경: terminologyBlock 과 termValidator 가 *같은* 글로서리 파일에 *별도* mtime cache 보유. 무효화 invariant ("둘 다 함께 비우기") 가 사람 손으로 강제됨 — slashRouter 가 `/glossary reload` 시 양쪽 `clearXCache()` 모두 호출.
+
+- `createMtimeFileCache(name, parse)` — generic factory
+- mtime 자동 체크, 캐시 hit / 자동 재read+parse
+- `read(filePath)`, `invalidate(filePath)`, `clear()`, `size()`
+
+**Refactor:**
+- `terminologyBlock.ts` — raw 본문 cache 를 mtimeFileCache 인스턴스로
+- `termValidator.ts` — parsed forbidden entries cache 를 mtimeFileCache 인스턴스로
+- 각 모듈은 여전히 자기 `clearXCache()` export (slashRouter 가 둘 다 호출 — 동작 동일)
+- 핵심 차이: boilerplate `if (cached && cached.mtime === mtime) return cached.content; ... ; cache.set(...)` 삭제. 새 캐시 추가 = 한 줄.
+
+---
+
+**Part B — `src/agent/postAnswerHooks/` (신규 registry)**
+
+배경: `agent.ts` 의 `_maybeEmitDevilRebuttal` / `_maybePostHocSelfCheck` / `_maybeRunTermValidator` 3개 private method + 3개 import + 3개 call site. 새 hook 추가 시 *3곳* 편집 필요. 패턴 동일 (안전 fallback, try/catch swallow) — 레지스트리화.
+
+```
+src/agent/postAnswerHooks/
+├── types.ts ← PostAnswerHook 인터페이스 + Context
+└── index.ts ← 3 hooks 등록 + runPostAnswerHooks(ctx) iterator
+```
+
+**Refactor:**
+- `agent.ts`: 3 method 삭제 (–66줄, 1617→1551), 3개 호출 → 1개 `runPostAnswerHooks(ctx)`
+- 새 hook 추가 = `postAnswerHooks/` 에 hook 객체 push (1곳)
+
+**효과 측정:**
+- 새 후처리 hook 추가: 옛 3 step → 새 1 step
+- agent.ts: –66줄
+
+---
+
+**기능 변경 없음 — 순수 구조 정리.** DevilRebuttal/SelfCheck/TermValidator 동작 동일, 글로서리 캐시 동작 동일.
+
+**아키텍처 정리 시리즈 (v2.2.195~197) 총평:**
+- 신규 utility 모듈 3개 (eventSourcedStore, mtimeFileCache, postAnswerHooks)
+- 신규 도메인별 핸들러 모듈 1개 (teamops/handlers/{_shared,trackers,index})
+- 코드 라인: slashRouter 4,174→3,448 (–726), agent.ts (–66)
+- 미래 부담 감소: 새 verification block 추가 1곳, 새 store 1곳, 새 hook 1곳
+- 감사 5건 중 4건 완료, 1건 (slashRouter 나머지) 보류 — 패턴 입증 후 추후 라운드
+
+**신규 패키징:** `astra-2.2.197.vsix`.
+
+---
+
+
+
+## v2.2.196 (2026-05-29)
+### 🏗️ 리팩터링 라운드 2 — slashRouter handler split (trackers cluster)
+아키텍처 감사의 HIGH 1번 (`slashRouter.ts` 4,174줄 god-file) 해결 시작. 첫 클러스터(트래커 3개) 를 도메인별 파일로 분리해 패턴 확립. 나머지 클러스터(coordination/communication/dashboards) 는 다음 라운드.
+
+**slashRouter.ts: 4,174 → 3,448줄 (–726, –17%)**
+
+**신규 구조: `src/features/teamops/handlers/`**
+
+```
+src/features/teamops/handlers/
+├── _shared.ts ← fmtKrw, parseAmount, daysUntil, parseTaskOwner,
+│ STAGE_ORDER, TERMINAL_STAGES, stageEmoji
+├── trackers.ts ← runRunway, runCustomers, runHire + 각자 helpers
+│ + registerSlashCommand 3건 자기 등록
+└── index.ts ← 배럴 (`import './trackers'`)
+```
+
+**Wiring:**
+- `src/extension.ts` — `import './features/teamops/handlers'` side-effect import (entry point, 순환 import 회피)
+- `src/features/datacollect/slashRouter.ts` — `chunk` + `Webview` 인터페이스 export, 옛 helpers 는 `_shared.ts` 에서 alias import 로 남은 인라인 핸들러(morning/evening/cohort/weekly/blocked/standup) 호환 유지
+
+**패턴 입증 (다음 라운드 청사진):**
+- `trackers.ts` 가 작동하는 한, 같은 패턴으로 다음 클러스터:
+ - `coordination.ts` — task / decisions / onesie / blocked / standup
+ - `communication.ts` — draft / feedback / meet
+ - `dashboards.ts` — morning / evening / cohort / weekly
+- 각 클러스터 = 새 파일 + 옛 코드 sed-delete + `index.ts` 에 한 줄 추가
+
+**Phase 1 (v2.2.195) 도 함께:** eventSourcedStore + SystemPromptBlock registry — 이미 출시. 합쳐서 감사 HIGH 2건 중 1.5건 해결, MED 1건 (4 store 중복) 해결.
+
+**아키텍처 감사 후속 진행도:**
+| Sev | 항목 | 상태 |
+|---|---|---|
+| HIGH | slashRouter god-file | 🟡 17% 축소 (trackers 분리). 나머지 v2.2.197+ |
+| HIGH | 5-block 체인 (system prompt) | ✅ 완료 (v2.2.195) |
+| MED | 4 store 중복 | ✅ 완료 (v2.2.195) |
+| MED | terminology+termValidator 2캐시 | ⏳ v2.2.197 |
+| LOW | _maybeEmitDevilRebuttal 류 hook 패턴 | ⏳ v2.2.197 |
+
+**기능 변경 없음 — 순수 구조 정리.** /runway, /customers, /hire 동작 동일.
+
+**신규 패키징:** `astra-2.2.196.vsix`.
+
+---
+
+
+
+## v2.2.195 (2026-05-29)
+### 🏗️ 리팩터링 라운드 1 — eventSourcedStore + SystemPromptBlock registry
+아키텍처 감사 결과 (HIGH 2건, MED 3건) 중 가장 효과·비용 비율 좋은 2개. 기능 변경 없음 — 순수 구조 정리.
+
+**Phase 1 — `src/features/_shared/eventSourcedStore.ts` (신규)**
+
+배경: customers/hire/runway/feedback 4개 store 의 `getXFilePath() + readX() + appendX()` 가 byte-for-byte 중복 (~240줄).
+
+- `createEventStore({ relPath, validate })` — generic factory
+- 4개 store 가 각각 ~12줄로 축소 (event type + validator + `computeStates` 만 남음)
+- BOM/인코딩 fix 같은 edge case 가 *한 곳* 에서 전파
+- 도메인 로직 (`computeCustomerStates`, `computeCandidateStates`, `computeRunwayStatus`) 은 그대로 도메인 파일에 남음 — I/O 만 추상화
+
+```typescript
+// 옛 customersStore (60줄): import fs/path/vscode + getCustomersFilePath + readEvents + appendEvent
+// 새 (5줄):
+const _store = createEventStore({ relPath, validate });
+export const getCustomersFilePath = _store.getFilePath;
+export const readEvents = _store.read;
+export const appendEvent = _store.append;
+```
+
+**Phase 2 — SystemPromptBlock registry (5 필드 → 1 Map)**
+
+배경: 새 verification 블록 추가 시 *5곳 편집 강제* — `_turnCtx` 필드 + `resetTurnContext` + `buildAstraModeSystemPrompt` 인자 + casual ternary + template literal 위치. 5번 반복 (conflict/cove/intent/citation/terminology). 이 패턴이 god-file 만드는 길.
+
+- [agent.ts](src/agent.ts) `_turnCtx`: 5 named `string` 필드 → 1 `dynamicBlocks: Map`
+- [agent.ts](src/agent.ts) `resetTurnContext()`: 5 reset → 1 `.clear()`
+- [agent.ts](src/agent.ts) `buildAstraModeSystemPrompt` 호출: 5 named param → 1 `dynamicBlocks` param
+- [buildAstraModeSystemPrompt.ts](src/agent/handlePrompt/buildAstraModeSystemPrompt.ts): 5 ternary gate + 5 위치 삽입 → 1 for-loop join
+- [memoryContext.ts](src/lib/contextBuilders/memoryContext.ts): 5 `turnCtx.X = build...` → 5 `blocks.set('id', build...)` (한 줄씩, 새 블록은 여기서 set 한 줄만 추가)
+
+**효과 측정:**
+- 새 블록 추가 = **25 → 1** edit 지점 (5 파일 × 5 step → 1 set call)
+- 옛 5 ternary `(!isCasualConversation && X && X.trim()) ? '\n\n' + X : ''` → 1 loop, casual gate 1번
+- 블록 순서 변경 = memoryContext 의 set 순서 변경만 (옛: template literal 재배치)
+
+**아키텍처 감사 후속 작업 (예정):**
+- v2.2.196 — slashRouter 4,174줄 handler 도메인별 분할 (HIGH 1, 미완)
+- v2.2.197 — PostAnswerHook registry (DevilRebuttal/SelfCheck/TermValidator 패턴 통합) + mtimeFileCache 공유 유틸 (terminologyBlock + termValidator 캐시 통합)
+
+**기능 변경 없음 — 순수 구조 변화.** 모든 verification 블록 동작 동일, 모든 store 동작 동일.
+
+**신규 패키징:** `astra-2.2.195.vsix`.
+
+---
+
+
+
## v2.2.194 (2026-05-29)
### 🔤 Post-gen Term Validator — 결정론적 글로서리 검증 (엔진 변경)
v2.2.192 의 Terminology Dictionary 가 *instructional* (LLM 에게 "표준 표기 사용" 지시) 였다면, 이건 *deterministic* — LLM 이 지시를 안 따랐을 때 결정론적 정규식 스캔으로 catch.
diff --git a/docs/records/ConnectAI/bugs/BUG-0015-짚어둘-관찰-사항-참고용-face-api-js-환경-face-api-js는-원래-브라우저용입니다-node-단.md b/docs/records/ConnectAI/bugs/BUG-0015-짚어둘-관찰-사항-참고용-face-api-js-환경-face-api-js는-원래-브라우저용입니다-node-단.md
new file mode 100644
index 0000000..e97d14f
--- /dev/null
+++ b/docs/records/ConnectAI/bugs/BUG-0015-짚어둘-관찰-사항-참고용-face-api-js-환경-face-api-js는-원래-브라우저용입니다-node-단.md
@@ -0,0 +1,16 @@
+# Bug: 짚어둘 관찰 사항 (참고용) face-api.js 환경: face-api.js는 원래 브라우저용입니다. Node 단독 실행 시 @tensorfl...
+
+## Date
+2026-06-01
+
+## Symptom
+짚어둘 관찰 사항 (참고용) face-api.js 환경: face-api.js는 원래 브라우저용입니다. Node 단독 실행 시 @tensorflow/tfjs-node + canvas 네이티브 바인딩이 필요해 빌드가 까다롭습니다. Electron 렌더러(브라우저 환경)에서 돌리면 가장 자연스럽습니다 → 아래 1번 질문과 연결됩니다. 정확도 KPI ≥98%: 유사도 임계값(threshold)과 얼굴당 reference 사진 수에 크게 좌우됩니다. reference를 인물당 여러 장 등록받는 걸 권장합니다(평균 descriptor). 데이터 무결성 0 Error: '이동' 중 실패 대비 — 이동은 사실상 "복사 후 검증 후 원본 삭제" 순서로 구현해야 안전합니다. 이 부분은 제가 기본값으로 안전하게 설계하겠습니다. Move vs Copy 3인 케이스: PRD는 2인까지만 예시. 3명 모두 포함된 사진이면 "1번=이동, 2·3번=복사"로 일반화하면 됩니다(아래 4번에서 확정). 아래 4가지...
+
+## Cause
+Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.
+
+## Fix
+결론: 제공해주신 관찰 사항은 기술적 구현의 난이도와 데이터 안정성을 결정짓는 매우 핵심적인 가이드라인입니다. 특히 `face-api.js`의 실행 환경(Electron vs Node)에 대한 판단과, 데이터 무결성을 위해 '복사 후 검증 후 삭제'라는 안전장치를 설계 원칙으로 세우신 점은 아키텍처의 신뢰도를 높이는 매우 탁월한 결정입니다. 제시해주신 4가지 사항을 바탕으로, 제가 설계를 진행하기 위해 확정해야 할 최종 의사결정 항목들을 정리하였습니다. 이 중 결정된 내용을 바탕으로 즉시 상세 설계에 착수하겠습니다. 1. 실행 환경 (Runtime Environment) - 현재: Electron 렌더러(브라우저 환경)를 권장하며, Node 단독 실행 시의 복잡성을 인지함. - 결정 필요: 타겟 플랫폼을 'Electron 기반 데스크톱 앱'으로 한정할 것인지, 아니면 서버 사이드(Node.js) 로직이 반드시 포함된 구조로 가져갈 것인지 확정해야 합니다. 2. 인식 정확도 전략 (A...
+
+## Prevention
+Keep automatic records tied to the active project and verify the relevant test or reproduction path.
diff --git a/docs/records/ConnectAI/chronicle.config.json b/docs/records/ConnectAI/chronicle.config.json
index b0d6a99..10bbf00 100644
--- a/docs/records/ConnectAI/chronicle.config.json
+++ b/docs/records/ConnectAI/chronicle.config.json
@@ -7,5 +7,5 @@
"corePurpose": "",
"detailLevel": "standard",
"createdAt": "2026-05-20T09:42:40.003Z",
- "updatedAt": "2026-05-29T07:30:36.702Z"
+ "updatedAt": "2026-06-01T02:30:37.893Z"
}
diff --git a/docs/records/ConnectAI/timeline.md b/docs/records/ConnectAI/timeline.md
index 257ca5d..a6a709c 100644
--- a/docs/records/ConnectAI/timeline.md
+++ b/docs/records/ConnectAI/timeline.md
@@ -231,3 +231,6 @@
## 2026-05-29
- Auto discussion record created: discussions\2026-05-29_진행해.md
+
+## 2026-06-01
+- Auto bug record created: bugs\BUG-0015-짚어둘-관찰-사항-참고용-face-api-js-환경-face-api-js는-원래-브라우저용입니다-node-단.md
diff --git a/package-lock.json b/package-lock.json
index c2c918e..4433408 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,7 +6,7 @@
"packages": {
"": {
"name": "astra",
- "version": "2.2.194",
+ "version": "2.2.201",
"license": "MIT",
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
diff --git a/package.json b/package.json
index a28b522..9f99688 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "astra",
"displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
- "version": "2.2.194",
+ "version": "2.2.201",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
diff --git a/src/agent.ts b/src/agent.ts
index a496f06..c7c0e1e 100644
--- a/src/agent.ts
+++ b/src/agent.ts
@@ -164,8 +164,7 @@ import { buildAgentModeSystemPrompt } from './agent/handlePrompt/buildAgentModeS
import { buildAstraModeSystemPrompt } from './agent/handlePrompt/buildAstraModeSystemPrompt';
import { computeBudgetedRequest } from './agent/handlePrompt/computeBudgetedRequest';
import { processFinalAnswer } from './agent/handlePrompt/processFinalAnswer';
-import { postHocSelfCheck, formatSelfCheckFooter, DEFAULT_SELF_CHECK_OPTIONS } from './agent/postHocSelfCheck';
-import { validateTermUsage, formatTermValidatorFooter } from './agent/termValidator';
+import { runPostAnswerHooks } from './agent/postAnswerHooks';
import { applyAutoContinuation } from './agent/handlePrompt/applyAutoContinuation';
export interface ChatMessage {
@@ -287,28 +286,26 @@ export class AgentExecutor {
lessons: string[];
/** 이번 turn 에 결정된 Knowledge Mix — scope footer 표시용. */
knowledgeMix: ResolvedKnowledgeMix | null;
- /** [CONFLICT WARNINGS] 시스템 프롬프트 블록 — 검색된 출처에서 충돌 신호 감지 시. */
- conflictWarnings: string;
- /** [VERIFICATION CHECKLIST] Chain-of-Verification 블록 — 그라운딩 자기 검증 지시. */
- coveChecklist: string;
- /** [INTENT CLARIFICATION GUIDANCE] — 모호 질의 감지 시 역질문 우선 지시. */
- intentClarification: string;
- /** [CITATION TRACE] — 답변 끝에 사용 출처 한 줄 정리 지시. */
- citationTrace: string;
+ /**
+ * 동적 시스템 프롬프트 블록 레지스트리 — turn 마다 memoryContext 가 채우고
+ * buildAstraModeSystemPrompt 가 iterate 해서 prompt 에 주입.
+ *
+ * 옛 구조: conflictWarnings/coveChecklist/intentClarification/citationTrace/terminology
+ * 5개 named field + 5개 reset + 5개 named param + 5개 ternary gate (총 25곳 edit).
+ * 새 구조: 1 Map. 새 블록 추가 = 1 set call.
+ *
+ * Key 는 디버그·재정의용 id (예: 'conflict-warnings'). Value 는 이미 빌드된
+ * 블록 본문 — 빈 문자열이면 주입 안 함. casual mode 게이팅은 호출자가 처리.
+ */
+ dynamicBlocks: Map;
/** Self-check 용 — selected chunks 의 (title, content) 요약. memoryContext 가 채움. */
selfCheckSources: Array<{ title: string; excerpt: string }>;
- /** [TERMINOLOGY DICTIONARY] — 사용자 편집 글로서리 + Term Check 지침. */
- terminology: string;
} = {
retrieval: null,
lessons: [],
knowledgeMix: null,
- conflictWarnings: '',
- coveChecklist: '',
- intentClarification: '',
- citationTrace: '',
+ dynamicBlocks: new Map(),
selfCheckSources: [],
- terminology: '',
};
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
@@ -316,12 +313,8 @@ export class AgentExecutor {
this._turnCtx.retrieval = null;
this._turnCtx.lessons = [];
this._turnCtx.knowledgeMix = null;
- this._turnCtx.conflictWarnings = '';
- this._turnCtx.coveChecklist = '';
- this._turnCtx.intentClarification = '';
- this._turnCtx.citationTrace = '';
+ this._turnCtx.dynamicBlocks.clear();
this._turnCtx.selfCheckSources = [];
- this._turnCtx.terminology = '';
}
private readonly options: AgentExecutorOptions;
@@ -673,11 +666,7 @@ export class AgentExecutor {
isCasualConversation,
localPathContext,
knowledgeMix: this._turnCtx.knowledgeMix,
- conflictWarningsCtx: this._turnCtx.conflictWarnings,
- coveChecklistCtx: this._turnCtx.coveChecklist,
- intentClarificationCtx: this._turnCtx.intentClarification,
- citationTraceCtx: this._turnCtx.citationTrace,
- terminologyCtx: this._turnCtx.terminology,
+ dynamicBlocks: this._turnCtx.dynamicBlocks,
});
// Context budget computation → src/agent/handlePrompt/computeBudgetedRequest.ts
const imageCount = (reqMessages as any[])
@@ -1221,25 +1210,21 @@ export class AgentExecutor {
memoryLayers: this._turnCtx.retrieval?.usedMemoryLayers ?? [],
note: `continuations=${continuationCount} historyDropped=${reqMessages.length - budgetedHistory.length}`,
});
- // ── Devil Agent (도현) — 비활성 시 silent skip. 활성 시 별도 LLM 호출로 반박 카드 emit. ──
- // 비동기 — main turn 완료에 영향 없음. 실패해도 main 답변은 보존됨.
- void this._maybeEmitDevilRebuttal({
+ // ── Post-answer hooks (v2.2.197) — Devil + SelfCheck + TermValidator 통합 레지스트리. ──
+ // 새 hook 추가 = `src/agent/postAnswerHooks/index.ts` 에 한 객체 push.
+ // 안전 fallback 내장 — 한 hook 실패가 다른 hook / main turn 영향 없음.
+ runPostAnswerHooks({
userPrompt: prompt || '',
assistantAnswer: finalAssistantContent,
baseUrl: ollamaUrl,
modelName: actualModel,
contextLength: ctxLimits.contextLength,
engine,
+ selfCheckSources: this._turnCtx.selfCheckSources,
+ callNonStreaming: (p) => this.callNonStreaming(p),
+ getAbortSignal: () => this.abortController?.signal,
+ getWebview: () => this.webview,
});
- // ── Post-hoc Self-Check (v2.2.191) — 별도 LLM 호출 1회로 답변 사후 검증. ──
- // 비동기 — Devil 과 동일 패턴. 결과를 footer 한 줄로 append.
- void this._maybePostHocSelfCheck({
- userPrompt: prompt || '',
- assistantAnswer: finalAssistantContent,
- });
- // ── Term Validator (v2.2.194) — 결정론적 정규식 스캔. LLM 호출 없음, 즉시 실행. ──
- // 글로서리 forbidden 단어가 답변에 등장 시 footer flag. 위반 없으면 ✓.
- this._maybeRunTermValidator(finalAssistantContent);
} else {
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
}
@@ -1394,73 +1379,6 @@ export class AgentExecutor {
* "lock() request could not be registered" error this method is helping
* to avoid.
*/
- /**
- * Devil Agent 반박 emit — main turn 완료 직후 호출 (fire-and-forget).
- * 비활성 시 즉시 return. 활성 시 별도 LLM 호출 (callNonStreaming 재사용) 로 짧은 비판 생성.
- * 성공 시 webview 에 'devilRebuttal' 메시지 전송 → UI 가 카드로 렌더.
- */
- private async _maybeEmitDevilRebuttal(opts: {
- userPrompt: string;
- assistantAnswer: string;
- baseUrl: string;
- modelName: string;
- contextLength: number;
- engine: 'lmstudio' | 'ollama';
- }): Promise {
- return maybeEmitDevilRebuttalFn({
- getAbortSignal: () => this.abortController?.signal,
- callNonStreaming: (p) => this.callNonStreaming(p),
- getWebview: () => this.webview,
- }, opts);
- }
-
- /**
- * Post-hoc Self-Check — 답변 *완료 후* LLM 1회 호출로 3가지 평가
- * (사용자 질의 직접 답함 / 출처 그라운딩 / 논리 모순). 비동기 — main turn 에 영향 없음.
- * 기본 OFF (g1nation.selfCheckEnabled). 결과는 footer 한 줄로 streamChunk append.
- */
- private async _maybePostHocSelfCheck(opts: {
- userPrompt: string;
- assistantAnswer: string;
- }): Promise {
- try {
- const cfg = getConfig();
- if (!cfg.selfCheckEnabled) return;
- if (!opts.userPrompt.trim() || !opts.assistantAnswer.trim()) return;
- const model = (cfg.selfCheckModel || '').trim() || cfg.defaultModel;
- if (!model || !cfg.ollamaUrl) return;
- const sources = this._turnCtx.selfCheckSources || [];
-
- const result = await postHocSelfCheck(opts.userPrompt, opts.assistantAnswer, sources, {
- ollamaUrl: cfg.ollamaUrl,
- model,
- timeoutMs: (cfg.selfCheckTimeoutSec ?? 6) * 1000,
- excerptLength: DEFAULT_SELF_CHECK_OPTIONS.excerptLength,
- maxSources: DEFAULT_SELF_CHECK_OPTIONS.maxSources,
- });
-
- // 성공 실패 모두 footer 표시 — 사용자가 self-check 가 *돌고 있는지* 알 수 있게.
- // 실패 시 흐릿한 한 줄, 성공 시 평가 한 줄.
- const footer = formatSelfCheckFooter(result, model);
- this.webview?.postMessage({ type: 'streamChunk', value: footer });
- } catch { /* swallow — self-check never breaks the turn */ }
- }
-
- /**
- * Post-gen Term Validator — 글로서리 forbidden 단어가 답변에 등장하는지 결정론적 스캔.
- * LLM 호출 없음, 동기 실행 (수 ms). 글로서리 없거나 disabled 면 silent no-op.
- */
- private _maybeRunTermValidator(answer: string): void {
- try {
- const cfg = getConfig();
- if (cfg.termValidatorEnabled === false) return;
- if (!answer || !answer.trim()) return;
- const result = validateTermUsage(answer, cfg.glossaryPath || '.astra/glossary.md');
- if (!result.ran || result.dictionarySize === 0) return; // 글로서리 없거나 비어 있음 → 표시 안 함
- const footer = formatTermValidatorFooter(result);
- if (footer) this.webview?.postMessage({ type: 'streamChunk', value: footer });
- } catch { /* swallow — validator never breaks the turn */ }
- }
private async callNonStreaming(params: {
baseUrl: string;
diff --git a/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts b/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts
index d90fc8a..287d70d 100644
--- a/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts
+++ b/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts
@@ -23,27 +23,12 @@ export interface BuildAstraModeSystemPromptInput {
/** From this._turnCtx.knowledgeMix — pass null when absent. */
knowledgeMix: any;
/**
- * [CONFLICT WARNINGS] 블록 — buildConflictWarningsBlock 산출. 빈 문자열이면 충돌 없음 → 주입 안 함.
- * v4 정책 텍스트의 "[CONFLICT WARNING] 플래그" 참조를 실제 데이터로 뒷받침.
+ * 동적 시스템 프롬프트 블록 Map (id → 본문). memoryContext 가 채움.
+ * 옛 named param 5개 (conflictWarningsCtx/coveChecklistCtx/intentClarificationCtx/
+ * citationTraceCtx/terminologyCtx) 를 통합. casual 모드는 자동 skip.
+ * 등록 순서대로 [CONTEXT] *밖* 에 join 되어 주입.
*/
- conflictWarningsCtx?: string;
- /**
- * [VERIFICATION CHECKLIST] CoVe 블록 — buildCoveChecklistBlock 산출. 답변 *작성 전*
- * 그라운딩 체크리스트로 모델 self-verify 지시. 빈 문자열이면 비활성.
- */
- coveChecklistCtx?: string;
- /**
- * [INTENT CLARIFICATION GUIDANCE] — 모호 질의 감지 시 *역질문 우선* 지시. 모호 아닐 때 빈 문자열.
- */
- intentClarificationCtx?: string;
- /**
- * [CITATION TRACE] — 답변 끝에 사용 출처 한 줄 정리 지시. 검색 결과 있을 때 채워짐.
- */
- citationTraceCtx?: string;
- /**
- * [TERMINOLOGY DICTIONARY] — 사용자 편집 글로서리 + Term Check 지침. 파일 있을 때만.
- */
- terminologyCtx?: string;
+ dynamicBlocks?: Map;
}
export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInput): string {
@@ -62,11 +47,7 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
isCasualConversation,
localPathContext,
knowledgeMix,
- conflictWarningsCtx,
- coveChecklistCtx,
- intentClarificationCtx,
- citationTraceCtx,
- terminologyCtx,
+ dynamicBlocks,
} = input;
// 기존 Astra 모드 (에이전트 미선택)
@@ -105,29 +86,15 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
// priorConclusionCtx 는 modeBridgeCtx 와 같은 위치 (base systemPrompt 직후) — 모델이
// 자기 직전 결론을 anchor 로 잡고 사용자의 follow-up 을 그 결론에 대한 정정으로 해석하게.
const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : '';
- // [CONFLICT WARNINGS] 는 [CONTEXT] 밖에 — token-truncation 시 보호. v4 정책이
- // 충돌 처리 *방법* 을 명시하고, 이 블록이 *어느 출처가 충돌* 인지 데이터 제공.
- // Casual conversation 모드에서는 RAG context 자체를 안 쓰므로 충돌 경고도 무의미 — 생략.
- const conflictWarningsBlock = (!isCasualConversation && conflictWarningsCtx && conflictWarningsCtx.trim())
- ? '\n\n' + conflictWarningsCtx
- : '';
- // [VERIFICATION CHECKLIST] CoVe — 답변 작성 전 self-verify 지시. Conflict 와 마찬가지로
- // [CONTEXT] 밖, casual 모드 비활성. CoVe 가 강하면 단정적 답변이 줄고 근거 인용 늘어남.
- const coveBlock = (!isCasualConversation && coveChecklistCtx && coveChecklistCtx.trim())
- ? '\n\n' + coveChecklistCtx
- : '';
- // [INTENT CLARIFICATION GUIDANCE] — 모호 차원 감지 시 *역질문 우선*. Casual 모드는 제외.
- // 위치: 다른 verification block 보다 *앞* — 모호하면 답변 자체를 안 만들어야 하므로.
- const intentBlock = (!isCasualConversation && intentClarificationCtx && intentClarificationCtx.trim())
- ? '\n\n' + intentClarificationCtx
- : '';
- // [CITATION TRACE] — 답변 끝에 출처 한 줄. CoVe 와 함께 동작 — CoVe 가 라벨, Citation 이 정리.
- const citationBlock = (!isCasualConversation && citationTraceCtx && citationTraceCtx.trim())
- ? '\n\n' + citationTraceCtx
- : '';
- // [TERMINOLOGY DICTIONARY] — 사용자 편집 글로서리. casual 모드 비활성 (greeting 에 용어 강제 의미 없음).
- const terminologyBlock = (!isCasualConversation && terminologyCtx && terminologyCtx.trim())
- ? '\n\n' + terminologyCtx
- : '';
- return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}${intentBlock}${terminologyBlock}${conflictWarningsBlock}${coveBlock}${citationBlock}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
+ // 동적 블록 join — [CONTEXT] *밖* 에 주입돼 token-truncation 시 보호. Casual 모드면
+ // RAG context 자체를 안 쓰므로 동적 블록도 의미 없음 → 일괄 skip.
+ // 등록 순서대로 join (memoryContext 가 메모리 호출 순으로 set — 현재: intent →
+ // terminology → conflict → cove → citation). 빈 본문 entry 는 자동 제외.
+ let dynamicBlocksJoined = '';
+ if (!isCasualConversation && dynamicBlocks && dynamicBlocks.size > 0) {
+ for (const body of dynamicBlocks.values()) {
+ if (body && body.trim()) dynamicBlocksJoined += '\n\n' + body;
+ }
+ }
+ return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
}
diff --git a/src/agent/postAnswerHooks/index.ts b/src/agent/postAnswerHooks/index.ts
new file mode 100644
index 0000000..b7930b4
--- /dev/null
+++ b/src/agent/postAnswerHooks/index.ts
@@ -0,0 +1,96 @@
+/**
+ * Post-answer hook registry — 답변 완료 후 실행되는 부가 작업 모음.
+ *
+ * 새 hook 추가 = 1 객체 push. agent.ts 는 이 배열을 iterate 만 함.
+ *
+ * 현재 등록 순서 (v2.2.197):
+ * 1. devilRebuttal — Devil Agent 반박 카드 (비활성 시 silent skip)
+ * 2. postHocSelfCheck — 답변 검증 LLM 호출 (opt-in, 기본 OFF)
+ * 3. termValidator — 결정론적 글로서리 forbidden 검사 (기본 ON)
+ */
+
+import type { PostAnswerHook, PostAnswerHookContext } from './types';
+import { maybeEmitDevilRebuttal as maybeEmitDevilRebuttalFn } from '../llm/devilRebuttal';
+import { postHocSelfCheck, formatSelfCheckFooter, DEFAULT_SELF_CHECK_OPTIONS } from '../postHocSelfCheck';
+import { validateTermUsage, formatTermValidatorFooter } from '../termValidator';
+import { getConfig } from '../../config';
+
+const devilRebuttalHook: PostAnswerHook = {
+ id: 'devil-rebuttal',
+ runAsync: true,
+ async run(ctx: PostAnswerHookContext): Promise {
+ await maybeEmitDevilRebuttalFn(
+ {
+ getAbortSignal: ctx.getAbortSignal,
+ callNonStreaming: ctx.callNonStreaming,
+ // agent.ts 에서 vscode.Webview 를 통과시키므로 실런타임 호환. 타입 cast 로 hook 일반화.
+ getWebview: ctx.getWebview as any,
+ },
+ {
+ userPrompt: ctx.userPrompt,
+ assistantAnswer: ctx.assistantAnswer,
+ baseUrl: ctx.baseUrl,
+ modelName: ctx.modelName,
+ contextLength: ctx.contextLength,
+ engine: ctx.engine,
+ },
+ );
+ },
+};
+
+const postHocSelfCheckHook: PostAnswerHook = {
+ id: 'self-check',
+ runAsync: true,
+ async run(ctx: PostAnswerHookContext): Promise {
+ const cfg = getConfig();
+ if (!cfg.selfCheckEnabled) return;
+ if (!ctx.userPrompt.trim() || !ctx.assistantAnswer.trim()) return;
+ const model = (cfg.selfCheckModel || '').trim() || cfg.defaultModel;
+ if (!model || !cfg.ollamaUrl) return;
+
+ const result = await postHocSelfCheck(ctx.userPrompt, ctx.assistantAnswer, ctx.selfCheckSources, {
+ ollamaUrl: cfg.ollamaUrl,
+ model,
+ timeoutMs: (cfg.selfCheckTimeoutSec ?? 6) * 1000,
+ excerptLength: DEFAULT_SELF_CHECK_OPTIONS.excerptLength,
+ maxSources: DEFAULT_SELF_CHECK_OPTIONS.maxSources,
+ });
+ const footer = formatSelfCheckFooter(result, model);
+ ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
+ },
+};
+
+const termValidatorHook: PostAnswerHook = {
+ id: 'term-validator',
+ runAsync: false,
+ run(ctx: PostAnswerHookContext): void {
+ const cfg = getConfig();
+ if (cfg.termValidatorEnabled === false) return;
+ if (!ctx.assistantAnswer || !ctx.assistantAnswer.trim()) return;
+ const result = validateTermUsage(ctx.assistantAnswer, cfg.glossaryPath || '.astra/glossary.md');
+ if (!result.ran || result.dictionarySize === 0) return;
+ const footer = formatTermValidatorFooter(result);
+ if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
+ },
+};
+
+export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
+ devilRebuttalHook,
+ postHocSelfCheckHook,
+ termValidatorHook,
+];
+
+/** 모든 hook 을 안전하게 실행 — 한 hook 의 throw 가 다른 hook 막지 않음. */
+export function runPostAnswerHooks(ctx: PostAnswerHookContext): void {
+ for (const hook of POST_ANSWER_HOOKS) {
+ try {
+ if (hook.runAsync) {
+ void Promise.resolve(hook.run(ctx)).catch(() => { /* swallow */ });
+ } else {
+ hook.run(ctx);
+ }
+ } catch { /* hook never breaks the turn */ }
+ }
+}
+
+export type { PostAnswerHook, PostAnswerHookContext } from './types';
diff --git a/src/agent/postAnswerHooks/types.ts b/src/agent/postAnswerHooks/types.ts
new file mode 100644
index 0000000..b145216
--- /dev/null
+++ b/src/agent/postAnswerHooks/types.ts
@@ -0,0 +1,48 @@
+/**
+ * Post-Answer Hook 인터페이스 — 답변 streaming 완료 후 실행되는 부가 작업.
+ *
+ * 옛 구조: `agent.ts` 의 `_maybeEmitDevilRebuttal`, `_maybePostHocSelfCheck`,
+ * `_maybeRunTermValidator` 3개 private method. 새 hook 추가 시 (1) method 정의
+ * (2) import (3) call site `void this._maybeX(...)` — 3곳 편집.
+ *
+ * 새 구조: 각 hook 이 `PostAnswerHook` 객체로 자기 module 에. agent.ts 는 1 loop.
+ * 새 hook = 1 파일 + index.ts 에 1 push.
+ */
+
+/**
+ * Hook 이 webview 에 postMessage 만 하면 되므로 vscode.Webview 또는 slashRouter 의
+ * 간이 Webview 둘 다 만족하는 최소 인터페이스로 정의.
+ */
+interface PostMessageWebview {
+ postMessage(msg: any): Thenable | boolean;
+}
+
+export interface PostAnswerHookContext {
+ userPrompt: string;
+ assistantAnswer: string;
+ /** LLM 호출용 — Devil/SelfCheck 가 사용. */
+ baseUrl: string;
+ modelName: string;
+ contextLength: number;
+ engine: 'lmstudio' | 'ollama';
+ /** Self-check 용 출처 미리보기. memoryContext 가 turnCtx 에 채움. */
+ selfCheckSources: Array<{ title: string; excerpt: string }>;
+ /** Devil Agent 가 호출 — non-streaming LLM. */
+ callNonStreaming: (params: any) => Promise<{ text: string; stopReason?: string }>;
+ /** Abort signal accessor. */
+ getAbortSignal: () => AbortSignal | undefined;
+ /** Webview accessor — hook 결과 streamChunk 송출. vscode.Webview / 간이 Webview 호환. */
+ getWebview: () => PostMessageWebview | undefined;
+}
+
+export interface PostAnswerHook {
+ /** 디버그·중복 방지용. */
+ id: string;
+ /**
+ * true → fire-and-forget (async, main turn 영향 없음).
+ * false → 동기 실행 (LLM 호출 없는 결정론적 hook, 예: termValidator).
+ */
+ runAsync: boolean;
+ /** 실행 본문. throw 해도 다른 hook 영향 없음 (caller 가 try/catch 로 감쌈). */
+ run(ctx: PostAnswerHookContext): void | Promise;
+}
diff --git a/src/agent/termValidator.ts b/src/agent/termValidator.ts
index 37ad281..ee9c8f0 100644
--- a/src/agent/termValidator.ts
+++ b/src/agent/termValidator.ts
@@ -21,6 +21,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
+import { createMtimeFileCache } from '../lib/mtimeFileCache';
const DEFAULT_GLOSSARY_REL_PATH = '.astra/glossary.md';
@@ -30,15 +31,11 @@ interface ForbiddenEntry {
source: 'x-marker' | 'banned-section';
}
-interface ValidatorCache {
- mtime: number;
- entries: ForbiddenEntry[];
-}
-
-const _cache = new Map();
+/** Parsed forbidden entries 캐시 — mtime 기반, 파일 편집 시 자동 재read+parse. */
+const _parsedCache = createMtimeFileCache('term-validator', (raw) => parseGlossaryRaw(raw));
export function clearTermValidatorCache(): void {
- _cache.clear();
+ _parsedCache.clear();
}
function getGlossaryFilePath(relPath: string): string | null {
@@ -62,80 +59,64 @@ function isValidForbiddenToken(token: string): boolean {
}
/**
- * Glossary markdown 파싱. mtime 캐시.
+ * Glossary markdown raw → forbidden entries. mtime 캐시는 mtimeFileCache 가 담당.
*/
-function parseGlossary(filePath: string): ForbiddenEntry[] {
- try {
- if (!fs.existsSync(filePath)) return [];
- const st = fs.statSync(filePath);
- const cached = _cache.get(filePath);
- if (cached && cached.mtime === st.mtimeMs) return cached.entries;
+function parseGlossaryRaw(content: string): ForbiddenEntry[] {
+ const entries: ForbiddenEntry[] = [];
- const content = fs.readFileSync(filePath, 'utf-8');
- const entries: ForbiddenEntry[] = [];
-
- // ─── Pattern 1: **Canonical** (X: typo1, typo2, ...) ───
- // `**ASTRA** (X: astra, Astra 외)` — Astra 외 같은 description 은 후보 필터로 제거.
- const xPattern = /\*\*([^*]+)\*\*\s*\(X:\s*([^)]+)\)/g;
- let m: RegExpExecArray | null;
- while ((m = xPattern.exec(content)) !== null) {
- const canonical = m[1].trim();
- const variants = m[2].split(',').map((v) => v.trim());
- for (const v of variants) {
- if (isValidForbiddenToken(v)) {
- entries.push({ forbidden: v, suggested: canonical, source: 'x-marker' });
- }
+ // ─── Pattern 1: **Canonical** (X: typo1, typo2, ...) ───
+ // `**ASTRA** (X: astra, Astra 외)` — Astra 외 같은 description 은 후보 필터로 제거.
+ const xPattern = /\*\*([^*]+)\*\*\s*\(X:\s*([^)]+)\)/g;
+ let m: RegExpExecArray | null;
+ while ((m = xPattern.exec(content)) !== null) {
+ const canonical = m[1].trim();
+ const variants = m[2].split(',').map((v) => v.trim());
+ for (const v of variants) {
+ if (isValidForbiddenToken(v)) {
+ entries.push({ forbidden: v, suggested: canonical, source: 'x-marker' });
}
}
-
- // ─── Pattern 2: 금지 섹션 내 `- ❌ "..."` 또는 `- ❌ ...` ───
- // H2/H3 제목에 "금지" 또는 "비추" 포함된 섹션 본문만 스캔.
- const sectionRegex = /^(#{2,3})\s+(.+)$/gm;
- const sections: { headerEnd: number; nextHeaderStart: number; title: string }[] = [];
- let sm: RegExpExecArray | null;
- let lastIdx = 0;
- const matches: { idx: number; level: number; title: string }[] = [];
- while ((sm = sectionRegex.exec(content)) !== null) {
- matches.push({ idx: sm.index + sm[0].length, level: sm[1].length, title: sm[2].trim() });
- }
- for (let i = 0; i < matches.length; i++) {
- const cur = matches[i];
- const next = matches[i + 1];
- sections.push({
- headerEnd: cur.idx,
- nextHeaderStart: next ? next.idx : content.length,
- title: cur.title,
- });
- }
- for (const sec of sections) {
- if (!/금지|비추|forbidden|avoid|don'?t/i.test(sec.title)) continue;
- const body = content.slice(sec.headerEnd, sec.nextHeaderStart);
- const itemRe = /^-\s*❌\s*(?:"([^"]+)"|'([^']+)'|([^\n—]+?))(?:\s*[—–-].*)?$/gm;
- let im: RegExpExecArray | null;
- while ((im = itemRe.exec(body)) !== null) {
- const phrase = (im[1] || im[2] || im[3] || '').trim();
- if (isValidForbiddenToken(phrase)) {
- entries.push({ forbidden: phrase, source: 'banned-section' });
- }
- }
- }
-
- // Dedup
- const seen = new Set();
- const deduped = entries.filter((e) => {
- const k = `${e.source}::${e.forbidden.toLowerCase()}`;
- if (seen.has(k)) return false;
- seen.add(k);
- return true;
- });
-
- _cache.set(filePath, { mtime: st.mtimeMs, entries: deduped });
- lastIdx = 0; // suppress lint unused
- void lastIdx;
- return deduped;
- } catch {
- return [];
}
+
+ // ─── Pattern 2: 금지 섹션 내 `- ❌ "..."` 또는 `- ❌ ...` ───
+ // H2/H3 제목에 "금지" 또는 "비추" 포함된 섹션 본문만 스캔.
+ const sectionRegex = /^(#{2,3})\s+(.+)$/gm;
+ const sections: { headerEnd: number; nextHeaderStart: number; title: string }[] = [];
+ const matches: { idx: number; level: number; title: string }[] = [];
+ let sm: RegExpExecArray | null;
+ while ((sm = sectionRegex.exec(content)) !== null) {
+ matches.push({ idx: sm.index + sm[0].length, level: sm[1].length, title: sm[2].trim() });
+ }
+ for (let i = 0; i < matches.length; i++) {
+ const cur = matches[i];
+ const next = matches[i + 1];
+ sections.push({
+ headerEnd: cur.idx,
+ nextHeaderStart: next ? next.idx : content.length,
+ title: cur.title,
+ });
+ }
+ for (const sec of sections) {
+ if (!/금지|비추|forbidden|avoid|don'?t/i.test(sec.title)) continue;
+ const body = content.slice(sec.headerEnd, sec.nextHeaderStart);
+ const itemRe = /^-\s*❌\s*(?:"([^"]+)"|'([^']+)'|([^\n—]+?))(?:\s*[—–-].*)?$/gm;
+ let im: RegExpExecArray | null;
+ while ((im = itemRe.exec(body)) !== null) {
+ const phrase = (im[1] || im[2] || im[3] || '').trim();
+ if (isValidForbiddenToken(phrase)) {
+ entries.push({ forbidden: phrase, source: 'banned-section' });
+ }
+ }
+ }
+
+ // Dedup
+ const seen = new Set();
+ return entries.filter((e) => {
+ const k = `${e.source}::${e.forbidden.toLowerCase()}`;
+ if (seen.has(k)) return false;
+ seen.add(k);
+ return true;
+ });
}
export interface TermViolation {
@@ -181,7 +162,7 @@ export function validateTermUsage(
if (!fp || !fs.existsSync(fp)) {
return { ran: false, dictionarySize: 0, violations: [], totalViolations: 0 };
}
- const entries = parseGlossary(fp);
+ const entries = _parsedCache.read(fp) ?? [];
if (entries.length === 0) {
return { ran: true, dictionarySize: 0, violations: [], totalViolations: 0 };
}
diff --git a/src/extension.ts b/src/extension.ts
index afd0c12..8c01430 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -1,6 +1,11 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
+// TeamOps + System + Datacollect handler 자기 등록 — side-effect import. slashRouter
+// 가 이미 로드된 후 등록되도록 entry point 에서. (v2.2.196~201 도메인별 파일 분리)
+import './features/teamops/handlers';
+import './features/system/handlers';
+import './features/datacollect/handlers';
// axios removed in favor of native fetch
import {
_getBrainDir,
diff --git a/src/features/_shared/eventSourcedStore.ts b/src/features/_shared/eventSourcedStore.ts
new file mode 100644
index 0000000..4392bd3
--- /dev/null
+++ b/src/features/_shared/eventSourcedStore.ts
@@ -0,0 +1,83 @@
+/**
+ * Generic event-sourced store — append-only `.jsonl` 파일 1개를 읽고/쓰는 공통 기반.
+ *
+ * 배경: customers, hire, runway, feedback 4개 store 가 같은 패턴 4번 반복
+ * (getXFilePath / readX / appendX / countX) — byte-for-byte 중복 ~240줄.
+ * 한 곳에서 잡으면 BOM/인코딩 edge case 등 fix 도 한 번에 전파.
+ *
+ * 사용:
+ * const store = createEventStore({
+ * relPath: '.astra/customers.jsonl',
+ * validate: (e) => typeof e.id === 'string' && typeof e.customerId === 'string',
+ * });
+ * store.read(); store.append(event); store.count(); store.getFilePath();
+ *
+ * 도메인별 로직 (computeStates 등) 은 그대로 도메인 파일에 남음 — 본 모듈은
+ * I/O 만 추상화.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as vscode from 'vscode';
+
+export interface EventStoreOptions {
+ /** workspace root 기준 상대 경로. 예: '.astra/customers.jsonl'. */
+ relPath: string;
+ /** 파싱된 객체가 유효한 event 인지 판정. false 면 malformed 로 skip. */
+ validate: (e: unknown) => e is E;
+}
+
+export interface EventStore {
+ getFilePath(): string | null;
+ read(): E[];
+ append(event: E): { ok: true; filePath: string } | { ok: false; error: string };
+ count(): number;
+}
+
+export function createEventStore(opts: EventStoreOptions): EventStore {
+ function getFilePath(): string | null {
+ const folders = vscode.workspace.workspaceFolders;
+ if (!folders || folders.length === 0) return null;
+ return path.join(folders[0].uri.fsPath, opts.relPath);
+ }
+
+ function read(): E[] {
+ const fp = getFilePath();
+ if (!fp || !fs.existsSync(fp)) return [];
+ const out: E[] = [];
+ let content = '';
+ try { content = fs.readFileSync(fp, 'utf-8'); } catch { return []; }
+ for (const line of content.split('\n')) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ try {
+ const parsed = JSON.parse(trimmed);
+ if (opts.validate(parsed)) out.push(parsed);
+ } catch { /* skip malformed — append-only 라 손상 1줄이 전체 무력화하면 안 됨 */ }
+ }
+ return out;
+ }
+
+ function append(event: E): { ok: true; filePath: string } | { ok: false; error: string } {
+ const fp = getFilePath();
+ if (!fp) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가. VS Code 에서 폴더를 열어 주세요.' };
+ try {
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
+ fs.appendFileSync(fp, JSON.stringify(event) + '\n', 'utf-8');
+ return { ok: true, filePath: fp };
+ } catch (e: any) {
+ return { ok: false, error: e?.message || String(e) };
+ }
+ }
+
+ function count(): number {
+ const fp = getFilePath();
+ if (!fp || !fs.existsSync(fp)) return 0;
+ try {
+ const content = fs.readFileSync(fp, 'utf-8');
+ return content.split('\n').filter((l) => l.trim()).length;
+ } catch { return 0; }
+ }
+
+ return { getFilePath, read, append, count };
+}
diff --git a/src/features/customers/customersStore.ts b/src/features/customers/customersStore.ts
index deed63a..f791c5c 100644
--- a/src/features/customers/customersStore.ts
+++ b/src/features/customers/customersStore.ts
@@ -11,9 +11,7 @@
* 민감 정보(고객사 이름, 매출) 포함되므로 외부로 안 보냄 — 로컬 only.
*/
-import * as fs from 'fs';
-import * as path from 'path';
-import * as vscode from 'vscode';
+import { createEventStore } from '../_shared/eventSourcedStore';
const STORE_REL_PATH = '.astra/customers.jsonl';
@@ -55,47 +53,22 @@ export interface CustomerState {
notes: { timestamp: string; type: CustomerEventType; memo: string }[];
}
-export function getCustomersFilePath(): string | null {
- const folders = vscode.workspace.workspaceFolders;
- if (!folders || folders.length === 0) return null;
- return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
-}
+const _store = createEventStore({
+ relPath: STORE_REL_PATH,
+ validate: (e): e is CustomerEvent => !!e
+ && typeof (e as any).id === 'string'
+ && typeof (e as any).customerId === 'string'
+ && typeof (e as any).type === 'string',
+});
+
+export const getCustomersFilePath = _store.getFilePath;
+export const readEvents = _store.read;
+export const appendEvent = _store.append;
export function customerIdFromName(name: string): string {
return name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w가-힣-]/g, '');
}
-export function readEvents(): CustomerEvent[] {
- const filePath = getCustomersFilePath();
- if (!filePath || !fs.existsSync(filePath)) return [];
- const out: CustomerEvent[] = [];
- let content = '';
- try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; }
- for (const line of content.split('\n')) {
- const trimmed = line.trim();
- if (!trimmed) continue;
- try {
- const e = JSON.parse(trimmed);
- if (e && typeof e.id === 'string' && typeof e.customerId === 'string' && typeof e.type === 'string') {
- out.push(e as CustomerEvent);
- }
- } catch { /* skip malformed */ }
- }
- return out;
-}
-
-export function appendEvent(event: CustomerEvent): { ok: true; filePath: string } | { ok: false; error: string } {
- const filePath = getCustomersFilePath();
- if (!filePath) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가.' };
- try {
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
- fs.appendFileSync(filePath, JSON.stringify(event) + '\n', 'utf-8');
- return { ok: true, filePath };
- } catch (e: any) {
- return { ok: false, error: e?.message || String(e) };
- }
-}
-
/**
* 이벤트 로그를 재생해 customerId 별 현재 상태 도출.
*
diff --git a/src/features/datacollect/handlers.ts b/src/features/datacollect/handlers.ts
new file mode 100644
index 0000000..c50dba5
--- /dev/null
+++ b/src/features/datacollect/handlers.ts
@@ -0,0 +1,744 @@
+/**
+ * Datacollect handlers — /research · /benchmark · /youtube · /blog · /wikify · /meet.
+ *
+ * v2.2.201 에서 slashRouter.ts 에서 분리. Datacollect bridge (port 3002) 통합
+ * 슬래시 명령 클러스터 — NotebookLM Deep Research · Playwright 웹 스캔 ·
+ * yt-dlp YouTube 자막 추출 · 본문 wikify · 회의록 합성.
+ *
+ * callLmSynthesis / repairKoreanGlitches 는 slashRouter 에 남음 (communication
+ * 핸들러도 사용하는 일반 LLM 호출 인프라). 본 파일은 *bridge 호출 시퀀스* 만 담당.
+ */
+
+import * as vscode from 'vscode';
+import { promises as fsp } from 'fs';
+import { registerSlashCommand, chunk, type Webview } from './slashRouter';
+import { callLmSynthesis } from './llm';
+import { bridgeFetch, BRIDGE_API } from './bridgeClient';
+import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt';
+import {
+ type YoutubeAnalysisMode,
+ formatHms,
+ fullScriptFromSegments,
+ buildInfoExtractionPrompt,
+ build4LensPrompt,
+} from './prompts/youtubePrompts';
+import { buildWikifyPrompt } from './prompts/wikifyPrompt';
+import { buildMeetPrompt } from './prompts/meetPrompt';
+import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
+import {
+ addBusinessDays,
+ toYmd,
+ extractMeetingDate,
+ resolveTaskDate,
+ parseActionItems,
+} from './scheduling/calendarHelpers';
+
+// ───────────────────────────── /research ─────────────────────────────
+
+async function runResearch(topic: string, view: Webview | undefined): Promise {
+ if (!topic) {
+ chunk(view, `사용법: \`/research <주제>\`\n주제를 입력해 NotebookLM Deep Research를 시작합니다.\n`);
+ return true;
+ }
+
+ chunk(view, `🚀 **Research 시작**: \`${topic}\`\n\n`);
+ const start = await bridgeFetch<{ success: boolean; notebookId: string; taskId: string }>(
+ BRIDGE_API.research.start,
+ { method: 'POST', body: JSON.stringify({ topic }) },
+ { timeoutMs: 60_000 },
+ );
+ chunk(view, `- notebookId: \`${start.notebookId}\`\n- taskId: \`${start.taskId}\`\n\n⏳ 상태 polling (5초 간격, 최대 10분)…\n`);
+
+ const deadline = Date.now() + 10 * 60_000;
+ const HEARTBEAT_MS = 30_000;
+ const MAX_CONSECUTIVE_FAILS = 5;
+ const COMPLETED_SET = new Set(['completed', 'done', 'success', 'finished']);
+ const FAILED_SET = new Set(['failed', 'error', 'cancelled', 'canceled', 'aborted']);
+
+ let lastStatus = '';
+ let lastChangeAt = Date.now();
+ let consecutiveFails = 0;
+ let pollCount = 0;
+ let researchOk = false;
+ while (Date.now() < deadline) {
+ await new Promise(r => setTimeout(r, 5_000));
+ pollCount++;
+ let st: { success: boolean; result: any } | undefined;
+ try {
+ st = await bridgeFetch<{ success: boolean; result: any }>(
+ `${BRIDGE_API.research.status}?notebookId=${encodeURIComponent(start.notebookId)}&taskId=${encodeURIComponent(start.taskId)}`,
+ { method: 'GET' },
+ { timeoutMs: 60_000 },
+ );
+ consecutiveFails = 0;
+ } catch (e: any) {
+ consecutiveFails++;
+ if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
+ chunk(view, `\n❌ Status polling 연속 실패 ${consecutiveFails}회 — bridge 가 응답하지 않습니다. 중단합니다.\n(원인: ${e?.message || String(e)})\n`);
+ return true;
+ }
+ chunk(view, `\n · status 호출 실패 ${consecutiveFails}/${MAX_CONSECUTIVE_FAILS} (${e?.message || 'unknown'})\n`);
+ continue;
+ }
+ const status = String(st.result?.status || st.result || '').trim().toLowerCase();
+ if (status && status !== lastStatus) {
+ chunk(view, ` · ${status}\n`);
+ lastStatus = status;
+ lastChangeAt = Date.now();
+ } else if (Date.now() - lastChangeAt > HEARTBEAT_MS) {
+ chunk(view, ` · ⏳ 대기 중 (${Math.round((Date.now() - lastChangeAt) / 1000)}s, 폴링 ${pollCount}회)\n`);
+ lastChangeAt = Date.now();
+ }
+ if (COMPLETED_SET.has(status)) { researchOk = true; break; }
+ if (FAILED_SET.has(status)) {
+ chunk(view, `\n❌ Research 실패: ${JSON.stringify(st.result).slice(0, 400)}\n`);
+ return true;
+ }
+ }
+
+ if (!researchOk) {
+ chunk(view, `\n❌ 10분 polling 후에도 완료 신호가 오지 않았습니다 (마지막 status: \`${lastStatus || '(없음)'}\`). 중단합니다.\n`);
+ return true;
+ }
+
+ chunk(view, `\n📥 import…\n`);
+ await bridgeFetch(BRIDGE_API.research.import, {
+ method: 'POST',
+ body: JSON.stringify({ notebookId: start.notebookId, taskId: start.taskId }),
+ }, {
+ timeoutMs: 300_000,
+ onHeartbeat: (elapsedMs) => chunk(view, ` · import 진행 중 (${Math.round(elapsedMs / 1000)}s)\n`),
+ });
+
+ chunk(view, `🧪 synthesize…\n\n`);
+ const synth = await bridgeFetch<{ success: boolean; markdown?: string; result?: string }>(
+ BRIDGE_API.research.synthesize,
+ {
+ method: 'POST',
+ body: JSON.stringify({ notebookId: start.notebookId, topic, rootTopic: topic, includeKnowledgeConnections: true }),
+ },
+ {
+ timeoutMs: 600_000,
+ onHeartbeat: (elapsedMs) => chunk(view, ` · synthesize LLM 작업 중 (${Math.round(elapsedMs / 1000)}s)\n`),
+ },
+ );
+ const md = synth.markdown || synth.result || '(빈 응답)';
+ chunk(view, `---\n\n${md}\n`);
+ return true;
+}
+
+// ───────────────────────────── /benchmark ─────────────────────────────
+
+async function runBenchmark(arg: string, view: Webview | undefined): Promise {
+ const tokens = arg.trim().split(/\s+/).filter(Boolean);
+ let url = '';
+ let depthArg: number | undefined;
+ let pagesArg: number | undefined;
+ const restParts: string[] = [];
+ for (const t of tokens) {
+ const m = /^(depth|pages)=(\d+)$/i.exec(t);
+ if (m) {
+ if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]);
+ else pagesArg = Number(m[2]);
+ } else if (!url) {
+ url = t;
+ } else {
+ restParts.push(t);
+ }
+ }
+ if (!url) {
+ chunk(view, `사용법: \`/benchmark [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`);
+ return true;
+ }
+ const userContent = restParts.join(' ');
+
+ const cfg = vscode.workspace.getConfiguration('g1nation');
+ const crawlDepth = depthArg ?? (cfg.get('datacollectCrawlDepth', 1) ?? 1);
+ const maxPages = pagesArg ?? (cfg.get('datacollectMaxPages', 8) ?? 8);
+
+ chunk(view, `🔍 **Web Benchmark 스캔**: ${url}\n(crawlDepth ${crawlDepth} · 최대 ${maxPages}페이지 · Playwright)\n\n⏳ 브라우저 기동 · 페이지 크롤링 · 디자인 토큰 추출 중…`);
+
+ const t0 = Date.now();
+ const heartbeat = setInterval(() => {
+ chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
+ }, 4000);
+ const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>(
+ BRIDGE_API.web.benchmarkScan,
+ {
+ method: 'POST',
+ body: JSON.stringify({ url, captureScreenshots: false, maxPages, crawlDepth }),
+ },
+ { timeoutMs: 6 * 60_000 },
+ ).finally(() => clearInterval(heartbeat));
+ const s = scan.scan;
+ chunk(view, `\n✅ **스캔 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${s?.sitemap?.totalPages ?? 1}페이지)\n\n`);
+
+ const looksEmpty = !s?.meta?.title
+ && !(s?.design?.colors?.palette?.length)
+ && !s?.microcopy?.headline;
+ if (looksEmpty) {
+ chunk(view, `⚠️ **스캔 결과가 비어 있습니다.** URL이 정확한지, 사이트가 접근 가능한지(봇 차단·JS 전용 렌더링 포함) 확인하세요. 스캔한 URL: \`${url}\`\n\n`);
+ }
+
+ const palette = s?.design?.colors?.palette?.slice(0, 5) || [];
+ const rawReport = [
+ `### 메타`,
+ `- **title**: ${s?.meta?.title || '(없음)'}`,
+ `- **description**: ${s?.meta?.description || '(없음)'}`,
+ `- **lang**: ${s?.meta?.lang || '(없음)'}`,
+ ``,
+ `### 디자인 토큰 (상위)`,
+ `- **컬러 팔레트 Top 5**: ${palette.map((p: any) => `\`${p.value}\` (×${p.count})`).join(', ') || '(없음)'}`,
+ `- **컬러 비율**: ${s?.design?.colors?.composition?.ratioLabel || '(없음)'}`,
+ `- **Primary Font**: \`${s?.design?.typography?.primaryFont || '(없음)'}\``,
+ ``,
+ `### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})`,
+ '```',
+ s?.sitemap?.ascii || '(없음)',
+ '```',
+ ].join('\n');
+
+ let finalReport: string;
+ if (looksEmpty) {
+ chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`);
+ finalReport = rawReport;
+ } else {
+ const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim();
+ chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
+ try {
+ const parts: string[] = [];
+ for (const part of [1, 2, 3] as const) {
+ chunk(view, `\n · 합성 ${part}/3 진행 중…`);
+ const partT0 = Date.now();
+ const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part));
+ if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`);
+ parts.push(out);
+ chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
+ }
+ finalReport = parts.join('\n\n---\n\n');
+ chunk(view, `\n\n`);
+ } catch (e: any) {
+ chunk(view, `\n\n⚠️ LLM 합성 실패: ${e?.message || String(e)}\n원시 스캔 요약만 표시·저장합니다. (LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
+ finalReport = rawReport;
+ }
+ }
+
+ chunk(view, finalReport + '\n\n');
+
+ try {
+ const today = new Date().toISOString().slice(0, 10);
+ let host = url;
+ try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ }
+ const title = `웹벤치마크 ${host} ${today}`;
+ const fileMarkdown = [
+ `# ${title}`,
+ ``,
+ `- **원본 URL**: ${url}`,
+ `- **스캔 시각**: ${new Date().toISOString()}`,
+ `- **크롤 옵션**: depth ${crawlDepth} · 최대 ${maxPages}페이지`,
+ `- **생성**: Astra /benchmark · Datacollect web-benchmark`,
+ ``,
+ finalReport,
+ ``,
+ ].join('\n');
+ const savePath = (cfg.get('datacollectSavePath', '') || '').trim();
+ const body: Record = { title, content: fileMarkdown };
+ if (savePath) body.saveDir = savePath;
+ const saved = await bridgeFetch<{ success: boolean; path?: string }>(
+ BRIDGE_API.wiki.save,
+ { method: 'POST', body: JSON.stringify(body) },
+ { timeoutMs: 30_000 },
+ );
+ chunk(view, `💾 **결과물 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`);
+ } catch (e: any) {
+ chunk(view, `⚠️ 결과물 저장 실패: ${e?.message || String(e)}\n`);
+ }
+
+ return true;
+}
+
+// ───────────────────────────── /youtube ─────────────────────────────
+
+function _looksLikeYoutubeChannelUrl(url: string): boolean {
+ return /youtube\.com\/(channel\/|@|c\/|user\/|playlist\?list=|playlist\/)/i.test(url)
+ || /youtube\.com\/[^/?#]+\/(videos|shorts|streams)\b/i.test(url);
+}
+
+function _normalizeYoutubeUrl(url: string): string {
+ try {
+ const u = new URL(url);
+ if (!/youtube\.com$|youtube\.com\.|youtu\.be$/i.test(u.hostname)) return url;
+ const p = u.pathname;
+ if (/\/(watch|shorts|playlist|videos|streams|featured|community|about)\b/i.test(p)) return url;
+ if (u.hostname.includes('youtu.be')) return url;
+ if (/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)\/?$/i.test(p)) {
+ u.pathname = p.replace(/\/?$/, '/videos');
+ return u.toString();
+ }
+ return url;
+ } catch {
+ return url;
+ }
+}
+
+const YOUTUBE_BATCH_MAX = 50;
+
+async function runYoutube(arg: string, view: Webview | undefined): Promise {
+ const BARE_MODE_KEYWORDS = new Set(['info', 'benchmark', 'both']);
+ const tokens = arg.trim().split(/\s+/).filter(Boolean);
+ const url = tokens[0] || '';
+ let limitOverride: number | null = null;
+ let mode: YoutubeAnalysisMode = 'both';
+ const contextTokens: string[] = [];
+ for (const tok of tokens.slice(1)) {
+ const nMatch = tok.match(/^n[:=](\d+)$/i);
+ if (nMatch) {
+ const n = parseInt(nMatch[1], 10);
+ if (Number.isFinite(n) && n > 0) limitOverride = Math.min(YOUTUBE_BATCH_MAX, n);
+ continue;
+ }
+ const modeMatch = tok.match(/^mode[:=](info|benchmark|both)$/i);
+ if (modeMatch) { mode = modeMatch[1].toLowerCase() as YoutubeAnalysisMode; continue; }
+ const lower = tok.toLowerCase();
+ if (BARE_MODE_KEYWORDS.has(lower)) { mode = lower as YoutubeAnalysisMode; continue; }
+ contextTokens.push(tok);
+ }
+ const userContent = contextTokens.join(' ');
+
+ if (!url) {
+ chunk(view, [
+ `사용법:\n`,
+ `- 단일 영상: \`/youtube <영상URL> [info|benchmark|both] [컨텍스트]\`\n`,
+ `- 채널/플레이리스트: \`/youtube <채널URL> [n:30] [info|benchmark|both] [컨텍스트]\`\n`,
+ `\n**분석 모드** (생략 시 \`both\`):\n`,
+ `- \`info\` — 영상의 *내용*을 지식 카드로 추출 (튜토리얼·강의·뉴스·인터뷰)\n`,
+ `- \`benchmark\` — 대본 역기획서 4-렌즈 분석 (콘텐츠 제작 벤치마크용)\n`,
+ `- \`both\` — 둘 다 생성 (영상당 LLM 호출 2회)\n`,
+ `\n예시:\n`,
+ `- \`/youtube https://youtu.be/abc info\`\n`,
+ `- \`/youtube https://youtube.com/@somechannel n:20 info AI 학습 자료\`\n`,
+ `\n💡 \`mode:info\` / \`mode=info\` 같은 명시형도 그대로 동작 (백워드 호환).\n`,
+ ].join(''));
+ return true;
+ }
+
+ const isChannel = _looksLikeYoutubeChannelUrl(url);
+ const limit = limitOverride ?? (isChannel ? 10 : 1);
+ const normalizedUrl = isChannel ? _normalizeYoutubeUrl(url) : url;
+ if (normalizedUrl !== url) {
+ chunk(view, `🔧 채널 URL 정규화: \`${url}\` → \`${normalizedUrl}\` (yt-dlp 영상 enumeration 을 위한 \`/videos\` 탭 명시)\n\n`);
+ }
+
+ const modeLabel = mode === 'info' ? '📋 정보 추출 (지식 카드)'
+ : mode === 'benchmark' ? '🎬 벤치마킹 (4-렌즈 역기획서)'
+ : '📋 정보 추출 + 🎬 벤치마킹 (둘 다)';
+ if (isChannel) {
+ const callsPerVideo = mode === 'both' ? 2 : 1;
+ chunk(view, `📺 **채널/플레이리스트 감지** → 최신 ${limit}개 영상을 1개씩 순차 분석·wiki화 합니다.\n` +
+ `분석 모드: **${modeLabel}** (영상당 LLM ${callsPerVideo}회 호출)\n` +
+ `각 영상은 자막추출 → LLM 분석 → wiki 저장 순으로 처리되며, 영상당 보통 30~${120 * callsPerVideo}초.\n` +
+ `중간에 멈추려면 Astra 사이드바의 ⏹ Stop 을 누르세요.\n\n`);
+ } else {
+ chunk(view, `📊 **분석 모드**: ${modeLabel}\n\n`);
+ }
+
+ chunk(view, `🎬 **YouTube 추출**: ${normalizedUrl}\n(자막 + 메타데이터${limit > 1 ? `, ${limit}개 영상` : ''})\n\n⏳ Python 추출기 기동 · 자막/메타 추출 중…`);
+ const t0 = Date.now();
+ const heartbeat = setInterval(() => {
+ chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
+ }, 4000);
+ const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000);
+ const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>(
+ BRIDGE_API.youtube.extract,
+ { method: 'POST', body: JSON.stringify({ source: normalizedUrl, withMetadata: true, limit }) },
+ {
+ timeoutMs: extractTimeoutMs,
+ onHeartbeat: limit > 1
+ ? (elapsedMs) => chunk(view, `\n · 추출 진행 중 (${Math.round(elapsedMs / 1000)}s, ${limit}개 영상)\n`)
+ : undefined,
+ },
+ ).finally(() => clearInterval(heartbeat));
+
+ const okVideos = (data.videos || []).filter((v: any) => v?.status === 'ok');
+ chunk(view, `\n✅ **추출 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${okVideos.length}/${data.totalVideos ?? (data.videos || []).length}개 영상)\n\n`);
+ if (okVideos.length === 0) {
+ chunk(view, `⚠️ 자막 추출에 성공한 영상이 없습니다. 자막이 없거나 비공개 영상일 수 있습니다.\n`);
+ return true;
+ }
+
+ const cfg = vscode.workspace.getConfiguration('g1nation');
+ const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim();
+ const sysInfo = '당신은 영상 콘텐츠를 지식 카드로 변환하는 정보 큐레이터입니다. 자막에 명시된 사실만 인용하세요.';
+ const sysBench = '당신은 유튜브 콘텐츠 시니어 PD입니다. 데이터에 근거한 제작 가이드만 제공하세요.';
+
+ type Section = { label: string; body: string };
+ async function runOneAnalysis(video: any, prompt: string, system: string, sectionLabel: string, progressTag: string): Promise {
+ chunk(view, `🧪 **${sectionLabel}**${progressTag} (모델 \`${model}\`)…`);
+ try {
+ const t = Date.now();
+ const body = await callLmSynthesis(prompt, system);
+ if (!body) throw new Error('LLM 응답이 비어 있습니다.');
+ chunk(view, ` ✓ (${Math.round((Date.now() - t) / 1000)}s)\n\n`);
+ chunk(view, body + '\n\n');
+ return { label: sectionLabel, body };
+ } catch (e: any) {
+ chunk(view, `\n\n⚠️ ${sectionLabel} 실패${progressTag}: ${e?.message || String(e)}\n`);
+ return null;
+ }
+ }
+
+ const total = okVideos.length;
+ let analyzedOk = 0;
+ let analyzedFail = 0;
+ let savedOk = 0;
+ let savedFail = 0;
+ const batchT0 = Date.now();
+ for (let i = 0; i < okVideos.length; i++) {
+ const video = okVideos[i];
+ const vTitle = video?.metadata?.title || video?.title || video?.video_id || '(제목 없음)';
+ const progressTag = total > 1 ? ` [${i + 1}/${total}]` : '';
+
+ if (total > 1) chunk(view, `\n━━━ **${progressTag.trim()} ${vTitle}** ━━━\n\n`);
+
+ const script = fullScriptFromSegments(video?.segments);
+ chunk(view, `## 📜 전체 스크립트 (Full Script)\n\n${script}\n\n---\n\n`);
+
+ const sections: Section[] = [];
+ if (mode === 'info' || mode === 'both') {
+ const sec = await runOneAnalysis(video, buildInfoExtractionPrompt(video, userContent), sysInfo, '📋 정보 추출 (지식 카드)', progressTag);
+ if (sec) sections.push(sec);
+ }
+ if (mode === 'benchmark' || mode === 'both') {
+ const sec = await runOneAnalysis(video, build4LensPrompt(video, userContent), sysBench, '🎬 벤치마킹 (4-렌즈 역기획서)', progressTag);
+ if (sec) sections.push(sec);
+ }
+
+ if (sections.length === 0) {
+ analyzedFail++;
+ chunk(view, `(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
+ continue;
+ }
+ analyzedOk++;
+
+ try {
+ const today = new Date().toISOString().slice(0, 10);
+ const videoUrl = video?.metadata?.webpage_url || `https://www.youtube.com/watch?v=${video?.video_id}`;
+ const modeSuffix = mode === 'info' ? ' (정보)' : mode === 'benchmark' ? ' (벤치마크)' : '';
+ const title = `유튜브분석 ${vTitle}${modeSuffix} ${today}`;
+ const sectionDivider = sections.length > 1 ? `\n\n---\n\n` : '';
+ const fileMarkdown = [
+ `# ${title}`,
+ ``,
+ `- **영상 URL**: ${videoUrl}`,
+ `- **분석 시각**: ${new Date().toISOString()}`,
+ `- **분석 모드**: ${mode}`,
+ `- **생성**: Astra /youtube · Datacollect youtube insight`,
+ ``,
+ `## 📜 전체 스크립트 (Full Script)`,
+ ``,
+ script,
+ ``,
+ `---`,
+ ``,
+ sections.map((s) => s.body).join(sectionDivider),
+ ``,
+ ].join('\n');
+ const savePath = (cfg.get('datacollectSavePath', '') || '').trim();
+ const body: Record = { title, content: fileMarkdown };
+ if (savePath) body.saveDir = savePath;
+ const saved = await bridgeFetch<{ success: boolean; path?: string }>(
+ BRIDGE_API.wiki.save,
+ { method: 'POST', body: JSON.stringify(body) },
+ { timeoutMs: 30_000 },
+ );
+ savedOk++;
+ chunk(view, `💾 **결과물 저장 완료**${progressTag}: \`${saved?.path || '(경로 미확인)'}\`\n\n`);
+ } catch (e: any) {
+ savedFail++;
+ chunk(view, `⚠️ 결과물 저장 실패${progressTag}: ${e?.message || String(e)}\n\n`);
+ }
+ }
+
+ if (total > 1) {
+ const batchSec = Math.round((Date.now() - batchT0) / 1000);
+ chunk(view, `\n━━━━━━━━━━━━━━━━━━━━\n`
+ + `🏁 **배치 완료** (총 ${batchSec}s · ${total}개 영상)\n`
+ + `- 분석: ✅ ${analyzedOk} / ❌ ${analyzedFail}\n`
+ + `- 저장: 💾 ${savedOk} / ⚠️ ${savedFail}\n`);
+ }
+
+ return true;
+}
+
+// ───────────────────────────── /blog ─────────────────────────────
+
+async function runBlog(keyword: string, view: Webview | undefined): Promise {
+ const target = 'http://127.0.0.1:8787/blog/';
+ chunk(view, `🖋️ **Blog Pipeline**\n\n`);
+ if (keyword) chunk(view, `요청 키워드: \`${keyword}\`\n\n`);
+ chunk(view, [
+ `현재 Blog Pipeline은 Datacollect의 별도 정적 페이지(`,
+ `[${target}](${target})`,
+ `)에서 단계별로 실행됩니다. Bridge(3002)에 대응 API가 없어 채팅에서 직접`,
+ ` 트리거할 수 없어, 다음 두 가지 중 하나를 선택해 주세요:\n\n`,
+ ].join(''));
+ chunk(view, `1. 위 링크에서 키워드/참고 자료/경험담을 입력해 직접 파이프라인 실행\n`);
+ chunk(view, `2. Bridge에 \`/api/blog/run\` 같은 endpoint를 노출하면 \`/blog\`도 end-to-end 실행 가능 — 원하시면 추가 작업으로 진행\n`);
+
+ try { await vscode.env.openExternal(vscode.Uri.parse(target)); } catch { /* best-effort */ }
+ return true;
+}
+
+// ───────────────────────────── /wikify ─────────────────────────────
+
+type WikifyResult = { ok: true } | { ok: false; reason: string };
+async function wikifyOne(url: string, userContent: string, view: Webview | undefined): Promise {
+ const cfg = vscode.workspace.getConfiguration('g1nation');
+
+ chunk(view, `⏳ 본문 추출 중…`);
+ const t0 = Date.now();
+ const heartbeat = setInterval(() => {
+ chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
+ }, 4000);
+ const data = await bridgeFetch<{ success: boolean; url: string; title?: string; description?: string; lang?: string; headings?: string[]; text?: string; textLength?: number; truncated?: boolean }>(
+ BRIDGE_API.web.extract,
+ { method: 'POST', body: JSON.stringify({ url }) },
+ { timeoutMs: 3 * 60_000 },
+ ).finally(() => clearInterval(heartbeat));
+ chunk(view, `\n✅ 본문 추출 (${Math.round((Date.now() - t0) / 1000)}s · ${(data.textLength ?? 0).toLocaleString()}자${data.truncated ? ', 일부 잘림' : ''})\n\n`);
+
+ if (!data.text || data.text.trim().length < 50) {
+ const reason = `본문 빈약 (${data.textLength ?? 0}자 — JS 전용 렌더링 또는 콘텐츠 없음)`;
+ chunk(view, `⚠️ 추출된 본문이 거의 없어 건너뜁니다. (${reason})\n`);
+ return { ok: false, reason };
+ }
+
+ const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim();
+ const wikiSystem = '당신은 지식 큐레이터입니다. 제공된 웹사이트 본문을 P-Reinforce v3.0 규격의 고밀도 위키 문서로 정리합니다. 본문에 없는 내용은 절대 지어내지 않으며, 모든 문서는 한국어로 작성합니다.';
+ chunk(view, `🧪 P-Reinforce 위키 합성 (모델 \`${model}\`)…`);
+ let report: string;
+ try {
+ const synthT0 = Date.now();
+ report = await callLmSynthesis(buildWikifyPrompt(data, userContent), wikiSystem);
+ if (!report) throw new Error('LLM 응답이 비어 있습니다.');
+ report = report.replace(/\[\[([^\[\]]+?)\](?!\])/g, '[[$1]]');
+ chunk(view, ` ✓ (${Math.round((Date.now() - synthT0) / 1000)}s)\n\n`);
+ } catch (e: any) {
+ const reason = `LLM 합성 실패: ${e?.message || String(e)}`;
+ chunk(view, `\n⚠️ 위키 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
+ return { ok: false, reason };
+ }
+ chunk(view, report + '\n\n');
+
+ try {
+ const today = new Date().toISOString().slice(0, 10);
+ let host = url;
+ try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ }
+ const title = `위키 ${(userContent.trim() || data.title || host)} ${today}`;
+ const savePath = (cfg.get('datacollectSavePath', '') || '').trim();
+ const body: Record = { title, content: report };
+ if (savePath) body.saveDir = savePath;
+ const saved = await bridgeFetch<{ success: boolean; path?: string }>(
+ BRIDGE_API.wiki.save,
+ { method: 'POST', body: JSON.stringify(body) },
+ { timeoutMs: 30_000 },
+ );
+ chunk(view, `💾 위키 문서 저장 완료: \`${saved?.path || '(경로 미확인)'}\`\n`);
+ } catch (e: any) {
+ chunk(view, `⚠️ 위키 문서 저장 실패: ${e?.message || String(e)}\n`);
+ }
+ return { ok: true };
+}
+
+async function runWikify(arg: string, view: Webview | undefined): Promise {
+ const isUrl = (t: string) => /^https?:\/\//i.test(t) || /^[a-z0-9-]+(\.[a-z0-9-]+)+/i.test(t);
+ const tokens = arg.trim().split(/\s+/).filter(Boolean);
+ const urls = tokens.filter(isUrl);
+ const userContent = tokens.filter((t) => !isUrl(t)).join(' ');
+ if (urls.length === 0) {
+ chunk(view, `사용법: \`/wikify [url2 url3 …] [주제명]\`\n예: \`/wikify https://example.com\`\n여러 링크를 공백으로 구분해 한 번에 넣으면 1개씩 순차 위키화합니다.\n`);
+ return true;
+ }
+
+ if (urls.length === 1) {
+ chunk(view, `📚 **위키화**: ${urls[0]}\n(본문 추출 → P-Reinforce v3.0 위키 문서 합성)\n\n`);
+ const result = await wikifyOne(urls[0], userContent, view);
+ if (!result.ok) chunk(view, `\n실패 사유: ${result.reason}\n`);
+ return true;
+ }
+
+ chunk(view, `📚 **위키화 배치**: 총 ${urls.length}개 링크를 순차 처리합니다.\n`);
+ const batchT0 = Date.now();
+ let okCount = 0;
+ const failures: Array<{ url: string; reason: string }> = [];
+ for (let i = 0; i < urls.length; i++) {
+ chunk(view, `\n---\n\n### [${i + 1}/${urls.length}] ${urls[i]}\n\n`);
+ try {
+ const result = await wikifyOne(urls[i], userContent, view);
+ if (result.ok) okCount++;
+ else failures.push({ url: urls[i], reason: result.reason });
+ } catch (e: any) {
+ const reason = `처리 오류: ${e?.message || String(e)}`;
+ chunk(view, `❌ [${i + 1}/${urls.length}] ${reason}\n`);
+ failures.push({ url: urls[i], reason });
+ }
+ }
+ chunk(view, `\n---\n\n🏁 **배치 완료**: ${okCount}/${urls.length}개 성공 (${Math.round((Date.now() - batchT0) / 1000)}s 소요)\n`);
+ if (failures.length > 0) {
+ chunk(view, `\n**실패 ${failures.length}건 사유**:\n`);
+ for (const f of failures) chunk(view, `- ${f.url} — ${f.reason}\n`);
+ }
+ return true;
+}
+
+// ───────────────────────────── /meet ─────────────────────────────
+
+async function runMeet(arg: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise {
+ const trimmed = arg.trim();
+ let filePath = '';
+ let metadata = '';
+ if (trimmed.startsWith('"')) {
+ const end = trimmed.indexOf('"', 1);
+ if (end > 0) {
+ filePath = trimmed.slice(1, end);
+ metadata = trimmed.slice(end + 1).trim();
+ }
+ }
+ if (!filePath) {
+ const sp = trimmed.indexOf(' ');
+ if (sp === -1) filePath = trimmed;
+ else { filePath = trimmed.slice(0, sp); metadata = trimmed.slice(sp + 1).trim(); }
+ }
+ if (!filePath) {
+ chunk(view, `사용법: \`/meet [참석자·날짜 등 메타데이터]\`\n예: \`/meet c:\\doc\\0101.txt\`\n경로에 공백이 있으면 따옴표로 감싸세요: \`/meet "c:\\my docs\\0101.txt"\`\n`);
+ return true;
+ }
+
+ chunk(view, `📝 **회의록 작성**: ${filePath}\n\n⏳ 녹취 파일 읽는 중…`);
+
+ let transcript: string;
+ try {
+ transcript = await fsp.readFile(filePath, 'utf-8');
+ } catch (e: any) {
+ chunk(view, `\n\n❌ 파일을 읽을 수 없습니다: ${e?.message || String(e)}\n경로가 정확한지 확인하세요.\n`);
+ return true;
+ }
+ if (!transcript || transcript.trim().length < 20) {
+ chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`);
+ return true;
+ }
+ const MAX = 60000;
+ const truncated = transcript.length > MAX;
+ if (truncated) transcript = transcript.slice(0, MAX);
+ chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}자${truncated ? ', 상한 초과로 일부 잘림' : ''})\n\n`);
+
+ const cfg = vscode.workspace.getConfiguration('g1nation');
+ const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim();
+ const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
+ chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
+ let report: string;
+ try {
+ const t0 = Date.now();
+ report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem);
+ if (!report) throw new Error('LLM 응답이 비어 있습니다.');
+ chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`);
+ } catch (e: any) {
+ chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
+ return true;
+ }
+ chunk(view, report + '\n\n');
+
+ try {
+ const today = new Date().toISOString().slice(0, 10);
+ const baseName = filePath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '') || 'meeting';
+ const title = `회의록 ${baseName} ${today}`;
+ const savePath = (cfg.get('datacollectSavePath', '') || '').trim();
+ const body: Record = { title, content: report };
+ if (savePath) body.saveDir = savePath;
+ const saved = await bridgeFetch<{ success: boolean; path?: string }>(
+ BRIDGE_API.wiki.save,
+ { method: 'POST', body: JSON.stringify(body) },
+ { timeoutMs: 30_000 },
+ );
+ chunk(view, `💾 **회의록 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`);
+ } catch (e: any) {
+ chunk(view, `⚠️ 회의록 저장 실패: ${e?.message || String(e)}\n`);
+ }
+
+ if (context) {
+ try {
+ const calCfg = readCalendarConfig(context);
+ if (!calCfg.refreshToken) {
+ chunk(view, `\nℹ️ 캘린더 자동 등록을 건너뜁니다 — Google Calendar OAuth(쓰기)가 연결되지 않았습니다. (Astra Settings → Google 섹션에서 연결)\n`);
+ } else {
+ const tasks = parseActionItems(report);
+ if (tasks.length === 0) {
+ chunk(view, `\nℹ️ 액션 아이템이 없어 캘린더에 등록할 항목이 없습니다.\n`);
+ } else {
+ const today = new Date();
+ const meetingDate = extractMeetingDate(report, today);
+ const titleMatch = report.match(/^#\s+(.+)$/m);
+ const meetTitle = titleMatch
+ ? titleMatch[1].replace(/^\[회의 제목\]\s*/, '').trim()
+ : '회의';
+ const gCfg = vscode.workspace.getConfiguration('g1nation');
+ const meetUsesTasks = gCfg.get('meetUsesTasks', true);
+ const meetUsesCalendar = gCfg.get('meetUsesCalendar', true);
+ if (!meetUsesTasks && !meetUsesCalendar) {
+ chunk(view, `\nℹ️ Google Tasks·Calendar 등록이 모두 꺼져 있어 액션 아이템 자동 등록을 건너뜁니다. (Settings 의 \`g1nation.meetUsesTasks\` / \`g1nation.meetUsesCalendar\` 확인)\n`);
+ } else {
+ const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + ');
+ chunk(view, `\n📝 **Google ${destLabel} 등록**: 액션 아이템 ${tasks.length}건…\n`);
+ let tasksOk = 0;
+ let calendarOk = 0;
+ let tentativeCount = 0;
+ for (const task of tasks) {
+ const { date, tentative } = resolveTaskDate(task.due, meetingDate, today);
+ if (tentative) tentativeCount++;
+ const evTitle = tentative ? `${task.work} (미확정)` : task.work;
+ const notes = `회의록: ${meetTitle}\n담당: ${task.owner}\n기한 표기: ${task.due || '(없음)'}\nAstra /meet 자동 등록`;
+
+ const successes: string[] = [];
+ const failures: string[] = [];
+ if (meetUsesTasks) {
+ const r = await createTask(context, { title: evTitle, due: date, notes });
+ if (r.ok) { tasksOk++; successes.push('Tasks'); }
+ else { failures.push(`Tasks: ${r.error}`); }
+ }
+ if (meetUsesCalendar) {
+ const r = await createCalendarEvent(context, {
+ title: evTitle, start: date, allDay: true, description: notes,
+ });
+ if (r.ok) { calendarOk++; successes.push('Calendar'); }
+ else { failures.push(`Calendar: ${r.error}`); }
+ }
+
+ if (failures.length === 0) {
+ chunk(view, ` · ${date} — ${evTitle} (${successes.join(' + ')})\n`);
+ } else {
+ chunk(view, ` · ${date} — ${evTitle}${successes.length ? ` (✅ ${successes.join(' + ')})` : ''}\n`);
+ for (const f of failures) chunk(view, ` ⚠️ ${f}\n`);
+ }
+ }
+ const summary: string[] = [];
+ if (meetUsesTasks) summary.push(`Tasks ${tasksOk}/${tasks.length}`);
+ if (meetUsesCalendar) summary.push(`Calendar ${calendarOk}/${tasks.length}`);
+ chunk(view, `✅ 등록 완료 — ${summary.join(' · ')}${tentativeCount > 0 ? ` · 미확정 ${tentativeCount}건` : ''}\n`);
+ }
+ }
+ }
+ } catch (e: any) {
+ chunk(view, `\n⚠️ 캘린더 등록 중 오류: ${e?.message || String(e)}\n`);
+ }
+ }
+ return true;
+}
+
+// ─── 등록 ─────────────────────────────────────────────────────────────────
+
+registerSlashCommand({ name: '/research', description: 'NotebookLM Deep Research 호출 후 위키 합성', handler: runResearch });
+registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
+registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
+registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
+registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify });
+registerSlashCommand({ name: '/meet', description: '회의 transcript → 회의록 합성 + 캘린더·task 등록', handler: runMeet });
diff --git a/src/features/datacollect/llm.ts b/src/features/datacollect/llm.ts
new file mode 100644
index 0000000..6b8038f
--- /dev/null
+++ b/src/features/datacollect/llm.ts
@@ -0,0 +1,122 @@
+/**
+ * Datacollect LLM 호출 인프라 — bridge `/api/lm` 프록시 통해 OpenAI 호환 chat
+ * completion 단발 호출.
+ *
+ * v2.2.201 에서 slashRouter.ts 에서 분리. 옛 위치는 slashRouter.callLmSynthesis
+ * 였으나 datacollect handlers + teamops/communication 양쪽이 import 하므로
+ * 별도 인프라 모듈로.
+ *
+ * `repairKoreanGlitches` 는 callLmSynthesis 출력 위생용 — 모델이 한·영 혼합
+ * 토큰 깨짐 (예: "핵ess") 을 뱉으면 LLM 1회 추가 호출로 교정.
+ */
+
+import * as vscode from 'vscode';
+import { bridgeFetch, BRIDGE_API } from './bridgeClient';
+
+/**
+ * Bridge `/api/lm` 프록시로 OpenAI 호환 chat completion 1회 호출.
+ * LLM 서버/모델은 Astra 설정(g1nation.ollamaUrl / defaultModel)을 사용한다.
+ */
+export async function callLmSynthesis(prompt: string, systemPrompt?: string): Promise {
+ const cfg = vscode.workspace.getConfiguration('g1nation');
+ const lmUrl = (cfg.get('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, '');
+ const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim();
+ const temperature = Math.max(0, Math.min(2, cfg.get('datacollectSynthesisTemperature', 0.1) ?? 0.1));
+ const baseSys = systemPrompt
+ || '당신은 시니어 UX/UI 분석가이자 프론트엔드 아키텍트다. 모든 보고서는 한국어로, 제공된 JSON 스캔 데이터의 구체적 수치를 인용해 작성한다. 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 명세를 작성하는 것이 미션이며, 다른 서비스로 재해석·확장하지 않는다.';
+ const sys = baseSys + '\n\n[출력 위생 규칙 — 반드시 준수]\n'
+ + '- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n'
+ + '- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n'
+ + '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.';
+ const res = await bridgeFetch(BRIDGE_API.lm.proxy, {
+ method: 'POST',
+ body: JSON.stringify({
+ url: `${lmUrl}/v1/chat/completions`,
+ payload: {
+ model,
+ messages: [
+ { role: 'system', content: sys },
+ { role: 'user', content: prompt },
+ ],
+ temperature,
+ top_p: 0.85,
+ top_k: 20,
+ repeat_penalty: 1.1,
+ },
+ }),
+ }, { timeoutMs: 120_000 });
+ const content = res?.choices?.[0]?.message?.content
+ ?? res?.choices?.[0]?.text
+ ?? res?.answer
+ ?? res?.response
+ ?? '';
+ let out = String(content)
+ .replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '')
+ .trim();
+ if (/[가-힣][a-z]{2,}/.test(out)) {
+ out = await repairKoreanGlitches(out, lmUrl, model);
+ }
+ return out;
+}
+
+/**
+ * 한글+영문이 한 단어로 깨진 표기(LLM 토큰 꼬임)를 LLM 1회 호출로 교정.
+ */
+async function repairKoreanGlitches(text: string, lmUrl: string, model: string): Promise {
+ try {
+ const res = await bridgeFetch(BRIDGE_API.lm.proxy, {
+ method: 'POST',
+ body: JSON.stringify({
+ url: `${lmUrl}/v1/chat/completions`,
+ payload: {
+ model,
+ messages: [
+ { role: 'system', content: '당신은 한국어 교정기입니다. 입력 텍스트에서 한글과 영문 알파벳이 한 단어로 잘못 합쳐진 깨진 표기(예: "핵ess"→"핵심", "결ently"→"결국")만 자연스러운 한국어로 교정합니다. 그 외 내용·문장·마크다운 구조·표·정상적인 영문 용어(API, JSON 등)는 한 글자도 바꾸지 않으며, 교정된 전체 텍스트만 그대로 출력합니다(설명·주석 금지).' },
+ { role: 'user', content: text },
+ ],
+ temperature: 0,
+ top_p: 0.7,
+ top_k: 20,
+ },
+ }),
+ }, { timeoutMs: 120_000 });
+ const fixed = String(
+ res?.choices?.[0]?.message?.content
+ ?? res?.choices?.[0]?.text
+ ?? res?.answer ?? res?.response ?? '',
+ ).replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '').trim();
+ if (!fixed || fixed.length < text.length * 0.7) return text;
+ return fixed;
+ } catch {
+ return text;
+ }
+}
+
+/**
+ * Datacollect bridge 가 자주 뱉는 환경 의존성 에러(Python 패키지 미설치, Python
+ * 자체 부재 등) 를 패턴 매칭해서 사용자에게 *해결 명령까지* 알려주는 가이드 텍스트.
+ * 없으면 빈 문자열 반환. slashRouter 의 catch 블록에서 일반 에러 메시지 뒤에 append.
+ */
+export function bridgeErrorRemedy(rawMsg: string): string {
+ const msg = String(rawMsg || '');
+ const pkgMatch = msg.match(/필수 패키지가 없습니다?[:\s]+([\w\-,\s.]+)/i)
+ || msg.match(/missing (?:python )?packages?[:\s]+([\w\-,\s.]+)/i);
+ if (pkgMatch) {
+ const pkgs = pkgMatch[1].split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).join(' ');
+ return `\n\n💡 **해결**: Datacollect bridge 가 도는 환경에서 아래 명령으로 누락된 Python 패키지를 설치하세요.\n\n`
+ + '```bash\n'
+ + `# macOS (homebrew Python — PEP 668 보호 우회):\n`
+ + `python3 -m pip install --user --break-system-packages ${pkgs}\n\n`
+ + `# 또는 가상환경(venv) 사용 시 그 venv 활성화 후:\n`
+ + `pip install ${pkgs}\n`
+ + '```\n\n'
+ + `설치 후 **bridge 재시작은 보통 불필요** — bridge 는 Python 을 child process 로 spawn 하므로 다음 호출이 바로 새 패키지를 인식합니다. 그래도 안 되면 \`npm run bridge\` 재시작.\n`;
+ }
+ if (/Python 3이 설치돼 있지 않거나 PATH/i.test(msg) || /command not found.*python/i.test(msg)) {
+ return `\n\n💡 **해결**: Python 3 이 설치돼 있어야 합니다. https://www.python.org 에서 설치 후 터미널에서 \`python3 --version\` 으로 확인하세요. 이미 설치돼 있으면 PATH 설정 확인 필요.`;
+ }
+ if (/ECONNREFUSED|fetch failed/i.test(msg) || /연결할 수 없습니다/i.test(msg)) {
+ return `\n\n💡 **해결**: Datacollect bridge 가 떠 있지 않습니다. \`Datacollector_MAC\` 프로젝트에서 \`npm run bridge\` 실행 후 다시 시도하세요.`;
+ }
+ return '';
+}
diff --git a/src/features/datacollect/slashRouter.ts b/src/features/datacollect/slashRouter.ts
index 2599ff7..7b34817 100644
--- a/src/features/datacollect/slashRouter.ts
+++ b/src/features/datacollect/slashRouter.ts
@@ -1,25 +1,7 @@
import * as vscode from 'vscode';
-import * as fs from 'fs';
-import * as path from 'path';
-import { promises as fsp } from 'fs';
import { logInfo } from '../../utils';
-import { bridgeFetch, getBridgeBaseUrl, BRIDGE_API } from './bridgeClient';
-import { createCalendarEvent, createTask, listTasks, readCalendarConfig, _addDaysDate } from '../calendar';
-import { ChronicleProjectStore } from '../../sidebar/managers/chronicleProjectStore';
-import {
- MemoryManager,
- distillStaleEpisodes,
- getLastDistillationRun,
- recordDistillationRun,
- type DistillationArchiveMode,
-} from '../../memory';
-import { getConfig } from '../../config';
-import {
- getGlossaryFilePath,
- GLOSSARY_TEMPLATE,
- clearGlossaryCache,
-} from '../../retrieval/terminologyBlock';
-import { clearTermValidatorCache } from '../../agent/termValidator';
+import { getBridgeBaseUrl } from './bridgeClient';
+import { bridgeErrorRemedy } from './llm';
/**
* Datacollect "라디오" slash 명령 라우터.
@@ -35,7 +17,7 @@ import { clearTermValidatorCache } from '../../agent/termValidator';
* 명령이 처리되면 true 반환 → chatHandlers가 일반 LLM 흐름으로 안 내려가게.
*/
-interface Webview {
+export interface Webview {
postMessage(msg: any): Thenable | boolean;
}
@@ -116,7 +98,7 @@ export function isSlashCommand(input: string): boolean {
return REGISTRY.has(head);
}
-function chunk(view: Webview | undefined, value: string) {
+export function chunk(view: Webview | undefined, value: string) {
view?.postMessage({ type: 'streamChunk', value });
}
@@ -170,7 +152,7 @@ export async function handleSlashCommand(
logInfo(`[SLASH] handleSlashCommand error head=${head}: ${errMsg}`);
chunk(view, `\n\n> ❌ **에러**: ${errMsg}\n`);
// 자주 발생하는 환경 의존성 에러는 사용자가 즉시 해결할 수 있게 명령 가이드 자동 첨부.
- const remedy = _bridgeErrorRemedy(errMsg);
+ const remedy = bridgeErrorRemedy(errMsg);
if (remedy) chunk(view, remedy);
// Python 패키지 미설치 패턴이면 한 클릭 설치 notification 도 같이 띄움.
// 채팅 텍스트만 보면 사용자가 명령 팔레트로 가기 귀찮으니까 actionable 버튼 제공.
@@ -190,999 +172,6 @@ export async function handleSlashCommand(
}
}
-// ───────────────────────────── /research ─────────────────────────────
-
-async function runResearch(topic: string, view: Webview | undefined): Promise {
- if (!topic) {
- chunk(view, `사용법: \`/research <주제>\`\n주제를 입력해 NotebookLM Deep Research를 시작합니다.\n`);
- return true;
- }
-
- chunk(view, `🚀 **Research 시작**: \`${topic}\`\n\n`);
- const start = await bridgeFetch<{ success: boolean; notebookId: string; taskId: string }>(
- BRIDGE_API.research.start,
- { method: 'POST', body: JSON.stringify({ topic }) },
- { timeoutMs: 60_000 },
- );
- chunk(view, `- notebookId: \`${start.notebookId}\`\n- taskId: \`${start.taskId}\`\n\n⏳ 상태 polling (5초 간격, 최대 10분)…\n`);
-
- // Deep research는 보통 1~5분. 5초 polling, 최대 120회(10분).
- //
- // hang 방어 3겹:
- // (1) Bridge status 가 5회 연속 실패하면 polling 포기 — bridge 가 죽은 거.
- // (2) heartbeat — 30초마다 진행 상태가 안 바뀌면 "⏳" 한 줄 흘려 사용자가
- // "멈춰 있나?" 느끼지 않게.
- // (3) status 비교는 트림 + 소문자 — bridge 가 "Completed " 식으로 흘려도 잡힘.
- const deadline = Date.now() + 10 * 60_000;
- const HEARTBEAT_MS = 30_000;
- const MAX_CONSECUTIVE_FAILS = 5;
- const COMPLETED_SET = new Set(['completed', 'done', 'success', 'finished']);
- const FAILED_SET = new Set(['failed', 'error', 'cancelled', 'canceled', 'aborted']);
-
- let lastStatus = '';
- let lastChangeAt = Date.now();
- let consecutiveFails = 0;
- let pollCount = 0;
- let researchOk = false;
- while (Date.now() < deadline) {
- await new Promise(r => setTimeout(r, 5_000));
- pollCount++;
- // status 한 번 호출이 30s를 넘는 사례(stale MCP 자식)가 보고돼 60s로 완화.
- let st: { success: boolean; result: any } | undefined;
- try {
- st = await bridgeFetch<{ success: boolean; result: any }>(
- `${BRIDGE_API.research.status}?notebookId=${encodeURIComponent(start.notebookId)}&taskId=${encodeURIComponent(start.taskId)}`,
- { method: 'GET' },
- { timeoutMs: 60_000 },
- );
- consecutiveFails = 0;
- } catch (e: any) {
- consecutiveFails++;
- if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
- chunk(view, `\n❌ Status polling 연속 실패 ${consecutiveFails}회 — bridge 가 응답하지 않습니다. 중단합니다.\n(원인: ${e?.message || String(e)})\n`);
- return true;
- }
- chunk(view, `\n · status 호출 실패 ${consecutiveFails}/${MAX_CONSECUTIVE_FAILS} (${e?.message || 'unknown'})\n`);
- continue;
- }
- const status = String(st.result?.status || st.result || '').trim().toLowerCase();
- if (status && status !== lastStatus) {
- chunk(view, ` · ${status}\n`);
- lastStatus = status;
- lastChangeAt = Date.now();
- } else if (Date.now() - lastChangeAt > HEARTBEAT_MS) {
- // 30초간 status 변화 없음 — 사용자에게 살아있다는 신호.
- chunk(view, ` · ⏳ 대기 중 (${Math.round((Date.now() - lastChangeAt) / 1000)}s, 폴링 ${pollCount}회)\n`);
- lastChangeAt = Date.now();
- }
- if (COMPLETED_SET.has(status)) { researchOk = true; break; }
- if (FAILED_SET.has(status)) {
- chunk(view, `\n❌ Research 실패: ${JSON.stringify(st.result).slice(0, 400)}\n`);
- return true;
- }
- }
-
- if (!researchOk) {
- chunk(view, `\n❌ 10분 polling 후에도 완료 신호가 오지 않았습니다 (마지막 status: \`${lastStatus || '(없음)'}\`). 중단합니다.\n`);
- return true;
- }
-
- chunk(view, `\n📥 import…\n`);
- // import는 deep research 결과를 노트북 소스로 옮기는 단계. 큰 리포트는 2~5분
- // 걸리는 경우가 흔해 120s에서 TRANSIENT_TIMEOUT으로 떨어지는 사례 보고됨. 300s로 늘림.
- // heartbeat — 30초마다 진행 표시 흘려 사용자가 "멈췄나?" 의심하지 않게.
- await bridgeFetch(BRIDGE_API.research.import, {
- method: 'POST',
- body: JSON.stringify({ notebookId: start.notebookId, taskId: start.taskId }),
- }, {
- timeoutMs: 300_000,
- onHeartbeat: (elapsedMs) => chunk(view, ` · import 진행 중 (${Math.round(elapsedMs / 1000)}s)\n`),
- });
-
- chunk(view, `🧪 synthesize…\n\n`);
- // synthesize는 LLM이 노트북 전체를 합성 — 큰 노트북은 5~10분. 600s로 cap.
- // heartbeat 필수: LLM 단일 호출이 수 분 걸리므로 hang 의심 방지.
- const synth = await bridgeFetch<{ success: boolean; markdown?: string; result?: string }>(
- BRIDGE_API.research.synthesize,
- {
- method: 'POST',
- body: JSON.stringify({ notebookId: start.notebookId, topic, rootTopic: topic, includeKnowledgeConnections: true }),
- },
- {
- timeoutMs: 600_000,
- onHeartbeat: (elapsedMs) => chunk(view, ` · synthesize LLM 작업 중 (${Math.round(elapsedMs / 1000)}s)\n`),
- },
- );
- const md = synth.markdown || synth.result || '(빈 응답)';
- chunk(view, `---\n\n${md}\n`);
- return true;
-}
-
-// ───────────────────────────── /benchmark ─────────────────────────────
-
-// SynthesisPart type + buildSynthesisPrompt → `src/features/datacollect/prompts/synthesisPrompt.ts`
-import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt';
-
-/**
- * Bridge `/api/lm` 프록시로 OpenAI 호환 chat completion 1회 호출.
- * LLM 서버/모델은 Astra 설정(g1nation.ollamaUrl / defaultModel)을 사용한다.
- */
-async function callLmSynthesis(prompt: string, systemPrompt?: string): Promise {
- const cfg = vscode.workspace.getConfiguration('g1nation');
- const lmUrl = (cfg.get('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, '');
- const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim();
- // temperature는 설정값(g1nation.datacollectSynthesisTemperature). 낮을수록 환각·
- // 깨진 문자가 줄어든다. 0~2 범위로 클램프, 기본 0.1.
- const temperature = Math.max(0, Math.min(2, cfg.get('datacollectSynthesisTemperature', 0.1) ?? 0.1));
- const baseSys = systemPrompt
- || '당신은 시니어 UX/UI 분석가이자 프론트엔드 아키텍트다. 모든 보고서는 한국어로, 제공된 JSON 스캔 데이터의 구체적 수치를 인용해 작성한다. 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 명세를 작성하는 것이 미션이며, 다른 서비스로 재해석·확장하지 않는다.';
- // 로컬 모델 출력 위생 — 한영 토큰 깨짐 방지 + 내부 검증 로그 유출 차단.
- const sys = baseSys + '\n\n[출력 위생 규칙 — 반드시 준수]\n'
- + '- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n'
- + '- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n'
- + '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.';
- const res = await bridgeFetch(BRIDGE_API.lm.proxy, {
- method: 'POST',
- body: JSON.stringify({
- url: `${lmUrl}/v1/chat/completions`,
- payload: {
- model,
- messages: [
- { role: 'system', content: sys },
- { role: 'user', content: prompt },
- ],
- temperature,
- // 깨진 저확률 토큰(한·영 혼합 등) 샘플링 억제 — top_p/top_k로 후보를
- // 좁히고 repeat_penalty로 반복 깨짐을 누른다.
- top_p: 0.85,
- top_k: 20,
- repeat_penalty: 1.1,
- },
- }),
- }, { timeoutMs: 120_000 });
- const content = res?.choices?.[0]?.message?.content
- ?? res?.choices?.[0]?.text
- ?? res?.answer
- ?? res?.response
- ?? '';
- // 안전망 — 모델이 그래도 내부 검증 로그를 붙이면 잘라낸다. [Self-Reflector Check]
- // 블록은 항상 답변 맨 끝에 오므로 그 지점부터 끝까지 제거한다.
- let out = String(content)
- .replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '')
- .trim();
- // 한·영 깨짐(예: "핵ess" — 한글 음절 + 영문 소문자 조각)이 감지되면 교정 패스를
- // 1회 돈다. 깨짐이 없으면(대부분) 추가 호출 없이 그대로 반환한다.
- if (/[가-힣][a-z]{2,}/.test(out)) {
- out = await repairKoreanGlitches(out, lmUrl, model);
- }
- return out;
-}
-
-/**
- * 한글+영문이 한 단어로 깨진 표기(LLM 토큰 꼬임)를 LLM 1회 호출로 교정.
- * 깨진 토큰만 고치고 나머지 내용·구조는 보존한다. 실패하거나 결과가 비정상적으로
- * 짧으면(LLM이 내용을 잘라먹은 경우) 원본을 그대로 반환한다.
- */
-async function repairKoreanGlitches(text: string, lmUrl: string, model: string): Promise {
- try {
- const res = await bridgeFetch(BRIDGE_API.lm.proxy, {
- method: 'POST',
- body: JSON.stringify({
- url: `${lmUrl}/v1/chat/completions`,
- payload: {
- model,
- messages: [
- { role: 'system', content: '당신은 한국어 교정기입니다. 입력 텍스트에서 한글과 영문 알파벳이 한 단어로 잘못 합쳐진 깨진 표기(예: "핵ess"→"핵심", "결ently"→"결국")만 자연스러운 한국어로 교정합니다. 그 외 내용·문장·마크다운 구조·표·정상적인 영문 용어(API, JSON 등)는 한 글자도 바꾸지 않으며, 교정된 전체 텍스트만 그대로 출력합니다(설명·주석 금지).' },
- { role: 'user', content: text },
- ],
- temperature: 0,
- top_p: 0.7,
- top_k: 20,
- },
- }),
- }, { timeoutMs: 120_000 });
- const fixed = String(
- res?.choices?.[0]?.message?.content
- ?? res?.choices?.[0]?.text
- ?? res?.answer ?? res?.response ?? '',
- ).replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '').trim();
- // 안전장치 — 교정 결과가 원본의 70% 미만이면 LLM이 내용을 잘라먹은 것이므로 원본 사용.
- if (!fixed || fixed.length < text.length * 0.7) return text;
- return fixed;
- } catch {
- return text; // 교정 실패는 비치명적 — 원본 그대로 반환.
- }
-}
-
-async function runBenchmark(arg: string, view: Webview | undefined): Promise {
- // 인자 파싱: 첫 비-옵션 토큰=URL, `depth=N`·`pages=N`=크롤 옵션, 나머지=보조 컨텍스트.
- // 예) `/benchmark caliverse.io depth=2 pages=12 우리 랜딩 참고용`
- // URL 토큰만 떼어내므로 "분석해줘" 같은 자연어가 섞여도 안전하다.
- const tokens = arg.trim().split(/\s+/).filter(Boolean);
- let url = '';
- let depthArg: number | undefined;
- let pagesArg: number | undefined;
- const restParts: string[] = [];
- for (const t of tokens) {
- const m = /^(depth|pages)=(\d+)$/i.exec(t);
- if (m) {
- if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]);
- else pagesArg = Number(m[2]);
- } else if (!url) {
- url = t;
- } else {
- restParts.push(t);
- }
- }
- if (!url) {
- chunk(view, `사용법: \`/benchmark [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`);
- return true;
- }
- const userContent = restParts.join(' ');
-
- // 크롤 옵션 우선순위: 명령 인자 > Settings 설정값 > 기본값(depth 1 / 8페이지).
- const cfg = vscode.workspace.getConfiguration('g1nation');
- const crawlDepth = depthArg ?? (cfg.get('datacollectCrawlDepth', 1) ?? 1);
- const maxPages = pagesArg ?? (cfg.get('datacollectMaxPages', 8) ?? 8);
-
- chunk(view, `🔍 **Web Benchmark 스캔**: ${url}\n(crawlDepth ${crawlDepth} · 최대 ${maxPages}페이지 · Playwright)\n\n⏳ 브라우저 기동 · 페이지 크롤링 · 디자인 토큰 추출 중…`);
-
- // 1) scan — 진행 중 멈춘 줄로 오해하지 않도록 4초마다 경과 시간을 누적 표시.
- const t0 = Date.now();
- const heartbeat = setInterval(() => {
- chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
- }, 4000);
- const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>(
- BRIDGE_API.web.benchmarkScan,
- {
- method: 'POST',
- body: JSON.stringify({ url, captureScreenshots: false, maxPages, crawlDepth }),
- },
- { timeoutMs: 6 * 60_000 },
- ).finally(() => clearInterval(heartbeat));
- const s = scan.scan;
- chunk(view, `\n✅ **스캔 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${s?.sitemap?.totalPages ?? 1}페이지)\n\n`);
-
- // 스캔이 에러 없이 끝나도(잘못된 URL·봇 차단·JS 렌더링 등) 알맹이가 빌 수 있다.
- const looksEmpty = !s?.meta?.title
- && !(s?.design?.colors?.palette?.length)
- && !s?.microcopy?.headline;
- if (looksEmpty) {
- chunk(view, `⚠️ **스캔 결과가 비어 있습니다.** URL이 정확한지, 사이트가 접근 가능한지(봇 차단·JS 전용 렌더링 포함) 확인하세요. 스캔한 URL: \`${url}\`\n\n`);
- }
-
- // raw 스캔 요약 — LLM 합성이 실패하거나 스캔이 비었을 때의 fallback 본문.
- const palette = s?.design?.colors?.palette?.slice(0, 5) || [];
- const rawReport = [
- `### 메타`,
- `- **title**: ${s?.meta?.title || '(없음)'}`,
- `- **description**: ${s?.meta?.description || '(없음)'}`,
- `- **lang**: ${s?.meta?.lang || '(없음)'}`,
- ``,
- `### 디자인 토큰 (상위)`,
- `- **컬러 팔레트 Top 5**: ${palette.map((p: any) => `\`${p.value}\` (×${p.count})`).join(', ') || '(없음)'}`,
- `- **컬러 비율**: ${s?.design?.colors?.composition?.ratioLabel || '(없음)'}`,
- `- **Primary Font**: \`${s?.design?.typography?.primaryFont || '(없음)'}\``,
- ``,
- `### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})`,
- '```',
- s?.sitemap?.ascii || '(없음)',
- '```',
- ].join('\n');
-
- // 2) synthesize — LLM 4-렌즈 합성 3단계 (Datacollect 웹앱과 동일한 리포트).
- // 스캔이 비었거나 합성이 실패하면 raw 요약으로 fallback.
- let finalReport: string;
- if (looksEmpty) {
- chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`);
- finalReport = rawReport;
- } else {
- const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim();
- chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
- try {
- const parts: string[] = [];
- for (const part of [1, 2, 3] as const) {
- chunk(view, `\n · 합성 ${part}/3 진행 중…`);
- const partT0 = Date.now();
- const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part));
- if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`);
- parts.push(out);
- chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
- }
- finalReport = parts.join('\n\n---\n\n');
- chunk(view, `\n\n`);
- } catch (e: any) {
- chunk(view, `\n\n⚠️ LLM 합성 실패: ${e?.message || String(e)}\n원시 스캔 요약만 표시·저장합니다. (LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
- finalReport = rawReport;
- }
- }
-
- chunk(view, finalReport + '\n\n');
-
- // 3) save — 저장 위치 우선순위: g1nation.datacollectSavePath > Bridge WIKI_RAW_PATH.
- // 어느 쪽이든 Astra 코드에는 절대경로가 하드코딩되지 않는다.
- try {
- const today = new Date().toISOString().slice(0, 10);
- let host = url;
- try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ }
- const title = `웹벤치마크 ${host} ${today}`;
- const fileMarkdown = [
- `# ${title}`,
- ``,
- `- **원본 URL**: ${url}`,
- `- **스캔 시각**: ${new Date().toISOString()}`,
- `- **크롤 옵션**: depth ${crawlDepth} · 최대 ${maxPages}페이지`,
- `- **생성**: Astra /benchmark · Datacollect web-benchmark`,
- ``,
- finalReport,
- ``,
- ].join('\n');
- const savePath = (cfg.get('datacollectSavePath', '') || '').trim();
- const body: Record = { title, content: fileMarkdown };
- if (savePath) body.saveDir = savePath;
- const saved = await bridgeFetch<{ success: boolean; path?: string }>(
- BRIDGE_API.wiki.save,
- { method: 'POST', body: JSON.stringify(body) },
- { timeoutMs: 30_000 },
- );
- chunk(view, `💾 **결과물 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`);
- } catch (e: any) {
- chunk(view, `⚠️ 결과물 저장 실패: ${e?.message || String(e)}\n`);
- }
-
- return true;
-}
-
-// ───────────────────────────── /youtube ─────────────────────────────
-
-// formatHms / fullScriptFromSegments / bucketSegments / YoutubeAnalysisMode / buildInfoExtractionPrompt / build4LensPrompt
-// → `src/features/datacollect/prompts/youtubePrompts.ts`
-import {
- type YoutubeAnalysisMode,
- formatHms,
- fullScriptFromSegments,
- buildInfoExtractionPrompt,
- build4LensPrompt,
-} from './prompts/youtubePrompts';
-
-/**
- * URL 이 *채널/플레이리스트* 처럼 보이는지 휴리스틱. yt-dlp 는 채널 URL 을
- * 그대로 받아 영상 목록을 enumerate 하므로, 우리는 채널일 때 default limit
- * 만 다르게 잡아주면 된다(단일 영상은 1, 채널은 10).
- */
-function _looksLikeYoutubeChannelUrl(url: string): boolean {
- return /youtube\.com\/(channel\/|@|c\/|user\/|playlist\?list=|playlist\/)/i.test(url)
- || /youtube\.com\/[^/?#]+\/(videos|shorts|streams)\b/i.test(url);
-}
-
-/**
- * 채널 URL 을 yt-dlp 가 *영상 목록* 으로 정확히 enumerate 하는 형태로 정규화.
- *
- * 의도: `https://www.youtube.com/@handle` 같은 채널 "루트" 를 그냥 yt-dlp 에
- * 넘기면 영상 ID 대신 채널 ID(`UC...`) 가 영상 entry 로 잘못 돌아오는 사례
- * 발견 (Deno-AI 채널 케이스). `/videos` 탭을 명시하면 정상 enumerate.
- *
- * 규칙:
- * - 이미 `/videos`, `/shorts`, `/streams`, `/playlist` 가 path 에 있으면 그대로
- * - 단일 영상 URL (`watch?v=`, `youtu.be/`, `/shorts/`) 는 그대로
- * - 그 외 채널 패턴 (`@handle`, `channel/UC..`, `c/name`, `user/name`) 만
- * `/videos` 를 append (query 가 있으면 path 뒤에 끼움)
- */
-function _normalizeYoutubeUrl(url: string): string {
- try {
- const u = new URL(url);
- if (!/youtube\.com$|youtube\.com\.|youtu\.be$/i.test(u.hostname)) return url;
- const p = u.pathname;
- // 이미 영상 단위거나 탭/플레이리스트가 명시된 경우는 손대지 말 것.
- if (/\/(watch|shorts|playlist|videos|streams|featured|community|about)\b/i.test(p)) return url;
- if (u.hostname.includes('youtu.be')) return url; // youtu.be/ 는 영상 short link
- // 채널 루트 패턴 — `/videos` 를 append (이미 끝 슬래시 있으면 정리).
- if (/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)\/?$/i.test(p)) {
- u.pathname = p.replace(/\/?$/, '/videos');
- return u.toString();
- }
- return url;
- } catch {
- return url; // URL 파싱 실패 시 손대지 않음
- }
-}
-
-/**
- * Datacollect bridge 가 자주 뱉는 환경 의존성 에러(Python 패키지 미설치, Python
- * 자체 부재 등) 를 패턴 매칭해서 사용자에게 *해결 명령까지* 알려주는 가이드 텍스트.
- * 없으면 빈 문자열 반환. slashRouter 의 catch 블록에서 일반 에러 메시지 뒤에
- * append 하는 안전망.
- */
-function _bridgeErrorRemedy(rawMsg: string): string {
- const msg = String(rawMsg || '');
- // 패턴 1 — Python 패키지 미설치 (bridge 가 명시적으로 알려줌).
- const pkgMatch = msg.match(/필수 패키지가 없습니다?[:\s]+([\w\-,\s.]+)/i)
- || msg.match(/missing (?:python )?packages?[:\s]+([\w\-,\s.]+)/i);
- if (pkgMatch) {
- const pkgs = pkgMatch[1].split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).join(' ');
- return `\n\n💡 **해결**: Datacollect bridge 가 도는 환경에서 아래 명령으로 누락된 Python 패키지를 설치하세요.\n\n`
- + '```bash\n'
- + `# macOS (homebrew Python — PEP 668 보호 우회):\n`
- + `python3 -m pip install --user --break-system-packages ${pkgs}\n\n`
- + `# 또는 가상환경(venv) 사용 시 그 venv 활성화 후:\n`
- + `pip install ${pkgs}\n`
- + '```\n\n'
- + `설치 후 **bridge 재시작은 보통 불필요** — bridge 는 Python 을 child process 로 spawn 하므로 다음 호출이 바로 새 패키지를 인식합니다. 그래도 안 되면 \`npm run bridge\` 재시작.\n`;
- }
- // 패턴 2 — Python 자체가 없거나 PATH 에 없음.
- if (/Python 3이 설치돼 있지 않거나 PATH/i.test(msg) || /command not found.*python/i.test(msg)) {
- return `\n\n💡 **해결**: Python 3 이 설치돼 있어야 합니다. https://www.python.org 에서 설치 후 터미널에서 \`python3 --version\` 으로 확인하세요. 이미 설치돼 있으면 PATH 설정 확인 필요.`;
- }
- // 패턴 3 — bridge 자체에 연결 실패.
- if (/ECONNREFUSED|fetch failed/i.test(msg) || /연결할 수 없습니다/i.test(msg)) {
- return `\n\n💡 **해결**: Datacollect bridge 가 떠 있지 않습니다. \`Datacollector_MAC\` 프로젝트에서 \`npm run bridge\` 실행 후 다시 시도하세요.`;
- }
- return '';
-}
-
-/**
- * 채널/플레이리스트 처리 시 한 번에 너무 많이 돌려 사용자가 후회하지 않도록 cap.
- * 영상 1건당 LLM 분석에 보통 30~120s 걸리는 점을 감안.
- */
-const YOUTUBE_BATCH_MAX = 50;
-
-async function runYoutube(arg: string, view: Webview | undefined): Promise {
- // 토큰 파싱 — URL 뒤로는 두 가지 형태의 키워드 + 자유 컨텍스트 텍스트.
- //
- // n:<숫자> → 채널일 때 가져올 영상 개수
- // mode: → 분석 모드 (key:value 형)
- // info / benchmark / both → 같은 모드의 bare keyword 형 (더 짧고 직관적)
- //
- // bare keyword 가 작동하는 이유: `info`/`benchmark`/`both` 는 영어 단어이고
- // 한국어 사용자가 컨텍스트로 쓸 가능성이 매우 낮아 충돌 위험 적음. 사용자가
- // 진짜 이 단어들을 컨텍스트로 넣고 싶으면 `mode:` 접두사를 빼지 말고 명시
- // (이 경우 일반 단어도 컨텍스트로 같이 넣을 수 있음).
- //
- // 위 패턴 중 하나도 매칭 안 되는 토큰은 모두 사용자 컨텍스트로 join.
- const BARE_MODE_KEYWORDS = new Set(['info', 'benchmark', 'both']);
- const tokens = arg.trim().split(/\s+/).filter(Boolean);
- const url = tokens[0] || '';
- let limitOverride: number | null = null;
- let mode: YoutubeAnalysisMode = 'both';
- const contextTokens: string[] = [];
- for (const tok of tokens.slice(1)) {
- const nMatch = tok.match(/^n[:=](\d+)$/i);
- if (nMatch) {
- const n = parseInt(nMatch[1], 10);
- if (Number.isFinite(n) && n > 0) {
- limitOverride = Math.min(YOUTUBE_BATCH_MAX, n);
- }
- continue;
- }
- const modeMatch = tok.match(/^mode[:=](info|benchmark|both)$/i);
- if (modeMatch) {
- mode = modeMatch[1].toLowerCase() as YoutubeAnalysisMode;
- continue;
- }
- // Bare keyword 형 — `info` / `benchmark` / `both` 자체를 토큰으로.
- const lower = tok.toLowerCase();
- if (BARE_MODE_KEYWORDS.has(lower)) {
- mode = lower as YoutubeAnalysisMode;
- continue;
- }
- contextTokens.push(tok);
- }
- const userContent = contextTokens.join(' ');
-
- if (!url) {
- chunk(view, [
- `사용법:\n`,
- `- 단일 영상: \`/youtube <영상URL> [info|benchmark|both] [컨텍스트]\`\n`,
- `- 채널/플레이리스트: \`/youtube <채널URL> [n:30] [info|benchmark|both] [컨텍스트]\`\n`,
- `\n**분석 모드** (생략 시 \`both\`):\n`,
- `- \`info\` — 영상의 *내용*을 지식 카드로 추출 (튜토리얼·강의·뉴스·인터뷰)\n`,
- `- \`benchmark\` — 대본 역기획서 4-렌즈 분석 (콘텐츠 제작 벤치마크용)\n`,
- `- \`both\` — 둘 다 생성 (영상당 LLM 호출 2회)\n`,
- `\n예시:\n`,
- `- \`/youtube https://youtu.be/abc info\`\n`,
- `- \`/youtube https://youtube.com/@somechannel n:20 info AI 학습 자료\`\n`,
- `\n💡 \`mode:info\` / \`mode=info\` 같은 명시형도 그대로 동작 (백워드 호환).\n`,
- ].join(''));
- return true;
- }
-
- // 채널 URL 감지 → 기본 10개. 단일 영상은 1개. 사용자가 `n:N` 으로 명시했으면 그 값.
- const isChannel = _looksLikeYoutubeChannelUrl(url);
- const limit = limitOverride ?? (isChannel ? 10 : 1);
-
- // yt-dlp 가 영상 목록을 enumerate 할 수 있도록 채널 루트 URL 에 `/videos` 탭을
- // 자동 append. 그렇지 않으면 채널 ID(UC...)가 영상 ID 로 잘못 들어가는 사고.
- const normalizedUrl = isChannel ? _normalizeYoutubeUrl(url) : url;
- if (normalizedUrl !== url) {
- chunk(view, `🔧 채널 URL 정규화: \`${url}\` → \`${normalizedUrl}\` (yt-dlp 영상 enumeration 을 위한 \`/videos\` 탭 명시)\n\n`);
- }
-
- const modeLabel = mode === 'info' ? '📋 정보 추출 (지식 카드)'
- : mode === 'benchmark' ? '🎬 벤치마킹 (4-렌즈 역기획서)'
- : '📋 정보 추출 + 🎬 벤치마킹 (둘 다)';
- if (isChannel) {
- const callsPerVideo = mode === 'both' ? 2 : 1;
- chunk(view, `📺 **채널/플레이리스트 감지** → 최신 ${limit}개 영상을 1개씩 순차 분석·wiki화 합니다.\n` +
- `분석 모드: **${modeLabel}** (영상당 LLM ${callsPerVideo}회 호출)\n` +
- `각 영상은 자막추출 → LLM 분석 → wiki 저장 순으로 처리되며, 영상당 보통 30~${120 * callsPerVideo}초.\n` +
- `중간에 멈추려면 Astra 사이드바의 ⏹ Stop 을 누르세요.\n\n`);
- } else {
- chunk(view, `📊 **분석 모드**: ${modeLabel}\n\n`);
- }
-
- chunk(view, `🎬 **YouTube 추출**: ${normalizedUrl}\n(자막 + 메타데이터${limit > 1 ? `, ${limit}개 영상` : ''})\n\n⏳ Python 추출기 기동 · 자막/메타 추출 중…`);
- // 1) extract — Bridge는 `source` 필드를 기대한다(`url`이 아님).
- const t0 = Date.now();
- const heartbeat = setInterval(() => {
- chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
- }, 4000);
- // 채널은 영상 수에 비례해 yt-dlp 시간이 늘어남 — limit 비례 timeout 으로 완화.
- const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000);
- const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>(
- BRIDGE_API.youtube.extract,
- { method: 'POST', body: JSON.stringify({ source: normalizedUrl, withMetadata: true, limit }) },
- {
- timeoutMs: extractTimeoutMs,
- onHeartbeat: limit > 1
- ? (elapsedMs) => chunk(view, `\n · 추출 진행 중 (${Math.round(elapsedMs / 1000)}s, ${limit}개 영상)\n`)
- : undefined,
- },
- ).finally(() => clearInterval(heartbeat));
-
- const okVideos = (data.videos || []).filter((v: any) => v?.status === 'ok');
- chunk(view, `\n✅ **추출 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${okVideos.length}/${data.totalVideos ?? (data.videos || []).length}개 영상)\n\n`);
- if (okVideos.length === 0) {
- chunk(view, `⚠️ 자막 추출에 성공한 영상이 없습니다. 자막이 없거나 비공개 영상일 수 있습니다.\n`);
- return true;
- }
-
- const cfg = vscode.workspace.getConfiguration('g1nation');
- const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim();
- // 시스템 프롬프트는 모드별로 분리 — info 는 *큐레이터* 톤, benchmark 는 *PD* 톤.
- // 작은 모델일수록 system prompt 의 역할 정의가 출력 품질을 크게 좌우.
- const sysInfo = '당신은 영상 콘텐츠를 지식 카드로 변환하는 정보 큐레이터입니다. 자막에 명시된 사실만 인용하세요.';
- const sysBench = '당신은 유튜브 콘텐츠 시니어 PD입니다. 데이터에 근거한 제작 가이드만 제공하세요.';
-
- // 각 영상의 분석을 mode 에 따라 1회 또는 2회 LLM 호출.
- // 결과는 (라벨, 보고서 본문) 의 배열로 모아 chat 출력 + wiki 저장에 같은 데이터 사용.
- type Section = { label: string; body: string };
- async function runOneAnalysis(video: any, prompt: string, system: string, sectionLabel: string, progressTag: string): Promise {
- chunk(view, `🧪 **${sectionLabel}**${progressTag} (모델 \`${model}\`)…`);
- try {
- const t = Date.now();
- const body = await callLmSynthesis(prompt, system);
- if (!body) throw new Error('LLM 응답이 비어 있습니다.');
- chunk(view, ` ✓ (${Math.round((Date.now() - t) / 1000)}s)\n\n`);
- chunk(view, body + '\n\n');
- return { label: sectionLabel, body };
- } catch (e: any) {
- chunk(view, `\n\n⚠️ ${sectionLabel} 실패${progressTag}: ${e?.message || String(e)}\n`);
- return null;
- }
- }
-
- // 2) 영상마다 LLM 분석 → wiki 저장. **queue 처럼 1개씩 순차** —
- // 채널 N개면 i/N 진행 표시. 하나가 실패해도 다음으로 계속 (continue 로
- // skip), 다 끝나면 마지막에 통계 요약을 한 줄로 흘림.
- const total = okVideos.length;
- let analyzedOk = 0;
- let analyzedFail = 0;
- let savedOk = 0;
- let savedFail = 0;
- const batchT0 = Date.now();
- for (let i = 0; i < okVideos.length; i++) {
- const video = okVideos[i];
- const vTitle = video?.metadata?.title || video?.title || video?.video_id || '(제목 없음)';
- const progressTag = total > 1 ? ` [${i + 1}/${total}]` : '';
-
- if (total > 1) {
- chunk(view, `\n━━━ **${progressTag.trim()} ${vTitle}** ━━━\n\n`);
- }
-
- // 보고서 앞에 영상 전체 스크립트를 먼저 출력 — 분석과 원문 대본을 함께 보도록.
- const script = fullScriptFromSegments(video?.segments);
- chunk(view, `## 📜 전체 스크립트 (Full Script)\n\n${script}\n\n---\n\n`);
-
- // mode 분기 — info / benchmark / both 에 맞게 0~2회 LLM 호출.
- const sections: Section[] = [];
- if (mode === 'info' || mode === 'both') {
- const sec = await runOneAnalysis(video, buildInfoExtractionPrompt(video, userContent), sysInfo, '📋 정보 추출 (지식 카드)', progressTag);
- if (sec) sections.push(sec);
- }
- if (mode === 'benchmark' || mode === 'both') {
- const sec = await runOneAnalysis(video, build4LensPrompt(video, userContent), sysBench, '🎬 벤치마킹 (4-렌즈 역기획서)', progressTag);
- if (sec) sections.push(sec);
- }
-
- if (sections.length === 0) {
- analyzedFail++;
- chunk(view, `(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
- continue;
- }
- analyzedOk++;
-
- // 3) save — benchmark와 동일하게 /api/wiki/save (datacollectSavePath > WIKI_RAW_PATH).
- // wiki 본문은 위에서 LLM 호출한 sections 를 그대로 한 파일에 이어붙여 보관.
- try {
- const today = new Date().toISOString().slice(0, 10);
- const videoUrl = video?.metadata?.webpage_url || `https://www.youtube.com/watch?v=${video?.video_id}`;
- // mode 별로 파일명 접미사 — 같은 영상의 info / benchmark / both 가 한 폴더에서 구분되도록.
- const modeSuffix = mode === 'info' ? ' (정보)'
- : mode === 'benchmark' ? ' (벤치마크)'
- : '';
- const title = `유튜브분석 ${vTitle}${modeSuffix} ${today}`;
- const sectionDivider = sections.length > 1 ? `\n\n---\n\n` : '';
- const fileMarkdown = [
- `# ${title}`,
- ``,
- `- **영상 URL**: ${videoUrl}`,
- `- **분석 시각**: ${new Date().toISOString()}`,
- `- **분석 모드**: ${mode}`,
- `- **생성**: Astra /youtube · Datacollect youtube insight`,
- ``,
- `## 📜 전체 스크립트 (Full Script)`,
- ``,
- script,
- ``,
- `---`,
- ``,
- sections.map((s) => s.body).join(sectionDivider),
- ``,
- ].join('\n');
- const savePath = (cfg.get('datacollectSavePath', '') || '').trim();
- const body: Record = { title, content: fileMarkdown };
- if (savePath) body.saveDir = savePath;
- const saved = await bridgeFetch<{ success: boolean; path?: string }>(
- BRIDGE_API.wiki.save,
- { method: 'POST', body: JSON.stringify(body) },
- { timeoutMs: 30_000 },
- );
- savedOk++;
- chunk(view, `💾 **결과물 저장 완료**${progressTag}: \`${saved?.path || '(경로 미확인)'}\`\n\n`);
- } catch (e: any) {
- savedFail++;
- chunk(view, `⚠️ 결과물 저장 실패${progressTag}: ${e?.message || String(e)}\n\n`);
- }
- }
-
- // 배치 처리(=채널/플레이리스트) 끝나면 통계 한 줄로 마무리. 단일 영상은 위에서 이미 끝.
- if (total > 1) {
- const batchSec = Math.round((Date.now() - batchT0) / 1000);
- chunk(view, `\n━━━━━━━━━━━━━━━━━━━━\n`
- + `🏁 **배치 완료** (총 ${batchSec}s · ${total}개 영상)\n`
- + `- 분석: ✅ ${analyzedOk} / ❌ ${analyzedFail}\n`
- + `- 저장: 💾 ${savedOk} / ⚠️ ${savedFail}\n`);
- }
-
- return true;
-}
-
-// ───────────────────────────── /blog ─────────────────────────────
-
-async function runBlog(keyword: string, view: Webview | undefined): Promise {
- // Blog Pipeline은 Datacollect의 별도 흐름(blog/app.js + local_platform_server 8787)으로
- // 실행된다. Bridge 3002에는 대응 endpoint가 없어 Astra가 직접 호출할 경로가 없음.
- // MVP에서는 사용자가 그쪽 UI로 빠르게 갈 수 있도록 안내만.
- const target = 'http://127.0.0.1:8787/blog/';
- chunk(view, `🖋️ **Blog Pipeline**\n\n`);
- if (keyword) {
- chunk(view, `요청 키워드: \`${keyword}\`\n\n`);
- }
- chunk(view, [
- `현재 Blog Pipeline은 Datacollect의 별도 정적 페이지(`,
- `[${target}](${target})`,
- `)에서 단계별로 실행됩니다. Bridge(3002)에 대응 API가 없어 채팅에서 직접`,
- ` 트리거할 수 없어, 다음 두 가지 중 하나를 선택해 주세요:\n\n`,
- ].join(''));
- chunk(view, `1. 위 링크에서 키워드/참고 자료/경험담을 입력해 직접 파이프라인 실행\n`);
- chunk(view, `2. Bridge에 \`/api/blog/run\` 같은 endpoint를 노출하면 \`/blog\`도 end-to-end 실행 가능 — 원하시면 추가 작업으로 진행\n`);
-
- try {
- await vscode.env.openExternal(vscode.Uri.parse(target));
- } catch { /* best-effort */ }
- return true;
-}
-
-// ───────────────────────────── /wikify ─────────────────────────────
-
-// buildWikifyPrompt → `src/features/datacollect/prompts/wikifyPrompt.ts`
-import { buildWikifyPrompt } from './prompts/wikifyPrompt';
-
-/**
- * URL 한 개를 위키화 — 본문 추출 → P-Reinforce v3.0 합성 → 저장.
- * 다중 링크 배치 시 runWikify가 이 함수를 순차 반복 호출한다.
- *
- * @returns `{ ok: true }` 시 위키 문서 생성·저장 성공. `{ ok: false, reason }` 시
- * 실패 원인 (본문 빈약 / 합성 실패 / 저장 실패) 을 문자열로 surface.
- * 옛 boolean 반환은 다중 URL 배치에서 사용자가 *어디서 깨졌는지* 모름 ─
- * 이번 변경으로 배치 끝 통계에 실패 사유까지 표시 가능.
- * (extract 자체 실패는 throw — 호출자가 잡는다.)
- */
-type WikifyResult = { ok: true } | { ok: false; reason: string };
-async function wikifyOne(url: string, userContent: string, view: Webview | undefined): Promise {
- const cfg = vscode.workspace.getConfiguration('g1nation');
-
- // 1) extract — Bridge가 Playwright로 main/article 본문 텍스트를 추출.
- chunk(view, `⏳ 본문 추출 중…`);
- const t0 = Date.now();
- const heartbeat = setInterval(() => {
- chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
- }, 4000);
- const data = await bridgeFetch<{ success: boolean; url: string; title?: string; description?: string; lang?: string; headings?: string[]; text?: string; textLength?: number; truncated?: boolean }>(
- BRIDGE_API.web.extract,
- { method: 'POST', body: JSON.stringify({ url }) },
- { timeoutMs: 3 * 60_000 },
- ).finally(() => clearInterval(heartbeat));
- chunk(view, `\n✅ 본문 추출 (${Math.round((Date.now() - t0) / 1000)}s · ${(data.textLength ?? 0).toLocaleString()}자${data.truncated ? ', 일부 잘림' : ''})\n\n`);
-
- if (!data.text || data.text.trim().length < 50) {
- const reason = `본문 빈약 (${data.textLength ?? 0}자 — JS 전용 렌더링 또는 콘텐츠 없음)`;
- chunk(view, `⚠️ 추출된 본문이 거의 없어 건너뜁니다. (${reason})\n`);
- return { ok: false, reason };
- }
-
- // 2) synthesize — P-Reinforce v3.0 위키 문서로 LLM 합성.
- const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim();
- const wikiSystem = '당신은 지식 큐레이터입니다. 제공된 웹사이트 본문을 P-Reinforce v3.0 규격의 고밀도 위키 문서로 정리합니다. 본문에 없는 내용은 절대 지어내지 않으며, 모든 문서는 한국어로 작성합니다.';
- chunk(view, `🧪 P-Reinforce 위키 합성 (모델 \`${model}\`)…`);
- let report: string;
- try {
- const synthT0 = Date.now();
- report = await callLmSynthesis(buildWikifyPrompt(data, userContent), wikiSystem);
- if (!report) throw new Error('LLM 응답이 비어 있습니다.');
- // LLM이 위키링크 [[ ]]의 닫는 대괄호를 하나 빠뜨리는 깨짐을 자동 교정.
- report = report.replace(/\[\[([^\[\]]+?)\](?!\])/g, '[[$1]]');
- chunk(view, ` ✓ (${Math.round((Date.now() - synthT0) / 1000)}s)\n\n`);
- } catch (e: any) {
- const reason = `LLM 합성 실패: ${e?.message || String(e)}`;
- chunk(view, `\n⚠️ 위키 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
- return { ok: false, reason };
- }
- chunk(view, report + '\n\n');
-
- // 3) save — /api/wiki/save (datacollectSavePath > WIKI_RAW_PATH).
- // report 자체가 frontmatter 포함 완결 위키 문서이므로 그대로 저장한다.
- try {
- const today = new Date().toISOString().slice(0, 10);
- let host = url;
- try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ }
- const title = `위키 ${(userContent.trim() || data.title || host)} ${today}`;
- const savePath = (cfg.get('datacollectSavePath', '') || '').trim();
- const body: Record = { title, content: report };
- if (savePath) body.saveDir = savePath;
- const saved = await bridgeFetch<{ success: boolean; path?: string }>(
- BRIDGE_API.wiki.save,
- { method: 'POST', body: JSON.stringify(body) },
- { timeoutMs: 30_000 },
- );
- chunk(view, `💾 위키 문서 저장 완료: \`${saved?.path || '(경로 미확인)'}\`\n`);
- } catch (e: any) {
- // 저장 실패해도 위키 문서 생성 자체는 성공했으므로 ok 로 본다. 다만 사용자
- // 에게 디스크 접근 문제 알림.
- chunk(view, `⚠️ 위키 문서 저장 실패: ${e?.message || String(e)}\n`);
- }
- return { ok: true };
-}
-
-async function runWikify(arg: string, view: Webview | undefined): Promise {
- // 토큰을 URL과 비-URL로 분류. URL처럼 생긴 토큰은 모두 처리 대상, 나머지는 공통 주제명.
- // 여러 링크를 공백으로 구분해 넣으면 1개씩 순차적으로 위키화한다.
- const isUrl = (t: string) => /^https?:\/\//i.test(t) || /^[a-z0-9-]+(\.[a-z0-9-]+)+/i.test(t);
- const tokens = arg.trim().split(/\s+/).filter(Boolean);
- const urls = tokens.filter(isUrl);
- const userContent = tokens.filter((t) => !isUrl(t)).join(' ');
- if (urls.length === 0) {
- chunk(view, `사용법: \`/wikify [url2 url3 …] [주제명]\`\n예: \`/wikify https://example.com\`\n여러 링크를 공백으로 구분해 한 번에 넣으면 1개씩 순차 위키화합니다.\n`);
- return true;
- }
-
- // 단일 링크 — 그대로 처리.
- if (urls.length === 1) {
- chunk(view, `📚 **위키화**: ${urls[0]}\n(본문 추출 → P-Reinforce v3.0 위키 문서 합성)\n\n`);
- const result = await wikifyOne(urls[0], userContent, view);
- if (!result.ok) {
- chunk(view, `\n실패 사유: ${result.reason}\n`);
- }
- return true;
- }
-
- // 다중 링크 — 1개씩 순차 처리. 한 건이 실패해도 나머지는 계속 진행.
- // 실패한 건은 *사유* 까지 모아 마지막에 표시 → 사용자가 어느 URL 이 왜 깨졌는지 즉시 파악.
- chunk(view, `📚 **위키화 배치**: 총 ${urls.length}개 링크를 순차 처리합니다.\n`);
- const batchT0 = Date.now();
- let okCount = 0;
- const failures: Array<{ url: string; reason: string }> = [];
- for (let i = 0; i < urls.length; i++) {
- chunk(view, `\n---\n\n### [${i + 1}/${urls.length}] ${urls[i]}\n\n`);
- try {
- const result = await wikifyOne(urls[i], userContent, view);
- if (result.ok) okCount++;
- else failures.push({ url: urls[i], reason: result.reason });
- } catch (e: any) {
- const reason = `처리 오류: ${e?.message || String(e)}`;
- chunk(view, `❌ [${i + 1}/${urls.length}] ${reason}\n`);
- failures.push({ url: urls[i], reason });
- }
- }
- chunk(view, `\n---\n\n🏁 **배치 완료**: ${okCount}/${urls.length}개 성공 (${Math.round((Date.now() - batchT0) / 1000)}s 소요)\n`);
- if (failures.length > 0) {
- chunk(view, `\n**실패 ${failures.length}건 사유**:\n`);
- for (const f of failures) chunk(view, `- ${f.url} — ${f.reason}\n`);
- }
- return true;
-}
-
-// ───────────────────────────── /meet ─────────────────────────────
-
-// buildMeetPrompt → `src/features/datacollect/prompts/meetPrompt.ts`
-import { buildMeetPrompt } from './prompts/meetPrompt';
-
-async function runMeet(arg: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise {
- // 경로 파싱 — 따옴표로 감싸면 공백 포함 경로 허용, 아니면 첫 공백 전까지가 경로.
- const trimmed = arg.trim();
- let filePath = '';
- let metadata = '';
- if (trimmed.startsWith('"')) {
- const end = trimmed.indexOf('"', 1);
- if (end > 0) {
- filePath = trimmed.slice(1, end);
- metadata = trimmed.slice(end + 1).trim();
- }
- }
- if (!filePath) {
- const sp = trimmed.indexOf(' ');
- if (sp === -1) {
- filePath = trimmed;
- } else {
- filePath = trimmed.slice(0, sp);
- metadata = trimmed.slice(sp + 1).trim();
- }
- }
- if (!filePath) {
- chunk(view, `사용법: \`/meet [참석자·날짜 등 메타데이터]\`\n예: \`/meet c:\\doc\\0101.txt\`\n경로에 공백이 있으면 따옴표로 감싸세요: \`/meet "c:\\my docs\\0101.txt"\`\n`);
- return true;
- }
-
- chunk(view, `📝 **회의록 작성**: ${filePath}\n\n⏳ 녹취 파일 읽는 중…`);
-
- // 1) 로컬 txt 파일 읽기 — ASTRA가 직접 (로컬 파일이라 Bridge 불필요).
- let transcript: string;
- try {
- transcript = await fsp.readFile(filePath, 'utf-8');
- } catch (e: any) {
- chunk(view, `\n\n❌ 파일을 읽을 수 없습니다: ${e?.message || String(e)}\n경로가 정확한지 확인하세요.\n`);
- return true;
- }
- if (!transcript || transcript.trim().length < 20) {
- chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`);
- return true;
- }
- // LLM 입력 폭주 방지 — 60000자 상한.
- const MAX = 60000;
- const truncated = transcript.length > MAX;
- if (truncated) transcript = transcript.slice(0, MAX);
- chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}자${truncated ? ', 상한 초과로 일부 잘림' : ''})\n\n`);
-
- // 2) LLM 회의록 합성.
- const cfg = vscode.workspace.getConfiguration('g1nation');
- const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim();
- const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
- chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
- let report: string;
- try {
- const t0 = Date.now();
- report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem);
- if (!report) throw new Error('LLM 응답이 비어 있습니다.');
- chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`);
- } catch (e: any) {
- chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
- return true;
- }
- chunk(view, report + '\n\n');
-
- // 3) 저장 — /api/wiki/save (datacollectSavePath > WIKI_RAW_PATH).
- // WIKI_RAW_PATH가 E:\Wiki\2nd\00_Raw 를 가리키므로 결과물이 그곳에 .md로 저장된다.
- try {
- const today = new Date().toISOString().slice(0, 10);
- const baseName = filePath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '') || 'meeting';
- const title = `회의록 ${baseName} ${today}`;
- const savePath = (cfg.get('datacollectSavePath', '') || '').trim();
- const body: Record = { title, content: report };
- if (savePath) body.saveDir = savePath;
- const saved = await bridgeFetch<{ success: boolean; path?: string }>(
- BRIDGE_API.wiki.save,
- { method: 'POST', body: JSON.stringify(body) },
- { timeoutMs: 30_000 },
- );
- chunk(view, `💾 **회의록 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`);
- } catch (e: any) {
- chunk(view, `⚠️ 회의록 저장 실패: ${e?.message || String(e)}\n`);
- }
-
- // 4) 캘린더 자동 등록 — 액션 아이템을 task별 종일 일정으로 Google Calendar에 등록.
- if (context) {
- try {
- const calCfg = readCalendarConfig(context);
- if (!calCfg.refreshToken) {
- chunk(view, `\nℹ️ 캘린더 자동 등록을 건너뜁니다 — Google Calendar OAuth(쓰기)가 연결되지 않았습니다. (Astra Settings → Google 섹션에서 연결)\n`);
- } else {
- const tasks = parseActionItems(report);
- if (tasks.length === 0) {
- chunk(view, `\nℹ️ 액션 아이템이 없어 캘린더에 등록할 항목이 없습니다.\n`);
- } else {
- const today = new Date();
- const meetingDate = extractMeetingDate(report, today);
- const titleMatch = report.match(/^#\s+(.+)$/m);
- const meetTitle = titleMatch
- ? titleMatch[1].replace(/^\[회의 제목\]\s*/, '').trim()
- : '회의';
- // 액션 아이템 destination — Tasks·Calendar 독립 토글. 기본 둘 다 true.
- // 양쪽 다 끄면 자동 등록 자체를 건너뛴다.
- const gCfg = vscode.workspace.getConfiguration('g1nation');
- const meetUsesTasks = gCfg.get('meetUsesTasks', true);
- const meetUsesCalendar = gCfg.get('meetUsesCalendar', true);
- if (!meetUsesTasks && !meetUsesCalendar) {
- chunk(view, `\nℹ️ Google Tasks·Calendar 등록이 모두 꺼져 있어 액션 아이템 자동 등록을 건너뜁니다. (Settings 의 \`g1nation.meetUsesTasks\` / \`g1nation.meetUsesCalendar\` 확인)\n`);
- } else {
- const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + ');
- chunk(view, `\n📝 **Google ${destLabel} 등록**: 액션 아이템 ${tasks.length}건…\n`);
- let tasksOk = 0;
- let calendarOk = 0;
- let tentativeCount = 0;
- for (const task of tasks) {
- const { date, tentative } = resolveTaskDate(task.due, meetingDate, today);
- if (tentative) tentativeCount++;
- const evTitle = tentative ? `${task.work} (미확정)` : task.work;
- const notes = `회의록: ${meetTitle}\n담당: ${task.owner}\n기한 표기: ${task.due || '(없음)'}\nAstra /meet 자동 등록`;
-
- const successes: string[] = [];
- const failures: string[] = [];
- if (meetUsesTasks) {
- const r = await createTask(context, { title: evTitle, due: date, notes });
- if (r.ok) { tasksOk++; successes.push('Tasks'); }
- else { failures.push(`Tasks: ${r.error}`); }
- }
- if (meetUsesCalendar) {
- const r = await createCalendarEvent(context, {
- title: evTitle,
- start: date,
- allDay: true,
- description: notes,
- });
- if (r.ok) { calendarOk++; successes.push('Calendar'); }
- else { failures.push(`Calendar: ${r.error}`); }
- }
-
- if (failures.length === 0) {
- chunk(view, ` · ${date} — ${evTitle} (${successes.join(' + ')})\n`);
- } else {
- chunk(view, ` · ${date} — ${evTitle}${successes.length ? ` (✅ ${successes.join(' + ')})` : ''}\n`);
- for (const f of failures) chunk(view, ` ⚠️ ${f}\n`);
- }
- }
- const summary: string[] = [];
- if (meetUsesTasks) summary.push(`Tasks ${tasksOk}/${tasks.length}`);
- if (meetUsesCalendar) summary.push(`Calendar ${calendarOk}/${tasks.length}`);
- chunk(view, `✅ 등록 완료 — ${summary.join(' · ')}${tentativeCount > 0 ? ` · 미확정 ${tentativeCount}건` : ''}\n`);
- }
- }
- }
- } catch (e: any) {
- chunk(view, `\n⚠️ 캘린더 등록 중 오류: ${e?.message || String(e)}\n`);
- }
- }
- return true;
-}
-
-// addBusinessDays / toYmd / extractMeetingDate / resolveTaskDate / parseActionItems
-// → `src/features/datacollect/scheduling/calendarHelpers.ts`
-import {
- addBusinessDays,
- toYmd,
- extractMeetingDate,
- resolveTaskDate,
- parseActionItems,
-} from './scheduling/calendarHelpers';
// ─── /task — 단발 작업을 Google Tasks + Calendar 양쪽에 등록 ─────────────
// `/task <제목> <시작일> <완료일>` (range) 또는 `/task <제목> <날짜>` (단일일)
@@ -1190,2950 +179,8 @@ import {
// /meet 와 달리 사용자가 직접 호출한 명령이므로 *항상* 양쪽 등록(설정 무시).
/** 유연한 한국 날짜 파서. YY/MM/DD · YYYY/MM/DD · YYYY-MM-DD 지원. 잘못된 입력은 null. */
-function parseFlexibleDate(s: string): string | null {
- if (!s) return null;
- let y: number, mo: number, d: number;
- let m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/);
- if (m) { y = 2000 + Number(m[1]); mo = Number(m[2]); d = Number(m[3]); }
- else if ((m = s.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); }
- else if ((m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); }
- else return null;
- if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
- // 진짜 유효한 날짜인지 (예: 2/30 차단) Date 로 round-trip 검증.
- const date = new Date(y, mo - 1, d);
- if (Number.isNaN(date.getTime()) || date.getMonth() !== mo - 1 || date.getDate() !== d) return null;
- return `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
-}
-async function runTask(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
- if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /task 실행 불가.\n'); return true; }
- if (!arg.trim()) {
- chunk(view, [
- '\n📋 **/task — Google Tasks + Calendar 동시 등록**',
- '',
- '사용법:',
- ' `/task [@담당자] <제목> <시작일> <완료일>` — 기간 작업',
- ' `/task [@담당자] <제목> <날짜>` — 하루짜리 작업',
- '',
- '날짜 형식: `YY/MM/DD` (예: `26/05/27`) · `YYYY-MM-DD` · `YYYY/MM/DD`',
- '담당자: `@` 접두사로 첫 토큰에 (예: `@기획자`). 생략 가능 — 있으면 제목 앞 `[담당자]` 로 prefix 됨.',
- '',
- '예시:',
- ' `/task @기획자 Apple 계정 생성 요청 26/05/27 26/06/28`',
- ' `/task @디자이너 메인 화면 시안 2026-07-01 2026-07-15`',
- ' `/task 약값 결제 26/06/01` (담당자 없음)',
- '',
- 'Tasks API + Calendar API 양쪽 등록. Tasks 는 마감일만(시작일은 노트), Calendar 는 기간 all-day 일정.',
- '',
- ].join('\n'));
- return true;
- }
- const tokens = arg.trim().split(/\s+/);
- if (tokens.length < 2) { chunk(view, '\n❌ 인자 부족 — 최소 제목 + 날짜 1개 필요.\n'); return true; }
-
- // 선택적 @담당자 토큰 — 첫 토큰이 @ 로 시작하면 owner 로 떼어내고 나머지로 진행.
- // 멀티-멤버 팀 운영에 owner 가시화 (Tasks/Calendar 제목 prefix 로 자동 표시).
- let owner: string | undefined;
- if (tokens[0]?.startsWith('@') && tokens[0].length > 1) {
- owner = tokens[0].slice(1);
- tokens.shift();
- if (tokens.length < 1) { chunk(view, '\n❌ 제목·날짜 누락 (담당자만 입력됨).\n'); return true; }
- }
-
- // 끝에서부터 날짜 매칭. 마지막 2개가 모두 날짜면 range, 마지막 1개만 날짜면 단일일.
- const lastDate = parseFlexibleDate(tokens[tokens.length - 1]);
- const secondLastDate = tokens.length >= 3 ? parseFlexibleDate(tokens[tokens.length - 2]) : null;
- let startYmd: string, endYmd: string, titleTokens: string[];
- if (lastDate && secondLastDate) {
- startYmd = secondLastDate;
- endYmd = lastDate;
- titleTokens = tokens.slice(0, -2);
- } else if (lastDate) {
- startYmd = lastDate;
- endYmd = lastDate;
- titleTokens = tokens.slice(0, -1);
- } else {
- chunk(view, `\n❌ 마지막 토큰이 날짜 형식이 아닙니다 ("${tokens[tokens.length - 1]}"). 사용 가능 형식: YY/MM/DD · YYYY-MM-DD · YYYY/MM/DD.\n사용법: \`/task\` (인자 없이) 실행해 도움말 보기.\n`);
- return true;
- }
- const baseTitle = titleTokens.join(' ').trim();
- if (!baseTitle) { chunk(view, '\n❌ 제목 누락.\n'); return true; }
- if (startYmd > endYmd) { chunk(view, `\n❌ 시작일(${startYmd})이 완료일(${endYmd})보다 늦습니다.\n`); return true; }
-
- // owner 가 있으면 제목 앞에 `[담당자]` prefix — Tasks·Calendar UI 양쪽에서 한눈에 보임.
- const title = owner ? `[${owner}] ${baseTitle}` : baseTitle;
-
- const isRange = startYmd !== endYmd;
- const periodLabel = isRange ? `${startYmd} ~ ${endYmd}` : startYmd;
- chunk(view, `\n📝 **Google Tasks + Calendar 등록**: ${title}\n · 기간: ${periodLabel}${owner ? ` · 담당: @${owner}` : ''}\n`);
-
- const notes = `${owner ? `담당: @${owner}\n` : ''}Astra /task 직접 등록\n기간: ${periodLabel}`;
- const successes: string[] = [];
- const failures: string[] = [];
- let calLink: string | undefined;
-
- // Tasks — due = 완료일. Tasks 모델은 start 없음 → 시작일은 노트에 포함.
- const taskNotes = isRange ? `${notes}\n(Tasks 는 마감일만 사용 — 시작일은 노트 참조)` : notes;
- const taskResult = await createTask(context, { title, due: endYmd, notes: taskNotes });
- if (taskResult.ok) successes.push('Tasks');
- else failures.push(`Tasks: ${taskResult.error}`);
-
- // Calendar — all-day 일정. Google Calendar 의 all-day end 는 *exclusive* 라
- // 완료일을 포함시키려면 +1 일 해서 전달해야 함.
- const calEnd = _addDaysDate(endYmd, 1);
- const calResult = await createCalendarEvent(context, {
- title, start: startYmd, end: calEnd, allDay: true, description: notes,
- });
- if (calResult.ok) {
- successes.push('Calendar');
- calLink = calResult.event.htmlLink;
- } else {
- failures.push(`Calendar: ${calResult.error}`);
- }
-
- if (failures.length === 0) {
- chunk(view, `✅ 등록 완료 — ${successes.join(' + ')}\n`);
- } else if (successes.length > 0) {
- chunk(view, `✅ 부분 성공 — ${successes.join(' + ')}\n`);
- for (const f of failures) chunk(view, ` ⚠️ ${f}\n`);
- } else {
- chunk(view, `❌ 모두 실패\n`);
- for (const f of failures) chunk(view, ` · ${f}\n`);
- }
- if (calLink) chunk(view, `🔗 Calendar 일정 열기: ${calLink}\n`);
- return true;
-}
-
-// ─── /decisions — Chronicle ADR (결정 기록) 검색 ─────────────────────────
-// "어제 우리가 어떻게 정했지?" 의 대답을 즉시 제공. ProjectChronicleManager 가
-// 이미 ADR 을 `/decisions/ADR-NNNN-.md` 형태로 쌓고 있으므로
-// 검색만 얹으면 됨. 키워드 검색 + `@담당자` 필터 (markdown 내용에서 grep).
-// 4인 팀 단독 운영자 단계의 "결정 망각" 통증을 가장 작은 코드로 해결.
-
-async function runDecisions(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
- if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /decisions 실행 불가.\n'); return true; }
-
- // 인자 파싱 — `@담당자` 토큰은 owner 필터, 나머지는 키워드.
- const tokens = arg.trim().split(/\s+/).filter(Boolean);
- let ownerFilter: string | undefined;
- const keywordParts: string[] = [];
- for (const t of tokens) {
- if (t.startsWith('@') && t.length > 1) ownerFilter = t.slice(1);
- else keywordParts.push(t);
- }
- const keyword = keywordParts.join(' ').toLowerCase().trim();
-
- if (!keyword && !ownerFilter) {
- chunk(view, [
- '\n📋 **/decisions — Chronicle 결정 기록 검색**',
- '',
- '사용법:',
- ' `/decisions <키워드>` — 키워드로 ADR 검색',
- ' `/decisions @<담당자>` — 담당자 언급 결정만',
- ' `/decisions <키워드> @<담당자>` — 둘 다 만족',
- '',
- '예시:',
- ' `/decisions 환불 정책`',
- ' `/decisions @기획자`',
- ' `/decisions 결제 흐름 @개발`',
- '',
- 'Chronicle ADR 파일 (`/decisions/ADR-NNNN-*.md`) 을 스캔합니다. 최신순 정렬, 최대 20건.',
- '',
- ].join('\n'));
- return true;
- }
-
- // 모든 chronicle 프로젝트의 decisions 디렉터리를 스캔. (single-operator 단계에선
- // 보통 1개 프로젝트지만, 다중 프로젝트도 한 번에 검색.)
- const store = new ChronicleProjectStore(context);
- const profiles = store.getAll();
- if (profiles.length === 0) {
- chunk(view, '\n❌ Chronicle 프로젝트가 없습니다. workspace 폴더를 열고 사이드바에서 chronicle 활성화하세요.\n');
- return true;
- }
-
- interface Hit { project: string; file: string; filePath: string; mtime: number; title: string; snippet: string; }
- const hits: Hit[] = [];
-
- for (const profile of profiles) {
- const decisionsDir = path.join(profile.recordRoot, 'decisions');
- if (!fs.existsSync(decisionsDir)) continue;
- let fileNames: string[] = [];
- try { fileNames = fs.readdirSync(decisionsDir); } catch { continue; }
- for (const fileName of fileNames) {
- if (!fileName.endsWith('.md') || !fileName.startsWith('ADR-')) continue;
- const filePath = path.join(decisionsDir, fileName);
- let content = '';
- try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
- const lower = content.toLowerCase();
-
- // 필터: 키워드는 내용 어디에든, owner 는 원문(대소문자 살림) 부분문자열.
- if (keyword && !lower.includes(keyword)) continue;
- if (ownerFilter && !content.includes(ownerFilter)) continue;
-
- // 제목 = 첫 번째 # 헤딩.
- const titleMatch = content.match(/^#\s+(.+)$/m);
- const title = titleMatch ? titleMatch[1].trim() : fileName.replace(/\.md$/, '');
-
- // 스니펫 — 키워드 주변 컨텍스트 (없으면 첫 본문 단락).
- let snippet = '';
- if (keyword) {
- const idx = lower.indexOf(keyword);
- if (idx >= 0) {
- const start = Math.max(0, idx - 60);
- const end = Math.min(content.length, idx + 180);
- snippet = content.slice(start, end).replace(/\s+/g, ' ').trim();
- }
- } else {
- const paragraphs = content.split(/\n\s*\n/).map(p => p.trim()).filter(p => p && !p.startsWith('#') && !p.startsWith('>'));
- snippet = (paragraphs[0] || '').slice(0, 220).replace(/\s+/g, ' ').trim();
- }
-
- let mtime = 0;
- try { mtime = fs.statSync(filePath).mtimeMs; } catch { /* keep 0 */ }
- hits.push({ project: profile.projectName, file: fileName, filePath, mtime, title, snippet });
- }
- }
-
- if (hits.length === 0) {
- const filterDesc = [keyword && `키워드 "${keyword}"`, ownerFilter && `@${ownerFilter}`].filter(Boolean).join(' + ');
- chunk(view, `\nℹ️ ${filterDesc} 에 매치되는 결정 기록 없음. (검색 대상: ${profiles.length}개 프로젝트)\n`);
- return true;
- }
-
- hits.sort((a, b) => b.mtime - a.mtime);
- const filterDesc = [keyword && `키워드: ${keyword}`, ownerFilter && `담당: @${ownerFilter}`].filter(Boolean).join(' · ');
- chunk(view, `\n📋 **결정 검색 결과 ${hits.length}건** (${filterDesc})\n\n`);
- const MAX_SHOW = 20;
- for (const h of hits.slice(0, MAX_SHOW)) {
- const date = h.mtime ? new Date(h.mtime).toISOString().slice(0, 10) : '날짜 미상';
- chunk(view, `### ${h.title}\n`);
- chunk(view, `- 📅 ${date} · 📁 ${h.project} · \`${h.file}\`\n`);
- if (h.snippet) chunk(view, `- 💬 …${h.snippet}…\n`);
- chunk(view, `- 🔗 \`${h.filePath}\`\n\n`);
- }
- if (hits.length > MAX_SHOW) chunk(view, `_…+${hits.length - MAX_SHOW}건 더 (필터를 좁히면 줄어듭니다)_\n`);
- return true;
-}
-
-// ─── /onesie [멤버] — 1:1 미팅 준비 카드 ───────────────────────────────
-// 단독 운영자(대표)가 멤버별 주간 1:1 을 5분에 끝낼 수 있게 자동 합성:
-// 1. Google Tasks API 에서 모든 task 가져와 멤버 필터 (제목 prefix `[멤버]`,
-// notes 의 `@멤버` 또는 `담당: 멤버` 패턴)
-// 2. 카테고리: 최근 30일 완료 / 지연 / 다가오는 / 마감일 없음
-// 3. Chronicle ADR 에서 해당 멤버 언급된 결정 최신순
-// 4. 자동 대화 토픽 제안 (지연 많으면 블로커, 완료 0이면 시간 사용 등)
-
-async function runOnesie(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
- if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /onesie 실행 불가.\n'); return true; }
- const memberRaw = arg.trim().split(/\s+/)[0] || '';
- const member = memberRaw.replace(/^@/, '').trim();
- if (!member) {
- chunk(view, [
- '\n📋 **/onesie [멤버] — 1:1 미팅 준비 카드**',
- '',
- '사용법: `/onesie <담당자>` 또는 `/onesie @<담당자>`',
- '예: `/onesie 기획자` · `/onesie @디자이너` · `/onesie 개발`',
- '',
- '대상자의 Tasks 진행 상황(완료/지연/다가오는)과 최근 Chronicle 결정 기록을 모아 1:1 준비 카드 생성. 자동 대화 토픽 제안 포함.',
- '',
- '※ `/task @<멤버> ...` 로 task 를 등록해 두면 자동으로 잡힙니다. `/meet` 액션 아이템도 owner 가 있으면 잡힘.',
- '',
- ].join('\n'));
- return true;
- }
-
- chunk(view, `\n📋 **1:1 준비 카드 — @${member}**\n`);
-
- // 1. Google Tasks 전체 조회 후 멤버 필터.
- chunk(view, '\n📥 Tasks 가져오는 중...\n');
- const taskResult = await listTasks(context, { showCompleted: true, maxResults: 200 });
- const allTasks = taskResult.ok ? taskResult.tasks : [];
- if (!taskResult.ok) chunk(view, `\n⚠️ Tasks 조회 실패: ${taskResult.error}\n (Chronicle 검색은 계속 진행)\n`);
-
- const memberPrefix = `[${member}]`;
- const memberTasks = allTasks.filter((t) =>
- t.title.includes(memberPrefix)
- || (t.notes || '').includes(`@${member}`)
- || (t.notes || '').includes(`담당: ${member}`),
- );
-
- // 2. 카테고리화.
- const today = new Date().toISOString().slice(0, 10);
- const thirtyDaysAgoIso = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
- const completedRecent = memberTasks
- .filter((t) => t.status === 'completed' && (t.completed || '') >= thirtyDaysAgoIso)
- .sort((a, b) => (b.completed || '').localeCompare(a.completed || ''));
- const overdue = memberTasks
- .filter((t) => t.status === 'needsAction' && t.due && t.due < today)
- .sort((a, b) => (a.due || '').localeCompare(b.due || ''));
- const upcoming = memberTasks
- .filter((t) => t.status === 'needsAction' && t.due && t.due >= today)
- .sort((a, b) => (a.due || '').localeCompare(b.due || ''));
- const noDate = memberTasks.filter((t) => t.status === 'needsAction' && !t.due);
-
- // 3. Chronicle ADR 에서 멤버 언급된 결정 (최신 5건).
- const store = new ChronicleProjectStore(context);
- const profiles = store.getAll();
- interface AdrHit { date: string; mtime: number; title: string; file: string; project: string; }
- const adrHits: AdrHit[] = [];
- for (const profile of profiles) {
- const decisionsDir = path.join(profile.recordRoot, 'decisions');
- if (!fs.existsSync(decisionsDir)) continue;
- let names: string[] = [];
- try { names = fs.readdirSync(decisionsDir); } catch { continue; }
- for (const fn of names) {
- if (!fn.endsWith('.md') || !fn.startsWith('ADR-')) continue;
- const fp = path.join(decisionsDir, fn);
- let content = '';
- try { content = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
- if (!content.includes(member)) continue;
- const titleMatch = content.match(/^#\s+(.+)$/m);
- const title = titleMatch ? titleMatch[1].trim() : fn.replace(/\.md$/, '');
- let mtime = 0;
- try { mtime = fs.statSync(fp).mtimeMs; } catch { /* keep 0 */ }
- const date = mtime ? new Date(mtime).toISOString().slice(0, 10) : '';
- adrHits.push({ date, mtime, title, file: fn, project: profile.projectName });
- }
- }
- adrHits.sort((a, b) => b.mtime - a.mtime);
-
- // 4. 렌더.
- chunk(view, `\n## 최근 30일 완료 (${completedRecent.length}건)\n`);
- if (completedRecent.length === 0) chunk(view, '_없음_\n');
- else for (const t of completedRecent.slice(0, 10)) {
- const d = (t.completed || '').slice(0, 10);
- chunk(view, `- ✅ ${d} — ${t.title}\n`);
- }
- if (completedRecent.length > 10) chunk(view, `_…+${completedRecent.length - 10}건 더_\n`);
-
- chunk(view, `\n## 지연 (${overdue.length}건)\n`);
- if (overdue.length === 0) chunk(view, '_없음_\n');
- else for (const t of overdue) chunk(view, `- 🔴 ${t.due} (마감 지남) — ${t.title}\n`);
-
- chunk(view, `\n## 진행 중 / 다가오는 (${upcoming.length}건)\n`);
- if (upcoming.length === 0) chunk(view, '_없음_\n');
- else for (const t of upcoming.slice(0, 10)) chunk(view, `- 🟡 ${t.due} — ${t.title}\n`);
- if (upcoming.length > 10) chunk(view, `_…+${upcoming.length - 10}건 더_\n`);
-
- if (noDate.length > 0) {
- chunk(view, `\n## 마감일 없음 (${noDate.length}건)\n`);
- for (const t of noDate.slice(0, 5)) chunk(view, `- ⚪ ${t.title}\n`);
- if (noDate.length > 5) chunk(view, `_…+${noDate.length - 5}건 더_\n`);
- }
-
- chunk(view, `\n## 최근 결정 — @${member} 언급 (${adrHits.length}건)\n`);
- if (adrHits.length === 0) chunk(view, '_없음_\n');
- else for (const h of adrHits.slice(0, 5)) {
- chunk(view, `- 📋 ${h.date} — ${h.title} (\`${h.file}\`)\n`);
- }
-
- // 5. 자동 대화 토픽 제안 — 상태 신호로부터 유도.
- const topics: string[] = [];
- if (overdue.length > 0) topics.push(`🔴 지연 ${overdue.length}건 블로커 확인 — 무엇이 막혔나, 도와줄 일은`);
- if (upcoming.length > 5) topics.push(`🟡 다가오는 마감 ${upcoming.length}건 — 우선순위 합의·과부하 여부`);
- if (completedRecent.length > 5) topics.push(`✅ 최근 완료 ${completedRecent.length}건 많음 — 회고 / 잘 된 점 / 패턴`);
- else if (completedRecent.length === 0 && memberTasks.length > 0) topics.push('⚠️ 최근 30일 완료 0건 — 어떤 일에 시간을 쓰고 있는지 확인');
- else if (memberTasks.length === 0) topics.push('⚠️ 등록된 Task 자체가 0 — 일하는 게 안 보임. owner 태깅 시작 필요');
- if (noDate.length > 3) topics.push(`⚪ 마감일 없는 작업 ${noDate.length}건 — 우선순위 합의로 마감 부여`);
- if (adrHits.length > 0) topics.push(`📋 최근 결정 (${adrHits[0].title.slice(0, 40)}…) — 이해·실행 상황 확인`);
-
- chunk(view, `\n## 💬 1:1 대화 토픽 제안\n`);
- if (topics.length === 0) chunk(view, '_특이사항 없음 — 일반 안부 + 다음 주 우선순위 정도_\n');
- else for (const t of topics) chunk(view, `- ${t}\n`);
- return true;
-}
-
-// ─── /draft [유형] [요청] — 외부 커뮤니케이션 초안 ───────────────────────
-// 단독 운영자가 매일 가장 많이 쓰는 텍스트 카테고리들의 초안 자동 생성.
-// 팀 보이스 가이드(`g1nation.teamVoiceGuide`)가 있으면 모든 초안에 자동 반영.
-
-const DRAFT_TYPES: Record = {
- email: {
- label: '이메일',
- systemPrompt: '한국어 비즈니스 이메일 초안. 격식 있되 과도하게 딱딱하지 않게. 인사 / 본문(목적·요청·맥락) / 맺음말 구조. 100~300자.',
- },
- slack: {
- label: '슬랙/메신저 메시지',
- systemPrompt: '슬랙·메신저용 짧고 명확한 한국어 메시지. 캐주얼하지만 프로페셔널. 50~150자 내. 필요하면 불릿 사용.',
- },
- blog: {
- label: '블로그 포스트',
- systemPrompt: '블로그 포스트 초안 한국어. 후크가 있는 도입부 + 본문 3~5개 섹션 + 결론. 800~2000자. 마크다운 헤더(##) 사용.',
- },
- newsletter: {
- label: '뉴스레터',
- systemPrompt: '뉴스레터용 한국어. 친근하면서 정보성. 헤드라인 + 본문 + 다음 액션 권유. 300~600자.',
- },
- 'investor-update': {
- label: '투자자 월간 업데이트',
- systemPrompt: '투자자/이해관계자용 월간 업데이트 한국어. 구조: ① 핵심 지표 ② 이번 달 성과 ③ 과제·이슈 ④ 다음 우선순위 ⑤ ask (필요한 도움). 격식, 정량 지표 우선.',
- },
- proposal: {
- label: '비즈니스 제안서',
- systemPrompt: '비즈니스 제안서 초안 한국어. 구조: 배경 / 제안 내용 / 기대 효과 / 일정 / (가능하면) 비용. 격식, 명확.',
- },
-};
-
-async function runDraft(arg: string, view: any, _context?: vscode.ExtensionContext): Promise {
- const tokens = arg.trim().split(/\s+/);
- if (!arg.trim() || tokens.length < 2) {
- const typeList = Object.entries(DRAFT_TYPES).map(([k, v]) => ` \`${k}\` — ${v.label}`).join('\n');
- chunk(view, [
- '\n📋 **/draft [유형] [요청] — 외부 커뮤니케이션 초안**',
- '',
- '사용법: `/draft <유형> <요청 내용>`',
- '',
- '유형 목록:',
- typeList,
- '',
- '예시:',
- ' `/draft email 김 대표님께 미팅 일정 조율 요청, 다음 주 가능 시간 3개 제안`',
- ' `/draft slack 디자이너에게 메인 시안 1차 컨펌 요청, 금요일까지 회신 부탁`',
- ' `/draft blog v2.2 릴리즈 노트 — Tasks 통합 및 4인 팀 운영 기능 소개`',
- ' `/draft investor-update 5월 월간 — MAU 30% 성장, 결제 흐름 개선 완료, 다음 달 신규 출시`',
- '',
- '※ Settings 의 `g1nation.teamVoiceGuide` 에 팀 보이스 가이드(말투/금기어/자주 쓰는 표현)를 저장하면 모든 초안에 자동 반영.',
- '',
- ].join('\n'));
- return true;
- }
-
- const typeKey = tokens[0].toLowerCase();
- const typeDef = DRAFT_TYPES[typeKey];
- if (!typeDef) {
- chunk(view, `\n❌ 알 수 없는 유형: \`${typeKey}\`. 사용 가능: ${Object.keys(DRAFT_TYPES).join(' · ')}\n`);
- return true;
- }
- const request = tokens.slice(1).join(' ').trim();
- if (!request) { chunk(view, '\n❌ 요청 내용 없음.\n'); return true; }
-
- const voiceGuide = (vscode.workspace.getConfiguration('g1nation').get('teamVoiceGuide', '') || '').trim();
-
- chunk(view, `\n📝 **${typeDef.label} 초안 작성 중**\n · 요청: ${request}\n · ${voiceGuide ? '팀 보이스 가이드 적용 (' + voiceGuide.length + '자)' : '팀 보이스 가이드 없음 (g1nation.teamVoiceGuide 설정 시 자동 반영)'}\n`);
-
- const systemPrompt = [
- typeDef.systemPrompt,
- '',
- voiceGuide ? `[팀 보이스 가이드 — 반드시 준수]\n${voiceGuide}` : '',
- '',
- '출력 형식: 초안 본문만. "네, 알겠습니다" 같은 인사·메타 설명 금지. 사용자가 그대로 복사해 보낼 수 있는 형태.',
- ].filter(Boolean).join('\n');
-
- try {
- const draft = await callLmSynthesis(request, systemPrompt);
- if (!draft || !draft.trim()) {
- chunk(view, '\n❌ 초안 생성 실패 (LLM 빈 응답).\n');
- return true;
- }
- chunk(view, `\n---\n${draft.trim()}\n---\n`);
- } catch (e: any) {
- chunk(view, `\n❌ 초안 생성 실패: ${e?.message ?? String(e)}\n`);
- }
- return true;
-}
-
-// ─── /feedback — 고객 피드백 누적 + 패턴 분석 ───────────────────────────
-// 슬랙·이메일·CS 채널에 흩어진 고객 피드백을 한 곳에 모으고 LLM 으로 카테고리
-// 자동 분류 + 일정 건수 누적되면 `/feedback summary` 로 패턴 리포트 생성.
-// 로컬 .jsonl 저장이라 민감 정보도 외부로 안 보냄 — ASTRA local-first 의 강점.
-
-import { appendFeedback, readFeedback, getFeedbackFilePath, countFeedback, type FeedbackEntry } from '../feedback/feedbackStore';
-import { appendRunway, readRunway, getRunwayFilePath, computeRunwayStatus, type RunwayEntry, type RunwayEntryType } from '../runway/runwayStore';
-import { appendEvent as appendCustomerEvent, readEvents as readCustomerEvents, getCustomersFilePath, customerIdFromName, computeCustomerStates, type CustomerEvent, type CustomerEventType, type CustomerState } from '../customers/customersStore';
-import { appendHireEvent, readHireEvents, getHireFilePath, candidateIdFromName, computeCandidateStates, KNOWN_STAGES, type HireEvent, type HireEventType, type CandidateState } from '../hire/hireStore';
-
-const FEEDBACK_CATEGORIZE_PROMPT = [
- '당신은 고객 피드백 분류기.',
- '',
- '[입력] 사용자가 제공하는 고객 피드백 텍스트 한 건.',
- '',
- '[출력 형식 — 정확히 이 JSON 한 줄, 다른 텍스트/설명 절대 금지]',
- '{"categories":["..."],"sentiment":"positive|neutral|negative"}',
- '',
- '[규칙]',
- '- categories: 1~3개. 짧은 한국어 단어. 일관된 분류 (예: UX, 결제, 성능, 안정성, 가격, 신뢰, 기능 요청, 버그, 사용성, 디자인, 고객지원). 명확하지 않으면 "기타".',
- '- sentiment: 긍정 호평 = positive, 단순 질문/중립 = neutral, 불만/버그/요청 = negative.',
- '- JSON 외 어떤 문자도 출력하지 마시오. 마크다운 코드블록도 금지.',
-].join('\n');
-
-const FEEDBACK_SUMMARY_PROMPT = [
- '당신은 고객 피드백 분석가. 사용자가 제공한 누적 피드백 데이터(JSON Lines)를 보고',
- '*패턴 분석 리포트* 를 한국어 마크다운으로 작성한다. 외부 정보 추측 금지 — 주어진 데이터에서만.',
- '',
- '[출력 형식 — 정확히 이 구조]',
- '',
- '## 카테고리 분포',
- '- 카테고리명 (N건, X%): 핵심 패턴 한 줄',
- '- ...',
- '',
- '## 감정 분포',
- '- 부정: N건 (X%)',
- '- 중립: N건 (X%)',
- '- 긍정: N건 (X%)',
- '',
- '## 반복 패턴 Top 3',
- '구체적 인용 1-2개씩 포함. "여러 명이 X 에 대해 Y 하다고 언급" 형태.',
- '1. ...',
- '2. ...',
- '3. ...',
- '',
- '## 추천 액션 (대표 의사결정 참고용)',
- '데이터에서 *명확하게* 보이는 신호만. 단정적 단어("반드시" 등) 금지, "검토 권장" 톤.',
- '- ...',
-].join('\n');
-
-async function feedbackSave(text: string, view: any): Promise {
- if (!text.trim()) { chunk(view, '\n❌ 피드백 텍스트가 비어 있습니다.\n'); return; }
-
- // 1. 즉시 저장 (LLM 분류 실패해도 원본은 보존).
- const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
- const initialEntry: FeedbackEntry = {
- id,
- timestamp: new Date().toISOString(),
- text: text.trim(),
- };
- const saveResult = appendFeedback(initialEntry);
- if (!saveResult.ok) { chunk(view, `\n❌ 저장 실패: ${saveResult.error}\n`); return; }
- chunk(view, `\n📥 **피드백 저장됨** (id: \`${id.slice(0, 13)}\`)\n · 누적 ${countFeedback()}건\n`);
-
- // 2. LLM 자동 분류 (실패해도 entry 는 이미 저장됨).
- chunk(view, '\n🤖 카테고리 자동 분류 중...\n');
- try {
- const llmOut = await callLmSynthesis(text.trim(), FEEDBACK_CATEGORIZE_PROMPT);
- // LLM 응답에서 JSON 추출 — 코드블록·잡 텍스트 둘러쌌어도 첫 { 부터 마지막 } 까지.
- const jsonMatch = llmOut.match(/\{[\s\S]*\}/);
- if (!jsonMatch) { chunk(view, ' ⚠️ 분류 실패 (LLM 응답에 JSON 없음). 원본은 저장됨, 수동으로 분류 추가 가능.\n'); return; }
- const parsed = JSON.parse(jsonMatch[0]);
- const categories: string[] = Array.isArray(parsed.categories) ? parsed.categories.map(String).slice(0, 3) : [];
- const sentiment = ['positive', 'neutral', 'negative'].includes(parsed.sentiment) ? parsed.sentiment : undefined;
-
- // 분류 결과를 추가 entry 로 append (기존 entry 는 그대로). 단순화 — 마지막 entry 가 가장 풍부.
- const enriched: FeedbackEntry = { ...initialEntry, categories, sentiment };
- // 마지막 줄을 교체 — append-only 정책 깨지 않게, 그냥 enriched 를 한 번 더 append 하고 원본 entry 는 두는 방식도 있음.
- // 깔끔하게: 별도 ID 로 새 entry append (categories 만 있는 경량 enrichment).
- // 더 깔끔하게는 파일 rewrite — 여기서는 rewrite 채택. .jsonl 작아서 비용 OK.
- const all = readFeedback().map((e) => (e.id === id ? enriched : e));
- const filePath = getFeedbackFilePath();
- if (filePath) {
- fs.writeFileSync(filePath, all.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
- }
- chunk(view, ` · 카테고리: ${categories.length > 0 ? categories.join(', ') : '(없음)'}\n · 감정: ${sentiment ?? '(미분류)'}\n`);
- } catch (e: any) {
- chunk(view, ` ⚠️ 분류 실패: ${e?.message || String(e)} (원본은 저장됨)\n`);
- }
-}
-
-function feedbackList(filterCategory: string | undefined, view: any): void {
- const all = readFeedback();
- const filtered = filterCategory
- ? all.filter((e) => (e.categories || []).some((c) => c === filterCategory || c.toLowerCase() === filterCategory.toLowerCase()))
- : all;
- if (filtered.length === 0) {
- chunk(view, filterCategory ? `\nℹ️ 카테고리 "${filterCategory}" 매치 0건.\n` : '\nℹ️ 누적 피드백 없음. `/feedback <텍스트>` 로 첫 항목 추가.\n');
- return;
- }
- chunk(view, `\n📋 **피드백 목록 (${filtered.length}건${filterCategory ? `, 카테고리 "${filterCategory}"` : ''})**\n\n`);
- // 최신 → 과거 순, 최대 20건.
- const sorted = filtered.slice().sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')).slice(0, 20);
- for (const e of sorted) {
- const date = (e.timestamp || '').slice(0, 10);
- const cats = (e.categories || []).join(', ') || '미분류';
- const sentEmoji = e.sentiment === 'positive' ? '🟢' : e.sentiment === 'negative' ? '🔴' : e.sentiment === 'neutral' ? '⚪' : '❓';
- chunk(view, `- ${sentEmoji} \`${date}\` [${cats}] ${e.text.slice(0, 120)}${e.text.length > 120 ? '…' : ''}\n`);
- }
- if (filtered.length > 20) chunk(view, `\n_…+${filtered.length - 20}건 더 (필터링하거나 \`/feedback path\` 로 직접 파일 열기)_\n`);
-}
-
-async function feedbackSummary(view: any): Promise {
- const all = readFeedback();
- if (all.length < 3) {
- chunk(view, `\nℹ️ 누적 ${all.length}건 — 패턴 분석엔 최소 3건 필요. \`/feedback <텍스트>\` 로 더 모아 주세요.\n`);
- return;
- }
- chunk(view, `\n📊 **패턴 분석 시작** (누적 ${all.length}건)\n · LLM 호출 중...\n`);
- // LLM 입력: 각 entry 의 categories + sentiment + 텍스트 (300자 cap) 만. 토큰 절약.
- const summaryInput = all.map((e) => JSON.stringify({
- timestamp: (e.timestamp || '').slice(0, 10),
- categories: e.categories || [],
- sentiment: e.sentiment || 'unknown',
- text: e.text.slice(0, 300),
- })).join('\n');
- try {
- const report = await callLmSynthesis(`[누적 피드백 ${all.length}건]\n\n${summaryInput}`, FEEDBACK_SUMMARY_PROMPT);
- if (!report || !report.trim()) { chunk(view, '\n❌ LLM 빈 응답.\n'); return; }
- chunk(view, `\n${report.trim()}\n`);
- } catch (e: any) {
- chunk(view, `\n❌ 분석 실패: ${e?.message || String(e)}\n`);
- }
-}
-
-function feedbackPath(view: any): void {
- const p = getFeedbackFilePath();
- if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음 — 저장 위치 결정 불가.\n'); return; }
- chunk(view, `\n📂 \`${p}\`\n · 누적 ${countFeedback()}건 (.jsonl 형식, 한 줄=한 항목)\n · 사람이 직접 편집 가능 — 카테고리 수정·삭제 등.\n`);
-}
-
-async function runFeedback(arg: string, view: any, _context?: vscode.ExtensionContext): Promise {
- const trimmed = arg.trim();
- if (!trimmed) {
- chunk(view, [
- '\n📋 **/feedback — 고객 피드백 누적 + 패턴 분석**',
- '',
- '사용법:',
- ' `/feedback <텍스트>` — 피드백 저장 (LLM 자동 카테고리 분류)',
- ' `/feedback list [카테고리]` — 누적 목록 (최신순, 최대 20건)',
- ' `/feedback summary` — 누적 데이터 패턴 분석 리포트 (LLM)',
- ' `/feedback path` — 저장 파일 경로 표시',
- '',
- '예시:',
- ' `/feedback 결제 흐름이 너무 복잡해서 중간에 포기했어요 - 김XX`',
- ' `/feedback list 결제`',
- ' `/feedback summary` (3건+ 누적 시)',
- '',
- '저장 위치: `/.astra/customer-feedback.jsonl` — 로컬 only, 외부 전송 없음.',
- '',
- ].join('\n'));
- return true;
- }
-
- // sub-command 라우팅 — 첫 토큰이 list/summary/path 면 sub, 아니면 전체를 텍스트로 저장.
- const firstSpace = trimmed.search(/\s/);
- const head = (firstSpace < 0 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase();
- const rest = firstSpace < 0 ? '' : trimmed.slice(firstSpace + 1).trim();
-
- switch (head) {
- case 'list':
- feedbackList(rest || undefined, view);
- return true;
- case 'summary': case 'analyze': case 'report':
- await feedbackSummary(view);
- return true;
- case 'path':
- feedbackPath(view);
- return true;
- default:
- // sub-command 아닌 모든 입력은 피드백 텍스트로 저장.
- await feedbackSave(trimmed, view);
- return true;
- }
-}
-
-// ─── /blocked — 전사 across 지연·블로커 한 화면 ─────────────────────────
-// `/onesie` 는 멤버 단위; `/blocked` 는 *전체 across*. 매일 아침 1번 보면
-// "오늘 어디 살펴봐야 하나" 가 즉시 잡힘. 단독 운영자(대표) 의 daily ritual.
-//
-// 카테고리:
-// 🔴 지연 (overdue, 마감 < 오늘) — 가장 위, 가장 오래 지연된 것부터
-// 🟡 이번 주 마감 (오늘~+7일) — 다가오는 위험
-// ⚪ 마감일 없음 — 우선순위 합의 대상
-// 옵션: `/blocked @멤버` → 그 멤버만 (overlap with /onesie 하지만 더 압축된 뷰)
-
-interface ParsedTaskOwner { owner: string | undefined; displayTitle: string; }
-
-function parseTaskOwner(title: string, notes?: string): ParsedTaskOwner {
- // 우선 제목 prefix `[owner]` — /task 와 /meet 양쪽이 이렇게 등록.
- const titlePrefix = title.match(/^\[([^\]]+)\]\s*(.+)$/);
- if (titlePrefix) return { owner: titlePrefix[1].trim(), displayTitle: titlePrefix[2].trim() };
- // 노트 fallback — `담당: 이름` 또는 `담당: @이름` (구버전 /meet entry 호환).
- const notesMatch = (notes || '').match(/담당:\s*(?:@)?([\S]+)/);
- if (notesMatch) return { owner: notesMatch[1].trim(), displayTitle: title };
- return { owner: undefined, displayTitle: title };
-}
-
-async function runBlocked(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
- if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /blocked 실행 불가.\n'); return true; }
-
- // 선택적 @멤버 필터.
- const memberFilter = (arg.trim().split(/\s+/)[0] || '').replace(/^@/, '').trim() || undefined;
-
- chunk(view, `\n🚨 **전사 블로커·지연 뷰**${memberFilter ? ` — @${memberFilter}` : ''}\n`);
- chunk(view, '\n📥 Tasks 가져오는 중...\n');
-
- const result = await listTasks(context, { showCompleted: false, maxResults: 300 });
- if (!result.ok) {
- chunk(view, `\n❌ Tasks 조회 실패: ${result.error}\n`);
- return true;
- }
- const tasks = result.tasks;
-
- interface Row { due?: string; owner?: string; title: string; }
- const today = new Date().toISOString().slice(0, 10);
- const weekLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
-
- const overdue: Row[] = [];
- const thisWeek: Row[] = [];
- const noDate: Row[] = [];
-
- for (const t of tasks) {
- const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
- if (memberFilter && (owner || '').toLowerCase() !== memberFilter.toLowerCase()) continue;
- const row: Row = { due: t.due, owner, title: displayTitle };
- if (!t.due) noDate.push(row);
- else if (t.due < today) overdue.push(row);
- else if (t.due <= weekLater) thisWeek.push(row);
- // 그 이후(>7일) 는 /blocked 에서 제외 — 멀리 있는 건 압박 아님.
- }
- overdue.sort((a, b) => (a.due || '').localeCompare(b.due || '')); // 오래된 것 먼저
- thisWeek.sort((a, b) => (a.due || '').localeCompare(b.due || '')); // 임박한 것 먼저
- noDate.sort((a, b) => (a.owner || 'zzz').localeCompare(b.owner || 'zzz'));
-
- const fmtRow = (r: Row): string => {
- const o = r.owner ? `@${r.owner}` : '(owner 없음)';
- return `- 📅 \`${r.due || '----------'}\` · **${o}** — ${r.title}`;
- };
-
- const totalShown = overdue.length + thisWeek.length + noDate.length;
- if (totalShown === 0) {
- chunk(view, `\n✅ 지연·임박 항목 없음${memberFilter ? ` (@${memberFilter})` : ''}. 진행 상황 양호.\n`);
- return true;
- }
-
- if (overdue.length > 0) {
- chunk(view, `\n## 🔴 지연 ${overdue.length}건 — 즉시 확인 필요\n`);
- const MAX_OVERDUE = 20;
- for (const r of overdue.slice(0, MAX_OVERDUE)) chunk(view, fmtRow(r) + '\n');
- if (overdue.length > MAX_OVERDUE) chunk(view, `_…+${overdue.length - MAX_OVERDUE}건 더_\n`);
- }
- if (thisWeek.length > 0) {
- chunk(view, `\n## 🟡 이번 주 마감 ${thisWeek.length}건 (~${weekLater})\n`);
- const MAX_WEEK = 15;
- for (const r of thisWeek.slice(0, MAX_WEEK)) chunk(view, fmtRow(r) + '\n');
- if (thisWeek.length > MAX_WEEK) chunk(view, `_…+${thisWeek.length - MAX_WEEK}건 더_\n`);
- }
- if (noDate.length > 0) {
- chunk(view, `\n## ⚪ 마감일 없음 ${noDate.length}건 — 우선순위 합의 필요\n`);
- const MAX_NODATE = 10;
- for (const r of noDate.slice(0, MAX_NODATE)) chunk(view, fmtRow(r) + '\n');
- if (noDate.length > MAX_NODATE) chunk(view, `_…+${noDate.length - MAX_NODATE}건 더_\n`);
- }
-
- // 멤버별 압박 요약 (전체 보기일 때만, 의미 있을 때만)
- if (!memberFilter && (overdue.length + thisWeek.length) > 0) {
- const counts = new Map();
- for (const r of overdue) {
- const k = r.owner || '(없음)';
- const c = counts.get(k) || { overdue: 0, week: 0 };
- c.overdue++;
- counts.set(k, c);
- }
- for (const r of thisWeek) {
- const k = r.owner || '(없음)';
- const c = counts.get(k) || { overdue: 0, week: 0 };
- c.week++;
- counts.set(k, c);
- }
- const ranked = [...counts.entries()]
- .sort((a, b) => (b[1].overdue * 2 + b[1].week) - (a[1].overdue * 2 + a[1].week));
- chunk(view, `\n## 📊 멤버별 압박 ${ranked.length}명\n`);
- for (const [member, c] of ranked) {
- chunk(view, `- **@${member}** — 지연 ${c.overdue}건${c.week ? ` · 이번 주 ${c.week}건` : ''}\n`);
- }
- chunk(view, '\n💡 압박 큰 멤버부터 `/onesie @<멤버>` 로 1:1 카드 확인 권장.\n');
- }
- return true;
-}
-
-// ─── /standup — 멤버별 공유용 스탠드업 카드 ─────────────────────────────
-// `/blocked` 는 *대표가 본다* 용도(분석적), `/standup` 은 *팀에 공유한다* 용도
-// (소통적). 같은 데이터 소스(Google Tasks)지만 다른 포맷·관점:
-// - 멤버 단위 그룹핑 (멤버별 한 섹션)
-// - "완료 / 진행 / 블로커" 3-row 표준 스탠드업 패턴
-// - 마크다운 단순 — 슬랙·노션에 그대로 복붙 가능
-// - 이번 주 결정(Chronicle ADR) 부록
-// 단독 운영자(대표)가 데이터 다 가지고 있으니 매주/매일 실행해 팀 채널에 게시.
-
-async function runStandup(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
- if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /standup 실행 불가.\n'); return true; }
-
- // 인자 파싱 — `daily` (1일 윈도우) / `weekly` (7일, 기본) / `monthly` (30일).
- const mode = (arg.trim().split(/\s+/)[0] || 'weekly').toLowerCase();
- const windowDays = mode === 'daily' ? 1 : mode === 'monthly' ? 30 : 7;
- const modeLabel = mode === 'daily' ? '일일' : mode === 'monthly' ? '월간' : '주간';
-
- if (arg.trim() && !['daily', 'weekly', 'monthly', ''].includes(mode)) {
- chunk(view, [
- '\n📋 **/standup [daily/weekly/monthly] — 팀 스탠드업 카드**',
- '',
- '사용법:',
- ' `/standup` — 주간 (기본, 7일 윈도우)',
- ' `/standup daily` — 일일 (1일 윈도우)',
- ' `/standup weekly` — 주간 (7일)',
- ' `/standup monthly` — 월간 (30일)',
- '',
- '멤버별로 완료 / 진행·예정 / 블로커 3-row + 이번 기간 결정 목록을 슬랙·노션에 복붙 가능한 마크다운으로 출력.',
- '',
- ].join('\n'));
- return true;
- }
-
- chunk(view, `\n📊 **팀 스탠드업 — ${modeLabel} (${windowDays}일 윈도우)**\n · ${new Date().toISOString().slice(0, 10)} 기준\n`);
- chunk(view, '\n📥 Tasks 가져오는 중...\n');
-
- const result = await listTasks(context, { showCompleted: true, maxResults: 300 });
- if (!result.ok) { chunk(view, `\n❌ Tasks 조회 실패: ${result.error}\n`); return true; }
-
- const today = new Date().toISOString().slice(0, 10);
- const windowAgoIso = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000).toISOString();
- const upcomingEnd = new Date(Date.now() + windowDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
-
- // 멤버별 그룹핑.
- interface MemberSlot { completed: { date: string; title: string }[]; upcoming: { due: string; title: string }[]; overdue: { due: string; title: string }[]; noDate: { title: string }[]; }
- const byMember = new Map();
- const ensure = (k: string): MemberSlot => {
- let s = byMember.get(k);
- if (!s) { s = { completed: [], upcoming: [], overdue: [], noDate: [] }; byMember.set(k, s); }
- return s;
- };
-
- for (const t of result.tasks) {
- const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
- const member = owner || '(owner 없음)';
- const slot = ensure(member);
- if (t.status === 'completed') {
- if ((t.completed || '') >= windowAgoIso) {
- slot.completed.push({ date: (t.completed || '').slice(0, 10), title: displayTitle });
- }
- } else {
- if (!t.due) slot.noDate.push({ title: displayTitle });
- else if (t.due < today) slot.overdue.push({ due: t.due, title: displayTitle });
- else if (t.due <= upcomingEnd) slot.upcoming.push({ due: t.due, title: displayTitle });
- }
- }
- // 각 카테고리 내부 정렬.
- for (const s of byMember.values()) {
- s.completed.sort((a, b) => b.date.localeCompare(a.date));
- s.upcoming.sort((a, b) => a.due.localeCompare(b.due));
- s.overdue.sort((a, b) => a.due.localeCompare(b.due));
- }
-
- // 멤버 0 명이면 안내.
- if (byMember.size === 0) {
- chunk(view, '\nℹ️ 이 기간에 활동이 있는 task 가 없습니다. `/task @<멤버> ...` 로 등록 시작.\n');
- return true;
- }
-
- // Chronicle ADR — 이번 윈도우 내 결정.
- const store = new ChronicleProjectStore(context);
- const profiles = store.getAll();
- interface RecentAdr { date: string; title: string; file: string; mtime: number; }
- const recentAdrs: RecentAdr[] = [];
- const windowAgoMs = Date.now() - windowDays * 24 * 60 * 60 * 1000;
- for (const profile of profiles) {
- const decisionsDir = path.join(profile.recordRoot, 'decisions');
- if (!fs.existsSync(decisionsDir)) continue;
- let names: string[] = [];
- try { names = fs.readdirSync(decisionsDir); } catch { continue; }
- for (const fn of names) {
- if (!fn.endsWith('.md') || !fn.startsWith('ADR-')) continue;
- const fp = path.join(decisionsDir, fn);
- let mtime = 0;
- try { mtime = fs.statSync(fp).mtimeMs; } catch { continue; }
- if (mtime < windowAgoMs) continue;
- let content = '';
- try { content = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
- const titleMatch = content.match(/^#\s+(.+)$/m);
- const title = titleMatch ? titleMatch[1].trim() : fn.replace(/\.md$/, '');
- recentAdrs.push({ date: new Date(mtime).toISOString().slice(0, 10), title, file: fn, mtime });
- }
- }
- recentAdrs.sort((a, b) => b.mtime - a.mtime);
-
- // 멤버 정렬 — 활동량(완료+진행+지연) 내림차순.
- const memberRanked = [...byMember.entries()].sort((a, b) => {
- const score = (s: MemberSlot) => s.completed.length + s.upcoming.length + s.overdue.length * 2;
- return score(b[1]) - score(a[1]);
- });
-
- // 출력 — 슬랙/노션 복붙 친화 단순 마크다운. 헤딩 ## 1회 + 멤버별 ### 그룹.
- chunk(view, '\n---\n');
- chunk(view, `\n## 📊 팀 스탠드업 — ${modeLabel}\n`);
- chunk(view, `*${windowDays}일 윈도우 · 기준일 ${today}*\n`);
-
- for (const [member, s] of memberRanked) {
- chunk(view, `\n### @${member}\n`);
-
- // 완료
- if (s.completed.length === 0) {
- chunk(view, `**✅ 완료**: _없음_\n`);
- } else {
- chunk(view, `**✅ 완료 (${s.completed.length})**:\n`);
- const MAX = 8;
- for (const c of s.completed.slice(0, MAX)) chunk(view, `- ${c.date} — ${c.title}\n`);
- if (s.completed.length > MAX) chunk(view, `- _…+${s.completed.length - MAX}건_\n`);
- }
-
- // 진행·예정
- if (s.upcoming.length === 0 && s.noDate.length === 0) {
- chunk(view, `**🎯 진행/예정**: _없음_\n`);
- } else {
- chunk(view, `**🎯 진행/예정 (${s.upcoming.length + s.noDate.length})**:\n`);
- const MAX = 6;
- for (const u of s.upcoming.slice(0, MAX)) chunk(view, `- ${u.due} — ${u.title}\n`);
- for (const n of s.noDate.slice(0, Math.max(0, MAX - s.upcoming.length))) chunk(view, `- (마감 미정) — ${n.title}\n`);
- const total = s.upcoming.length + s.noDate.length;
- if (total > MAX) chunk(view, `- _…+${total - MAX}건_\n`);
- }
-
- // 블로커
- if (s.overdue.length === 0) {
- chunk(view, `**🚧 블로커**: _없음_\n`);
- } else {
- chunk(view, `**🚧 블로커 (${s.overdue.length}건 지연)**:\n`);
- for (const o of s.overdue.slice(0, 5)) chunk(view, `- 🔴 ${o.due} (지남) — ${o.title}\n`);
- if (s.overdue.length > 5) chunk(view, `- _…+${s.overdue.length - 5}건_\n`);
- }
- }
-
- // 결정 사항 부록.
- if (recentAdrs.length > 0) {
- chunk(view, `\n---\n\n## 📋 이번 ${modeLabel} 결정 (${recentAdrs.length}건)\n`);
- for (const a of recentAdrs.slice(0, 10)) chunk(view, `- ${a.date} — ${a.title}\n`);
- if (recentAdrs.length > 10) chunk(view, `- _…+${recentAdrs.length - 10}건_\n`);
- }
-
- chunk(view, '\n---\n\n💡 위 마크다운을 그대로 슬랙·노션에 복붙하세요. 멤버 활동 순 정렬.\n');
- return true;
-}
-
-// ─── /runway — 현금 / 월 소진율 / 남은 개월수 ─────────────────────────────
-// 4인 기업 CEO 가 매일·매주 확인하는 가장 중요한 숫자. 회계 시스템 연동 아닌
-// 가벼운 트래커 — 통장 잔고 입력 + 큰 지출/수입 기록 + 자동 계산. 로컬 .jsonl.
-
-function _fmtKrw(n: number): string {
- const sign = n < 0 ? '-' : '';
- const abs = Math.abs(n);
- if (abs >= 100_000_000) return `${sign}${(abs / 100_000_000).toFixed(2)}억`;
- if (abs >= 10_000) return `${sign}${(abs / 10_000).toFixed(0)}만`;
- return `${sign}${abs.toLocaleString('ko-KR')}`;
-}
-
-function _parseAmount(token: string): number | null {
- if (!token) return null;
- let s = token.replace(/[,_]/g, '').trim();
- let mul = 1;
- const m = s.match(/^(-?[\d.]+)\s*(억|만|k|m|b)?$/i);
- if (!m) return null;
- const base = parseFloat(m[1]);
- if (!Number.isFinite(base)) return null;
- const unit = (m[2] || '').toLowerCase();
- if (unit === '억') mul = 100_000_000;
- else if (unit === '만') mul = 10_000;
- else if (unit === 'k') mul = 1_000;
- else if (unit === 'm') mul = 1_000_000;
- else if (unit === 'b') mul = 1_000_000_000;
- return base * mul;
-}
-
-function _runwayShowStatus(view: any): void {
- const s = computeRunwayStatus();
- chunk(view, '\n💰 **/runway — 현금 / 월 소진율 / 런웨이**\n');
- if (s.latestCash === null) {
- chunk(view, '\nℹ️ 잔고 기록 없음. 시작: `/runway cash 5000만` (현재 통장 잔고 입력)\n');
- return;
- }
- const cashDate = (s.latestCashAt || '').slice(0, 10);
- chunk(view, `\n## 현재 현금\n- **${_fmtKrw(s.latestCash)}원** _(기준: ${cashDate})_\n`);
-
- chunk(view, '\n## 월 소진율 (burn)\n');
- if (s.explicitBurn !== null) {
- chunk(view, `- **${_fmtKrw(s.explicitBurn)}원/월** _(수동 설정)_\n`);
- } else if (s.computedBurn !== null) {
- const ann = s.last30Days < 30 ? ` _(${s.last30Days}일 데이터 → 30일 환산)_` : ' _(최근 30일 실적)_';
- chunk(view, `- **${_fmtKrw(s.computedBurn)}원/월**${ann}\n`);
- chunk(view, ` · 지출 ${_fmtKrw(s.last30Expense)}원 − 수입 ${_fmtKrw(s.last30Revenue)}원\n`);
- } else {
- chunk(view, '- _데이터 부족_ — `/runway burn 1500만` 또는 `/runway expense 300만 급여` 로 기록\n');
- }
-
- chunk(view, '\n## 런웨이\n');
- if (s.runwayMonths === null) {
- chunk(view, '- _계산 불가_ (잔고 또는 burn 미정)\n');
- } else if (!Number.isFinite(s.runwayMonths)) {
- chunk(view, '- ♾️ **흑자 운영** (지출 ≤ 수입)\n');
- } else {
- const m = s.runwayMonths;
- const emoji = m < 3 ? '🔴' : m < 6 ? '🟡' : '🟢';
- const months = m.toFixed(1);
- const exitDate = new Date(Date.now() + m * 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
- chunk(view, `- ${emoji} **${months}개월** _(예상 소진: ${exitDate})_\n`);
- if (m < 3) chunk(view, ' · ⚠️ **3개월 미만** — 즉시 자금 조달 또는 비용 절감 필요\n');
- else if (m < 6) chunk(view, ' · ⚠️ **6개월 미만** — 자금 계획 점검 권장\n');
- }
-
- chunk(view, `\n_누적 ${s.totalEntries}건 기록. \`/runway log\` 로 전체 보기, \`/runway path\` 로 파일 위치._\n`);
-}
-
-function _runwayLog(view: any, limit: number): void {
- const all = readRunway().slice().sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')).slice(0, limit);
- if (all.length === 0) { chunk(view, '\nℹ️ 기록 없음. `/runway cash 5000만` 으로 시작.\n'); return; }
- chunk(view, `\n📒 **최근 ${all.length}건** (최신순)\n\n`);
- const emoji: Record = {
- snapshot: '💰', expense: '💸', revenue: '💵', burn: '🔥',
- };
- for (const e of all) {
- const date = (e.timestamp || '').slice(0, 10);
- const cat = e.category ? ` [${e.category}]` : '';
- const memo = e.memo ? ` — ${e.memo}` : '';
- const typeLabel = e.type === 'snapshot' ? '잔고' : e.type === 'expense' ? '지출' : e.type === 'revenue' ? '수입' : '월 burn';
- chunk(view, `- ${emoji[e.type]} \`${date}\` ${typeLabel}: ${_fmtKrw(e.amount)}원${cat}${memo}\n`);
- }
-}
-
-async function runRunway(arg: string, view: any, _context?: vscode.ExtensionContext): Promise {
- const trimmed = arg.trim();
- if (!trimmed) { _runwayShowStatus(view); return true; }
-
- const parts = trimmed.split(/\s+/);
- const sub = parts[0].toLowerCase();
-
- if (sub === 'help' || sub === '?') {
- chunk(view, [
- '\n💰 **/runway — 현금 / 월 소진율 / 런웨이**',
- '',
- '사용법:',
- ' `/runway` — 현재 상태 카드 (현금 / burn / 남은 개월수)',
- ' `/runway cash <금액> [메모]` — 통장 잔고 스냅샷 기록',
- ' `/runway expense <금액> [메모]` — 지출 기록 (월 burn 자동 계산에 반영)',
- ' `/runway revenue <금액> [메모]` — 수입 기록 (burn 상쇄)',
- ' `/runway burn <월 금액>` — 월 소진율 수동 설정 (자동 계산보다 우선)',
- ' `/runway log [N]` — 최근 N건 기록 (기본 20)',
- ' `/runway path` — .jsonl 파일 경로',
- '',
- '금액 단위: `5000만` / `1.5억` / `300000` 모두 OK. 소수점·콤마 허용.',
- '저장 위치: `/.astra/runway.jsonl` (로컬 only, 외부 안 보냄).\n',
- ].join('\n'));
- return true;
- }
-
- if (sub === 'path') {
- const p = getRunwayFilePath();
- if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음 — 저장 위치 결정 불가.\n'); return true; }
- const count = readRunway().length;
- chunk(view, `\n📂 \`${p}\`\n · 누적 ${count}건 (.jsonl 형식, 한 줄=한 항목)\n · 사람이 직접 편집 가능.\n`);
- return true;
- }
-
- if (sub === 'log') {
- const n = parts[1] ? parseInt(parts[1], 10) : 20;
- _runwayLog(view, Number.isFinite(n) && n > 0 ? n : 20);
- return true;
- }
-
- if (sub === 'cash' || sub === 'expense' || sub === 'revenue' || sub === 'burn') {
- const amount = _parseAmount(parts[1] || '');
- if (amount === null) {
- chunk(view, `\n❌ 금액 파싱 실패: "${parts[1] || ''}". 예: \`5000만\` / \`1.5억\` / \`300000\`\n`);
- return true;
- }
- const memo = parts.slice(2).join(' ').trim() || undefined;
- const typeMap: Record = { cash: 'snapshot', expense: 'expense', revenue: 'revenue', burn: 'burn' };
- const entry: RunwayEntry = {
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- timestamp: new Date().toISOString(),
- type: typeMap[sub],
- amount,
- currency: 'KRW',
- memo,
- };
- const res = appendRunway(entry);
- if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
- const labels: Record = { cash: '잔고 스냅샷', expense: '지출', revenue: '수입', burn: '월 burn 설정' };
- chunk(view, `\n✅ ${labels[sub]} 기록: **${_fmtKrw(amount)}원**${memo ? ` — ${memo}` : ''}\n`);
- // 상태 카드 자동 표시 — 변화 직후 확인이 자연스러움.
- _runwayShowStatus(view);
- return true;
- }
-
- chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/runway help\` 참조.\n`);
- return true;
-}
-
-// ─── /customers — 고객사 / MRR / 갱신 / 위험 ──────────────────────────────
-// 4인 기업 매출 시야. event-sourced 로그 — 같은 customerId 의 이벤트를 시간순
-// 재생해 현재 상태 (MRR / 갱신일 / status) 도출. CRM 아닌 가벼운 트래커.
-
-function _daysUntil(isoDate: string | undefined, now: Date = new Date()): number | null {
- if (!isoDate) return null;
- const t = Date.parse(isoDate);
- if (!Number.isFinite(t)) return null;
- return Math.ceil((t - now.getTime()) / (24 * 60 * 60 * 1000));
-}
-
-function _customersDashboard(view: any): void {
- const states = computeCustomerStates();
- const all = Array.from(states.values());
- if (all.length === 0) {
- chunk(view, '\n📒 **/customers — 고객사 / MRR / 갱신**\n\nℹ️ 등록된 고객 없음. 시작: `/customers add <이름> [갱신일] [요금제]`\n예: `/customers add 큐브앤코 200만 2026-12-01 enterprise`\n');
- return;
- }
-
- const active = all.filter((c) => c.status === 'active');
- const atRisk = all.filter((c) => c.status === 'at-risk');
- const churned = all.filter((c) => c.status === 'churned');
- const totalMrr = active.reduce((sum, c) => sum + c.mrr, 0) + atRisk.reduce((sum, c) => sum + c.mrr, 0);
- const riskMrr = atRisk.reduce((sum, c) => sum + c.mrr, 0);
-
- chunk(view, '\n📒 **/customers — 고객사 대시보드**\n');
- chunk(view, '\n## 요약\n');
- chunk(view, `- **MRR**: ${_fmtKrw(totalMrr)}원/월 _(연 ${_fmtKrw(totalMrr * 12)}원)_\n`);
- chunk(view, `- 활성 ${active.length}곳 · 위험 ${atRisk.length}곳 · 이탈 ${churned.length}곳\n`);
- if (riskMrr > 0) chunk(view, `- ⚠️ **위험 MRR**: ${_fmtKrw(riskMrr)}원/월 _(전체의 ${((riskMrr / totalMrr) * 100).toFixed(0)}%)_\n`);
-
- // 갱신 임박 — 30일 이내, 활성 또는 위험.
- const upcoming = [...active, ...atRisk]
- .map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
- .filter((x) => x.days !== null && x.days >= 0 && x.days <= 30)
- .sort((a, b) => (a.days as number) - (b.days as number));
- if (upcoming.length > 0) {
- chunk(view, '\n## 🔔 30일 내 갱신\n');
- for (const { c, days } of upcoming) {
- const emoji = c.status === 'at-risk' ? '⚠️' : (days as number) <= 7 ? '🔴' : (days as number) <= 14 ? '🟡' : '🟢';
- chunk(view, `- ${emoji} **${c.customerName}** — ${c.renewalAt} _(D-${days})_ · ${_fmtKrw(c.mrr)}원/월\n`);
- }
- }
-
- if (atRisk.length > 0) {
- chunk(view, '\n## ⚠️ 위험 고객\n');
- for (const c of atRisk.sort((a, b) => b.mrr - a.mrr)) {
- const lastRisk = c.notes.slice().reverse().find((n) => n.type === 'risk');
- chunk(view, `- **${c.customerName}** — ${_fmtKrw(c.mrr)}원/월${lastRisk ? ` · ${lastRisk.memo.slice(0, 60)}` : ''}\n`);
- }
- }
-
- // 활성 — MRR 순 top 10.
- if (active.length > 0) {
- chunk(view, '\n## 활성 고객 (MRR 순)\n');
- const top = active.slice().sort((a, b) => b.mrr - a.mrr).slice(0, 10);
- for (const c of top) {
- const renewalNote = c.renewalAt ? ` · 갱신 ${c.renewalAt}` : '';
- chunk(view, `- ${c.customerName} — ${_fmtKrw(c.mrr)}원/월${renewalNote}\n`);
- }
- if (active.length > 10) chunk(view, `- _…+${active.length - 10}곳_\n`);
- }
-
- chunk(view, `\n_누적 이벤트 ${readCustomerEvents().length}건. \`/customers help\` 로 명령어._\n`);
-}
-
-function _customersList(view: any, filter: string | undefined): void {
- const states = computeCustomerStates();
- let all = Array.from(states.values());
- if (filter === 'active' || filter === 'at-risk' || filter === 'risk' || filter === 'churned') {
- const target = filter === 'risk' ? 'at-risk' : filter;
- all = all.filter((c) => c.status === target);
- }
- if (all.length === 0) { chunk(view, `\nℹ️ 매치 없음${filter ? ` (필터: ${filter})` : ''}.\n`); return; }
- chunk(view, `\n📋 **고객 목록 (${all.length}곳${filter ? `, ${filter}` : ''})**\n\n`);
- const sorted = all.slice().sort((a, b) => b.mrr - a.mrr);
- for (const c of sorted) {
- const emoji = c.status === 'active' ? '🟢' : c.status === 'at-risk' ? '🟡' : '🔴';
- const renewalNote = c.renewalAt ? ` · 갱신 ${c.renewalAt}` : '';
- const planNote = c.plan ? ` · ${c.plan}` : '';
- chunk(view, `- ${emoji} **${c.customerName}** — ${_fmtKrw(c.mrr)}원/월${planNote}${renewalNote}\n`);
- }
-}
-
-function _customersShow(view: any, name: string): void {
- const states = computeCustomerStates();
- const cid = customerIdFromName(name);
- const c = states.get(cid);
- if (!c) {
- // partial match fallback
- const candidates = Array.from(states.values()).filter((x) => x.customerName.toLowerCase().includes(name.toLowerCase()));
- if (candidates.length === 0) { chunk(view, `\n❌ "${name}" 매치 0건.\n`); return; }
- if (candidates.length > 1) {
- chunk(view, `\nℹ️ "${name}" 부분 매치 ${candidates.length}곳:\n`);
- for (const x of candidates) chunk(view, `- ${x.customerName}\n`);
- return;
- }
- return _customersShow(view, candidates[0].customerName);
- }
-
- const emoji = c.status === 'active' ? '🟢' : c.status === 'at-risk' ? '🟡' : '🔴';
- chunk(view, `\n${emoji} **${c.customerName}** _(${c.status})_\n`);
- chunk(view, `\n- MRR: **${_fmtKrw(c.mrr)}원/월** _(연 ${_fmtKrw(c.mrr * 12)}원)_\n`);
- if (c.plan) chunk(view, `- 요금제: ${c.plan}\n`);
- if (c.renewalAt) {
- const d = _daysUntil(c.renewalAt);
- const dn = d !== null ? (d >= 0 ? `D-${d}` : `${-d}일 지남`) : '';
- chunk(view, `- 갱신일: ${c.renewalAt} _(${dn})_\n`);
- }
- chunk(view, `- 시작: ${(c.startedAt || '').slice(0, 10)} · 누적 이벤트 ${c.eventCount}건\n`);
-
- if (c.notes.length > 0) {
- chunk(view, `\n## 메모·이벤트 (${c.notes.length}건, 최신순)\n`);
- const recent = c.notes.slice().reverse().slice(0, 10);
- for (const n of recent) {
- const date = (n.timestamp || '').slice(0, 10);
- const tagEmoji = n.type === 'risk' ? '⚠️' : n.type === 'churn' ? '💀' : '📝';
- chunk(view, `- ${tagEmoji} \`${date}\` ${n.memo}\n`);
- }
- if (c.notes.length > 10) chunk(view, `- _…+${c.notes.length - 10}건_\n`);
- }
-}
-
-async function runCustomers(arg: string, view: any, _context?: vscode.ExtensionContext): Promise {
- const trimmed = arg.trim();
- if (!trimmed) { _customersDashboard(view); return true; }
-
- const parts = trimmed.split(/\s+/);
- const sub = parts[0].toLowerCase();
-
- if (sub === 'help' || sub === '?') {
- chunk(view, [
- '\n📒 **/customers — 고객사 / MRR / 갱신 트래커**',
- '',
- '사용법:',
- ' `/customers` — 대시보드 (MRR, 위험, 갱신 임박)',
- ' `/customers add <이름> [갱신일] [요금제]` — 신규 등록',
- ' `/customers update <이름> mrr=<금액> renewal=<날짜>`— 정보 수정',
- ' `/customers renew <이름> <새 갱신일> [새 MRR]` — 갱신 처리 (active 로 복귀)',
- ' `/customers risk <이름> <사유>` — 위험 표시',
- ' `/customers churn <이름> <사유>` — 이탈 처리 (MRR=0)',
- ' `/customers note <이름> <텍스트>` — 자유 메모',
- ' `/customers show <이름>` — 상세 (부분 매치 OK)',
- ' `/customers list [active/risk/churned]` — 필터 목록',
- ' `/customers path` — .jsonl 파일 경로',
- '',
- 'MRR 금액 단위: `200만` / `1.5억` / `300000` 모두 OK.',
- '갱신일: `YYYY-MM-DD` (예: `2026-12-01`).',
- '저장: `/.astra/customers.jsonl` (로컬 only, 외부 안 보냄).\n',
- ].join('\n'));
- return true;
- }
-
- if (sub === 'path') {
- const p = getCustomersFilePath();
- if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음.\n'); return true; }
- chunk(view, `\n📂 \`${p}\`\n · 누적 이벤트 ${readCustomerEvents().length}건 (.jsonl).\n`);
- return true;
- }
-
- if (sub === 'list') { _customersList(view, parts[1]?.toLowerCase()); return true; }
-
- if (sub === 'show') {
- const name = parts.slice(1).join(' ').trim();
- if (!name) { chunk(view, '\n❌ 사용법: `/customers show <이름>`\n'); return true; }
- _customersShow(view, name);
- return true;
- }
-
- // 이벤트 기록 서브명령들 — add / update / renew / risk / churn / note
- if (sub === 'add') {
- const name = parts[1];
- const mrrToken = parts[2];
- const renewalToken = parts[3];
- const planToken = parts[4];
- if (!name || !mrrToken) {
- chunk(view, '\n❌ 사용법: `/customers add <이름> [갱신일] [요금제]`\n예: `/customers add 큐브앤코 200만 2026-12-01 enterprise`\n');
- return true;
- }
- const mrr = _parseAmount(mrrToken);
- if (mrr === null) { chunk(view, `\n❌ MRR 파싱 실패: "${mrrToken}"\n`); return true; }
- const event: CustomerEvent = {
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- timestamp: new Date().toISOString(),
- customerId: customerIdFromName(name),
- customerName: name,
- type: 'add',
- mrr,
- renewalAt: renewalToken && /^\d{4}-\d{2}-\d{2}$/.test(renewalToken) ? renewalToken : undefined,
- plan: planToken,
- };
- const res = appendCustomerEvent(event);
- if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
- chunk(view, `\n✅ **${name}** 등록 — MRR ${_fmtKrw(mrr)}원/월${event.renewalAt ? ` · 갱신 ${event.renewalAt}` : ''}${event.plan ? ` · ${event.plan}` : ''}\n`);
- return true;
- }
-
- if (sub === 'renew') {
- const name = parts[1];
- const newRenewal = parts[2];
- const newMrrToken = parts[3];
- if (!name || !newRenewal) { chunk(view, '\n❌ 사용법: `/customers renew <이름> <새 갱신일> [새 MRR]`\n'); return true; }
- if (!/^\d{4}-\d{2}-\d{2}$/.test(newRenewal)) { chunk(view, `\n❌ 갱신일 형식: YYYY-MM-DD (입력: "${newRenewal}")\n`); return true; }
- const newMrr = newMrrToken ? _parseAmount(newMrrToken) : null;
- const event: CustomerEvent = {
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- timestamp: new Date().toISOString(),
- customerId: customerIdFromName(name),
- customerName: name,
- type: 'renew',
- renewalAt: newRenewal,
- mrr: newMrr ?? undefined,
- };
- const res = appendCustomerEvent(event);
- if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
- chunk(view, `\n🔄 **${name}** 갱신 — ${newRenewal}${newMrr !== null ? ` · MRR ${_fmtKrw(newMrr)}원/월` : ''}\n`);
- return true;
- }
-
- if (sub === 'risk' || sub === 'churn' || sub === 'note') {
- const name = parts[1];
- const memo = parts.slice(2).join(' ').trim();
- if (!name || !memo) {
- chunk(view, `\n❌ 사용법: \`/customers ${sub} <이름> <${sub === 'note' ? '텍스트' : '사유'}>\`\n`);
- return true;
- }
- const event: CustomerEvent = {
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- timestamp: new Date().toISOString(),
- customerId: customerIdFromName(name),
- customerName: name,
- type: sub,
- memo,
- };
- const res = appendCustomerEvent(event);
- if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
- const emoji = sub === 'risk' ? '⚠️' : sub === 'churn' ? '💀' : '📝';
- const label = sub === 'risk' ? '위험 표시' : sub === 'churn' ? '이탈 처리' : '메모 추가';
- chunk(view, `\n${emoji} **${name}** ${label}: ${memo}\n`);
- return true;
- }
-
- if (sub === 'update') {
- const name = parts[1];
- if (!name) { chunk(view, '\n❌ 사용법: `/customers update <이름> mrr=<금액> renewal= plan=<요금제>`\n'); return true; }
- const rest = parts.slice(2);
- const event: CustomerEvent = {
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- timestamp: new Date().toISOString(),
- customerId: customerIdFromName(name),
- customerName: name,
- type: 'update',
- };
- let touched = false;
- for (const kv of rest) {
- const m = kv.match(/^(\w+)=(.+)$/);
- if (!m) continue;
- const k = m[1].toLowerCase();
- const v = m[2];
- if (k === 'mrr') {
- const n = _parseAmount(v);
- if (n !== null) { event.mrr = n; touched = true; }
- } else if (k === 'renewal' || k === 'renewalat') {
- if (/^\d{4}-\d{2}-\d{2}$/.test(v)) { event.renewalAt = v; touched = true; }
- } else if (k === 'plan') {
- event.plan = v; touched = true;
- }
- }
- if (!touched) { chunk(view, '\n❌ 변경할 필드 없음. 예: `/customers update 큐브앤코 mrr=300만 renewal=2026-12-15`\n'); return true; }
- const res = appendCustomerEvent(event);
- if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
- const changes: string[] = [];
- if (event.mrr !== undefined) changes.push(`MRR=${_fmtKrw(event.mrr)}원`);
- if (event.renewalAt) changes.push(`갱신=${event.renewalAt}`);
- if (event.plan) changes.push(`요금제=${event.plan}`);
- chunk(view, `\n✏️ **${name}** 업데이트: ${changes.join(' · ')}\n`);
- return true;
- }
-
- chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/customers help\` 참조.\n`);
- return true;
-}
-
-// ─── /hire — 채용 파이프라인 ──────────────────────────────────────────────
-// event-sourced. 후보자별 단계 + 노트 누적. 4인 → 확장 시점의 시야 확보.
-
-const STAGE_ORDER: Record = {
- inbox: 1, screened: 2, interview: 3, final: 4, offer: 5,
- accepted: 6, hired: 7, rejected: 99, declined: 99,
-};
-const TERMINAL_STAGES = new Set(['hired', 'rejected', 'declined']);
-
-function _stageEmoji(stage: string): string {
- switch (stage) {
- case 'inbox': return '📥';
- case 'screened': return '🔍';
- case 'interview': return '💬';
- case 'final': return '🎯';
- case 'offer': return '📨';
- case 'accepted': return '🤝';
- case 'hired': return '🎉';
- case 'rejected': return '❌';
- case 'declined': return '🚪';
- default: return '•';
- }
-}
-
-function _hireDashboard(view: any): void {
- const states = computeCandidateStates();
- const all = Array.from(states.values());
- if (all.length === 0) {
- chunk(view, '\n👥 **/hire — 채용 파이프라인**\n\nℹ️ 등록된 후보 없음. 시작: `/hire add <이름> <역할>`\n예: `/hire add 김개발 백엔드`\n');
- return;
- }
-
- const active = all.filter((c) => !TERMINAL_STAGES.has(c.stage));
- const hired = all.filter((c) => c.stage === 'hired');
- const rejected = all.filter((c) => c.stage === 'rejected' || c.stage === 'declined');
-
- chunk(view, '\n👥 **/hire — 채용 파이프라인**\n');
- chunk(view, '\n## 요약\n');
- chunk(view, `- 진행 중 **${active.length}명** · 합격 ${hired.length}명 · 종료 ${rejected.length}명\n`);
-
- // 역할별 그룹
- const byRole = new Map();
- for (const c of active) {
- const role = c.role || '미지정';
- if (!byRole.has(role)) byRole.set(role, []);
- byRole.get(role)!.push(c);
- }
- if (byRole.size > 0) {
- chunk(view, '\n## 역할별 진행\n');
- for (const [role, cs] of byRole) {
- chunk(view, `- **${role}**: ${cs.length}명\n`);
- }
- }
-
- // 단계별 후보 — 진행 중만
- const byStage = new Map();
- for (const c of active) {
- if (!byStage.has(c.stage)) byStage.set(c.stage, []);
- byStage.get(c.stage)!.push(c);
- }
- const sortedStages = Array.from(byStage.keys()).sort((a, b) => (STAGE_ORDER[a] ?? 50) - (STAGE_ORDER[b] ?? 50));
- if (sortedStages.length > 0) {
- chunk(view, '\n## 단계별\n');
- for (const stage of sortedStages) {
- const list = byStage.get(stage)!;
- chunk(view, `\n### ${_stageEmoji(stage)} ${stage} (${list.length})\n`);
- for (const c of list.slice().sort((a, b) => (b.lastEventAt || '').localeCompare(a.lastEventAt || ''))) {
- const daysIn = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
- const stale = daysIn > 7 ? ` ⏰ ${daysIn}일 정체` : '';
- const salary = c.salary !== undefined ? ` · ${_fmtKrw(c.salary)}원` : '';
- chunk(view, `- ${c.candidateName} _(${c.role || '미지정'})_${salary}${stale}\n`);
- }
- }
- }
-
- // 최근 합격
- if (hired.length > 0) {
- chunk(view, `\n## 🎉 최근 합격 (${hired.length}명)\n`);
- const recent = hired.slice().sort((a, b) => (b.lastEventAt || '').localeCompare(a.lastEventAt || '')).slice(0, 5);
- for (const c of recent) {
- chunk(view, `- ${c.candidateName} _(${c.role})_ — ${(c.lastEventAt || '').slice(0, 10)}\n`);
- }
- }
-
- chunk(view, `\n_누적 이벤트 ${readHireEvents().length}건. \`/hire help\` 로 명령어._\n`);
-}
-
-function _hireList(view: any, filter: string | undefined): void {
- const states = computeCandidateStates();
- let all = Array.from(states.values());
- if (filter) {
- const f = filter.toLowerCase();
- if (f === 'active') all = all.filter((c) => !TERMINAL_STAGES.has(c.stage));
- else if (f === 'closed' || f === 'terminal') all = all.filter((c) => TERMINAL_STAGES.has(c.stage));
- else all = all.filter((c) => c.stage === f || (c.role || '').toLowerCase() === f);
- }
- if (all.length === 0) { chunk(view, `\nℹ️ 매치 없음${filter ? ` (필터: ${filter})` : ''}.\n`); return; }
- chunk(view, `\n📋 **후보 목록 (${all.length}명${filter ? `, ${filter}` : ''})**\n\n`);
- const sorted = all.slice().sort((a, b) => (STAGE_ORDER[a.stage] ?? 50) - (STAGE_ORDER[b.stage] ?? 50));
- for (const c of sorted) {
- const daysIn = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
- chunk(view, `- ${_stageEmoji(c.stage)} **${c.candidateName}** _(${c.role || '미지정'})_ · ${c.stage} · ${daysIn}일 전\n`);
- }
-}
-
-function _hireShow(view: any, name: string): void {
- const states = computeCandidateStates();
- const cid = candidateIdFromName(name);
- let c = states.get(cid);
- if (!c) {
- const candidates = Array.from(states.values()).filter((x) => x.candidateName.toLowerCase().includes(name.toLowerCase()));
- if (candidates.length === 0) { chunk(view, `\n❌ "${name}" 매치 0건.\n`); return; }
- if (candidates.length > 1) {
- chunk(view, `\nℹ️ "${name}" 부분 매치 ${candidates.length}명:\n`);
- for (const x of candidates) chunk(view, `- ${x.candidateName} (${x.role})\n`);
- return;
- }
- c = candidates[0];
- }
-
- chunk(view, `\n${_stageEmoji(c.stage)} **${c.candidateName}** _(${c.role || '미지정'})_\n`);
- chunk(view, `\n- 단계: **${c.stage}**\n`);
- if (c.salary !== undefined) chunk(view, `- 제안 연봉: ${_fmtKrw(c.salary)}원\n`);
- chunk(view, `- 시작: ${(c.addedAt || '').slice(0, 10)} · 최근 변경: ${(c.lastEventAt || '').slice(0, 10)} · 이벤트 ${c.eventCount}건\n`);
-
- if (c.notes.length > 0) {
- chunk(view, `\n## 메모·이벤트 (${c.notes.length}건)\n`);
- const recent = c.notes.slice().reverse().slice(0, 10);
- for (const n of recent) {
- const date = (n.timestamp || '').slice(0, 10);
- chunk(view, `- \`${date}\` ${n.memo}\n`);
- }
- if (c.notes.length > 10) chunk(view, `- _…+${c.notes.length - 10}건_\n`);
- }
-}
-
-async function runHire(arg: string, view: any, _context?: vscode.ExtensionContext): Promise {
- const trimmed = arg.trim();
- if (!trimmed) { _hireDashboard(view); return true; }
-
- const parts = trimmed.split(/\s+/);
- const sub = parts[0].toLowerCase();
-
- if (sub === 'help' || sub === '?') {
- chunk(view, [
- '\n👥 **/hire — 채용 파이프라인**',
- '',
- '사용법:',
- ' `/hire` — 파이프라인 대시보드',
- ' `/hire add <이름> <역할>` — 신규 후보 (inbox 단계)',
- ' `/hire stage <이름> <새 단계>` — 단계 이동',
- ' `/hire note <이름> <텍스트>` — 자유 메모',
- ' `/hire offer <이름> <연봉> [입사일]` — 오퍼 발송 (단계=offer)',
- ' `/hire hire <이름> [입사일]` — 입사 확정 (단계=hired)',
- ' `/hire reject <이름> <사유>` — 거절 (회사 측)',
- ' `/hire decline <이름> <사유>` — 후보 사양',
- ' `/hire show <이름>` — 상세 + 이력',
- ' `/hire list [active/closed/단계명/역할]` — 필터 목록',
- ' `/hire path` — 파일 위치',
- '',
- '단계 (기본 파이프라인): inbox → screened → interview → final → offer → accepted → hired',
- '터미널: rejected · declined',
- '',
- '연봉 단위: `4500만` / `1억` / `45000000` 모두 OK.',
- '입사일: `YYYY-MM-DD`.',
- '저장: `/.astra/hire.jsonl` (로컬 only).\n',
- ].join('\n'));
- return true;
- }
-
- if (sub === 'path') {
- const p = getHireFilePath();
- if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음.\n'); return true; }
- chunk(view, `\n📂 \`${p}\`\n · 누적 이벤트 ${readHireEvents().length}건 (.jsonl).\n`);
- return true;
- }
-
- if (sub === 'list') { _hireList(view, parts[1]); return true; }
- if (sub === 'show') {
- const name = parts.slice(1).join(' ').trim();
- if (!name) { chunk(view, '\n❌ 사용법: `/hire show <이름>`\n'); return true; }
- _hireShow(view, name);
- return true;
- }
-
- if (sub === 'add') {
- const name = parts[1];
- const role = parts.slice(2).join(' ').trim();
- if (!name || !role) { chunk(view, '\n❌ 사용법: `/hire add <이름> <역할>`\n예: `/hire add 김개발 백엔드 시니어`\n'); return true; }
- const event: HireEvent = {
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- timestamp: new Date().toISOString(),
- candidateId: candidateIdFromName(name),
- candidateName: name,
- role,
- type: 'add',
- stage: 'inbox',
- };
- const res = appendHireEvent(event);
- if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
- chunk(view, `\n📥 **${name}** 등록 — ${role} (inbox)\n`);
- return true;
- }
-
- if (sub === 'stage') {
- const name = parts[1];
- const newStage = parts[2]?.toLowerCase();
- if (!name || !newStage) { chunk(view, '\n❌ 사용법: `/hire stage <이름> <단계>`\n'); return true; }
- const existing = computeCandidateStates().get(candidateIdFromName(name));
- if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음. \`/hire add\` 먼저.\n`); return true; }
- const event: HireEvent = {
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- timestamp: new Date().toISOString(),
- candidateId: existing.candidateId,
- candidateName: existing.candidateName,
- role: existing.role,
- type: 'stage',
- stage: newStage,
- };
- const res = appendHireEvent(event);
- if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
- chunk(view, `\n${_stageEmoji(newStage)} **${existing.candidateName}**: ${existing.stage} → ${newStage}\n`);
- return true;
- }
-
- if (sub === 'offer') {
- const name = parts[1];
- const salaryToken = parts[2];
- const startDate = parts[3];
- if (!name || !salaryToken) { chunk(view, '\n❌ 사용법: `/hire offer <이름> <연봉> [입사일]`\n예: `/hire offer 김개발 6000만 2026-07-01`\n'); return true; }
- const existing = computeCandidateStates().get(candidateIdFromName(name));
- if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; }
- const salary = _parseAmount(salaryToken);
- if (salary === null) { chunk(view, `\n❌ 연봉 파싱 실패: "${salaryToken}"\n`); return true; }
- const event: HireEvent = {
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- timestamp: new Date().toISOString(),
- candidateId: existing.candidateId,
- candidateName: existing.candidateName,
- role: existing.role,
- type: 'offer',
- stage: 'offer',
- salary,
- memo: startDate ? `오퍼 발송 (입사 예정: ${startDate})` : '오퍼 발송',
- };
- const res = appendHireEvent(event);
- if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
- chunk(view, `\n📨 **${existing.candidateName}** 오퍼 — ${_fmtKrw(salary)}원${startDate ? ` · 입사 ${startDate}` : ''}\n`);
- return true;
- }
-
- if (sub === 'hire') {
- const name = parts[1];
- const startDate = parts[2];
- if (!name) { chunk(view, '\n❌ 사용법: `/hire hire <이름> [입사일]`\n'); return true; }
- const existing = computeCandidateStates().get(candidateIdFromName(name));
- if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; }
- const event: HireEvent = {
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- timestamp: new Date().toISOString(),
- candidateId: existing.candidateId,
- candidateName: existing.candidateName,
- role: existing.role,
- type: 'hire',
- stage: 'hired',
- memo: startDate ? `입사 확정 (시작: ${startDate})` : '입사 확정',
- };
- const res = appendHireEvent(event);
- if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
- chunk(view, `\n🎉 **${existing.candidateName}** 입사 확정 — ${existing.role}${startDate ? ` (${startDate})` : ''}\n`);
- return true;
- }
-
- if (sub === 'reject' || sub === 'decline' || sub === 'note') {
- const name = parts[1];
- const memo = parts.slice(2).join(' ').trim();
- if (!name || !memo) { chunk(view, `\n❌ 사용법: \`/hire ${sub} <이름> <${sub === 'note' ? '텍스트' : '사유'}>\`\n`); return true; }
- const existing = computeCandidateStates().get(candidateIdFromName(name));
- if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; }
- const event: HireEvent = {
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- timestamp: new Date().toISOString(),
- candidateId: existing.candidateId,
- candidateName: existing.candidateName,
- role: existing.role,
- type: sub,
- stage: sub === 'note' ? undefined : sub === 'reject' ? 'rejected' : 'declined',
- memo,
- };
- const res = appendHireEvent(event);
- if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
- const labels: Record = { reject: '❌ 거절', decline: '🚪 사양', note: '📝 메모' };
- chunk(view, `\n${labels[sub]} **${existing.candidateName}**: ${memo}\n`);
- return true;
- }
-
- chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/hire help\` 참조.\n`);
- return true;
-}
-
-// ─── /morning — 매일 아침 통합 대시보드 ──────────────────────────────────
-// CEO 의 첫 명령. 지금까지 만든 모든 트래커의 핵심을 한 화면에:
-// 1. 🚨 긴급 (runway < 3개월, 위험 고객, 지연 작업, 정체 후보)
-// 2. 💰 재무 (현금 / burn / 런웨이)
-// 3. 📒 고객 (MRR / 갱신 임박 / 위험)
-// 4. 👥 팀 (지연/임박 작업 멤버별 카운트)
-// 5. 🎯 채용 (단계별 + 정체)
-// 6. 📋 오늘의 액션 (위 데이터에서 자동 도출)
-
-async function runMorning(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
- const mode = (arg.trim().split(/\s+/)[0] || '').toLowerCase();
- const brief = mode === 'brief' || mode === 'short';
-
- const today = new Date().toISOString().slice(0, 10);
- chunk(view, `\n☀️ **오늘 (${today}) — 통합 대시보드**\n`);
-
- // ─── 1. 데이터 수집 ──────────────────────────────────────────
- const runway = computeRunwayStatus();
- const customerStates = computeCustomerStates();
- const customers = Array.from(customerStates.values());
- const candidateStates = computeCandidateStates();
- const candidates = Array.from(candidateStates.values());
-
- // Tasks 는 OAuth 필요 — 실패해도 다른 섹션은 계속 보여줘야 함.
- let tasks: any[] = [];
- let tasksError: string | undefined;
- if (context) {
- try {
- const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
- if (res.ok) tasks = res.tasks;
- else tasksError = res.error;
- } catch (e: any) { tasksError = e?.message || String(e); }
- }
-
- // ─── 2. 🚨 긴급 알림 ─────────────────────────────────────────
- const urgent: string[] = [];
-
- // runway < 3개월
- if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 3) {
- urgent.push(`🔴 **런웨이 ${runway.runwayMonths.toFixed(1)}개월** — 즉시 자금 조달/절감 필요`);
- } else if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 6) {
- urgent.push(`🟡 런웨이 ${runway.runwayMonths.toFixed(1)}개월 — 자금 계획 점검 권장`);
- }
-
- // 위험 고객
- const atRiskCustomers = customers.filter((c) => c.status === 'at-risk');
- const atRiskMrr = atRiskCustomers.reduce((s, c) => s + c.mrr, 0);
- if (atRiskCustomers.length > 0) {
- urgent.push(`⚠️ 위험 고객 **${atRiskCustomers.length}곳** (MRR ${_fmtKrw(atRiskMrr)}원/월 노출)`);
- }
-
- // 7일 내 갱신
- const upcomingRenewals = [...customers].filter((c) => c.status !== 'churned')
- .map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
- .filter((x) => x.days !== null && x.days >= 0 && x.days <= 7);
- if (upcomingRenewals.length > 0) {
- urgent.push(`🔔 7일 내 갱신 **${upcomingRenewals.length}건**`);
- }
-
- // 지연 작업 카운트
- const overdueTasks = tasks.filter((t) => t.due && t.due < today);
- if (overdueTasks.length > 0) {
- urgent.push(`🚧 지연 작업 **${overdueTasks.length}건**`);
- }
-
- // 정체 후보 (7일+)
- const activeCandidate = candidates.filter((c) => !TERMINAL_STAGES.has(c.stage));
- const stalled = activeCandidate.filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000);
- if (stalled.length > 0) {
- urgent.push(`⏰ 정체 후보 **${stalled.length}명** (7일+ 미변동)`);
- }
-
- if (urgent.length === 0) {
- chunk(view, '\n## ✅ 긴급 알림 없음\n');
- } else {
- chunk(view, `\n## 🚨 긴급 (${urgent.length}건)\n`);
- for (const u of urgent) chunk(view, `- ${u}\n`);
- }
-
- if (brief) {
- // brief 모드는 액션 3개만 도출하고 종료.
- chunk(view, '\n## 📋 오늘의 액션 (top 3)\n');
- const actions = _morningActions(runway, customers, upcomingRenewals, overdueTasks, stalled, candidates);
- for (const a of actions.slice(0, 3)) chunk(view, `- ${a}\n`);
- return true;
- }
-
- // ─── 3. 💰 재무 ──────────────────────────────────────────────
- chunk(view, '\n## 💰 재무\n');
- if (runway.latestCash === null) {
- chunk(view, '- _데이터 없음_ — `/runway cash <금액>` 으로 시작\n');
- } else {
- chunk(view, `- 현금 **${_fmtKrw(runway.latestCash)}원** _(${(runway.latestCashAt || '').slice(0, 10)})_\n`);
- if (runway.effectiveBurn !== null) {
- chunk(view, `- 월 burn ${_fmtKrw(runway.effectiveBurn)}원 ${runway.explicitBurn !== null ? '_(수동)_' : '_(30일 실적)_'}\n`);
- }
- if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths)) {
- const emoji = runway.runwayMonths < 3 ? '🔴' : runway.runwayMonths < 6 ? '🟡' : '🟢';
- chunk(view, `- 런웨이 ${emoji} **${runway.runwayMonths.toFixed(1)}개월**\n`);
- } else if (runway.runwayMonths !== null) {
- chunk(view, '- 런웨이 ♾️ 흑자 운영\n');
- }
- }
-
- // ─── 4. 📒 고객 ──────────────────────────────────────────────
- chunk(view, '\n## 📒 고객\n');
- if (customers.length === 0) {
- chunk(view, '- _데이터 없음_ — `/customers add` 로 시작\n');
- } else {
- const active = customers.filter((c) => c.status === 'active');
- const totalMrr = [...active, ...atRiskCustomers].reduce((s, c) => s + c.mrr, 0);
- chunk(view, `- 총 MRR **${_fmtKrw(totalMrr)}원/월** _(연 ${_fmtKrw(totalMrr * 12)}원)_\n`);
- chunk(view, `- 활성 ${active.length} · 위험 ${atRiskCustomers.length} · 이탈 ${customers.length - active.length - atRiskCustomers.length}\n`);
- if (upcomingRenewals.length > 0) {
- for (const { c, days } of upcomingRenewals.slice(0, 5)) {
- const emoji = (days as number) <= 3 ? '🔴' : '🟡';
- chunk(view, ` - ${emoji} ${c.customerName} — D-${days} · ${_fmtKrw(c.mrr)}원/월\n`);
- }
- }
- }
-
- // ─── 5. 👥 팀 ────────────────────────────────────────────────
- chunk(view, '\n## 👥 팀\n');
- if (tasksError) {
- chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`);
- } else if (tasks.length === 0) {
- chunk(view, '- _Tasks 없음_ — `/task` 로 등록 시작\n');
- } else {
- const memberOverdue = new Map();
- const weekLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
- let weekCount = 0;
- for (const t of tasks) {
- const { owner } = parseTaskOwner(t.title, t.notes);
- const k = owner || '(미지정)';
- if (t.due && t.due < today) memberOverdue.set(k, (memberOverdue.get(k) || 0) + 1);
- if (t.due && t.due >= today && t.due <= weekLater) weekCount++;
- }
- chunk(view, `- 지연 ${overdueTasks.length}건 · 이번 주 ${weekCount}건 (전체 ${tasks.length})\n`);
- if (memberOverdue.size > 0) {
- const ranked = [...memberOverdue.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4);
- for (const [member, n] of ranked) {
- chunk(view, ` - **@${member}** 지연 ${n}건\n`);
- }
- }
- }
-
- // ─── 6. 🎯 채용 ──────────────────────────────────────────────
- chunk(view, '\n## 🎯 채용\n');
- if (candidates.length === 0) {
- chunk(view, '- _데이터 없음_\n');
- } else {
- const hired = candidates.filter((c) => c.stage === 'hired').length;
- chunk(view, `- 진행 중 ${activeCandidate.length}명 · 합격 ${hired}\n`);
- // 단계별 카운트 (진행 중만)
- const stageCount = new Map();
- for (const c of activeCandidate) stageCount.set(c.stage, (stageCount.get(c.stage) || 0) + 1);
- const stages = [...stageCount.entries()].sort((a, b) => (STAGE_ORDER[a[0]] ?? 50) - (STAGE_ORDER[b[0]] ?? 50));
- if (stages.length > 0) {
- const parts = stages.map(([s, n]) => `${_stageEmoji(s)} ${s} ${n}`);
- chunk(view, ` - ${parts.join(' · ')}\n`);
- }
- if (stalled.length > 0) {
- chunk(view, `- ⏰ 정체 ${stalled.length}명:\n`);
- for (const c of stalled.slice(0, 3)) {
- const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
- chunk(view, ` - ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`);
- }
- }
- }
-
- // ─── 7. 📋 오늘의 액션 ──────────────────────────────────────
- chunk(view, '\n## 📋 오늘의 액션\n');
- const actions = _morningActions(runway, customers, upcomingRenewals, overdueTasks, stalled, candidates);
- if (actions.length === 0) {
- chunk(view, '- ✨ 특별한 조치 필요 없음. 깊은 작업 시간 확보 권장.\n');
- } else {
- for (const a of actions.slice(0, 5)) chunk(view, `- ${a}\n`);
- }
-
- return true;
-}
-
-/**
- * 데이터에서 자동으로 액션 3~5개 도출 — 우선순위 순.
- * 단순 휴리스틱: runway 위험 > 위험 고객 > 갱신 임박 > 지연 작업 > 정체 후보.
- */
-function _morningActions(
- runway: ReturnType,
- customers: CustomerState[],
- upcomingRenewals: Array<{ c: CustomerState; days: number | null }>,
- overdueTasks: any[],
- stalled: CandidateState[],
- candidates: CandidateState[],
-): string[] {
- const actions: string[] = [];
-
- if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 3) {
- actions.push(`💸 **자금 조달 계획** — 런웨이 ${runway.runwayMonths.toFixed(1)}개월. 투자자 미팅 / 비용 절감 즉시.`);
- }
-
- const atRisk = customers.filter((c) => c.status === 'at-risk').sort((a, b) => b.mrr - a.mrr);
- if (atRisk.length > 0) {
- const top = atRisk[0];
- actions.push(`📞 **${top.customerName}** 위험 대응 — MRR ${_fmtKrw(top.mrr)}원. 사유 점검 후 액션.`);
- }
-
- if (upcomingRenewals.length > 0) {
- const next = upcomingRenewals[0];
- actions.push(`📨 **${next.c.customerName}** 갱신 D-${next.days} — 갱신 의사 확인 / 가격 협의.`);
- }
-
- if (overdueTasks.length >= 5) {
- actions.push(`🚧 지연 작업 ${overdueTasks.length}건 — \`/blocked\` 로 멤버별 확인 후 우선순위 재조정.`);
- } else if (overdueTasks.length > 0) {
- actions.push(`🚧 지연 작업 ${overdueTasks.length}건 — \`/blocked\` 로 확인.`);
- }
-
- if (stalled.length > 0) {
- const next = stalled[0];
- const days = Math.floor((Date.now() - Date.parse(next.lastEventAt)) / (24 * 60 * 60 * 1000));
- actions.push(`👥 **${next.candidateName}** 채용 후속 — ${next.stage} 단계 ${days}일 정체.`);
- }
-
- // 채용 단계가 inbox 에 5명 이상 쌓이면 스크리닝 필요.
- const inboxCount = candidates.filter((c) => c.stage === 'inbox').length;
- if (inboxCount >= 5) {
- actions.push(`📥 채용 inbox ${inboxCount}명 누적 — 스크리닝 시간 확보.`);
- }
-
- return actions;
-}
-
-// ─── /evening — 하루 마무리 카드 ─────────────────────────────────────────
-// `/morning` 의 짝. 오늘 진척(완료 작업·고객/채용 이벤트·재무 기록) + 내일 준비
-// (다가오는 갱신·지연 작업·정체 후보) + 짧은 회고 프롬프트 한 줄.
-
-async function runEvening(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
- const today = new Date().toISOString().slice(0, 10);
- const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
- const dayStartMs = Date.parse(today + 'T00:00:00');
-
- chunk(view, `\n🌙 **오늘 (${today}) — 마무리 카드**\n`);
-
- // ─── 1. 오늘의 진척 ──────────────────────────────────────────
- // 1a. 완료된 Tasks (오늘 completed)
- let completedTasks: any[] = [];
- let tasksError: string | undefined;
- if (context) {
- try {
- const res = await listTasks(context, { showCompleted: true, maxResults: 300 });
- if (res.ok) {
- completedTasks = res.tasks.filter((t: any) => t.status === 'completed' && t.completed && Date.parse(t.completed) >= dayStartMs);
- } else {
- tasksError = res.error;
- }
- } catch (e: any) { tasksError = e?.message || String(e); }
- }
-
- // 1b. 오늘의 customer / hire / runway 이벤트
- const customerEvents = readCustomerEvents().filter((e) => Date.parse(e.timestamp) >= dayStartMs);
- const hireEvents = readHireEvents().filter((e) => Date.parse(e.timestamp) >= dayStartMs);
- const runwayToday = readRunway().filter((e) => Date.parse(e.timestamp) >= dayStartMs);
-
- chunk(view, '\n## ✅ 오늘의 진척\n');
- const progressEmpty = completedTasks.length === 0 && customerEvents.length === 0 && hireEvents.length === 0 && runwayToday.length === 0;
- if (progressEmpty) {
- chunk(view, '- _기록된 진척 없음._ (작업 완료 / 고객 이벤트 / 채용 이동 등이 오늘 입력되지 않음)\n');
- if (tasksError) chunk(view, ` _Tasks 조회 실패: ${tasksError}_\n`);
- } else {
- if (completedTasks.length > 0) {
- chunk(view, `\n### 작업 완료 (${completedTasks.length}건)\n`);
- // 멤버별 그룹
- const byOwner = new Map();
- for (const t of completedTasks) {
- const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
- const k = owner || '(미지정)';
- if (!byOwner.has(k)) byOwner.set(k, []);
- byOwner.get(k)!.push({ title: displayTitle });
- }
- const ranked = [...byOwner.entries()].sort((a, b) => b[1].length - a[1].length);
- for (const [owner, list] of ranked) {
- chunk(view, `- **@${owner}** (${list.length}건)\n`);
- for (const t of list.slice(0, 5)) chunk(view, ` - ${t.title}\n`);
- if (list.length > 5) chunk(view, ` - _…+${list.length - 5}건_\n`);
- }
- }
-
- if (customerEvents.length > 0) {
- chunk(view, `\n### 📒 고객 이벤트 (${customerEvents.length}건)\n`);
- for (const e of customerEvents.slice(0, 10)) {
- const tagEmoji = e.type === 'add' ? '➕' : e.type === 'renew' ? '🔄' : e.type === 'risk' ? '⚠️' : e.type === 'churn' ? '💀' : '📝';
- const detail = e.type === 'add' || e.type === 'renew' || e.type === 'update'
- ? (e.mrr !== undefined ? ` MRR ${_fmtKrw(e.mrr)}원` : '')
- : (e.memo ? ` — ${e.memo.slice(0, 60)}` : '');
- chunk(view, `- ${tagEmoji} ${e.customerName} ${e.type}${detail}\n`);
- }
- }
-
- if (hireEvents.length > 0) {
- chunk(view, `\n### 🎯 채용 이벤트 (${hireEvents.length}건)\n`);
- for (const e of hireEvents.slice(0, 10)) {
- const stageNote = e.stage ? ` → ${e.stage}` : '';
- const memo = e.memo ? ` — ${e.memo.slice(0, 60)}` : '';
- chunk(view, `- ${e.candidateName} ${e.type}${stageNote}${memo}\n`);
- }
- }
-
- if (runwayToday.length > 0) {
- chunk(view, `\n### 💰 재무 기록 (${runwayToday.length}건)\n`);
- for (const e of runwayToday) {
- const typeLabel = e.type === 'snapshot' ? '잔고' : e.type === 'expense' ? '지출' : e.type === 'revenue' ? '수입' : '월 burn';
- chunk(view, `- ${typeLabel}: ${_fmtKrw(e.amount)}원${e.memo ? ` — ${e.memo}` : ''}\n`);
- }
- }
- }
-
- // ─── 2. 내일 준비 ────────────────────────────────────────────
- chunk(view, '\n## 🌅 내일 준비\n');
-
- // 2a. 내일 마감 작업
- let tomorrowTasks: any[] = [];
- if (context && !tasksError) {
- try {
- const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
- if (res.ok) tomorrowTasks = res.tasks.filter((t: any) => t.due === tomorrow);
- } catch { /* ignore */ }
- }
- if (tomorrowTasks.length > 0) {
- chunk(view, `\n### 내일 마감 (${tomorrowTasks.length}건)\n`);
- for (const t of tomorrowTasks.slice(0, 8)) {
- const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
- chunk(view, `- **@${owner || '(미지정)'}** — ${displayTitle}\n`);
- }
- }
-
- // 2b. 7일 내 갱신 (다시 표시 — /morning 과 중복 가능하나 마무리에서 한 번 더 강조)
- const customers = Array.from(computeCustomerStates().values());
- const upcomingRenewals = customers.filter((c) => c.status !== 'churned')
- .map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
- .filter((x) => x.days !== null && x.days >= 0 && x.days <= 7);
- if (upcomingRenewals.length > 0) {
- chunk(view, `\n### 🔔 7일 내 갱신 (${upcomingRenewals.length}건)\n`);
- for (const { c, days } of upcomingRenewals.slice(0, 5)) {
- const emoji = (days as number) <= 3 ? '🔴' : '🟡';
- chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\n`);
- }
- }
-
- // 2c. 정체 후보
- const stalled = Array.from(computeCandidateStates().values())
- .filter((c) => !TERMINAL_STAGES.has(c.stage))
- .filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000);
- if (stalled.length > 0) {
- chunk(view, `\n### ⏰ 정체 후보 (${stalled.length}명)\n`);
- for (const c of stalled.slice(0, 3)) {
- const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
- chunk(view, `- ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`);
- }
- }
-
- if (tomorrowTasks.length === 0 && upcomingRenewals.length === 0 && stalled.length === 0) {
- chunk(view, '- _내일 마감·갱신 임박·정체 후보 모두 없음._ ✨\n');
- }
-
- // ─── 3. 회고 한 줄 ──────────────────────────────────────────
- const reflections = [
- '오늘 가장 중요한 한 가지는 무엇이었나? 의도한 대로 됐나?',
- '내일 무엇을 안 하기로 했나? 안 할 일을 정해야 할 일이 또렷해진다.',
- '오늘 한 결정 중 일주일 뒤에도 옳을 결정은 어느 것인가?',
- '시간이 가장 많이 든 활동은 가장 영향력 있는 활동과 일치했나?',
- '오늘 멤버들에게 충분한 명확함을 줬나? 무엇을 미루지 않고 답해야 하나?',
- '에너지가 가장 좋았던 30분은 무엇을 하던 때였나?',
- '오늘 안 한 일 중 내일도 안 해도 되는 일은 무엇인가?',
- '리스크 한 가지를 꼽는다면? 그것에 대해 누구와 이야기해야 하나?',
- ];
- // 날짜 기반 결정적 선택 — 같은 날 같은 질문 (재실행해도 동일).
- const idx = (Date.parse(today) / (24 * 60 * 60 * 1000)) % reflections.length;
- chunk(view, `\n## 🧭 회고\n> ${reflections[idx]}\n`);
-
- chunk(view, '\n_명령 한 줄로 기록 남기기:_ `/decisions` · `/feedback` · `/customers note` · `/hire note`\n');
- return true;
-}
-
-// ─── /memory — 메모리 라이프사이클 관리 ─────────────────────────────────
-// Temporal Markers (expiresAt) + Distillation Loop (stale episodes → LongTerm digest)
-// 의 사용자 진입점. 자동 트리거는 세션 종료 시, 수동은 여기서.
-
-function _formatDate(epoch: number | undefined): string {
- if (!epoch) return '-';
- return new Date(epoch).toISOString().slice(0, 10);
-}
-
-function _memoryOverview(view: any): void {
- const cfg = getConfig();
- const brainPath = cfg.localBrainPath;
- if (!brainPath) { chunk(view, '\n❌ Brain path 미설정.\n'); return; }
-
- // 직접 두 store 만 띄움 — 전체 MemoryManager 인스턴스화는 무거움.
- const mgr = new MemoryManager(brainPath, {
- longTermMaxEntries: cfg.memoryLongTermFiles ?? 100,
- episodicMaxEpisodes: 50,
- });
- const lt = mgr.getLongTermMemory();
- const ep = mgr.getEpisodicMemory();
- const allLt = lt.getAllEntries({ includeExpired: true });
- const activeLt = lt.getAllEntries();
- const expiredLt = allLt.length - activeLt.length;
- const allEpisodes = ep.loadAllEpisodes();
- const stalePromoted = allEpisodes.filter((e) => e.promoted).length;
- const staleCandidates = ep.findStaleEpisodes(cfg.distillationAgeThresholdDays).length;
- const last = getLastDistillationRun(brainPath);
-
- chunk(view, '\n🧠 **/memory — 메모리 라이프사이클**\n');
- chunk(view, `\n## 📊 현재 상태\n`);
- chunk(view, `- **LongTerm**: 활성 ${activeLt.length}개${expiredLt > 0 ? ` (만료 ${expiredLt}개 숨김)` : ''}\n`);
-
- // 카테고리 분포
- const catCounts = new Map();
- for (const e of activeLt) catCounts.set(e.category, (catCounts.get(e.category) || 0) + 1);
- if (catCounts.size > 0) {
- const parts = [...catCounts.entries()].map(([c, n]) => `${c} ${n}`).join(' · ');
- chunk(view, ` - 카테고리: ${parts}\n`);
- }
-
- chunk(view, `- **Episodic**: 전체 ${allEpisodes.length}개 (승급 ${stalePromoted} · 미승급 stale 후보 ${staleCandidates})\n`);
-
- chunk(view, `\n## 🔄 Distillation\n`);
- chunk(view, `- 임계: **${cfg.distillationAgeThresholdDays}일** 이상 stale episode → LongTerm 'episode-digest' 승급\n`);
- chunk(view, `- 자동 트리거 간격: ${cfg.distillationIntervalDays}일\n`);
- chunk(view, `- Archive 모드: \`${cfg.distillationArchiveMode}\`\n`);
- if (last) {
- const ago = Math.floor((Date.now() - last.timestamp) / (24 * 60 * 60 * 1000));
- chunk(view, `- 마지막 실행: ${_formatDate(last.timestamp)} (${ago}일 전) — 승급 ${last.report?.promotedCount ?? 0}개\n`);
- } else {
- chunk(view, `- 마지막 실행: _없음_ — \`/memory distill\` 로 첫 실행\n`);
- }
-
- // 만료 임박 LongTerm
- const upcoming = activeLt
- .filter((e) => e.expiresAt && (e.expiresAt - Date.now()) <= 30 * 24 * 60 * 60 * 1000)
- .sort((a, b) => (a.expiresAt || 0) - (b.expiresAt || 0));
- if (upcoming.length > 0) {
- chunk(view, `\n## ⏰ 30일 내 만료 LongTerm (${upcoming.length}개)\n`);
- for (const e of upcoming.slice(0, 5)) {
- const days = Math.ceil(((e.expiresAt || 0) - Date.now()) / (24 * 60 * 60 * 1000));
- chunk(view, `- D-${days} [${e.category}] \`${e.id.slice(0, 8)}\` ${e.content.slice(0, 80)}\n`);
- }
- if (upcoming.length > 5) chunk(view, `- _…+${upcoming.length - 5}개_\n`);
- }
-
- chunk(view, `\n_명령어:_ \`/memory distill\` · \`/memory expire \` · \`/memory list-expiring [days]\` · \`/memory help\`\n`);
-}
-
-async function runMemory(arg: string, view: any, _context?: vscode.ExtensionContext): Promise {
- const trimmed = arg.trim();
- if (!trimmed) { _memoryOverview(view); return true; }
-
- const parts = trimmed.split(/\s+/);
- const sub = parts[0].toLowerCase();
- const cfg = getConfig();
- const brainPath = cfg.localBrainPath;
- if (!brainPath) { chunk(view, '\n❌ Brain path 미설정.\n'); return true; }
-
- if (sub === 'help' || sub === '?') {
- chunk(view, [
- '\n🧠 **/memory — 메모리 라이프사이클**',
- '',
- '사용법:',
- ' `/memory` — 현재 상태 (LongTerm/Episodic 카운트, distillation, 만료 임박)',
- ' `/memory distill [age_days]` — Stale episodes → LongTerm digest 승급 (기본 30일 임계)',
- ' `/memory expire ` — LongTerm entry 에 만료일 설정',
- ' `/memory list-expiring [days]` — N일 내 만료 LongTerm 목록 (기본 30)',
- ' `/memory list-promoted` — 승급된 episodes (digest 형태로 LongTerm 에 살아 있음)',
- '',
- 'Temporal Markers: LongTerm entry 의 expiresAt < now 이면 검색에서 자동 제외.',
- 'Distillation Loop: 자동 트리거는 세션 종료 시 (interval 기준), 수동은 `/memory distill`.',
- '저장: `{brainPath}/memory/long_term.json` + `{brainPath}/memory/episodes/*.json`.\n',
- ].join('\n'));
- return true;
- }
-
- if (sub === 'distill') {
- const ageOverride = parts[1] ? parseInt(parts[1], 10) : undefined;
- const age = Number.isFinite(ageOverride) && (ageOverride as number) > 0
- ? (ageOverride as number)
- : cfg.distillationAgeThresholdDays;
- chunk(view, `\n🔄 Distillation 시작 — ${age}일 이상 stale episodes 대상...\n`);
- const mgr = new MemoryManager(brainPath);
- const report = distillStaleEpisodes(mgr.getEpisodicMemory(), mgr.getLongTermMemory(), brainPath, {
- ageThresholdDays: age,
- archiveMode: cfg.distillationArchiveMode as DistillationArchiveMode,
- });
- recordDistillationRun(brainPath, report);
- chunk(view, `\n✅ **Distillation 완료** (${report.durationMs}ms)\n`);
- chunk(view, `- 후보 ${report.candidateCount}개 → 승급 ${report.promotedCount}개`);
- chunk(view, cfg.distillationArchiveMode === 'archive-file' ? ` · 아카이브 ${report.archivedCount}개\n` : '\n');
- if (report.skipped.length > 0) {
- chunk(view, `- ⚠️ 스킵 ${report.skipped.length}개:\n`);
- for (const s of report.skipped.slice(0, 3)) chunk(view, ` - \`${s.episodeId.slice(0, 8)}\`: ${s.reason}\n`);
- }
- if (report.longTermDigestIds.length > 0) {
- chunk(view, `\n_생성된 digest IDs (앞 8자):_ ${report.longTermDigestIds.slice(0, 5).map((id) => `\`${id.slice(0, 8)}\``).join(' · ')}\n`);
- }
- return true;
- }
-
- if (sub === 'expire') {
- const idPrefix = parts[1];
- const dateStr = parts[2];
- if (!idPrefix || !dateStr) {
- chunk(view, '\n❌ 사용법: `/memory expire `\n예: `/memory expire a3f7d2c9 2026-09-30`\n');
- return true;
- }
- if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
- chunk(view, `\n❌ 날짜 형식: YYYY-MM-DD (입력: "${dateStr}")\n`);
- return true;
- }
- const expiresAt = Date.parse(dateStr + 'T23:59:59');
- if (!Number.isFinite(expiresAt)) { chunk(view, `\n❌ 날짜 파싱 실패: "${dateStr}"\n`); return true; }
- const mgr = new MemoryManager(brainPath);
- const updated = mgr.getLongTermMemory().setExpiration(idPrefix, expiresAt);
- if (!updated) { chunk(view, `\n❌ "${idPrefix}" prefix 매치 entry 없음.\n`); return true; }
- chunk(view, `\n⏰ **${updated.category}** \`${updated.id.slice(0, 8)}\` 만료일 설정 → ${dateStr}\n`);
- chunk(view, `> ${updated.content.slice(0, 120)}\n`);
- return true;
- }
-
- if (sub === 'list-expiring') {
- const days = parts[1] ? parseInt(parts[1], 10) : 30;
- const window = Number.isFinite(days) && days > 0 ? days : 30;
- const mgr = new MemoryManager(brainPath);
- const all = mgr.getLongTermMemory().getAllEntries();
- const horizon = Date.now() + window * 24 * 60 * 60 * 1000;
- const upcoming = all
- .filter((e) => e.expiresAt && e.expiresAt <= horizon)
- .sort((a, b) => (a.expiresAt || 0) - (b.expiresAt || 0));
- if (upcoming.length === 0) { chunk(view, `\nℹ️ ${window}일 내 만료 예정 entry 없음.\n`); return true; }
- chunk(view, `\n⏰ **${window}일 내 만료 LongTerm (${upcoming.length}개)**\n\n`);
- for (const e of upcoming) {
- const d = Math.ceil(((e.expiresAt || 0) - Date.now()) / (24 * 60 * 60 * 1000));
- chunk(view, `- D-${d} [${e.category}] \`${e.id.slice(0, 8)}\` ${e.content.slice(0, 100)}\n`);
- }
- return true;
- }
-
- if (sub === 'list-promoted') {
- const mgr = new MemoryManager(brainPath);
- const promoted = mgr.getEpisodicMemory().loadAllEpisodes().filter((e) => e.promoted);
- if (promoted.length === 0) { chunk(view, '\nℹ️ 승급된 episode 없음. `/memory distill` 로 첫 승급.\n'); return true; }
- chunk(view, `\n📚 **승급된 episodes (${promoted.length}개)** — LongTerm 에 digest 형태로 살아 있음\n\n`);
- for (const e of promoted.slice(0, 15)) {
- const date = (new Date(e.timestamp)).toISOString().slice(0, 10);
- chunk(view, `- \`${date}\` ${e.title} → digest \`${(e.promotedToLongTermId || '').slice(0, 8)}\`\n`);
- }
- if (promoted.length > 15) chunk(view, `- _…+${promoted.length - 15}개_\n`);
- return true;
- }
-
- chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/memory help\` 참조.\n`);
- return true;
-}
-
-// ─── /cohort — MoM 추세 분석 (customers + runway events 결합) ─────────────
-// `/customers` event log 와 `/runway` event log 의 timestamp 를 월별 그룹핑해
-// "이번 달 vs 지난 달" 추세를 한 화면에. 회계 SaaS 의 board metric 의 미니 버전.
-
-interface MonthlyBucket {
- yearMonth: string; // 'YYYY-MM'
- newCustomers: number;
- churnedCustomers: number;
- renewals: number;
- mrrDelta: number; // 이 달의 customers 이벤트로 인한 MRR 변화 합
- expenseTotal: number; // 이 달의 expense 합계
- revenueTotal: number; // 이 달의 revenue 합계
- cashSnapshots: number[]; // 이 달의 cash snapshot 들 (마지막 = 월말 잔고 근사)
-}
-
-function _yearMonth(iso: string): string {
- return (iso || '').slice(0, 7);
-}
-
-function _buildMonthlyBuckets(monthsBack: number): Map {
- const map = new Map();
- const now = new Date();
- // 빈 버킷 미리 생성 — 이벤트 없는 달도 행으로 표시.
- for (let i = monthsBack - 1; i >= 0; i--) {
- const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
- const ym = d.toISOString().slice(0, 7);
- map.set(ym, {
- yearMonth: ym,
- newCustomers: 0, churnedCustomers: 0, renewals: 0,
- mrrDelta: 0, expenseTotal: 0, revenueTotal: 0,
- cashSnapshots: [],
- });
- }
-
- // customers events
- for (const e of readCustomerEvents()) {
- const ym = _yearMonth(e.timestamp);
- const b = map.get(ym);
- if (!b) continue;
- if (e.type === 'add') {
- b.newCustomers++;
- if (e.mrr) b.mrrDelta += e.mrr;
- } else if (e.type === 'churn') {
- b.churnedCustomers++;
- // churn 의 mrr 감소는 직전 mrr 만큼 빼지만 event 에 그 값이 없음. 보수적으로 0.
- } else if (e.type === 'renew') {
- b.renewals++;
- // renew 도 mrrDelta 변화 가능 — 새 mrr 가 있을 때만 반영.
- } else if (e.type === 'update' && e.mrr !== undefined) {
- // update 는 새 mrr 와 직전 mrr 차이를 모르므로 0. 향후 state 추적 시 보강.
- }
- }
-
- // runway events
- for (const e of readRunway()) {
- const ym = _yearMonth(e.timestamp);
- const b = map.get(ym);
- if (!b) continue;
- if (e.type === 'expense') b.expenseTotal += e.amount;
- else if (e.type === 'revenue') b.revenueTotal += e.amount;
- else if (e.type === 'snapshot') b.cashSnapshots.push(e.amount);
- }
-
- return map;
-}
-
-function _cohortDashboard(view: any, monthsBack: number): void {
- const buckets = _buildMonthlyBuckets(monthsBack);
- if (buckets.size === 0) {
- chunk(view, '\nℹ️ 데이터 없음. `/customers add` / `/runway cash` 로 시작.\n');
- return;
- }
-
- const rows = Array.from(buckets.values());
- chunk(view, `\n📈 **/cohort — 최근 ${monthsBack}개월 추세**\n`);
-
- // ─── 표: MRR / 고객 추이 ───
- chunk(view, '\n## 고객 & MRR 추이\n');
- chunk(view, '| 월 | 신규 | 갱신 | 이탈 | MRR Δ |\n');
- chunk(view, '|---|---:|---:|---:|---:|\n');
- for (const r of rows) {
- chunk(view, `| ${r.yearMonth} | ${r.newCustomers} | ${r.renewals} | ${r.churnedCustomers} | ${r.mrrDelta > 0 ? '+' : ''}${_fmtKrw(r.mrrDelta)} |\n`);
- }
-
- // 합계 + 증감
- const totNew = rows.reduce((s, r) => s + r.newCustomers, 0);
- const totChurn = rows.reduce((s, r) => s + r.churnedCustomers, 0);
- const totMrr = rows.reduce((s, r) => s + r.mrrDelta, 0);
- chunk(view, `\n- **누적 ${monthsBack}개월**: 신규 +${totNew} · 이탈 -${totChurn} · 순 ${totNew - totChurn >= 0 ? '+' : ''}${totNew - totChurn}\n`);
- chunk(view, `- **MRR 순증**: ${totMrr >= 0 ? '+' : ''}${_fmtKrw(totMrr)}원/월 _(추가된 신규 MRR 만, 이탈로 인한 감소는 history 부재로 미반영)_\n`);
-
- // Churn rate (월평균)
- const avgNew = totNew / monthsBack;
- const avgChurn = totChurn / monthsBack;
- if (avgNew > 0 || avgChurn > 0) {
- chunk(view, `- 월평균 신규 ${avgNew.toFixed(1)}곳, 월평균 이탈 ${avgChurn.toFixed(1)}곳`);
- if (totNew > 0) chunk(view, ` (이탈/신규 비율 ${((totChurn / totNew) * 100).toFixed(0)}%)\n`);
- else chunk(view, '\n');
- }
-
- // ─── 재무 추이 ───
- chunk(view, '\n## 재무 추이\n');
- chunk(view, '| 월 | 지출 | 수입 | 순 burn | 월말 잔고 |\n');
- chunk(view, '|---|---:|---:|---:|---:|\n');
- for (const r of rows) {
- const netBurn = r.expenseTotal - r.revenueTotal;
- const lastCash = r.cashSnapshots.length > 0 ? r.cashSnapshots[r.cashSnapshots.length - 1] : null;
- const cashCell = lastCash !== null ? _fmtKrw(lastCash) : '-';
- chunk(view, `| ${r.yearMonth} | ${_fmtKrw(r.expenseTotal)} | ${_fmtKrw(r.revenueTotal)} | ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)} | ${cashCell} |\n`);
- }
-
- const totExp = rows.reduce((s, r) => s + r.expenseTotal, 0);
- const totRev = rows.reduce((s, r) => s + r.revenueTotal, 0);
- const totBurn = totExp - totRev;
- const avgBurn = totBurn / monthsBack;
- chunk(view, `\n- **${monthsBack}개월 누계**: 지출 ${_fmtKrw(totExp)} · 수입 ${_fmtKrw(totRev)} · 순 burn ${totBurn > 0 ? '+' : ''}${_fmtKrw(totBurn)}\n`);
- chunk(view, `- **월평균 burn**: ${_fmtKrw(avgBurn)}원/월\n`);
-
- // ─── 인사이트 한 줄 ───
- chunk(view, '\n## 💡 인사이트\n');
- const insights: string[] = [];
- // 신규/이탈 추세 — 최근 3개월 vs 그 이전
- if (monthsBack >= 6) {
- const recent3 = rows.slice(-3);
- const prior3 = rows.slice(-6, -3);
- const recentNew = recent3.reduce((s, r) => s + r.newCustomers, 0);
- const priorNew = prior3.reduce((s, r) => s + r.newCustomers, 0);
- if (recentNew > priorNew * 1.2) insights.push('🟢 최근 3개월 신규 획득 가속 (이전 3개월 대비 +20%↑)');
- else if (recentNew < priorNew * 0.8 && priorNew >= 2) insights.push('🟡 최근 3개월 신규 획득 둔화 (이전 3개월 대비 -20%↓)');
-
- const recentBurn = recent3.reduce((s, r) => s + (r.expenseTotal - r.revenueTotal), 0);
- const priorBurn = prior3.reduce((s, r) => s + (r.expenseTotal - r.revenueTotal), 0);
- if (priorBurn > 0 && recentBurn > priorBurn * 1.3) insights.push('🔴 최근 3개월 burn 가속 (이전 3개월 대비 +30%↑) — 비용 점검 권장');
- }
- if (avgBurn > 0 && totRev > 0) {
- const coverage = totRev / totExp;
- if (coverage > 0.8) insights.push(`🟢 매출 커버리지 ${(coverage * 100).toFixed(0)}% — 흑자 진입 임박`);
- else if (coverage < 0.2 && totExp > 0) insights.push(`🟡 매출 커버리지 ${(coverage * 100).toFixed(0)}% — 매출 기반 약함`);
- }
- if (insights.length === 0) {
- chunk(view, '- _데이터 부족 또는 추세 신호 약함._ 더 누적되면 인사이트 표시.\n');
- } else {
- for (const i of insights) chunk(view, `- ${i}\n`);
- }
-
- chunk(view, '\n_데이터 출처: `.astra/customers.jsonl` + `.astra/runway.jsonl`. 더 많은 이벤트 누적 시 추세 정확도↑._\n');
-}
-
-async function runCohort(arg: string, view: any, _context?: vscode.ExtensionContext): Promise {
- const trimmed = arg.trim().toLowerCase();
- if (trimmed === 'help' || trimmed === '?') {
- chunk(view, [
- '\n📈 **/cohort — MoM 추세 분석**',
- '',
- '사용법:',
- ' `/cohort` — 최근 6개월 추세 (기본)',
- ' `/cohort yearly` — 최근 12개월',
- ' `/cohort ` — 최근 N개월 (1~24)',
- '',
- '데이터 출처: `/customers` events + `/runway` events 의 timestamp 월별 그룹핑.',
- '표시 항목: 신규/갱신/이탈 + MRR 변화 + 지출/수입/burn + 월말 잔고 + 인사이트 한 줄.\n',
- ].join('\n'));
- return true;
- }
-
- let monthsBack = 6;
- if (trimmed === 'yearly' || trimmed === 'year') monthsBack = 12;
- else if (/^\d+$/.test(trimmed)) {
- monthsBack = Math.max(1, Math.min(24, parseInt(trimmed, 10)));
- }
-
- _cohortDashboard(view, monthsBack);
- return true;
-}
-
-// ─── /weekly — 주간 리뷰 카드 (CEO 전략 시야) ────────────────────────────
-// `/morning` 일 단위, `/evening` 일 끝 — 그 *주 단위 짝*. 7일 진척 + 지난 주 대비
-// + 다음 주 준비 + 회고. `/standup weekly` (팀 공유) 와 달리 *대표가 본다* 용도 —
-// cross-data delta 포커스.
-
-function _isoWeek(d: Date): { year: number; week: number; label: string } {
- // ISO week (월요일 시작). 단순 구현 — Thursday-of-week 트릭.
- const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
- const dayNum = (target.getUTCDay() + 6) % 7;
- target.setUTCDate(target.getUTCDate() - dayNum + 3);
- const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
- const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
- firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
- const week = 1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000));
- return { year: target.getUTCFullYear(), week, label: `${target.getUTCFullYear()}-W${String(week).padStart(2, '0')}` };
-}
-
-interface WeeklyWindow {
- startIso: string; // YYYY-MM-DD (inclusive)
- endIso: string; // YYYY-MM-DD (inclusive)
- startMs: number;
- endMs: number;
- label: string; // 'YYYY-Wnn (5/22-5/28)'
-}
-
-function _thisWeekWindow(now: Date = new Date()): WeeklyWindow {
- // 월요일 00:00 ~ 일요일 23:59:59
- const dayOfWeek = now.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
- const daysFromMonday = (dayOfWeek + 6) % 7;
- const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysFromMonday);
- const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6, 23, 59, 59);
- const { label: yw } = _isoWeek(monday);
- const startIso = monday.toISOString().slice(0, 10);
- const endIso = sunday.toISOString().slice(0, 10);
- const shortStart = `${monday.getMonth() + 1}/${monday.getDate()}`;
- const shortEnd = `${sunday.getMonth() + 1}/${sunday.getDate()}`;
- return {
- startIso, endIso,
- startMs: monday.getTime(), endMs: sunday.getTime(),
- label: `${yw} (${shortStart}-${shortEnd})`,
- };
-}
-
-function _priorWeekWindow(thisWeek: WeeklyWindow): WeeklyWindow {
- const priorMonday = new Date(thisWeek.startMs - 7 * 24 * 60 * 60 * 1000);
- const priorSunday = new Date(thisWeek.startMs - 1000);
- const { label: yw } = _isoWeek(priorMonday);
- const startIso = priorMonday.toISOString().slice(0, 10);
- const endIso = priorSunday.toISOString().slice(0, 10);
- const shortStart = `${priorMonday.getMonth() + 1}/${priorMonday.getDate()}`;
- const shortEnd = `${priorSunday.getMonth() + 1}/${priorSunday.getDate()}`;
- return {
- startIso, endIso,
- startMs: priorMonday.getTime(), endMs: priorSunday.getTime(),
- label: `${yw} (${shortStart}-${shortEnd})`,
- };
-}
-
-interface WeeklyAggregate {
- taskCompleted: number;
- taskByOwner: Map;
- customerEvents: number;
- customerNewCount: number;
- customerRenewCount: number;
- customerRiskCount: number;
- customerChurnCount: number;
- customerNewMrr: number;
- hireEvents: number;
- hireMoved: number;
- hireAdded: number;
- hireHired: number;
- runwayExpense: number;
- runwayRevenue: number;
- runwayLastCash: number | null;
- runwayFirstCash: number | null;
- adrCount: number;
-}
-
-function _aggregateWeek(
- win: WeeklyWindow,
- completedTasks: any[],
- cevs: CustomerEvent[],
- hevs: HireEvent[],
- rs: RunwayEntry[],
- adrs: { date: string; title: string }[],
-): WeeklyAggregate {
- const agg: WeeklyAggregate = {
- taskCompleted: 0, taskByOwner: new Map(),
- customerEvents: 0, customerNewCount: 0, customerRenewCount: 0,
- customerRiskCount: 0, customerChurnCount: 0, customerNewMrr: 0,
- hireEvents: 0, hireMoved: 0, hireAdded: 0, hireHired: 0,
- runwayExpense: 0, runwayRevenue: 0, runwayLastCash: null, runwayFirstCash: null,
- adrCount: 0,
- };
-
- // Tasks completed in window
- for (const t of completedTasks) {
- if (!t.completed) continue;
- const ms = Date.parse(t.completed);
- if (ms < win.startMs || ms > win.endMs) continue;
- agg.taskCompleted++;
- const { owner } = parseTaskOwner(t.title, t.notes);
- const k = owner || '(미지정)';
- agg.taskByOwner.set(k, (agg.taskByOwner.get(k) || 0) + 1);
- }
-
- // Customers
- for (const e of cevs) {
- const ms = Date.parse(e.timestamp);
- if (ms < win.startMs || ms > win.endMs) continue;
- agg.customerEvents++;
- if (e.type === 'add') { agg.customerNewCount++; if (e.mrr) agg.customerNewMrr += e.mrr; }
- else if (e.type === 'renew') agg.customerRenewCount++;
- else if (e.type === 'risk') agg.customerRiskCount++;
- else if (e.type === 'churn') agg.customerChurnCount++;
- }
-
- // Hire
- for (const e of hevs) {
- const ms = Date.parse(e.timestamp);
- if (ms < win.startMs || ms > win.endMs) continue;
- agg.hireEvents++;
- if (e.type === 'add') agg.hireAdded++;
- else if (e.type === 'stage') agg.hireMoved++;
- else if (e.type === 'hire') agg.hireHired++;
- }
-
- // Runway — first/last cash snapshot within window
- const cashInWin = rs
- .filter((r) => r.type === 'snapshot')
- .filter((r) => { const ms = Date.parse(r.timestamp); return ms >= win.startMs && ms <= win.endMs; })
- .sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
- if (cashInWin.length > 0) {
- agg.runwayFirstCash = cashInWin[0].amount;
- agg.runwayLastCash = cashInWin[cashInWin.length - 1].amount;
- }
- for (const e of rs) {
- const ms = Date.parse(e.timestamp);
- if (ms < win.startMs || ms > win.endMs) continue;
- if (e.type === 'expense') agg.runwayExpense += e.amount;
- else if (e.type === 'revenue') agg.runwayRevenue += e.amount;
- }
-
- // ADRs
- for (const a of adrs) {
- if (a.date >= win.startIso && a.date <= win.endIso) agg.adrCount++;
- }
-
- return agg;
-}
-
-function _deltaSymbol(now: number, prev: number): string {
- if (prev === 0 && now === 0) return '→';
- if (prev === 0) return `↑${now}`;
- const diff = now - prev;
- if (diff > 0) return `↑${diff}`;
- if (diff < 0) return `↓${Math.abs(diff)}`;
- return '→';
-}
-
-async function runWeekly(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
- const trimmed = arg.trim().toLowerCase();
- if (trimmed === 'help' || trimmed === '?') {
- chunk(view, [
- '\n📅 **/weekly — 주간 리뷰 카드 (대표용)**',
- '',
- '사용법:',
- ' `/weekly` — 이번 주 (월~일) 리뷰 + 지난 주 대비 + 다음 주 준비',
- '',
- '`/standup weekly` 와 차이:',
- '- `/standup weekly` → 팀 공유용 (멤버별 완료/진행/블로커, 슬랙 복붙)',
- '- `/weekly` → 대표용 전략 시야 (cross-data delta, 회고 프롬프트)',
- '',
- '데이터 출처: Google Tasks + /customers + /hire + /runway + Chronicle ADR.\n',
- ].join('\n'));
- return true;
- }
-
- const thisWeek = _thisWeekWindow();
- const priorWeek = _priorWeekWindow(thisWeek);
- chunk(view, `\n📅 **/weekly — 주간 리뷰 ${thisWeek.label}**\n`);
-
- // ─── 데이터 수집 ──────────────────────────────────────────────
- let completedTasks: any[] = [];
- let tasksError: string | undefined;
- if (context) {
- try {
- const res = await listTasks(context, { showCompleted: true, maxResults: 500 });
- if (res.ok) completedTasks = res.tasks.filter((t: any) => t.status === 'completed');
- else tasksError = res.error;
- } catch (e: any) { tasksError = e?.message || String(e); }
- }
-
- const cevs = readCustomerEvents();
- const hevs = readHireEvents();
- const rs = readRunway();
-
- // ADR 수집 — chronicleProjectStore 직접 fs 스캔.
- const adrs: { date: string; title: string }[] = [];
- try {
- const folders = vscode.workspace.workspaceFolders;
- if (folders && folders.length > 0) {
- const wsRoot = folders[0].uri.fsPath;
- const cs = new ChronicleProjectStore(context!);
- const projects = cs.getAll();
- // 워크스페이스 매칭 프로젝트만
- for (const p of projects) {
- const recRoot = p.recordRoot;
- const decisionsDir = path.join(recRoot, 'decisions');
- if (!fs.existsSync(decisionsDir)) continue;
- const files = fs.readdirSync(decisionsDir).filter((f) => f.startsWith('ADR-') && f.endsWith('.md'));
- for (const f of files) {
- try {
- const full = path.join(decisionsDir, f);
- const stat = fs.statSync(full);
- const d = new Date(stat.mtimeMs).toISOString().slice(0, 10);
- const title = f.replace(/^ADR-\d+-/, '').replace(/\.md$/, '').replace(/[-_]/g, ' ');
- adrs.push({ date: d, title });
- } catch { /* skip */ }
- }
- }
- }
- } catch { /* ignore */ }
-
- const aggNow = _aggregateWeek(thisWeek, completedTasks, cevs, hevs, rs, adrs);
- const aggPrev = _aggregateWeek(priorWeek, completedTasks, cevs, hevs, rs, adrs);
-
- // ─── Section 1: 이번 주 진척 ──────────────────────────────────
- chunk(view, '\n## ✅ 이번 주 진척\n');
-
- // 작업
- if (tasksError) {
- chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`);
- } else if (aggNow.taskCompleted === 0) {
- chunk(view, '- _완료된 작업 없음._\n');
- } else {
- chunk(view, `\n### 작업 (${aggNow.taskCompleted}건 완료)\n`);
- const ranked = [...aggNow.taskByOwner.entries()].sort((a, b) => b[1] - a[1]);
- for (const [owner, n] of ranked) {
- const prev = aggPrev.taskByOwner.get(owner) || 0;
- chunk(view, `- **@${owner}**: ${n}건 ${_deltaSymbol(n, prev)}\n`);
- }
- }
-
- // 고객
- if (aggNow.customerEvents > 0) {
- chunk(view, `\n### 📒 고객 (${aggNow.customerEvents} 이벤트)\n`);
- if (aggNow.customerNewCount > 0) chunk(view, `- 신규 ${aggNow.customerNewCount}곳 _(MRR +${_fmtKrw(aggNow.customerNewMrr)})_\n`);
- if (aggNow.customerRenewCount > 0) chunk(view, `- 갱신 ${aggNow.customerRenewCount}곳\n`);
- if (aggNow.customerRiskCount > 0) chunk(view, `- ⚠️ 위험 표시 ${aggNow.customerRiskCount}곳\n`);
- if (aggNow.customerChurnCount > 0) chunk(view, `- 💀 이탈 ${aggNow.customerChurnCount}곳\n`);
- }
-
- // 채용
- if (aggNow.hireEvents > 0) {
- chunk(view, `\n### 🎯 채용 (${aggNow.hireEvents} 이벤트)\n`);
- if (aggNow.hireAdded > 0) chunk(view, `- 신규 후보 ${aggNow.hireAdded}명\n`);
- if (aggNow.hireMoved > 0) chunk(view, `- 단계 이동 ${aggNow.hireMoved}건\n`);
- if (aggNow.hireHired > 0) chunk(view, `- 🎉 합격 ${aggNow.hireHired}명\n`);
- }
-
- // 재무
- if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0 || aggNow.runwayLastCash !== null) {
- chunk(view, '\n### 💰 재무\n');
- if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0) {
- const netBurn = aggNow.runwayExpense - aggNow.runwayRevenue;
- chunk(view, `- 지출 ${_fmtKrw(aggNow.runwayExpense)} · 수입 ${_fmtKrw(aggNow.runwayRevenue)} · 순 burn ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)}\n`);
- }
- if (aggNow.runwayLastCash !== null && aggNow.runwayFirstCash !== null) {
- const delta = aggNow.runwayLastCash - aggNow.runwayFirstCash;
- chunk(view, `- 잔고: ${_fmtKrw(aggNow.runwayFirstCash)} → ${_fmtKrw(aggNow.runwayLastCash)} _(Δ ${delta >= 0 ? '+' : ''}${_fmtKrw(delta)})_\n`);
- } else if (aggNow.runwayLastCash !== null) {
- chunk(view, `- 현재 잔고: ${_fmtKrw(aggNow.runwayLastCash)}\n`);
- }
- }
-
- if (aggNow.adrCount > 0) {
- chunk(view, `\n### 📋 결정 (ADR ${aggNow.adrCount}건)\n`);
- const weekAdrs = adrs.filter((a) => a.date >= thisWeek.startIso && a.date <= thisWeek.endIso).slice(0, 5);
- for (const a of weekAdrs) chunk(view, `- ${a.date} — ${a.title}\n`);
- }
-
- // ─── Section 2: 지난 주 대비 ──────────────────────────────────
- chunk(view, `\n## 🔄 지난 주 대비 (${priorWeek.label})\n`);
- const compRows: string[] = [];
- compRows.push(`- 작업: ${aggNow.taskCompleted}건 ${_deltaSymbol(aggNow.taskCompleted, aggPrev.taskCompleted)} _(이번 ${aggNow.taskCompleted} ← 지난 ${aggPrev.taskCompleted})_`);
- compRows.push(`- 신규 고객: ${aggNow.customerNewCount} ${_deltaSymbol(aggNow.customerNewCount, aggPrev.customerNewCount)}`);
- compRows.push(`- 이탈: ${aggNow.customerChurnCount} ${_deltaSymbol(aggNow.customerChurnCount, aggPrev.customerChurnCount)}`);
- const burnNow = aggNow.runwayExpense - aggNow.runwayRevenue;
- const burnPrev = aggPrev.runwayExpense - aggPrev.runwayRevenue;
- if (burnNow !== 0 || burnPrev !== 0) {
- const diff = burnNow - burnPrev;
- const arrow = diff > 0 ? '↑' : diff < 0 ? '↓' : '→';
- compRows.push(`- 순 burn: ${_fmtKrw(burnNow)} ${arrow} _(지난 ${_fmtKrw(burnPrev)})_`);
- }
- compRows.push(`- 채용 이벤트: ${aggNow.hireEvents} ${_deltaSymbol(aggNow.hireEvents, aggPrev.hireEvents)}`);
- for (const r of compRows) chunk(view, r + '\n');
-
- // ─── Section 3: 다음 주 준비 ──────────────────────────────────
- chunk(view, '\n## 🌅 다음 주 준비\n');
-
- // 7일 내 갱신
- const customerStates = computeCustomerStates();
- const upcoming = Array.from(customerStates.values())
- .filter((c) => c.status !== 'churned')
- .map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
- .filter((x) => x.days !== null && x.days >= 0 && x.days <= 14)
- .sort((a, b) => (a.days as number) - (b.days as number));
- if (upcoming.length > 0) {
- chunk(view, `\n### 🔔 14일 내 갱신 (${upcoming.length}건)\n`);
- for (const { c, days } of upcoming.slice(0, 5)) {
- const emoji = (days as number) <= 7 ? '🔴' : '🟡';
- chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\n`);
- }
- }
-
- // 다음 주 마감 작업
- let nextWeekDue = 0;
- if (!tasksError && context) {
- try {
- const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
- if (res.ok) {
- const startNext = new Date(thisWeek.endMs + 1000);
- const endNext = new Date(thisWeek.endMs + 7 * 24 * 60 * 60 * 1000);
- const startIso = startNext.toISOString().slice(0, 10);
- const endIso = endNext.toISOString().slice(0, 10);
- nextWeekDue = res.tasks.filter((t: any) => t.due && t.due >= startIso && t.due <= endIso).length;
- }
- } catch { /* ignore */ }
- }
- if (nextWeekDue > 0) chunk(view, `\n- 📅 다음 주 마감 작업: ${nextWeekDue}건\n`);
-
- // 정체 후보
- const stalled = Array.from(computeCandidateStates().values())
- .filter((c) => !TERMINAL_STAGES.has(c.stage))
- .filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000);
- if (stalled.length > 0) {
- chunk(view, `\n### ⏰ 정체 후보 (${stalled.length}명)\n`);
- for (const c of stalled.slice(0, 3)) {
- const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
- chunk(view, `- ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`);
- }
- }
-
- // ─── Section 4: 주간 회고 프롬프트 ───────────────────────────
- const reflections = [
- '이번 주 가장 중요한 결정은 무엇이었나? 다음 주에 어떤 결정을 미루면 안 되나?',
- '이번 주 가장 시간을 많이 쓴 활동이 가장 영향력 있는 활동과 일치했나?',
- '이번 주 멤버 중 누구와 1:1 시간이 충분치 않았나? 다음 주 일정 잡기.',
- '이번 주 *안 한 일* 중 다음 주에도 안 해도 되는 일은 무엇인가?',
- '이번 주 발견한 리스크 한 가지를 꼽는다면? 누구와 이야기해야 하나?',
- '이번 주 가장 좋았던 30분은 무엇을 하던 때였나? 다음 주에 어떻게 복제할까?',
- ];
- const weekKey = Math.floor(thisWeek.startMs / (1000 * 60 * 60 * 24 * 7));
- const idx = weekKey % reflections.length;
- chunk(view, `\n## 💭 주간 회고\n> ${reflections[idx]}\n`);
-
- chunk(view, '\n_답을 어디 적으면 좋은지:_ `/decisions` (ADR 작성) · `/feedback` (고객 학습) · `/onesie @멤버` (1:1 준비)\n');
- return true;
-}
-
-// ─── /glossary — Terminology Dictionary 사용자 진입점 ───────────────────
-// 사용자 편집 글로서리 파일(.astra/glossary.md) 의 상태/경로/초기화/캐시 비우기.
-// 글로서리 본문은 *에디터에서 직접 편집* — 슬래시 명령은 admin/util 역할.
-
-async function runGlossary(arg: string, view: any, _context?: vscode.ExtensionContext): Promise {
- const trimmed = arg.trim().toLowerCase();
- const cfg = getConfig();
- const fp = getGlossaryFilePath(cfg.glossaryPath || '.astra/glossary.md');
-
- if (trimmed === 'help' || trimmed === '?') {
- chunk(view, [
- '\n📖 **/glossary — Terminology Dictionary**',
- '',
- '사용법:',
- ' `/glossary` — 상태 (파일 존재/크기, system prompt 주입 여부)',
- ' `/glossary path` — 글로서리 파일 절대 경로',
- ' `/glossary init` — 권장 템플릿으로 글로서리 파일 생성 (이미 있으면 덮어쓰지 않음)',
- ' `/glossary reload` — 캐시 비우기 (편집 직후 즉시 반영 강제)',
- '',
- '편집 방법: 위 path 의 markdown 파일을 *VS Code 에서 직접 편집*.',
- '효과: 다음 채팅 turn 부터 ASTRA 시스템 프롬프트의 [TERMINOLOGY DICTIONARY] 블록에 자동 주입.',
- '글로서리 본문은 자유 markdown — H2/H3 섹션 구분 권장.\n',
- ].join('\n'));
- return true;
- }
-
- if (!fp) { chunk(view, '\n❌ Workspace 폴더 없음 — 글로서리 사용 불가.\n'); return true; }
-
- if (trimmed === 'path') {
- chunk(view, `\n📂 \`${fp}\`\n`);
- return true;
- }
-
- if (trimmed === 'init') {
- if (fs.existsSync(fp)) {
- chunk(view, `\nℹ️ 이미 존재: \`${fp}\` — 덮어쓰지 않음. 새로 시작하려면 파일 직접 삭제 후 재실행.\n`);
- return true;
- }
- try {
- fs.mkdirSync(path.dirname(fp), { recursive: true });
- fs.writeFileSync(fp, GLOSSARY_TEMPLATE, 'utf-8');
- clearGlossaryCache();
- chunk(view, `\n✅ 글로서리 초기화 — \`${fp}\`\n`);
- chunk(view, '편집 후 다음 채팅 turn 부터 자동 반영. 즉시 반영 강제는 `/glossary reload`.\n');
- } catch (e: any) {
- chunk(view, `\n❌ 생성 실패: ${e?.message || String(e)}\n`);
- }
- return true;
- }
-
- if (trimmed === 'reload') {
- clearGlossaryCache();
- clearTermValidatorCache();
- chunk(view, '\n🔄 글로서리 캐시 비움 (system prompt + Term Validator 모두). 다음 채팅 turn 에 파일 재읽기.\n');
- return true;
- }
-
- // 기본 상태 카드
- chunk(view, '\n📖 **/glossary — Terminology Dictionary 상태**\n');
- chunk(view, `\n- 경로: \`${fp}\`\n`);
- if (!fs.existsSync(fp)) {
- chunk(view, `- ❌ 파일 없음 — \`/glossary init\` 로 권장 템플릿 생성 가능\n`);
- chunk(view, '- system prompt 주입: ⊘ (no-op)\n');
- return true;
- }
- try {
- const stat = fs.statSync(fp);
- const raw = fs.readFileSync(fp, 'utf-8');
- const len = raw.length;
- const cap = cfg.glossaryMaxBodyLength ?? 4000;
- const truncated = len > cap;
- const mtime = new Date(stat.mtimeMs).toISOString().slice(0, 10);
- chunk(view, `- ✅ 파일 ${len}자 (${truncated ? `cap ${cap} 초과 — 잘림` : `cap ${cap} 이내`}) · 마지막 편집 ${mtime}\n`);
- chunk(view, `- Enabled: ${cfg.glossaryEnabled !== false ? '✓' : '✗'}\n`);
- chunk(view, `- system prompt 주입: ${cfg.glossaryEnabled !== false && len > 0 ? '✓ 매 turn 자동' : '⊘'}\n`);
- // 첫 5줄 미리보기
- const preview = raw.split('\n').slice(0, 5).join('\n');
- chunk(view, `\n### 미리보기 (첫 5줄)\n\`\`\`\n${preview}\n\`\`\`\n`);
- } catch (e: any) {
- chunk(view, `- ⚠️ 읽기 실패: ${e?.message || String(e)}\n`);
- }
- return true;
-}
-
-// ─── /help — 카테고리별 슬래시 명령 브라우저 ─────────────────────────────
-// 누적된 명령이 25개+ — 사용자 발견 부담 줄이기. listSlashCommands() 로 *현재 등록된*
-// 명령만 동적 표시 (외부 플러그인이 추가한 명령도 자동 노출).
-
-interface HelpCategory {
- title: string;
- emoji: string;
- /** 매칭 함수 — 명령 이름 받아 boolean. */
- match: (name: string) => boolean;
- blurb?: string;
-}
-
-const HELP_CATEGORIES: HelpCategory[] = [
- {
- title: '일과 리듬 (Daily Cycle)',
- emoji: '☀️',
- match: (n) => ['/morning', '/evening', '/weekly', '/cohort', '/standup'].includes(n),
- blurb: '아침 (`/morning`) · 저녁 (`/evening`) · 주말 (`/weekly`) · 분기 (`/cohort`) · 팀 공유 (`/standup`)',
- },
- {
- title: '4인 팀 운영 트래커',
- emoji: '🏢',
- match: (n) => ['/runway', '/customers', '/hire'].includes(n),
- blurb: '재무 (`/runway`) · 매출 (`/customers`) · 채용 (`/hire`) — 모두 event-sourced `.astra/*.jsonl`',
- },
- {
- title: '작업·블로커·1:1',
- emoji: '📋',
- match: (n) => ['/task', '/blocked', '/onesie', '/decisions'].includes(n),
- blurb: 'Google Tasks 등록 (`/task`) · 지연 분석 (`/blocked`) · 멤버 1:1 카드 (`/onesie`) · ADR (`/decisions`)',
- },
- {
- title: '외부 출력·기록',
- emoji: '✉️',
- match: (n) => ['/draft', '/feedback'].includes(n),
- blurb: 'email/slack/blog/newsletter 초안 (`/draft`) · 고객 피드백 누적+분석 (`/feedback`)',
- },
- {
- title: '리서치·분석',
- emoji: '🔬',
- match: (n) => ['/research', '/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n),
- blurb: 'Datacollect bridge 통합 — Deep Research / 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록',
- },
- {
- title: '시스템·메모리',
- emoji: '⚙️',
- match: (n) => ['/memory', '/glossary'].includes(n),
- blurb: 'Temporal Markers + Distillation (`/memory`) · 용어집 (`/glossary`)',
- },
- {
- title: '주식·외부',
- emoji: '📈',
- match: (n) => ['/stocks'].includes(n),
- blurb: 'Yahoo 가격 + Sheets 동기화 + 텔레그램 보고서 + LLM judge',
- },
-];
-
-async function runHelp(arg: string, view: any, _context?: vscode.ExtensionContext): Promise {
- const trimmed = arg.trim().toLowerCase();
- const allCommands = listSlashCommands();
- const allNames = allCommands.map((c) => c.name.toLowerCase());
-
- // 특정 명령에 대한 도움말 — `/help `
- if (trimmed && trimmed !== 'help' && trimmed !== '?') {
- const target = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
- const def = allCommands.find((c) => c.name.toLowerCase() === target);
- if (!def) {
- chunk(view, `\n❌ 명령 "${target}" 등록되지 않음. \`/help\` 로 전체 목록.\n`);
- return true;
- }
- chunk(view, `\n📘 **${def.name}**\n\n${def.description || '_(설명 없음)_'}\n\n`);
- chunk(view, `세부 도움말은 \`${def.name} help\` 또는 \`${def.name} ?\` 로 직접 호출 (대부분 명령이 자체 도움말 제공).\n`);
- return true;
- }
-
- chunk(view, `\n📚 **ASTRA 슬래시 명령 — 전체 ${allCommands.length}개**\n`);
- chunk(view, '_사용: `/<명령>` · 세부 도움말: `/<명령> help` · 특정 명령 검색: `/help <명령>`_\n');
-
- const usedNames = new Set();
- for (const cat of HELP_CATEGORIES) {
- const matched = allCommands.filter((c) => cat.match(c.name.toLowerCase()));
- if (matched.length === 0) continue;
- chunk(view, `\n## ${cat.emoji} ${cat.title}\n`);
- if (cat.blurb) chunk(view, `_${cat.blurb}_\n\n`);
- for (const c of matched) {
- usedNames.add(c.name.toLowerCase());
- chunk(view, `- \`${c.name}\` — ${c.description || '_(설명 없음)_'}\n`);
- }
- }
-
- // 미분류 명령 (외부 플러그인 등)
- const uncategorized = allCommands.filter((c) => !usedNames.has(c.name.toLowerCase()) && c.name !== '/help');
- if (uncategorized.length > 0) {
- chunk(view, '\n## 🔌 기타·외부 플러그인\n\n');
- for (const c of uncategorized) {
- chunk(view, `- \`${c.name}\` — ${c.description || '_(설명 없음)_'}\n`);
- }
- }
-
- // ASTRA Engine 상태 요약 (verification 6종)
- const cfg = getConfig();
- chunk(view, '\n---\n\n## 🛡️ ASTRA 추론 엔진 (v2.2.183~ 누적)\n');
- chunk(view, '_각 turn 시스템 프롬프트 자동 주입 (casual 모드 제외):_\n\n');
- const engineStatus = [
- { label: 'Intent Clarification', on: cfg.intentClarificationEnabled !== false, key: '[INTENT CLARIFICATION GUIDANCE]', when: '답변 시작 전' },
- { label: 'Terminology Dictionary', on: cfg.glossaryEnabled !== false, key: '[TERMINOLOGY DICTIONARY]', when: '답변 작성 중' },
- { label: 'Conflict Surface', on: cfg.conflictHighlightingEnabled !== false, key: '[CONFLICT WARNINGS]', when: '답변 작성 중' },
- { label: 'Chain-of-Verification (CoVe)', on: cfg.coveEnabled !== false, key: '[VERIFICATION CHECKLIST]', when: '답변 작성 중' },
- { label: 'Citation Trace', on: cfg.citationTraceEnabled !== false, key: '[CITATION TRACE]', when: '답변 끝' },
- { label: 'Post-hoc Self-Check', on: cfg.selfCheckEnabled === true, key: 'footer (별도 LLM 호출)', when: '답변 완료 후' },
- ];
- for (const e of engineStatus) {
- chunk(view, `- ${e.on ? '✓' : '⊘'} **${e.label}** — \`${e.key}\` _(${e.when})_\n`);
- }
- chunk(view, '\n_추가 검색 신호:_ Recency · Actionability · Hierarchical Level · Semantic Re-rank (opt-in)\n');
- chunk(view, '_메모리 관리:_ Temporal Markers (만료일) · Distillation Loop (stale episodes → LongTerm digest)\n');
-
- return true;
-}
// ─── 기본 명령 등록 ───────────────────────────────────────────────────────
// Astra 가 기본 제공하는 6개 명령. 외부 플러그인 / 사용자 정의 명령은 동일하게
@@ -4144,31 +191,11 @@ async function runHelp(arg: string, view: any, _context?: vscode.ExtensionContex
// registerSlashCommand({ name: '/my', description: '...', handler: runMyCmd });
//
// 이 한 줄이면 isSlashCommand(input) 와 handleSlashCommand 가 자동으로 인식.
-registerSlashCommand({ name: '/research', description: 'NotebookLM Deep Research 호출 후 위키 합성', handler: runResearch });
-registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
-registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
-registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
-registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify });
-registerSlashCommand({ name: '/meet', description: '회의 transcript → 회의록 합성 + 캘린더·task 등록', handler: runMeet });
-registerSlashCommand({ name: '/task', description: '단일 작업을 Google Tasks + Calendar 양쪽에 등록 (제목 + 시작일 + 완료일)', handler: runTask });
-registerSlashCommand({ name: '/decisions', description: 'Chronicle 결정 기록(ADR) 검색 — 키워드/담당자로 필터', handler: runDecisions });
-registerSlashCommand({ name: '/onesie', description: '멤버별 1:1 미팅 준비 카드 — Tasks 진행 + 최근 결정 + 대화 토픽 제안', handler: runOnesie });
-registerSlashCommand({ name: '/draft', description: '외부 커뮤니케이션 초안 — email/slack/blog/newsletter/investor-update/proposal', handler: runDraft });
-registerSlashCommand({ name: '/feedback', description: '고객 피드백 누적 + 자동 카테고리 분류 + 패턴 분석 (로컬 .jsonl)', handler: runFeedback });
-registerSlashCommand({ name: '/blocked', description: '전사 across 지연·블로커 한 화면 — 일일 아침 ritual', handler: runBlocked });
-registerSlashCommand({ name: '/standup', description: '팀 스탠드업 카드 (멤버별 완료/진행/블로커, 슬랙 복붙 친화)', handler: runStandup });
-registerSlashCommand({ name: '/runway', description: '현금 / 월 소진율 / 런웨이 — 4인 기업 CEO 의 가장 중요한 숫자 (로컬 .jsonl)', handler: runRunway });
-registerSlashCommand({ name: '/customers', description: '고객사 / MRR / 갱신 / 위험 트래커 — event-sourced 로그 (로컬 .jsonl)', handler: runCustomers });
-registerSlashCommand({ name: '/hire', description: '채용 파이프라인 — 후보자 단계·오퍼·합격 트래커 (로컬 .jsonl)', handler: runHire });
-registerSlashCommand({ name: '/morning', description: '매일 아침 통합 대시보드 — runway/customers/tasks/hire 핵심 한 화면 + 오늘의 액션', handler: runMorning });
-registerSlashCommand({ name: '/evening', description: '하루 마무리 카드 — 오늘의 진척 + 내일 준비 + 회고 한 줄', handler: runEvening });
-registerSlashCommand({ name: '/memory', description: '메모리 라이프사이클 — Temporal Markers (만료일) + Distillation Loop (stale episodes → LongTerm digest)', handler: runMemory });
-registerSlashCommand({ name: '/cohort', description: 'MoM 추세 분석 — customers + runway events 월별 그룹핑 (신규/이탈/MRR/burn)', handler: runCohort });
-registerSlashCommand({ name: '/weekly', description: '주간 리뷰 카드 (대표용) — 이번 주 진척 + 지난 주 대비 + 다음 주 준비 + 회고', handler: runWeekly });
-registerSlashCommand({ name: '/glossary', description: 'Terminology Dictionary — 표준 용어집 (.astra/glossary.md) 상태/생성/리로드. 다음 turn 부터 system prompt 자동 주입', handler: runGlossary });
-registerSlashCommand({ name: '/help', description: '슬래시 명령 전체 목록 (카테고리별) + ASTRA 추론 엔진 상태. `/help <명령>` 으로 특정 명령 정보.', handler: runHelp });
// /stocks 는 `src/features/stocks/slashStocks.ts` 의 sub-command 라우터로 위임.
// list/check/signal/sync/add/remove/judge/report/run/path 9 개의 subcommand 가 그 안에서 분기.
import { handleStocksCommand } from '../stocks';
registerSlashCommand({ name: '/stocks', description: '종목 모니터링 — Yahoo 가격, Sheets 동기화, 텔레그램 보고서, LLM judge', handler: handleStocksCommand });
+
+// TeamOps handlers — v2.2.196 부터 도메인별 파일로 분리. 배럴 import 는 entry point
+// (`src/extension.ts`) 에서 처리 — 순환 import 피하기 위함.
diff --git a/src/features/feedback/feedbackStore.ts b/src/features/feedback/feedbackStore.ts
index b1ce8af..c438b33 100644
--- a/src/features/feedback/feedbackStore.ts
+++ b/src/features/feedback/feedbackStore.ts
@@ -12,9 +12,7 @@
* 위치 확인 가능. 민감 정보 포함 가능성 있으므로 외부로 안 보냄 — 로컬 only.
*/
-import * as fs from 'fs';
-import * as path from 'path';
-import * as vscode from 'vscode';
+import { createEventStore } from '../_shared/eventSourcedStore';
const STORE_REL_PATH = '.astra/customer-feedback.jsonl';
@@ -33,49 +31,14 @@ export interface FeedbackEntry {
sentiment?: 'positive' | 'neutral' | 'negative';
}
-export function getFeedbackFilePath(): string | null {
- const folders = vscode.workspace.workspaceFolders;
- if (!folders || folders.length === 0) return null;
- return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
-}
+const _store = createEventStore({
+ relPath: STORE_REL_PATH,
+ validate: (e): e is FeedbackEntry => !!e
+ && typeof (e as any).id === 'string'
+ && typeof (e as any).text === 'string',
+});
-export function readFeedback(): FeedbackEntry[] {
- const filePath = getFeedbackFilePath();
- if (!filePath || !fs.existsSync(filePath)) return [];
- const out: FeedbackEntry[] = [];
- let content = '';
- try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; }
- for (const line of content.split('\n')) {
- const trimmed = line.trim();
- if (!trimmed) continue;
- try {
- const entry = JSON.parse(trimmed);
- if (entry && typeof entry.id === 'string' && typeof entry.text === 'string') {
- out.push(entry as FeedbackEntry);
- }
- } catch { /* skip malformed line — append-only 라 손상 1줄이 전체 무력화하면 안 됨 */ }
- }
- return out;
-}
-
-export function appendFeedback(entry: FeedbackEntry): { ok: true; filePath: string } | { ok: false; error: string } {
- const filePath = getFeedbackFilePath();
- if (!filePath) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가. VS Code 에서 폴더를 열어 주세요.' };
- try {
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
- fs.appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf-8');
- return { ok: true, filePath };
- } catch (e: any) {
- return { ok: false, error: e?.message || String(e) };
- }
-}
-
-/** 누적 항목 수 — 빠른 확인용. */
-export function countFeedback(): number {
- const filePath = getFeedbackFilePath();
- if (!filePath || !fs.existsSync(filePath)) return 0;
- try {
- const content = fs.readFileSync(filePath, 'utf-8');
- return content.split('\n').filter((l) => l.trim()).length;
- } catch { return 0; }
-}
+export const getFeedbackFilePath = _store.getFilePath;
+export const readFeedback = _store.read;
+export const appendFeedback = _store.append;
+export const countFeedback = _store.count;
diff --git a/src/features/hire/hireStore.ts b/src/features/hire/hireStore.ts
index 0dba0ab..6ace8f1 100644
--- a/src/features/hire/hireStore.ts
+++ b/src/features/hire/hireStore.ts
@@ -13,7 +13,7 @@
import * as fs from 'fs';
import * as path from 'path';
-import * as vscode from 'vscode';
+import { createEventStore } from '../_shared/eventSourcedStore';
const STORE_REL_PATH = '.astra/hire.jsonl';
@@ -53,47 +53,22 @@ export interface CandidateState {
notes: { timestamp: string; type: HireEventType; memo: string }[];
}
-export function getHireFilePath(): string | null {
- const folders = vscode.workspace.workspaceFolders;
- if (!folders || folders.length === 0) return null;
- return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
-}
+const _store = createEventStore({
+ relPath: STORE_REL_PATH,
+ validate: (e): e is HireEvent => !!e
+ && typeof (e as any).id === 'string'
+ && typeof (e as any).candidateId === 'string'
+ && typeof (e as any).type === 'string',
+});
+
+export const getHireFilePath = _store.getFilePath;
+export const readHireEvents = _store.read;
+export const appendHireEvent = _store.append;
export function candidateIdFromName(name: string): string {
return name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w가-힣-]/g, '');
}
-export function readHireEvents(): HireEvent[] {
- const filePath = getHireFilePath();
- if (!filePath || !fs.existsSync(filePath)) return [];
- const out: HireEvent[] = [];
- let content = '';
- try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; }
- for (const line of content.split('\n')) {
- const trimmed = line.trim();
- if (!trimmed) continue;
- try {
- const e = JSON.parse(trimmed);
- if (e && typeof e.id === 'string' && typeof e.candidateId === 'string' && typeof e.type === 'string') {
- out.push(e as HireEvent);
- }
- } catch { /* skip malformed */ }
- }
- return out;
-}
-
-export function appendHireEvent(event: HireEvent): { ok: true; filePath: string } | { ok: false; error: string } {
- const filePath = getHireFilePath();
- if (!filePath) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가.' };
- try {
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
- fs.appendFileSync(filePath, JSON.stringify(event) + '\n', 'utf-8');
- return { ok: true, filePath };
- } catch (e: any) {
- return { ok: false, error: e?.message || String(e) };
- }
-}
-
export function computeCandidateStates(): Map {
const events = readHireEvents().slice().sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
const states = new Map();
diff --git a/src/features/runway/runwayStore.ts b/src/features/runway/runwayStore.ts
index f92de30..7515071 100644
--- a/src/features/runway/runwayStore.ts
+++ b/src/features/runway/runwayStore.ts
@@ -11,9 +11,7 @@
* 민감 정보(현금 잔고) 포함되므로 외부로 안 보냄 — 로컬 only.
*/
-import * as fs from 'fs';
-import * as path from 'path';
-import * as vscode from 'vscode';
+import { createEventStore } from '../_shared/eventSourcedStore';
const STORE_REL_PATH = '.astra/runway.jsonl';
@@ -36,42 +34,17 @@ export interface RunwayEntry {
memo?: string;
}
-export function getRunwayFilePath(): string | null {
- const folders = vscode.workspace.workspaceFolders;
- if (!folders || folders.length === 0) return null;
- return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
-}
+const _store = createEventStore({
+ relPath: STORE_REL_PATH,
+ validate: (e): e is RunwayEntry => !!e
+ && typeof (e as any).id === 'string'
+ && typeof (e as any).amount === 'number'
+ && typeof (e as any).type === 'string',
+});
-export function readRunway(): RunwayEntry[] {
- const filePath = getRunwayFilePath();
- if (!filePath || !fs.existsSync(filePath)) return [];
- const out: RunwayEntry[] = [];
- let content = '';
- try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; }
- for (const line of content.split('\n')) {
- const trimmed = line.trim();
- if (!trimmed) continue;
- try {
- const entry = JSON.parse(trimmed);
- if (entry && typeof entry.id === 'string' && typeof entry.amount === 'number' && typeof entry.type === 'string') {
- out.push(entry as RunwayEntry);
- }
- } catch { /* skip malformed line */ }
- }
- return out;
-}
-
-export function appendRunway(entry: RunwayEntry): { ok: true; filePath: string } | { ok: false; error: string } {
- const filePath = getRunwayFilePath();
- if (!filePath) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가. VS Code 에서 폴더를 열어 주세요.' };
- try {
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
- fs.appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf-8');
- return { ok: true, filePath };
- } catch (e: any) {
- return { ok: false, error: e?.message || String(e) };
- }
-}
+export const getRunwayFilePath = _store.getFilePath;
+export const readRunway = _store.read;
+export const appendRunway = _store.append;
/**
* 현재 상태 계산 — 최신 snapshot, 최근 30일 net burn, 명시적 burn 설정 중 우선순위.
diff --git a/src/features/system/handlers.ts b/src/features/system/handlers.ts
new file mode 100644
index 0000000..b99a217
--- /dev/null
+++ b/src/features/system/handlers.ts
@@ -0,0 +1,394 @@
+/**
+ * System handlers — /memory · /glossary · /help (인프라·관리·발견).
+ *
+ * v2.2.200 에서 slashRouter.ts 에서 분리. 4인 팀 운영 도메인 아닌
+ * "ASTRA 자체 인프라" 슬래시 명령. teamops handlers 와 같은 패턴.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { registerSlashCommand, chunk, listSlashCommands } from '../datacollect/slashRouter';
+import { getConfig } from '../../config';
+import {
+ MemoryManager,
+ distillStaleEpisodes,
+ getLastDistillationRun,
+ recordDistillationRun,
+ type DistillationArchiveMode,
+} from '../../memory';
+import {
+ getGlossaryFilePath,
+ GLOSSARY_TEMPLATE,
+ clearGlossaryCache,
+} from '../../retrieval/terminologyBlock';
+import { clearTermValidatorCache } from '../../agent/termValidator';
+
+// ─── /memory — 메모리 라이프사이클 관리 ─────────────────────────────────
+
+function _formatDate(epoch: number | undefined): string {
+ if (!epoch) return '-';
+ return new Date(epoch).toISOString().slice(0, 10);
+}
+
+function _memoryOverview(view: any): void {
+ const cfg = getConfig();
+ const brainPath = cfg.localBrainPath;
+ if (!brainPath) { chunk(view, '\n❌ Brain path 미설정.\n'); return; }
+
+ const mgr = new MemoryManager(brainPath, {
+ longTermMaxEntries: cfg.memoryLongTermFiles ?? 100,
+ episodicMaxEpisodes: 50,
+ });
+ const lt = mgr.getLongTermMemory();
+ const ep = mgr.getEpisodicMemory();
+ const allLt = lt.getAllEntries({ includeExpired: true });
+ const activeLt = lt.getAllEntries();
+ const expiredLt = allLt.length - activeLt.length;
+ const allEpisodes = ep.loadAllEpisodes();
+ const stalePromoted = allEpisodes.filter((e) => e.promoted).length;
+ const staleCandidates = ep.findStaleEpisodes(cfg.distillationAgeThresholdDays).length;
+ const last = getLastDistillationRun(brainPath);
+
+ chunk(view, '\n🧠 **/memory — 메모리 라이프사이클**\n');
+ chunk(view, `\n## 📊 현재 상태\n`);
+ chunk(view, `- **LongTerm**: 활성 ${activeLt.length}개${expiredLt > 0 ? ` (만료 ${expiredLt}개 숨김)` : ''}\n`);
+
+ const catCounts = new Map();
+ for (const e of activeLt) catCounts.set(e.category, (catCounts.get(e.category) || 0) + 1);
+ if (catCounts.size > 0) {
+ const parts = [...catCounts.entries()].map(([c, n]) => `${c} ${n}`).join(' · ');
+ chunk(view, ` - 카테고리: ${parts}\n`);
+ }
+
+ chunk(view, `- **Episodic**: 전체 ${allEpisodes.length}개 (승급 ${stalePromoted} · 미승급 stale 후보 ${staleCandidates})\n`);
+
+ chunk(view, `\n## 🔄 Distillation\n`);
+ chunk(view, `- 임계: **${cfg.distillationAgeThresholdDays}일** 이상 stale episode → LongTerm 'episode-digest' 승급\n`);
+ chunk(view, `- 자동 트리거 간격: ${cfg.distillationIntervalDays}일\n`);
+ chunk(view, `- Archive 모드: \`${cfg.distillationArchiveMode}\`\n`);
+ if (last) {
+ const ago = Math.floor((Date.now() - last.timestamp) / (24 * 60 * 60 * 1000));
+ chunk(view, `- 마지막 실행: ${_formatDate(last.timestamp)} (${ago}일 전) — 승급 ${last.report?.promotedCount ?? 0}개\n`);
+ } else {
+ chunk(view, `- 마지막 실행: _없음_ — \`/memory distill\` 로 첫 실행\n`);
+ }
+
+ const upcoming = activeLt
+ .filter((e) => e.expiresAt && (e.expiresAt - Date.now()) <= 30 * 24 * 60 * 60 * 1000)
+ .sort((a, b) => (a.expiresAt || 0) - (b.expiresAt || 0));
+ if (upcoming.length > 0) {
+ chunk(view, `\n## ⏰ 30일 내 만료 LongTerm (${upcoming.length}개)\n`);
+ for (const e of upcoming.slice(0, 5)) {
+ const days = Math.ceil(((e.expiresAt || 0) - Date.now()) / (24 * 60 * 60 * 1000));
+ chunk(view, `- D-${days} [${e.category}] \`${e.id.slice(0, 8)}\` ${e.content.slice(0, 80)}\n`);
+ }
+ if (upcoming.length > 5) chunk(view, `- _…+${upcoming.length - 5}개_\n`);
+ }
+
+ chunk(view, `\n_명령어:_ \`/memory distill\` · \`/memory expire \` · \`/memory list-expiring [days]\` · \`/memory help\`\n`);
+}
+
+async function runMemory(arg: string, view: any): Promise {
+ const trimmed = arg.trim();
+ if (!trimmed) { _memoryOverview(view); return true; }
+
+ const parts = trimmed.split(/\s+/);
+ const sub = parts[0].toLowerCase();
+ const cfg = getConfig();
+ const brainPath = cfg.localBrainPath;
+ if (!brainPath) { chunk(view, '\n❌ Brain path 미설정.\n'); return true; }
+
+ if (sub === 'help' || sub === '?') {
+ chunk(view, [
+ '\n🧠 **/memory — 메모리 라이프사이클**',
+ '',
+ '사용법:',
+ ' `/memory` — 현재 상태 (LongTerm/Episodic 카운트, distillation, 만료 임박)',
+ ' `/memory distill [age_days]` — Stale episodes → LongTerm digest 승급 (기본 30일 임계)',
+ ' `/memory expire ` — LongTerm entry 에 만료일 설정',
+ ' `/memory list-expiring [days]` — N일 내 만료 LongTerm 목록 (기본 30)',
+ ' `/memory list-promoted` — 승급된 episodes (digest 형태로 LongTerm 에 살아 있음)',
+ '',
+ 'Temporal Markers: LongTerm entry 의 expiresAt < now 이면 검색에서 자동 제외.',
+ 'Distillation Loop: 자동 트리거는 세션 종료 시 (interval 기준), 수동은 `/memory distill`.',
+ '저장: `{brainPath}/memory/long_term.json` + `{brainPath}/memory/episodes/*.json`.\n',
+ ].join('\n'));
+ return true;
+ }
+
+ if (sub === 'distill') {
+ const ageOverride = parts[1] ? parseInt(parts[1], 10) : undefined;
+ const age = Number.isFinite(ageOverride) && (ageOverride as number) > 0
+ ? (ageOverride as number)
+ : cfg.distillationAgeThresholdDays;
+ chunk(view, `\n🔄 Distillation 시작 — ${age}일 이상 stale episodes 대상...\n`);
+ const mgr = new MemoryManager(brainPath);
+ const report = distillStaleEpisodes(mgr.getEpisodicMemory(), mgr.getLongTermMemory(), brainPath, {
+ ageThresholdDays: age,
+ archiveMode: cfg.distillationArchiveMode as DistillationArchiveMode,
+ });
+ recordDistillationRun(brainPath, report);
+ chunk(view, `\n✅ **Distillation 완료** (${report.durationMs}ms)\n`);
+ chunk(view, `- 후보 ${report.candidateCount}개 → 승급 ${report.promotedCount}개`);
+ chunk(view, cfg.distillationArchiveMode === 'archive-file' ? ` · 아카이브 ${report.archivedCount}개\n` : '\n');
+ if (report.skipped.length > 0) {
+ chunk(view, `- ⚠️ 스킵 ${report.skipped.length}개:\n`);
+ for (const s of report.skipped.slice(0, 3)) chunk(view, ` - \`${s.episodeId.slice(0, 8)}\`: ${s.reason}\n`);
+ }
+ if (report.longTermDigestIds.length > 0) {
+ chunk(view, `\n_생성된 digest IDs (앞 8자):_ ${report.longTermDigestIds.slice(0, 5).map((id) => `\`${id.slice(0, 8)}\``).join(' · ')}\n`);
+ }
+ return true;
+ }
+
+ if (sub === 'expire') {
+ const idPrefix = parts[1];
+ const dateStr = parts[2];
+ if (!idPrefix || !dateStr) {
+ chunk(view, '\n❌ 사용법: `/memory expire `\n예: `/memory expire a3f7d2c9 2026-09-30`\n');
+ return true;
+ }
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
+ chunk(view, `\n❌ 날짜 형식: YYYY-MM-DD (입력: "${dateStr}")\n`);
+ return true;
+ }
+ const expiresAt = Date.parse(dateStr + 'T23:59:59');
+ if (!Number.isFinite(expiresAt)) { chunk(view, `\n❌ 날짜 파싱 실패: "${dateStr}"\n`); return true; }
+ const mgr = new MemoryManager(brainPath);
+ const updated = mgr.getLongTermMemory().setExpiration(idPrefix, expiresAt);
+ if (!updated) { chunk(view, `\n❌ "${idPrefix}" prefix 매치 entry 없음.\n`); return true; }
+ chunk(view, `\n⏰ **${updated.category}** \`${updated.id.slice(0, 8)}\` 만료일 설정 → ${dateStr}\n`);
+ chunk(view, `> ${updated.content.slice(0, 120)}\n`);
+ return true;
+ }
+
+ if (sub === 'list-expiring') {
+ const days = parts[1] ? parseInt(parts[1], 10) : 30;
+ const window = Number.isFinite(days) && days > 0 ? days : 30;
+ const mgr = new MemoryManager(brainPath);
+ const all = mgr.getLongTermMemory().getAllEntries();
+ const horizon = Date.now() + window * 24 * 60 * 60 * 1000;
+ const upcoming = all
+ .filter((e) => e.expiresAt && e.expiresAt <= horizon)
+ .sort((a, b) => (a.expiresAt || 0) - (b.expiresAt || 0));
+ if (upcoming.length === 0) { chunk(view, `\nℹ️ ${window}일 내 만료 예정 entry 없음.\n`); return true; }
+ chunk(view, `\n⏰ **${window}일 내 만료 LongTerm (${upcoming.length}개)**\n\n`);
+ for (const e of upcoming) {
+ const d = Math.ceil(((e.expiresAt || 0) - Date.now()) / (24 * 60 * 60 * 1000));
+ chunk(view, `- D-${d} [${e.category}] \`${e.id.slice(0, 8)}\` ${e.content.slice(0, 100)}\n`);
+ }
+ return true;
+ }
+
+ if (sub === 'list-promoted') {
+ const mgr = new MemoryManager(brainPath);
+ const promoted = mgr.getEpisodicMemory().loadAllEpisodes().filter((e) => e.promoted);
+ if (promoted.length === 0) { chunk(view, '\nℹ️ 승급된 episode 없음. `/memory distill` 로 첫 승급.\n'); return true; }
+ chunk(view, `\n📚 **승급된 episodes (${promoted.length}개)** — LongTerm 에 digest 형태로 살아 있음\n\n`);
+ for (const e of promoted.slice(0, 15)) {
+ const date = (new Date(e.timestamp)).toISOString().slice(0, 10);
+ chunk(view, `- \`${date}\` ${e.title} → digest \`${(e.promotedToLongTermId || '').slice(0, 8)}\`\n`);
+ }
+ if (promoted.length > 15) chunk(view, `- _…+${promoted.length - 15}개_\n`);
+ return true;
+ }
+
+ chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/memory help\` 참조.\n`);
+ return true;
+}
+
+// ─── /glossary — Terminology Dictionary 사용자 진입점 ───────────────────
+
+async function runGlossary(arg: string, view: any): Promise {
+ const trimmed = arg.trim().toLowerCase();
+ const cfg = getConfig();
+ const fp = getGlossaryFilePath(cfg.glossaryPath || '.astra/glossary.md');
+
+ if (trimmed === 'help' || trimmed === '?') {
+ chunk(view, [
+ '\n📖 **/glossary — Terminology Dictionary**',
+ '',
+ '사용법:',
+ ' `/glossary` — 상태 (파일 존재/크기, system prompt 주입 여부)',
+ ' `/glossary path` — 글로서리 파일 절대 경로',
+ ' `/glossary init` — 권장 템플릿으로 글로서리 파일 생성 (이미 있으면 덮어쓰지 않음)',
+ ' `/glossary reload` — 캐시 비우기 (편집 직후 즉시 반영 강제)',
+ '',
+ '편집 방법: 위 path 의 markdown 파일을 *VS Code 에서 직접 편집*.',
+ '효과: 다음 채팅 turn 부터 ASTRA 시스템 프롬프트의 [TERMINOLOGY DICTIONARY] 블록에 자동 주입.',
+ '글로서리 본문은 자유 markdown — H2/H3 섹션 구분 권장.\n',
+ ].join('\n'));
+ return true;
+ }
+
+ if (!fp) { chunk(view, '\n❌ Workspace 폴더 없음 — 글로서리 사용 불가.\n'); return true; }
+
+ if (trimmed === 'path') { chunk(view, `\n📂 \`${fp}\`\n`); return true; }
+
+ if (trimmed === 'init') {
+ if (fs.existsSync(fp)) {
+ chunk(view, `\nℹ️ 이미 존재: \`${fp}\` — 덮어쓰지 않음. 새로 시작하려면 파일 직접 삭제 후 재실행.\n`);
+ return true;
+ }
+ try {
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
+ fs.writeFileSync(fp, GLOSSARY_TEMPLATE, 'utf-8');
+ clearGlossaryCache();
+ chunk(view, `\n✅ 글로서리 초기화 — \`${fp}\`\n`);
+ chunk(view, '편집 후 다음 채팅 turn 부터 자동 반영. 즉시 반영 강제는 `/glossary reload`.\n');
+ } catch (e: any) {
+ chunk(view, `\n❌ 생성 실패: ${e?.message || String(e)}\n`);
+ }
+ return true;
+ }
+
+ if (trimmed === 'reload') {
+ clearGlossaryCache();
+ clearTermValidatorCache();
+ chunk(view, '\n🔄 글로서리 캐시 비움 (system prompt + Term Validator 모두). 다음 채팅 turn 에 파일 재읽기.\n');
+ return true;
+ }
+
+ chunk(view, '\n📖 **/glossary — Terminology Dictionary 상태**\n');
+ chunk(view, `\n- 경로: \`${fp}\`\n`);
+ if (!fs.existsSync(fp)) {
+ chunk(view, `- ❌ 파일 없음 — \`/glossary init\` 로 권장 템플릿 생성 가능\n`);
+ chunk(view, '- system prompt 주입: ⊘ (no-op)\n');
+ return true;
+ }
+ try {
+ const stat = fs.statSync(fp);
+ const raw = fs.readFileSync(fp, 'utf-8');
+ const len = raw.length;
+ const cap = cfg.glossaryMaxBodyLength ?? 4000;
+ const truncated = len > cap;
+ const mtime = new Date(stat.mtimeMs).toISOString().slice(0, 10);
+ chunk(view, `- ✅ 파일 ${len}자 (${truncated ? `cap ${cap} 초과 — 잘림` : `cap ${cap} 이내`}) · 마지막 편집 ${mtime}\n`);
+ chunk(view, `- Enabled: ${cfg.glossaryEnabled !== false ? '✓' : '✗'}\n`);
+ chunk(view, `- system prompt 주입: ${cfg.glossaryEnabled !== false && len > 0 ? '✓ 매 turn 자동' : '⊘'}\n`);
+ const preview = raw.split('\n').slice(0, 5).join('\n');
+ chunk(view, `\n### 미리보기 (첫 5줄)\n\`\`\`\n${preview}\n\`\`\`\n`);
+ } catch (e: any) {
+ chunk(view, `- ⚠️ 읽기 실패: ${e?.message || String(e)}\n`);
+ }
+ return true;
+}
+
+// ─── /help — 카테고리별 슬래시 명령 브라우저 ─────────────────────────────
+
+interface HelpCategory {
+ title: string;
+ emoji: string;
+ match: (name: string) => boolean;
+ blurb?: string;
+}
+
+const HELP_CATEGORIES: HelpCategory[] = [
+ {
+ title: '일과 리듬 (Daily Cycle)',
+ emoji: '☀️',
+ match: (n) => ['/morning', '/evening', '/weekly', '/cohort', '/standup'].includes(n),
+ blurb: '아침 (`/morning`) · 저녁 (`/evening`) · 주말 (`/weekly`) · 분기 (`/cohort`) · 팀 공유 (`/standup`)',
+ },
+ {
+ title: '4인 팀 운영 트래커',
+ emoji: '🏢',
+ match: (n) => ['/runway', '/customers', '/hire'].includes(n),
+ blurb: '재무 (`/runway`) · 매출 (`/customers`) · 채용 (`/hire`) — 모두 event-sourced `.astra/*.jsonl`',
+ },
+ {
+ title: '작업·블로커·1:1',
+ emoji: '📋',
+ match: (n) => ['/task', '/blocked', '/onesie', '/decisions'].includes(n),
+ blurb: 'Google Tasks 등록 (`/task`) · 지연 분석 (`/blocked`) · 멤버 1:1 카드 (`/onesie`) · ADR (`/decisions`)',
+ },
+ {
+ title: '외부 출력·기록',
+ emoji: '✉️',
+ match: (n) => ['/draft', '/feedback'].includes(n),
+ blurb: 'email/slack/blog/newsletter 초안 (`/draft`) · 고객 피드백 누적+분석 (`/feedback`)',
+ },
+ {
+ title: '리서치·분석',
+ emoji: '🔬',
+ match: (n) => ['/research', '/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n),
+ blurb: 'Datacollect bridge 통합 — Deep Research / 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록',
+ },
+ {
+ title: '시스템·메모리',
+ emoji: '⚙️',
+ match: (n) => ['/memory', '/glossary'].includes(n),
+ blurb: 'Temporal Markers + Distillation (`/memory`) · 용어집 (`/glossary`)',
+ },
+ {
+ title: '주식·외부',
+ emoji: '📈',
+ match: (n) => ['/stocks'].includes(n),
+ blurb: 'Yahoo 가격 + Sheets 동기화 + 텔레그램 보고서 + LLM judge',
+ },
+];
+
+async function runHelp(arg: string, view: any): Promise {
+ const trimmed = arg.trim().toLowerCase();
+ const allCommands = listSlashCommands();
+
+ if (trimmed && trimmed !== 'help' && trimmed !== '?') {
+ const target = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
+ const def = allCommands.find((c) => c.name.toLowerCase() === target);
+ if (!def) {
+ chunk(view, `\n❌ 명령 "${target}" 등록되지 않음. \`/help\` 로 전체 목록.\n`);
+ return true;
+ }
+ chunk(view, `\n📘 **${def.name}**\n\n${def.description || '_(설명 없음)_'}\n\n`);
+ chunk(view, `세부 도움말은 \`${def.name} help\` 또는 \`${def.name} ?\` 로 직접 호출 (대부분 명령이 자체 도움말 제공).\n`);
+ return true;
+ }
+
+ chunk(view, `\n📚 **ASTRA 슬래시 명령 — 전체 ${allCommands.length}개**\n`);
+ chunk(view, '_사용: `/<명령>` · 세부 도움말: `/<명령> help` · 특정 명령 검색: `/help <명령>`_\n');
+
+ const usedNames = new Set();
+ for (const cat of HELP_CATEGORIES) {
+ const matched = allCommands.filter((c) => cat.match(c.name.toLowerCase()));
+ if (matched.length === 0) continue;
+ chunk(view, `\n## ${cat.emoji} ${cat.title}\n`);
+ if (cat.blurb) chunk(view, `_${cat.blurb}_\n\n`);
+ for (const c of matched) {
+ usedNames.add(c.name.toLowerCase());
+ chunk(view, `- \`${c.name}\` — ${c.description || '_(설명 없음)_'}\n`);
+ }
+ }
+
+ const uncategorized = allCommands.filter((c) => !usedNames.has(c.name.toLowerCase()) && c.name !== '/help');
+ if (uncategorized.length > 0) {
+ chunk(view, '\n## 🔌 기타·외부 플러그인\n\n');
+ for (const c of uncategorized) {
+ chunk(view, `- \`${c.name}\` — ${c.description || '_(설명 없음)_'}\n`);
+ }
+ }
+
+ const cfg = getConfig();
+ chunk(view, '\n---\n\n## 🛡️ ASTRA 추론 엔진 (v2.2.183~ 누적)\n');
+ chunk(view, '_각 turn 시스템 프롬프트 자동 주입 (casual 모드 제외):_\n\n');
+ const engineStatus = [
+ { label: 'Intent Clarification', on: cfg.intentClarificationEnabled !== false, key: '[INTENT CLARIFICATION GUIDANCE]', when: '답변 시작 전' },
+ { label: 'Terminology Dictionary', on: cfg.glossaryEnabled !== false, key: '[TERMINOLOGY DICTIONARY]', when: '답변 작성 중' },
+ { label: 'Conflict Surface', on: cfg.conflictHighlightingEnabled !== false, key: '[CONFLICT WARNINGS]', when: '답변 작성 중' },
+ { label: 'Chain-of-Verification (CoVe)', on: cfg.coveEnabled !== false, key: '[VERIFICATION CHECKLIST]', when: '답변 작성 중' },
+ { label: 'Citation Trace', on: cfg.citationTraceEnabled !== false, key: '[CITATION TRACE]', when: '답변 끝' },
+ { label: 'Post-hoc Self-Check', on: cfg.selfCheckEnabled === true, key: 'footer (별도 LLM 호출)', when: '답변 완료 후' },
+ ];
+ for (const e of engineStatus) {
+ chunk(view, `- ${e.on ? '✓' : '⊘'} **${e.label}** — \`${e.key}\` _(${e.when})_\n`);
+ }
+ chunk(view, '\n_추가 검색 신호:_ Recency · Actionability · Hierarchical Level · Semantic Re-rank (opt-in)\n');
+ chunk(view, '_메모리 관리:_ Temporal Markers (만료일) · Distillation Loop (stale episodes → LongTerm digest)\n');
+
+ return true;
+}
+
+// ─── 등록 ─────────────────────────────────────────────────────────────────
+
+registerSlashCommand({ name: '/memory', description: '메모리 라이프사이클 — Temporal Markers (만료일) + Distillation Loop (stale episodes → LongTerm digest)', handler: runMemory });
+registerSlashCommand({ name: '/glossary', description: 'Terminology Dictionary — 표준 용어집 (.astra/glossary.md) 상태/생성/리로드. 다음 turn 부터 system prompt 자동 주입', handler: runGlossary });
+registerSlashCommand({ name: '/help', description: '슬래시 명령 전체 목록 (카테고리별) + ASTRA 추론 엔진 상태. `/help <명령>` 으로 특정 명령 정보.', handler: runHelp });
diff --git a/src/features/teamops/handlers/_shared.ts b/src/features/teamops/handlers/_shared.ts
new file mode 100644
index 0000000..8cfe02b
--- /dev/null
+++ b/src/features/teamops/handlers/_shared.ts
@@ -0,0 +1,84 @@
+/**
+ * TeamOps handlers 공통 헬퍼 — 4인 팀 운영 슬래시 명령 클러스터가 공유하는 함수·상수.
+ *
+ * 이전 위치: `src/features/datacollect/slashRouter.ts` 내부 module-local 함수
+ * (v2.2.196 에서 분리). 핸들러 도메인별 분할 시 공통 항목만 여기에.
+ *
+ * 추후 datacollect / system 핸들러도 비슷한 공유 helpers 가 필요해지면
+ * `src/features/_shared/` 로 promote 고려.
+ */
+
+/** 한국식 KRW 숫자 포맷 — 만/억 단위 자동. 마이너스 부호 보존. */
+export function fmtKrw(n: number): string {
+ const sign = n < 0 ? '-' : '';
+ const abs = Math.abs(n);
+ if (abs >= 100_000_000) return `${sign}${(abs / 100_000_000).toFixed(2)}억`;
+ if (abs >= 10_000) return `${sign}${(abs / 10_000).toFixed(0)}만`;
+ return `${sign}${abs.toLocaleString('ko-KR')}`;
+}
+
+/** 한국식 금액 토큰 파싱 — "5000만", "1.5억", "300000", "1,500,000", "10k", "5m" 모두 인식. */
+export function parseAmount(token: string): number | null {
+ if (!token) return null;
+ const s = token.replace(/[,_]/g, '').trim();
+ const m = s.match(/^(-?[\d.]+)\s*(억|만|k|m|b)?$/i);
+ if (!m) return null;
+ const base = parseFloat(m[1]);
+ if (!Number.isFinite(base)) return null;
+ let mul = 1;
+ const unit = (m[2] || '').toLowerCase();
+ if (unit === '억') mul = 100_000_000;
+ else if (unit === '만') mul = 10_000;
+ else if (unit === 'k') mul = 1_000;
+ else if (unit === 'm') mul = 1_000_000;
+ else if (unit === 'b') mul = 1_000_000_000;
+ return base * mul;
+}
+
+/** D-day 계산 — ISO 날짜 (YYYY-MM-DD) 받아 오늘부터 며칠 후인지. 음수면 지난 일수. */
+export function daysUntil(isoDate: string | undefined, now: Date = new Date()): number | null {
+ if (!isoDate) return null;
+ const t = Date.parse(isoDate);
+ if (!Number.isFinite(t)) return null;
+ return Math.ceil((t - now.getTime()) / (24 * 60 * 60 * 1000));
+}
+
+export interface ParsedTaskOwner { owner: string | undefined; displayTitle: string; }
+
+/**
+ * Task 제목·notes 에서 owner 추출. 두 패턴 지원:
+ * 1. 제목 prefix `[멤버] 실제 제목` — /task, /meet 가 등록한 형식
+ * 2. notes 의 `담당: @이름` 또는 `담당: 이름` — 일부 외부 등록 호환
+ */
+export function parseTaskOwner(title: string, notes?: string): ParsedTaskOwner {
+ const titlePrefix = title.match(/^\[([^\]]+)\]\s*(.+)$/);
+ if (titlePrefix) return { owner: titlePrefix[1].trim(), displayTitle: titlePrefix[2].trim() };
+ const notesMatch = (notes || '').match(/담당:\s*(?:@)?([\S]+)/);
+ if (notesMatch) return { owner: notesMatch[1].trim(), displayTitle: title };
+ return { owner: undefined, displayTitle: title };
+}
+
+/** /hire 파이프라인 단계 정렬 가중치. inbox 1, hired 7, terminal 99. */
+export const STAGE_ORDER: Record = {
+ inbox: 1, screened: 2, interview: 3, final: 4, offer: 5,
+ accepted: 6, hired: 7, rejected: 99, declined: 99,
+};
+
+/** 종료된 후보 단계. 검색·통계에서 active 와 구분. */
+export const TERMINAL_STAGES = new Set(['hired', 'rejected', 'declined']);
+
+/** 단계별 emoji — UI 표시. */
+export function stageEmoji(stage: string): string {
+ switch (stage) {
+ case 'inbox': return '📥';
+ case 'screened': return '🔍';
+ case 'interview': return '💬';
+ case 'final': return '🎯';
+ case 'offer': return '📨';
+ case 'accepted': return '🤝';
+ case 'hired': return '🎉';
+ case 'rejected': return '❌';
+ case 'declined': return '🚪';
+ default: return '•';
+ }
+}
diff --git a/src/features/teamops/handlers/communication.ts b/src/features/teamops/handlers/communication.ts
new file mode 100644
index 0000000..727176e
--- /dev/null
+++ b/src/features/teamops/handlers/communication.ts
@@ -0,0 +1,255 @@
+/**
+ * TeamOps Communication — /draft · /feedback (외부 출력·기록).
+ *
+ * v2.2.199 에서 slashRouter.ts 에서 분리. (원래는 v2.2.200 예정이었으나 coordination
+ * 추출 시 register 라인이 인접해 묶여 함께 진행.)
+ */
+
+import * as fs from 'fs';
+import * as vscode from 'vscode';
+import { registerSlashCommand, chunk } from '../../datacollect/slashRouter';
+import { callLmSynthesis } from '../../datacollect/llm';
+import {
+ appendFeedback, readFeedback, getFeedbackFilePath, countFeedback,
+ type FeedbackEntry,
+} from '../../feedback/feedbackStore';
+
+// ─── /draft — 외부 커뮤니케이션 초안 ─────────────────────────────────────
+
+const DRAFT_TYPES: Record = {
+ email: {
+ label: '이메일',
+ systemPrompt: '한국어 비즈니스 이메일 초안. 격식 있되 과도하게 딱딱하지 않게. 인사 / 본문(목적·요청·맥락) / 맺음말 구조. 100~300자.',
+ },
+ slack: {
+ label: '슬랙/메신저 메시지',
+ systemPrompt: '슬랙·메신저용 짧고 명확한 한국어 메시지. 캐주얼하지만 프로페셔널. 50~150자 내. 필요하면 불릿 사용.',
+ },
+ blog: {
+ label: '블로그 포스트',
+ systemPrompt: '블로그 포스트 초안 한국어. 후크가 있는 도입부 + 본문 3~5개 섹션 + 결론. 800~2000자. 마크다운 헤더(##) 사용.',
+ },
+ newsletter: {
+ label: '뉴스레터',
+ systemPrompt: '뉴스레터용 한국어. 친근하면서 정보성. 헤드라인 + 본문 + 다음 액션 권유. 300~600자.',
+ },
+ 'investor-update': {
+ label: '투자자 월간 업데이트',
+ systemPrompt: '투자자/이해관계자용 월간 업데이트 한국어. 구조: ① 핵심 지표 ② 이번 달 성과 ③ 과제·이슈 ④ 다음 우선순위 ⑤ ask (필요한 도움). 격식, 정량 지표 우선.',
+ },
+ proposal: {
+ label: '비즈니스 제안서',
+ systemPrompt: '비즈니스 제안서 초안 한국어. 구조: 배경 / 제안 내용 / 기대 효과 / 일정 / (가능하면) 비용. 격식, 명확.',
+ },
+};
+
+async function runDraft(arg: string, view: any): Promise {
+ const tokens = arg.trim().split(/\s+/);
+ if (!arg.trim() || tokens.length < 2) {
+ const typeList = Object.entries(DRAFT_TYPES).map(([k, v]) => ` \`${k}\` — ${v.label}`).join('\n');
+ chunk(view, [
+ '\n📋 **/draft [유형] [요청] — 외부 커뮤니케이션 초안**',
+ '',
+ '사용법: `/draft <유형> <요청 내용>`',
+ '',
+ '유형 목록:',
+ typeList,
+ '',
+ '예시:',
+ ' `/draft email 김 대표님께 미팅 일정 조율 요청, 다음 주 가능 시간 3개 제안`',
+ ' `/draft slack 디자이너에게 메인 시안 1차 컨펌 요청, 금요일까지 회신 부탁`',
+ ' `/draft blog v2.2 릴리즈 노트 — Tasks 통합 및 4인 팀 운영 기능 소개`',
+ ' `/draft investor-update 5월 월간 — MAU 30% 성장, 결제 흐름 개선 완료, 다음 달 신규 출시`',
+ '',
+ '※ Settings 의 `g1nation.teamVoiceGuide` 에 팀 보이스 가이드(말투/금기어/자주 쓰는 표현)를 저장하면 모든 초안에 자동 반영.',
+ '',
+ ].join('\n'));
+ return true;
+ }
+
+ const typeKey = tokens[0].toLowerCase();
+ const typeDef = DRAFT_TYPES[typeKey];
+ if (!typeDef) {
+ chunk(view, `\n❌ 알 수 없는 유형: \`${typeKey}\`. 사용 가능: ${Object.keys(DRAFT_TYPES).join(' · ')}\n`);
+ return true;
+ }
+ const request = tokens.slice(1).join(' ').trim();
+ if (!request) { chunk(view, '\n❌ 요청 내용 없음.\n'); return true; }
+
+ const voiceGuide = (vscode.workspace.getConfiguration('g1nation').get('teamVoiceGuide', '') || '').trim();
+ chunk(view, `\n📝 **${typeDef.label} 초안 작성 중**\n · 요청: ${request}\n · ${voiceGuide ? '팀 보이스 가이드 적용 (' + voiceGuide.length + '자)' : '팀 보이스 가이드 없음 (g1nation.teamVoiceGuide 설정 시 자동 반영)'}\n`);
+
+ const systemPrompt = [
+ typeDef.systemPrompt,
+ '',
+ voiceGuide ? `[팀 보이스 가이드 — 반드시 준수]\n${voiceGuide}` : '',
+ '',
+ '출력 형식: 초안 본문만. "네, 알겠습니다" 같은 인사·메타 설명 금지. 사용자가 그대로 복사해 보낼 수 있는 형태.',
+ ].filter(Boolean).join('\n');
+
+ try {
+ const draft = await callLmSynthesis(request, systemPrompt);
+ if (!draft || !draft.trim()) { chunk(view, '\n❌ 초안 생성 실패 (LLM 빈 응답).\n'); return true; }
+ chunk(view, `\n---\n${draft.trim()}\n---\n`);
+ } catch (e: any) {
+ chunk(view, `\n❌ 초안 생성 실패: ${e?.message ?? String(e)}\n`);
+ }
+ return true;
+}
+
+// ─── /feedback — 고객 피드백 누적 + 패턴 분석 ───────────────────────────
+
+const FEEDBACK_CATEGORIZE_PROMPT = [
+ '당신은 고객 피드백 분류기.',
+ '',
+ '[입력] 사용자가 제공하는 고객 피드백 텍스트 한 건.',
+ '',
+ '[출력 형식 — 정확히 이 JSON 한 줄, 다른 텍스트/설명 절대 금지]',
+ '{"categories":["..."],"sentiment":"positive|neutral|negative"}',
+ '',
+ '[규칙]',
+ '- categories: 1~3개. 짧은 한국어 단어. 일관된 분류 (예: UX, 결제, 성능, 안정성, 가격, 신뢰, 기능 요청, 버그, 사용성, 디자인, 고객지원). 명확하지 않으면 "기타".',
+ '- sentiment: 긍정 호평 = positive, 단순 질문/중립 = neutral, 불만/버그/요청 = negative.',
+ '- JSON 외 어떤 문자도 출력하지 마시오. 마크다운 코드블록도 금지.',
+].join('\n');
+
+const FEEDBACK_SUMMARY_PROMPT = [
+ '당신은 고객 피드백 분석가. 사용자가 제공한 누적 피드백 데이터(JSON Lines)를 보고',
+ '*패턴 분석 리포트* 를 한국어 마크다운으로 작성한다. 외부 정보 추측 금지 — 주어진 데이터에서만.',
+ '',
+ '[출력 형식 — 정확히 이 구조]',
+ '',
+ '## 카테고리 분포',
+ '- 카테고리명 (N건, X%): 핵심 패턴 한 줄',
+ '- ...',
+ '',
+ '## 감정 분포',
+ '- 부정: N건 (X%)',
+ '- 중립: N건 (X%)',
+ '- 긍정: N건 (X%)',
+ '',
+ '## 반복 패턴 Top 3',
+ '구체적 인용 1-2개씩 포함. "여러 명이 X 에 대해 Y 하다고 언급" 형태.',
+ '1. ...',
+ '2. ...',
+ '3. ...',
+ '',
+ '## 추천 액션 (대표 의사결정 참고용)',
+ '데이터에서 *명확하게* 보이는 신호만. 단정적 단어("반드시" 등) 금지, "검토 권장" 톤.',
+ '- ...',
+].join('\n');
+
+async function feedbackSave(text: string, view: any): Promise {
+ if (!text.trim()) { chunk(view, '\n❌ 피드백 텍스트가 비어 있습니다.\n'); return; }
+
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const initialEntry: FeedbackEntry = { id, timestamp: new Date().toISOString(), text: text.trim() };
+ const saveResult = appendFeedback(initialEntry);
+ if (!saveResult.ok) { chunk(view, `\n❌ 저장 실패: ${saveResult.error}\n`); return; }
+ chunk(view, `\n📥 **피드백 저장됨** (id: \`${id.slice(0, 13)}\`)\n · 누적 ${countFeedback()}건\n`);
+
+ chunk(view, '\n🤖 카테고리 자동 분류 중...\n');
+ try {
+ const llmOut = await callLmSynthesis(text.trim(), FEEDBACK_CATEGORIZE_PROMPT);
+ const jsonMatch = llmOut.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) { chunk(view, ' ⚠️ 분류 실패 (LLM 응답에 JSON 없음). 원본은 저장됨, 수동으로 분류 추가 가능.\n'); return; }
+ const parsed = JSON.parse(jsonMatch[0]);
+ const categories: string[] = Array.isArray(parsed.categories) ? parsed.categories.map(String).slice(0, 3) : [];
+ const sentiment = ['positive', 'neutral', 'negative'].includes(parsed.sentiment) ? parsed.sentiment : undefined;
+ const enriched: FeedbackEntry = { ...initialEntry, categories, sentiment };
+ const all = readFeedback().map((e) => (e.id === id ? enriched : e));
+ const filePath = getFeedbackFilePath();
+ if (filePath) fs.writeFileSync(filePath, all.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
+ chunk(view, ` · 카테고리: ${categories.length > 0 ? categories.join(', ') : '(없음)'}\n · 감정: ${sentiment ?? '(미분류)'}\n`);
+ } catch (e: any) {
+ chunk(view, ` ⚠️ 분류 실패: ${e?.message || String(e)} (원본은 저장됨)\n`);
+ }
+}
+
+function feedbackList(filterCategory: string | undefined, view: any): void {
+ const all = readFeedback();
+ const filtered = filterCategory
+ ? all.filter((e) => (e.categories || []).some((c) => c === filterCategory || c.toLowerCase() === filterCategory.toLowerCase()))
+ : all;
+ if (filtered.length === 0) {
+ chunk(view, filterCategory ? `\nℹ️ 카테고리 "${filterCategory}" 매치 0건.\n` : '\nℹ️ 누적 피드백 없음. `/feedback <텍스트>` 로 첫 항목 추가.\n');
+ return;
+ }
+ chunk(view, `\n📋 **피드백 목록 (${filtered.length}건${filterCategory ? `, 카테고리 "${filterCategory}"` : ''})**\n\n`);
+ const sorted = filtered.slice().sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')).slice(0, 20);
+ for (const e of sorted) {
+ const date = (e.timestamp || '').slice(0, 10);
+ const cats = (e.categories || []).join(', ') || '미분류';
+ const sentEmoji = e.sentiment === 'positive' ? '🟢' : e.sentiment === 'negative' ? '🔴' : e.sentiment === 'neutral' ? '⚪' : '❓';
+ chunk(view, `- ${sentEmoji} \`${date}\` [${cats}] ${e.text.slice(0, 120)}${e.text.length > 120 ? '…' : ''}\n`);
+ }
+ if (filtered.length > 20) chunk(view, `\n_…+${filtered.length - 20}건 더 (필터링하거나 \`/feedback path\` 로 직접 파일 열기)_\n`);
+}
+
+async function feedbackSummary(view: any): Promise {
+ const all = readFeedback();
+ if (all.length < 3) {
+ chunk(view, `\nℹ️ 누적 ${all.length}건 — 패턴 분석엔 최소 3건 필요. \`/feedback <텍스트>\` 로 더 모아 주세요.\n`);
+ return;
+ }
+ chunk(view, `\n📊 **패턴 분석 시작** (누적 ${all.length}건)\n · LLM 호출 중...\n`);
+ const summaryInput = all.map((e) => JSON.stringify({
+ timestamp: (e.timestamp || '').slice(0, 10),
+ categories: e.categories || [],
+ sentiment: e.sentiment || 'unknown',
+ text: e.text.slice(0, 300),
+ })).join('\n');
+ try {
+ const report = await callLmSynthesis(`[누적 피드백 ${all.length}건]\n\n${summaryInput}`, FEEDBACK_SUMMARY_PROMPT);
+ if (!report || !report.trim()) { chunk(view, '\n❌ LLM 빈 응답.\n'); return; }
+ chunk(view, `\n${report.trim()}\n`);
+ } catch (e: any) {
+ chunk(view, `\n❌ 분석 실패: ${e?.message || String(e)}\n`);
+ }
+}
+
+function feedbackPath(view: any): void {
+ const p = getFeedbackFilePath();
+ if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음 — 저장 위치 결정 불가.\n'); return; }
+ chunk(view, `\n📂 \`${p}\`\n · 누적 ${countFeedback()}건 (.jsonl 형식, 한 줄=한 항목)\n · 사람이 직접 편집 가능 — 카테고리 수정·삭제 등.\n`);
+}
+
+async function runFeedback(arg: string, view: any): Promise {
+ const trimmed = arg.trim();
+ if (!trimmed) {
+ chunk(view, [
+ '\n📋 **/feedback — 고객 피드백 누적 + 패턴 분석**',
+ '',
+ '사용법:',
+ ' `/feedback <텍스트>` — 피드백 저장 (LLM 자동 카테고리 분류)',
+ ' `/feedback list [카테고리]` — 누적 목록 (최신순, 최대 20건)',
+ ' `/feedback summary` — 누적 데이터 패턴 분석 리포트 (LLM)',
+ ' `/feedback path` — 저장 파일 경로 표시',
+ '',
+ '예시:',
+ ' `/feedback 결제 흐름이 너무 복잡해서 중간에 포기했어요 - 김XX`',
+ ' `/feedback list 결제`',
+ ' `/feedback summary` (3건+ 누적 시)',
+ '',
+ '저장 위치: `/.astra/customer-feedback.jsonl` — 로컬 only, 외부 전송 없음.',
+ '',
+ ].join('\n'));
+ return true;
+ }
+
+ const firstSpace = trimmed.search(/\s/);
+ const head = (firstSpace < 0 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase();
+ const rest = firstSpace < 0 ? '' : trimmed.slice(firstSpace + 1).trim();
+
+ switch (head) {
+ case 'list': feedbackList(rest || undefined, view); return true;
+ case 'summary': case 'analyze': case 'report': await feedbackSummary(view); return true;
+ case 'path': feedbackPath(view); return true;
+ default: await feedbackSave(trimmed, view); return true;
+ }
+}
+
+// ─── 등록 ─────────────────────────────────────────────────────────────────
+
+registerSlashCommand({ name: '/draft', description: '외부 커뮤니케이션 초안 — email/slack/blog/newsletter/investor-update/proposal', handler: runDraft });
+registerSlashCommand({ name: '/feedback', description: '고객 피드백 누적 + 자동 카테고리 분류 + 패턴 분석 (로컬 .jsonl)', handler: runFeedback });
diff --git a/src/features/teamops/handlers/coordination.ts b/src/features/teamops/handlers/coordination.ts
new file mode 100644
index 0000000..eff61ff
--- /dev/null
+++ b/src/features/teamops/handlers/coordination.ts
@@ -0,0 +1,572 @@
+/**
+ * TeamOps Coordination — /task · /decisions · /onesie · /blocked · /standup.
+ *
+ * v2.2.199 에서 slashRouter.ts 에서 분리. 작업·결정·1:1·블로커·스탠드업 등
+ * "팀 운영의 실시간 부분" 클러스터.
+ *
+ * 공통 헬퍼는 `./_shared.ts` 에서. 옛 slashRouter 의 local parseTaskOwner/
+ * parseFlexibleDate 도 여기로 (parseFlexibleDate 는 /task 만 사용).
+ */
+
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+import { registerSlashCommand, chunk } from '../../datacollect/slashRouter';
+import { parseTaskOwner } from './_shared';
+import { createCalendarEvent, createTask, listTasks, _addDaysDate } from '../../calendar';
+import { ChronicleProjectStore } from '../../../sidebar/managers/chronicleProjectStore';
+
+// ─── 공통 헬퍼 — /task 전용 ──────────────────────────────────────────────
+
+/** 유연한 한국 날짜 파서. YY/MM/DD · YYYY/MM/DD · YYYY-MM-DD 지원. */
+function parseFlexibleDate(s: string): string | null {
+ if (!s) return null;
+ let y: number, mo: number, d: number;
+ let m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/);
+ if (m) { y = 2000 + Number(m[1]); mo = Number(m[2]); d = Number(m[3]); }
+ else if ((m = s.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); }
+ else if ((m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); }
+ else return null;
+ if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
+ const date = new Date(y, mo - 1, d);
+ if (Number.isNaN(date.getTime()) || date.getMonth() !== mo - 1 || date.getDate() !== d) return null;
+ return `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
+}
+
+// ─── /task — Google Tasks + Calendar 동시 등록 ──────────────────────────
+
+async function runTask(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
+ if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /task 실행 불가.\n'); return true; }
+ if (!arg.trim()) {
+ chunk(view, [
+ '\n📋 **/task — Google Tasks + Calendar 동시 등록**',
+ '',
+ '사용법:',
+ ' `/task [@담당자] <제목> <시작일> <완료일>` — 기간 작업',
+ ' `/task [@담당자] <제목> <날짜>` — 하루짜리 작업',
+ '',
+ '날짜 형식: `YY/MM/DD` (예: `26/05/27`) · `YYYY-MM-DD` · `YYYY/MM/DD`',
+ '담당자: `@` 접두사로 첫 토큰에 (예: `@기획자`). 생략 가능 — 있으면 제목 앞 `[담당자]` 로 prefix 됨.',
+ '',
+ '예시:',
+ ' `/task @기획자 Apple 계정 생성 요청 26/05/27 26/06/28`',
+ ' `/task @디자이너 메인 화면 시안 2026-07-01 2026-07-15`',
+ ' `/task 약값 결제 26/06/01` (담당자 없음)',
+ '',
+ 'Tasks API + Calendar API 양쪽 등록. Tasks 는 마감일만(시작일은 노트), Calendar 는 기간 all-day 일정.',
+ '',
+ ].join('\n'));
+ return true;
+ }
+
+ const tokens = arg.trim().split(/\s+/);
+ if (tokens.length < 2) { chunk(view, '\n❌ 인자 부족 — 최소 제목 + 날짜 1개 필요.\n'); return true; }
+
+ let owner: string | undefined;
+ if (tokens[0]?.startsWith('@') && tokens[0].length > 1) {
+ owner = tokens[0].slice(1);
+ tokens.shift();
+ if (tokens.length < 1) { chunk(view, '\n❌ 제목·날짜 누락 (담당자만 입력됨).\n'); return true; }
+ }
+
+ const lastDate = parseFlexibleDate(tokens[tokens.length - 1]);
+ const secondLastDate = tokens.length >= 3 ? parseFlexibleDate(tokens[tokens.length - 2]) : null;
+ let startYmd: string, endYmd: string, titleTokens: string[];
+ if (lastDate && secondLastDate) {
+ startYmd = secondLastDate; endYmd = lastDate; titleTokens = tokens.slice(0, -2);
+ } else if (lastDate) {
+ startYmd = lastDate; endYmd = lastDate; titleTokens = tokens.slice(0, -1);
+ } else {
+ chunk(view, `\n❌ 마지막 토큰이 날짜 형식이 아닙니다 ("${tokens[tokens.length - 1]}"). 사용 가능 형식: YY/MM/DD · YYYY-MM-DD · YYYY/MM/DD.\n사용법: \`/task\` (인자 없이) 실행해 도움말 보기.\n`);
+ return true;
+ }
+ const baseTitle = titleTokens.join(' ').trim();
+ if (!baseTitle) { chunk(view, '\n❌ 제목 누락.\n'); return true; }
+ if (startYmd > endYmd) { chunk(view, `\n❌ 시작일(${startYmd})이 완료일(${endYmd})보다 늦습니다.\n`); return true; }
+
+ const title = owner ? `[${owner}] ${baseTitle}` : baseTitle;
+ const isRange = startYmd !== endYmd;
+ const periodLabel = isRange ? `${startYmd} ~ ${endYmd}` : startYmd;
+ chunk(view, `\n📝 **Google Tasks + Calendar 등록**: ${title}\n · 기간: ${periodLabel}${owner ? ` · 담당: @${owner}` : ''}\n`);
+
+ const notes = `${owner ? `담당: @${owner}\n` : ''}Astra /task 직접 등록\n기간: ${periodLabel}`;
+ const successes: string[] = [];
+ const failures: string[] = [];
+ let calLink: string | undefined;
+
+ const taskNotes = isRange ? `${notes}\n(Tasks 는 마감일만 사용 — 시작일은 노트 참조)` : notes;
+ const taskResult = await createTask(context, { title, due: endYmd, notes: taskNotes });
+ if (taskResult.ok) successes.push('Tasks');
+ else failures.push(`Tasks: ${taskResult.error}`);
+
+ const calEnd = _addDaysDate(endYmd, 1);
+ const calResult = await createCalendarEvent(context, { title, start: startYmd, end: calEnd, allDay: true, description: notes });
+ if (calResult.ok) { successes.push('Calendar'); calLink = calResult.event.htmlLink; }
+ else failures.push(`Calendar: ${calResult.error}`);
+
+ if (failures.length === 0) chunk(view, `✅ 등록 완료 — ${successes.join(' + ')}\n`);
+ else if (successes.length > 0) {
+ chunk(view, `✅ 부분 성공 — ${successes.join(' + ')}\n`);
+ for (const f of failures) chunk(view, ` ⚠️ ${f}\n`);
+ } else {
+ chunk(view, `❌ 모두 실패\n`);
+ for (const f of failures) chunk(view, ` · ${f}\n`);
+ }
+ if (calLink) chunk(view, `🔗 Calendar 일정 열기: ${calLink}\n`);
+ return true;
+}
+
+// ─── /decisions — Chronicle ADR 검색 ─────────────────────────────────────
+
+async function runDecisions(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
+ if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /decisions 실행 불가.\n'); return true; }
+
+ const tokens = arg.trim().split(/\s+/).filter(Boolean);
+ let ownerFilter: string | undefined;
+ const keywordParts: string[] = [];
+ for (const t of tokens) {
+ if (t.startsWith('@') && t.length > 1) ownerFilter = t.slice(1);
+ else keywordParts.push(t);
+ }
+ const keyword = keywordParts.join(' ').toLowerCase().trim();
+
+ if (!keyword && !ownerFilter) {
+ chunk(view, [
+ '\n📋 **/decisions — Chronicle 결정 기록 검색**',
+ '',
+ '사용법:',
+ ' `/decisions <키워드>` — 키워드로 ADR 검색',
+ ' `/decisions @<담당자>` — 담당자 언급 결정만',
+ ' `/decisions <키워드> @<담당자>` — 둘 다 만족',
+ '',
+ '예시:',
+ ' `/decisions 환불 정책`',
+ ' `/decisions @기획자`',
+ ' `/decisions 결제 흐름 @개발`',
+ '',
+ 'Chronicle ADR 파일 (`/decisions/ADR-NNNN-*.md`) 을 스캔합니다. 최신순 정렬, 최대 20건.',
+ '',
+ ].join('\n'));
+ return true;
+ }
+
+ const store = new ChronicleProjectStore(context);
+ const profiles = store.getAll();
+ if (profiles.length === 0) {
+ chunk(view, '\n❌ Chronicle 프로젝트가 없습니다. workspace 폴더를 열고 사이드바에서 chronicle 활성화하세요.\n');
+ return true;
+ }
+
+ interface Hit { project: string; file: string; filePath: string; mtime: number; title: string; snippet: string; }
+ const hits: Hit[] = [];
+
+ for (const profile of profiles) {
+ const decisionsDir = path.join(profile.recordRoot, 'decisions');
+ if (!fs.existsSync(decisionsDir)) continue;
+ let fileNames: string[] = [];
+ try { fileNames = fs.readdirSync(decisionsDir); } catch { continue; }
+ for (const fileName of fileNames) {
+ if (!fileName.endsWith('.md') || !fileName.startsWith('ADR-')) continue;
+ const filePath = path.join(decisionsDir, fileName);
+ let content = '';
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
+ const lower = content.toLowerCase();
+
+ if (keyword && !lower.includes(keyword)) continue;
+ if (ownerFilter && !content.includes(ownerFilter)) continue;
+
+ const titleMatch = content.match(/^#\s+(.+)$/m);
+ const title = titleMatch ? titleMatch[1].trim() : fileName.replace(/\.md$/, '');
+
+ let snippet = '';
+ if (keyword) {
+ const idx = lower.indexOf(keyword);
+ if (idx >= 0) {
+ const start = Math.max(0, idx - 60);
+ const end = Math.min(content.length, idx + 180);
+ snippet = content.slice(start, end).replace(/\s+/g, ' ').trim();
+ }
+ } else {
+ const paragraphs = content.split(/\n\s*\n/).map(p => p.trim()).filter(p => p && !p.startsWith('#') && !p.startsWith('>'));
+ snippet = (paragraphs[0] || '').slice(0, 220).replace(/\s+/g, ' ').trim();
+ }
+
+ let mtime = 0;
+ try { mtime = fs.statSync(filePath).mtimeMs; } catch { /* keep 0 */ }
+ hits.push({ project: profile.projectName, file: fileName, filePath, mtime, title, snippet });
+ }
+ }
+
+ if (hits.length === 0) {
+ const filterDesc = [keyword && `키워드 "${keyword}"`, ownerFilter && `@${ownerFilter}`].filter(Boolean).join(' + ');
+ chunk(view, `\nℹ️ ${filterDesc} 에 매치되는 결정 기록 없음. (검색 대상: ${profiles.length}개 프로젝트)\n`);
+ return true;
+ }
+
+ hits.sort((a, b) => b.mtime - a.mtime);
+ const filterDesc = [keyword && `키워드: ${keyword}`, ownerFilter && `담당: @${ownerFilter}`].filter(Boolean).join(' · ');
+ chunk(view, `\n📋 **결정 검색 결과 ${hits.length}건** (${filterDesc})\n\n`);
+ const MAX_SHOW = 20;
+ for (const h of hits.slice(0, MAX_SHOW)) {
+ const date = h.mtime ? new Date(h.mtime).toISOString().slice(0, 10) : '날짜 미상';
+ chunk(view, `### ${h.title}\n`);
+ chunk(view, `- 📅 ${date} · 📁 ${h.project} · \`${h.file}\`\n`);
+ if (h.snippet) chunk(view, `- 💬 …${h.snippet}…\n`);
+ chunk(view, `- 🔗 \`${h.filePath}\`\n\n`);
+ }
+ if (hits.length > MAX_SHOW) chunk(view, `_…+${hits.length - MAX_SHOW}건 더 (필터를 좁히면 줄어듭니다)_\n`);
+ return true;
+}
+
+// ─── /onesie — 멤버별 1:1 미팅 준비 카드 ─────────────────────────────────
+
+async function runOnesie(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
+ if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /onesie 실행 불가.\n'); return true; }
+ const memberRaw = arg.trim().split(/\s+/)[0] || '';
+ const member = memberRaw.replace(/^@/, '').trim();
+ if (!member) {
+ chunk(view, [
+ '\n📋 **/onesie [멤버] — 1:1 미팅 준비 카드**',
+ '',
+ '사용법: `/onesie <담당자>` 또는 `/onesie @<담당자>`',
+ '예: `/onesie 기획자` · `/onesie @디자이너` · `/onesie 개발`',
+ '',
+ '대상자의 Tasks 진행 상황(완료/지연/다가오는)과 최근 Chronicle 결정 기록을 모아 1:1 준비 카드 생성. 자동 대화 토픽 제안 포함.',
+ '',
+ '※ `/task @<멤버> ...` 로 task 를 등록해 두면 자동으로 잡힙니다. `/meet` 액션 아이템도 owner 가 있으면 잡힘.',
+ '',
+ ].join('\n'));
+ return true;
+ }
+
+ chunk(view, `\n📋 **1:1 준비 카드 — @${member}**\n`);
+
+ chunk(view, '\n📥 Tasks 가져오는 중...\n');
+ const taskResult = await listTasks(context, { showCompleted: true, maxResults: 200 });
+ const allTasks = taskResult.ok ? taskResult.tasks : [];
+ if (!taskResult.ok) chunk(view, `\n⚠️ Tasks 조회 실패: ${taskResult.error}\n (Chronicle 검색은 계속 진행)\n`);
+
+ const memberPrefix = `[${member}]`;
+ const memberTasks = allTasks.filter((t) =>
+ t.title.includes(memberPrefix)
+ || (t.notes || '').includes(`@${member}`)
+ || (t.notes || '').includes(`담당: ${member}`),
+ );
+
+ const today = new Date().toISOString().slice(0, 10);
+ const thirtyDaysAgoIso = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
+ const completedRecent = memberTasks
+ .filter((t) => t.status === 'completed' && (t.completed || '') >= thirtyDaysAgoIso)
+ .sort((a, b) => (b.completed || '').localeCompare(a.completed || ''));
+ const overdue = memberTasks
+ .filter((t) => t.status === 'needsAction' && t.due && t.due < today)
+ .sort((a, b) => (a.due || '').localeCompare(b.due || ''));
+ const upcoming = memberTasks
+ .filter((t) => t.status === 'needsAction' && t.due && t.due >= today)
+ .sort((a, b) => (a.due || '').localeCompare(b.due || ''));
+ const noDate = memberTasks.filter((t) => t.status === 'needsAction' && !t.due);
+
+ const store = new ChronicleProjectStore(context);
+ const profiles = store.getAll();
+ interface AdrHit { date: string; mtime: number; title: string; file: string; project: string; }
+ const adrHits: AdrHit[] = [];
+ for (const profile of profiles) {
+ const decisionsDir = path.join(profile.recordRoot, 'decisions');
+ if (!fs.existsSync(decisionsDir)) continue;
+ let names: string[] = [];
+ try { names = fs.readdirSync(decisionsDir); } catch { continue; }
+ for (const fn of names) {
+ if (!fn.endsWith('.md') || !fn.startsWith('ADR-')) continue;
+ const fp = path.join(decisionsDir, fn);
+ let content = '';
+ try { content = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
+ if (!content.includes(member)) continue;
+ const titleMatch = content.match(/^#\s+(.+)$/m);
+ const title = titleMatch ? titleMatch[1].trim() : fn.replace(/\.md$/, '');
+ let mtime = 0;
+ try { mtime = fs.statSync(fp).mtimeMs; } catch { /* keep 0 */ }
+ const date = mtime ? new Date(mtime).toISOString().slice(0, 10) : '';
+ adrHits.push({ date, mtime, title, file: fn, project: profile.projectName });
+ }
+ }
+ adrHits.sort((a, b) => b.mtime - a.mtime);
+
+ chunk(view, `\n## 최근 30일 완료 (${completedRecent.length}건)\n`);
+ if (completedRecent.length === 0) chunk(view, '_없음_\n');
+ else for (const t of completedRecent.slice(0, 10)) {
+ const d = (t.completed || '').slice(0, 10);
+ chunk(view, `- ✅ ${d} — ${t.title}\n`);
+ }
+ if (completedRecent.length > 10) chunk(view, `_…+${completedRecent.length - 10}건 더_\n`);
+
+ chunk(view, `\n## 지연 (${overdue.length}건)\n`);
+ if (overdue.length === 0) chunk(view, '_없음_\n');
+ else for (const t of overdue) chunk(view, `- 🔴 ${t.due} (마감 지남) — ${t.title}\n`);
+
+ chunk(view, `\n## 진행 중 / 다가오는 (${upcoming.length}건)\n`);
+ if (upcoming.length === 0) chunk(view, '_없음_\n');
+ else for (const t of upcoming.slice(0, 10)) chunk(view, `- 🟡 ${t.due} — ${t.title}\n`);
+ if (upcoming.length > 10) chunk(view, `_…+${upcoming.length - 10}건 더_\n`);
+
+ if (noDate.length > 0) {
+ chunk(view, `\n## 마감일 없음 (${noDate.length}건)\n`);
+ for (const t of noDate.slice(0, 5)) chunk(view, `- ⚪ ${t.title}\n`);
+ if (noDate.length > 5) chunk(view, `_…+${noDate.length - 5}건 더_\n`);
+ }
+
+ chunk(view, `\n## 최근 결정 — @${member} 언급 (${adrHits.length}건)\n`);
+ if (adrHits.length === 0) chunk(view, '_없음_\n');
+ else for (const h of adrHits.slice(0, 5)) chunk(view, `- 📋 ${h.date} — ${h.title} (\`${h.file}\`)\n`);
+
+ const topics: string[] = [];
+ if (overdue.length > 0) topics.push(`🔴 지연 ${overdue.length}건 블로커 확인 — 무엇이 막혔나, 도와줄 일은`);
+ if (upcoming.length > 5) topics.push(`🟡 다가오는 마감 ${upcoming.length}건 — 우선순위 합의·과부하 여부`);
+ if (completedRecent.length > 5) topics.push(`✅ 최근 완료 ${completedRecent.length}건 많음 — 회고 / 잘 된 점 / 패턴`);
+ else if (completedRecent.length === 0 && memberTasks.length > 0) topics.push('⚠️ 최근 30일 완료 0건 — 어떤 일에 시간을 쓰고 있는지 확인');
+ else if (memberTasks.length === 0) topics.push('⚠️ 등록된 Task 자체가 0 — 일하는 게 안 보임. owner 태깅 시작 필요');
+ if (noDate.length > 3) topics.push(`⚪ 마감일 없는 작업 ${noDate.length}건 — 우선순위 합의로 마감 부여`);
+ if (adrHits.length > 0) topics.push(`📋 최근 결정 (${adrHits[0].title.slice(0, 40)}…) — 이해·실행 상황 확인`);
+
+ chunk(view, `\n## 💬 1:1 대화 토픽 제안\n`);
+ if (topics.length === 0) chunk(view, '_특이사항 없음 — 일반 안부 + 다음 주 우선순위 정도_\n');
+ else for (const t of topics) chunk(view, `- ${t}\n`);
+ return true;
+}
+
+// ─── /blocked — 전사 across 지연·블로커 한 화면 ──────────────────────────
+
+async function runBlocked(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
+ if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /blocked 실행 불가.\n'); return true; }
+
+ const memberFilter = (arg.trim().split(/\s+/)[0] || '').replace(/^@/, '').trim() || undefined;
+
+ chunk(view, `\n🚨 **전사 블로커·지연 뷰**${memberFilter ? ` — @${memberFilter}` : ''}\n`);
+ chunk(view, '\n📥 Tasks 가져오는 중...\n');
+
+ const result = await listTasks(context, { showCompleted: false, maxResults: 300 });
+ if (!result.ok) { chunk(view, `\n❌ Tasks 조회 실패: ${result.error}\n`); return true; }
+ const tasks = result.tasks;
+
+ interface Row { due?: string; owner?: string; title: string; }
+ const today = new Date().toISOString().slice(0, 10);
+ const weekLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
+
+ const overdue: Row[] = [];
+ const thisWeek: Row[] = [];
+ const noDate: Row[] = [];
+
+ for (const t of tasks) {
+ const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
+ if (memberFilter && (owner || '').toLowerCase() !== memberFilter.toLowerCase()) continue;
+ const row: Row = { due: t.due, owner, title: displayTitle };
+ if (!t.due) noDate.push(row);
+ else if (t.due < today) overdue.push(row);
+ else if (t.due <= weekLater) thisWeek.push(row);
+ }
+ overdue.sort((a, b) => (a.due || '').localeCompare(b.due || ''));
+ thisWeek.sort((a, b) => (a.due || '').localeCompare(b.due || ''));
+ noDate.sort((a, b) => (a.owner || 'zzz').localeCompare(b.owner || 'zzz'));
+
+ const fmtRow = (r: Row): string => {
+ const o = r.owner ? `@${r.owner}` : '(owner 없음)';
+ return `- 📅 \`${r.due || '----------'}\` · **${o}** — ${r.title}`;
+ };
+
+ const totalShown = overdue.length + thisWeek.length + noDate.length;
+ if (totalShown === 0) {
+ chunk(view, `\n✅ 지연·임박 항목 없음${memberFilter ? ` (@${memberFilter})` : ''}. 진행 상황 양호.\n`);
+ return true;
+ }
+
+ if (overdue.length > 0) {
+ chunk(view, `\n## 🔴 지연 ${overdue.length}건 — 즉시 확인 필요\n`);
+ const MAX_OVERDUE = 20;
+ for (const r of overdue.slice(0, MAX_OVERDUE)) chunk(view, fmtRow(r) + '\n');
+ if (overdue.length > MAX_OVERDUE) chunk(view, `_…+${overdue.length - MAX_OVERDUE}건 더_\n`);
+ }
+ if (thisWeek.length > 0) {
+ chunk(view, `\n## 🟡 이번 주 마감 ${thisWeek.length}건 (~${weekLater})\n`);
+ const MAX_WEEK = 15;
+ for (const r of thisWeek.slice(0, MAX_WEEK)) chunk(view, fmtRow(r) + '\n');
+ if (thisWeek.length > MAX_WEEK) chunk(view, `_…+${thisWeek.length - MAX_WEEK}건 더_\n`);
+ }
+ if (noDate.length > 0) {
+ chunk(view, `\n## ⚪ 마감일 없음 ${noDate.length}건 — 우선순위 합의 필요\n`);
+ const MAX_NODATE = 10;
+ for (const r of noDate.slice(0, MAX_NODATE)) chunk(view, fmtRow(r) + '\n');
+ if (noDate.length > MAX_NODATE) chunk(view, `_…+${noDate.length - MAX_NODATE}건 더_\n`);
+ }
+
+ if (!memberFilter && (overdue.length + thisWeek.length) > 0) {
+ const counts = new Map();
+ for (const r of overdue) {
+ const k = r.owner || '(없음)';
+ const c = counts.get(k) || { overdue: 0, week: 0 };
+ c.overdue++;
+ counts.set(k, c);
+ }
+ for (const r of thisWeek) {
+ const k = r.owner || '(없음)';
+ const c = counts.get(k) || { overdue: 0, week: 0 };
+ c.week++;
+ counts.set(k, c);
+ }
+ const ranked = [...counts.entries()]
+ .sort((a, b) => (b[1].overdue * 2 + b[1].week) - (a[1].overdue * 2 + a[1].week));
+ chunk(view, `\n## 📊 멤버별 압박 ${ranked.length}명\n`);
+ for (const [member, c] of ranked) {
+ chunk(view, `- **@${member}** — 지연 ${c.overdue}건${c.week ? ` · 이번 주 ${c.week}건` : ''}\n`);
+ }
+ chunk(view, '\n💡 압박 큰 멤버부터 `/onesie @<멤버>` 로 1:1 카드 확인 권장.\n');
+ }
+ return true;
+}
+
+// ─── /standup — 팀 스탠드업 카드 (슬랙 복붙 친화) ────────────────────────
+
+async function runStandup(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
+ if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /standup 실행 불가.\n'); return true; }
+
+ const mode = (arg.trim().split(/\s+/)[0] || 'weekly').toLowerCase();
+ const windowDays = mode === 'daily' ? 1 : mode === 'monthly' ? 30 : 7;
+ const modeLabel = mode === 'daily' ? '일일' : mode === 'monthly' ? '월간' : '주간';
+
+ if (arg.trim() && !['daily', 'weekly', 'monthly', ''].includes(mode)) {
+ chunk(view, [
+ '\n📋 **/standup [daily/weekly/monthly] — 팀 스탠드업 카드**',
+ '',
+ '사용법:',
+ ' `/standup` — 주간 (기본, 7일 윈도우)',
+ ' `/standup daily` — 일일 (1일 윈도우)',
+ ' `/standup weekly` — 주간 (7일)',
+ ' `/standup monthly` — 월간 (30일)',
+ '',
+ '멤버별로 완료 / 진행·예정 / 블로커 3-row + 이번 기간 결정 목록을 슬랙·노션에 복붙 가능한 마크다운으로 출력.',
+ '',
+ ].join('\n'));
+ return true;
+ }
+
+ chunk(view, `\n📊 **팀 스탠드업 — ${modeLabel} (${windowDays}일 윈도우)**\n · ${new Date().toISOString().slice(0, 10)} 기준\n`);
+ chunk(view, '\n📥 Tasks 가져오는 중...\n');
+
+ const result = await listTasks(context, { showCompleted: true, maxResults: 300 });
+ if (!result.ok) { chunk(view, `\n❌ Tasks 조회 실패: ${result.error}\n`); return true; }
+
+ const today = new Date().toISOString().slice(0, 10);
+ const windowAgoIso = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000).toISOString();
+ const upcomingEnd = new Date(Date.now() + windowDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
+
+ interface MemberSlot { completed: { date: string; title: string }[]; upcoming: { due: string; title: string }[]; overdue: { due: string; title: string }[]; noDate: { title: string }[]; }
+ const byMember = new Map();
+ const ensure = (k: string): MemberSlot => {
+ let s = byMember.get(k);
+ if (!s) { s = { completed: [], upcoming: [], overdue: [], noDate: [] }; byMember.set(k, s); }
+ return s;
+ };
+
+ for (const t of result.tasks) {
+ const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
+ const member = owner || '(owner 없음)';
+ const slot = ensure(member);
+ if (t.status === 'completed') {
+ if ((t.completed || '') >= windowAgoIso) {
+ slot.completed.push({ date: (t.completed || '').slice(0, 10), title: displayTitle });
+ }
+ } else {
+ if (!t.due) slot.noDate.push({ title: displayTitle });
+ else if (t.due < today) slot.overdue.push({ due: t.due, title: displayTitle });
+ else if (t.due <= upcomingEnd) slot.upcoming.push({ due: t.due, title: displayTitle });
+ }
+ }
+ for (const s of byMember.values()) {
+ s.completed.sort((a, b) => b.date.localeCompare(a.date));
+ s.upcoming.sort((a, b) => a.due.localeCompare(b.due));
+ s.overdue.sort((a, b) => a.due.localeCompare(b.due));
+ }
+
+ if (byMember.size === 0) {
+ chunk(view, '\nℹ️ 이 기간에 활동이 있는 task 가 없습니다. `/task @<멤버> ...` 로 등록 시작.\n');
+ return true;
+ }
+
+ const store = new ChronicleProjectStore(context);
+ const profiles = store.getAll();
+ interface RecentAdr { date: string; title: string; file: string; mtime: number; }
+ const recentAdrs: RecentAdr[] = [];
+ const windowAgoMs = Date.now() - windowDays * 24 * 60 * 60 * 1000;
+ for (const profile of profiles) {
+ const decisionsDir = path.join(profile.recordRoot, 'decisions');
+ if (!fs.existsSync(decisionsDir)) continue;
+ let names: string[] = [];
+ try { names = fs.readdirSync(decisionsDir); } catch { continue; }
+ for (const fn of names) {
+ if (!fn.endsWith('.md') || !fn.startsWith('ADR-')) continue;
+ const fp = path.join(decisionsDir, fn);
+ let mtime = 0;
+ try { mtime = fs.statSync(fp).mtimeMs; } catch { continue; }
+ if (mtime < windowAgoMs) continue;
+ let content = '';
+ try { content = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
+ const titleMatch = content.match(/^#\s+(.+)$/m);
+ const title = titleMatch ? titleMatch[1].trim() : fn.replace(/\.md$/, '');
+ recentAdrs.push({ date: new Date(mtime).toISOString().slice(0, 10), title, file: fn, mtime });
+ }
+ }
+ recentAdrs.sort((a, b) => b.mtime - a.mtime);
+
+ const memberRanked = [...byMember.entries()].sort((a, b) => {
+ const score = (s: MemberSlot) => s.completed.length + s.upcoming.length + s.overdue.length * 2;
+ return score(b[1]) - score(a[1]);
+ });
+
+ chunk(view, '\n---\n');
+ chunk(view, `\n## 📊 팀 스탠드업 — ${modeLabel}\n`);
+ chunk(view, `*${windowDays}일 윈도우 · 기준일 ${today}*\n`);
+
+ for (const [member, s] of memberRanked) {
+ chunk(view, `\n### @${member}\n`);
+
+ if (s.completed.length === 0) chunk(view, `**✅ 완료**: _없음_\n`);
+ else {
+ chunk(view, `**✅ 완료 (${s.completed.length})**:\n`);
+ const MAX = 8;
+ for (const c of s.completed.slice(0, MAX)) chunk(view, `- ${c.date} — ${c.title}\n`);
+ if (s.completed.length > MAX) chunk(view, `- _…+${s.completed.length - MAX}건_\n`);
+ }
+
+ if (s.upcoming.length === 0 && s.noDate.length === 0) chunk(view, `**🎯 진행/예정**: _없음_\n`);
+ else {
+ chunk(view, `**🎯 진행/예정 (${s.upcoming.length + s.noDate.length})**:\n`);
+ const MAX = 6;
+ for (const u of s.upcoming.slice(0, MAX)) chunk(view, `- ${u.due} — ${u.title}\n`);
+ for (const n of s.noDate.slice(0, Math.max(0, MAX - s.upcoming.length))) chunk(view, `- (마감 미정) — ${n.title}\n`);
+ const total = s.upcoming.length + s.noDate.length;
+ if (total > MAX) chunk(view, `- _…+${total - MAX}건_\n`);
+ }
+
+ if (s.overdue.length === 0) chunk(view, `**🚧 블로커**: _없음_\n`);
+ else {
+ chunk(view, `**🚧 블로커 (${s.overdue.length}건 지연)**:\n`);
+ for (const o of s.overdue.slice(0, 5)) chunk(view, `- 🔴 ${o.due} (지남) — ${o.title}\n`);
+ if (s.overdue.length > 5) chunk(view, `- _…+${s.overdue.length - 5}건_\n`);
+ }
+ }
+
+ if (recentAdrs.length > 0) {
+ chunk(view, `\n---\n\n## 📋 이번 ${modeLabel} 결정 (${recentAdrs.length}건)\n`);
+ for (const a of recentAdrs.slice(0, 10)) chunk(view, `- ${a.date} — ${a.title}\n`);
+ if (recentAdrs.length > 10) chunk(view, `- _…+${recentAdrs.length - 10}건_\n`);
+ }
+
+ chunk(view, '\n---\n\n💡 위 마크다운을 그대로 슬랙·노션에 복붙하세요. 멤버 활동 순 정렬.\n');
+ return true;
+}
+
+// ─── 등록 ─────────────────────────────────────────────────────────────────
+
+registerSlashCommand({ name: '/task', description: '단일 작업을 Google Tasks + Calendar 양쪽에 등록 (제목 + 시작일 + 완료일)', handler: runTask });
+registerSlashCommand({ name: '/decisions', description: 'Chronicle 결정 기록(ADR) 검색 — 키워드/담당자로 필터', handler: runDecisions });
+registerSlashCommand({ name: '/onesie', description: '멤버별 1:1 미팅 준비 카드 — Tasks 진행 + 최근 결정 + 대화 토픽 제안', handler: runOnesie });
+registerSlashCommand({ name: '/blocked', description: '전사 across 지연·블로커 한 화면 — 일일 아침 ritual', handler: runBlocked });
+registerSlashCommand({ name: '/standup', description: '팀 스탠드업 카드 (멤버별 완료/진행/블로커, 슬랙 복붙 친화)', handler: runStandup });
diff --git a/src/features/teamops/handlers/dashboards.ts b/src/features/teamops/handlers/dashboards.ts
new file mode 100644
index 0000000..c2fbc11
--- /dev/null
+++ b/src/features/teamops/handlers/dashboards.ts
@@ -0,0 +1,806 @@
+/**
+ * TeamOps Dashboards — /morning · /evening · /cohort · /weekly (CEO 일·주 리듬).
+ *
+ * v2.2.198 에서 slashRouter.ts 에서 분리. cross-data 합성 (Tasks + customers +
+ * hire + runway + Chronicle ADR) 으로 일/주/월 단위 시야 제공.
+ *
+ * 공통 헬퍼는 `./_shared.ts` 에서. dashboards 전용 helper (_isoWeek, _aggregateWeek,
+ * _morningActions 등) 는 이 파일 안에.
+ */
+
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+import { registerSlashCommand, chunk } from '../../datacollect/slashRouter';
+import {
+ fmtKrw as _fmtKrw, daysUntil as _daysUntil,
+ parseTaskOwner, stageEmoji as _stageEmoji,
+ STAGE_ORDER, TERMINAL_STAGES,
+} from './_shared';
+import { listTasks } from '../../calendar';
+import { ChronicleProjectStore } from '../../../sidebar/managers/chronicleProjectStore';
+import {
+ computeRunwayStatus, readRunway, type RunwayEntry,
+} from '../../runway/runwayStore';
+import {
+ computeCustomerStates, readEvents as readCustomerEvents,
+ type CustomerEvent, type CustomerState,
+} from '../../customers/customersStore';
+import {
+ computeCandidateStates, readHireEvents,
+ type HireEvent, type CandidateState,
+} from '../../hire/hireStore';
+
+// ─── /morning — 매일 아침 통합 대시보드 ──────────────────────────────────
+
+async function runMorning(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
+ const mode = (arg.trim().split(/\s+/)[0] || '').toLowerCase();
+ const brief = mode === 'brief' || mode === 'short';
+
+ const today = new Date().toISOString().slice(0, 10);
+ chunk(view, `\n☀️ **오늘 (${today}) — 통합 대시보드**\n`);
+
+ const runway = computeRunwayStatus();
+ const customerStates = computeCustomerStates();
+ const customers = Array.from(customerStates.values());
+ const candidateStates = computeCandidateStates();
+ const candidates = Array.from(candidateStates.values());
+
+ let tasks: any[] = [];
+ let tasksError: string | undefined;
+ if (context) {
+ try {
+ const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
+ if (res.ok) tasks = res.tasks;
+ else tasksError = res.error;
+ } catch (e: any) { tasksError = e?.message || String(e); }
+ }
+
+ const urgent: string[] = [];
+ if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 3) {
+ urgent.push(`🔴 **런웨이 ${runway.runwayMonths.toFixed(1)}개월** — 즉시 자금 조달/절감 필요`);
+ } else if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 6) {
+ urgent.push(`🟡 런웨이 ${runway.runwayMonths.toFixed(1)}개월 — 자금 계획 점검 권장`);
+ }
+ const atRiskCustomers = customers.filter((c) => c.status === 'at-risk');
+ const atRiskMrr = atRiskCustomers.reduce((s, c) => s + c.mrr, 0);
+ if (atRiskCustomers.length > 0) {
+ urgent.push(`⚠️ 위험 고객 **${atRiskCustomers.length}곳** (MRR ${_fmtKrw(atRiskMrr)}원/월 노출)`);
+ }
+ const upcomingRenewals = [...customers].filter((c) => c.status !== 'churned')
+ .map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
+ .filter((x) => x.days !== null && x.days >= 0 && x.days <= 7);
+ if (upcomingRenewals.length > 0) urgent.push(`🔔 7일 내 갱신 **${upcomingRenewals.length}건**`);
+ const overdueTasks = tasks.filter((t) => t.due && t.due < today);
+ if (overdueTasks.length > 0) urgent.push(`🚧 지연 작업 **${overdueTasks.length}건**`);
+ const activeCandidate = candidates.filter((c) => !TERMINAL_STAGES.has(c.stage));
+ const stalled = activeCandidate.filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000);
+ if (stalled.length > 0) urgent.push(`⏰ 정체 후보 **${stalled.length}명** (7일+ 미변동)`);
+
+ if (urgent.length === 0) chunk(view, '\n## ✅ 긴급 알림 없음\n');
+ else {
+ chunk(view, `\n## 🚨 긴급 (${urgent.length}건)\n`);
+ for (const u of urgent) chunk(view, `- ${u}\n`);
+ }
+
+ if (brief) {
+ chunk(view, '\n## 📋 오늘의 액션 (top 3)\n');
+ const actions = _morningActions(runway, customers, upcomingRenewals, overdueTasks, stalled, candidates);
+ for (const a of actions.slice(0, 3)) chunk(view, `- ${a}\n`);
+ return true;
+ }
+
+ chunk(view, '\n## 💰 재무\n');
+ if (runway.latestCash === null) {
+ chunk(view, '- _데이터 없음_ — `/runway cash <금액>` 으로 시작\n');
+ } else {
+ chunk(view, `- 현금 **${_fmtKrw(runway.latestCash)}원** _(${(runway.latestCashAt || '').slice(0, 10)})_\n`);
+ if (runway.effectiveBurn !== null) {
+ chunk(view, `- 월 burn ${_fmtKrw(runway.effectiveBurn)}원 ${runway.explicitBurn !== null ? '_(수동)_' : '_(30일 실적)_'}\n`);
+ }
+ if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths)) {
+ const emoji = runway.runwayMonths < 3 ? '🔴' : runway.runwayMonths < 6 ? '🟡' : '🟢';
+ chunk(view, `- 런웨이 ${emoji} **${runway.runwayMonths.toFixed(1)}개월**\n`);
+ } else if (runway.runwayMonths !== null) {
+ chunk(view, '- 런웨이 ♾️ 흑자 운영\n');
+ }
+ }
+
+ chunk(view, '\n## 📒 고객\n');
+ if (customers.length === 0) {
+ chunk(view, '- _데이터 없음_ — `/customers add` 로 시작\n');
+ } else {
+ const active = customers.filter((c) => c.status === 'active');
+ const totalMrr = [...active, ...atRiskCustomers].reduce((s, c) => s + c.mrr, 0);
+ chunk(view, `- 총 MRR **${_fmtKrw(totalMrr)}원/월** _(연 ${_fmtKrw(totalMrr * 12)}원)_\n`);
+ chunk(view, `- 활성 ${active.length} · 위험 ${atRiskCustomers.length} · 이탈 ${customers.length - active.length - atRiskCustomers.length}\n`);
+ if (upcomingRenewals.length > 0) {
+ for (const { c, days } of upcomingRenewals.slice(0, 5)) {
+ const emoji = (days as number) <= 3 ? '🔴' : '🟡';
+ chunk(view, ` - ${emoji} ${c.customerName} — D-${days} · ${_fmtKrw(c.mrr)}원/월\n`);
+ }
+ }
+ }
+
+ chunk(view, '\n## 👥 팀\n');
+ if (tasksError) {
+ chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`);
+ } else if (tasks.length === 0) {
+ chunk(view, '- _Tasks 없음_ — `/task` 로 등록 시작\n');
+ } else {
+ const memberOverdue = new Map();
+ const weekLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
+ let weekCount = 0;
+ for (const t of tasks) {
+ const { owner } = parseTaskOwner(t.title, t.notes);
+ const k = owner || '(미지정)';
+ if (t.due && t.due < today) memberOverdue.set(k, (memberOverdue.get(k) || 0) + 1);
+ if (t.due && t.due >= today && t.due <= weekLater) weekCount++;
+ }
+ chunk(view, `- 지연 ${overdueTasks.length}건 · 이번 주 ${weekCount}건 (전체 ${tasks.length})\n`);
+ if (memberOverdue.size > 0) {
+ const ranked = [...memberOverdue.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4);
+ for (const [member, n] of ranked) chunk(view, ` - **@${member}** 지연 ${n}건\n`);
+ }
+ }
+
+ chunk(view, '\n## 🎯 채용\n');
+ if (candidates.length === 0) {
+ chunk(view, '- _데이터 없음_\n');
+ } else {
+ const hired = candidates.filter((c) => c.stage === 'hired').length;
+ chunk(view, `- 진행 중 ${activeCandidate.length}명 · 합격 ${hired}\n`);
+ const stageCount = new Map();
+ for (const c of activeCandidate) stageCount.set(c.stage, (stageCount.get(c.stage) || 0) + 1);
+ const stages = [...stageCount.entries()].sort((a, b) => (STAGE_ORDER[a[0]] ?? 50) - (STAGE_ORDER[b[0]] ?? 50));
+ if (stages.length > 0) {
+ const parts = stages.map(([s, n]) => `${_stageEmoji(s)} ${s} ${n}`);
+ chunk(view, ` - ${parts.join(' · ')}\n`);
+ }
+ if (stalled.length > 0) {
+ chunk(view, `- ⏰ 정체 ${stalled.length}명:\n`);
+ for (const c of stalled.slice(0, 3)) {
+ const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
+ chunk(view, ` - ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`);
+ }
+ }
+ }
+
+ chunk(view, '\n## 📋 오늘의 액션\n');
+ const actions = _morningActions(runway, customers, upcomingRenewals, overdueTasks, stalled, candidates);
+ if (actions.length === 0) {
+ chunk(view, '- ✨ 특별한 조치 필요 없음. 깊은 작업 시간 확보 권장.\n');
+ } else {
+ for (const a of actions.slice(0, 5)) chunk(view, `- ${a}\n`);
+ }
+ return true;
+}
+
+function _morningActions(
+ runway: ReturnType,
+ customers: CustomerState[],
+ upcomingRenewals: Array<{ c: CustomerState; days: number | null }>,
+ overdueTasks: any[],
+ stalled: CandidateState[],
+ candidates: CandidateState[],
+): string[] {
+ const actions: string[] = [];
+ if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 3) {
+ actions.push(`💸 **자금 조달 계획** — 런웨이 ${runway.runwayMonths.toFixed(1)}개월. 투자자 미팅 / 비용 절감 즉시.`);
+ }
+ const atRisk = customers.filter((c) => c.status === 'at-risk').sort((a, b) => b.mrr - a.mrr);
+ if (atRisk.length > 0) {
+ const top = atRisk[0];
+ actions.push(`📞 **${top.customerName}** 위험 대응 — MRR ${_fmtKrw(top.mrr)}원. 사유 점검 후 액션.`);
+ }
+ if (upcomingRenewals.length > 0) {
+ const next = upcomingRenewals[0];
+ actions.push(`📨 **${next.c.customerName}** 갱신 D-${next.days} — 갱신 의사 확인 / 가격 협의.`);
+ }
+ if (overdueTasks.length >= 5) {
+ actions.push(`🚧 지연 작업 ${overdueTasks.length}건 — \`/blocked\` 로 멤버별 확인 후 우선순위 재조정.`);
+ } else if (overdueTasks.length > 0) {
+ actions.push(`🚧 지연 작업 ${overdueTasks.length}건 — \`/blocked\` 로 확인.`);
+ }
+ if (stalled.length > 0) {
+ const next = stalled[0];
+ const days = Math.floor((Date.now() - Date.parse(next.lastEventAt)) / (24 * 60 * 60 * 1000));
+ actions.push(`👥 **${next.candidateName}** 채용 후속 — ${next.stage} 단계 ${days}일 정체.`);
+ }
+ const inboxCount = candidates.filter((c) => c.stage === 'inbox').length;
+ if (inboxCount >= 5) actions.push(`📥 채용 inbox ${inboxCount}명 누적 — 스크리닝 시간 확보.`);
+ return actions;
+}
+
+// ─── /evening — 하루 마무리 카드 ─────────────────────────────────────────
+
+async function runEvening(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
+ const today = new Date().toISOString().slice(0, 10);
+ const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
+ const dayStartMs = Date.parse(today + 'T00:00:00');
+
+ chunk(view, `\n🌙 **오늘 (${today}) — 마무리 카드**\n`);
+
+ let completedTasks: any[] = [];
+ let tasksError: string | undefined;
+ if (context) {
+ try {
+ const res = await listTasks(context, { showCompleted: true, maxResults: 300 });
+ if (res.ok) {
+ completedTasks = res.tasks.filter((t: any) => t.status === 'completed' && t.completed && Date.parse(t.completed) >= dayStartMs);
+ } else { tasksError = res.error; }
+ } catch (e: any) { tasksError = e?.message || String(e); }
+ }
+
+ const customerEvents = readCustomerEvents().filter((e) => Date.parse(e.timestamp) >= dayStartMs);
+ const hireEvents = readHireEvents().filter((e) => Date.parse(e.timestamp) >= dayStartMs);
+ const runwayToday = readRunway().filter((e) => Date.parse(e.timestamp) >= dayStartMs);
+
+ chunk(view, '\n## ✅ 오늘의 진척\n');
+ const progressEmpty = completedTasks.length === 0 && customerEvents.length === 0 && hireEvents.length === 0 && runwayToday.length === 0;
+ if (progressEmpty) {
+ chunk(view, '- _기록된 진척 없음._ (작업 완료 / 고객 이벤트 / 채용 이동 등이 오늘 입력되지 않음)\n');
+ if (tasksError) chunk(view, ` _Tasks 조회 실패: ${tasksError}_\n`);
+ } else {
+ if (completedTasks.length > 0) {
+ chunk(view, `\n### 작업 완료 (${completedTasks.length}건)\n`);
+ const byOwner = new Map();
+ for (const t of completedTasks) {
+ const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
+ const k = owner || '(미지정)';
+ if (!byOwner.has(k)) byOwner.set(k, []);
+ byOwner.get(k)!.push({ title: displayTitle });
+ }
+ const ranked = [...byOwner.entries()].sort((a, b) => b[1].length - a[1].length);
+ for (const [owner, list] of ranked) {
+ chunk(view, `- **@${owner}** (${list.length}건)\n`);
+ for (const t of list.slice(0, 5)) chunk(view, ` - ${t.title}\n`);
+ if (list.length > 5) chunk(view, ` - _…+${list.length - 5}건_\n`);
+ }
+ }
+ if (customerEvents.length > 0) {
+ chunk(view, `\n### 📒 고객 이벤트 (${customerEvents.length}건)\n`);
+ for (const e of customerEvents.slice(0, 10)) {
+ const tagEmoji = e.type === 'add' ? '➕' : e.type === 'renew' ? '🔄' : e.type === 'risk' ? '⚠️' : e.type === 'churn' ? '💀' : '📝';
+ const detail = e.type === 'add' || e.type === 'renew' || e.type === 'update'
+ ? (e.mrr !== undefined ? ` MRR ${_fmtKrw(e.mrr)}원` : '')
+ : (e.memo ? ` — ${e.memo.slice(0, 60)}` : '');
+ chunk(view, `- ${tagEmoji} ${e.customerName} ${e.type}${detail}\n`);
+ }
+ }
+ if (hireEvents.length > 0) {
+ chunk(view, `\n### 🎯 채용 이벤트 (${hireEvents.length}건)\n`);
+ for (const e of hireEvents.slice(0, 10)) {
+ const stageNote = e.stage ? ` → ${e.stage}` : '';
+ const memo = e.memo ? ` — ${e.memo.slice(0, 60)}` : '';
+ chunk(view, `- ${e.candidateName} ${e.type}${stageNote}${memo}\n`);
+ }
+ }
+ if (runwayToday.length > 0) {
+ chunk(view, `\n### 💰 재무 기록 (${runwayToday.length}건)\n`);
+ for (const e of runwayToday) {
+ const typeLabel = e.type === 'snapshot' ? '잔고' : e.type === 'expense' ? '지출' : e.type === 'revenue' ? '수입' : '월 burn';
+ chunk(view, `- ${typeLabel}: ${_fmtKrw(e.amount)}원${e.memo ? ` — ${e.memo}` : ''}\n`);
+ }
+ }
+ }
+
+ chunk(view, '\n## 🌅 내일 준비\n');
+ let tomorrowTasks: any[] = [];
+ if (context && !tasksError) {
+ try {
+ const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
+ if (res.ok) tomorrowTasks = res.tasks.filter((t: any) => t.due === tomorrow);
+ } catch { /* ignore */ }
+ }
+ if (tomorrowTasks.length > 0) {
+ chunk(view, `\n### 내일 마감 (${tomorrowTasks.length}건)\n`);
+ for (const t of tomorrowTasks.slice(0, 8)) {
+ const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
+ chunk(view, `- **@${owner || '(미지정)'}** — ${displayTitle}\n`);
+ }
+ }
+
+ const customers = Array.from(computeCustomerStates().values());
+ const upcomingRenewals = customers.filter((c) => c.status !== 'churned')
+ .map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
+ .filter((x) => x.days !== null && x.days >= 0 && x.days <= 7);
+ if (upcomingRenewals.length > 0) {
+ chunk(view, `\n### 🔔 7일 내 갱신 (${upcomingRenewals.length}건)\n`);
+ for (const { c, days } of upcomingRenewals.slice(0, 5)) {
+ const emoji = (days as number) <= 3 ? '🔴' : '🟡';
+ chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\n`);
+ }
+ }
+
+ const stalled = Array.from(computeCandidateStates().values())
+ .filter((c) => !TERMINAL_STAGES.has(c.stage))
+ .filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000);
+ if (stalled.length > 0) {
+ chunk(view, `\n### ⏰ 정체 후보 (${stalled.length}명)\n`);
+ for (const c of stalled.slice(0, 3)) {
+ const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
+ chunk(view, `- ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`);
+ }
+ }
+
+ if (tomorrowTasks.length === 0 && upcomingRenewals.length === 0 && stalled.length === 0) {
+ chunk(view, '- _내일 마감·갱신 임박·정체 후보 모두 없음._ ✨\n');
+ }
+
+ const reflections = [
+ '오늘 가장 중요한 한 가지는 무엇이었나? 의도한 대로 됐나?',
+ '내일 무엇을 안 하기로 했나? 안 할 일을 정해야 할 일이 또렷해진다.',
+ '오늘 한 결정 중 일주일 뒤에도 옳을 결정은 어느 것인가?',
+ '시간이 가장 많이 든 활동은 가장 영향력 있는 활동과 일치했나?',
+ '오늘 멤버들에게 충분한 명확함을 줬나? 무엇을 미루지 않고 답해야 하나?',
+ '에너지가 가장 좋았던 30분은 무엇을 하던 때였나?',
+ '오늘 안 한 일 중 내일도 안 해도 되는 일은 무엇인가?',
+ '리스크 한 가지를 꼽는다면? 그것에 대해 누구와 이야기해야 하나?',
+ ];
+ const idx = (Date.parse(today) / (24 * 60 * 60 * 1000)) % reflections.length;
+ chunk(view, `\n## 🧭 회고\n> ${reflections[idx]}\n`);
+ chunk(view, '\n_명령 한 줄로 기록 남기기:_ `/decisions` · `/feedback` · `/customers note` · `/hire note`\n');
+ return true;
+}
+
+// ─── /cohort — MoM 추세 분석 ─────────────────────────────────────────────
+
+interface MonthlyBucket {
+ yearMonth: string;
+ newCustomers: number;
+ churnedCustomers: number;
+ renewals: number;
+ mrrDelta: number;
+ expenseTotal: number;
+ revenueTotal: number;
+ cashSnapshots: number[];
+}
+
+function _yearMonth(iso: string): string {
+ return (iso || '').slice(0, 7);
+}
+
+function _buildMonthlyBuckets(monthsBack: number): Map {
+ const map = new Map();
+ const now = new Date();
+ for (let i = monthsBack - 1; i >= 0; i--) {
+ const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
+ const ym = d.toISOString().slice(0, 7);
+ map.set(ym, {
+ yearMonth: ym, newCustomers: 0, churnedCustomers: 0, renewals: 0,
+ mrrDelta: 0, expenseTotal: 0, revenueTotal: 0, cashSnapshots: [],
+ });
+ }
+ for (const e of readCustomerEvents()) {
+ const ym = _yearMonth(e.timestamp);
+ const b = map.get(ym);
+ if (!b) continue;
+ if (e.type === 'add') {
+ b.newCustomers++;
+ if (e.mrr) b.mrrDelta += e.mrr;
+ } else if (e.type === 'churn') b.churnedCustomers++;
+ else if (e.type === 'renew') b.renewals++;
+ }
+ for (const e of readRunway()) {
+ const ym = _yearMonth(e.timestamp);
+ const b = map.get(ym);
+ if (!b) continue;
+ if (e.type === 'expense') b.expenseTotal += e.amount;
+ else if (e.type === 'revenue') b.revenueTotal += e.amount;
+ else if (e.type === 'snapshot') b.cashSnapshots.push(e.amount);
+ }
+ return map;
+}
+
+function _cohortDashboard(view: any, monthsBack: number): void {
+ const buckets = _buildMonthlyBuckets(monthsBack);
+ if (buckets.size === 0) {
+ chunk(view, '\nℹ️ 데이터 없음. `/customers add` / `/runway cash` 로 시작.\n');
+ return;
+ }
+ const rows = Array.from(buckets.values());
+ chunk(view, `\n📈 **/cohort — 최근 ${monthsBack}개월 추세**\n`);
+
+ chunk(view, '\n## 고객 & MRR 추이\n');
+ chunk(view, '| 월 | 신규 | 갱신 | 이탈 | MRR Δ |\n');
+ chunk(view, '|---|---:|---:|---:|---:|\n');
+ for (const r of rows) {
+ chunk(view, `| ${r.yearMonth} | ${r.newCustomers} | ${r.renewals} | ${r.churnedCustomers} | ${r.mrrDelta > 0 ? '+' : ''}${_fmtKrw(r.mrrDelta)} |\n`);
+ }
+ const totNew = rows.reduce((s, r) => s + r.newCustomers, 0);
+ const totChurn = rows.reduce((s, r) => s + r.churnedCustomers, 0);
+ const totMrr = rows.reduce((s, r) => s + r.mrrDelta, 0);
+ chunk(view, `\n- **누적 ${monthsBack}개월**: 신규 +${totNew} · 이탈 -${totChurn} · 순 ${totNew - totChurn >= 0 ? '+' : ''}${totNew - totChurn}\n`);
+ chunk(view, `- **MRR 순증**: ${totMrr >= 0 ? '+' : ''}${_fmtKrw(totMrr)}원/월 _(추가된 신규 MRR 만, 이탈로 인한 감소는 history 부재로 미반영)_\n`);
+ const avgNew = totNew / monthsBack;
+ const avgChurn = totChurn / monthsBack;
+ if (avgNew > 0 || avgChurn > 0) {
+ chunk(view, `- 월평균 신규 ${avgNew.toFixed(1)}곳, 월평균 이탈 ${avgChurn.toFixed(1)}곳`);
+ if (totNew > 0) chunk(view, ` (이탈/신규 비율 ${((totChurn / totNew) * 100).toFixed(0)}%)\n`);
+ else chunk(view, '\n');
+ }
+
+ chunk(view, '\n## 재무 추이\n');
+ chunk(view, '| 월 | 지출 | 수입 | 순 burn | 월말 잔고 |\n');
+ chunk(view, '|---|---:|---:|---:|---:|\n');
+ for (const r of rows) {
+ const netBurn = r.expenseTotal - r.revenueTotal;
+ const lastCash = r.cashSnapshots.length > 0 ? r.cashSnapshots[r.cashSnapshots.length - 1] : null;
+ const cashCell = lastCash !== null ? _fmtKrw(lastCash) : '-';
+ chunk(view, `| ${r.yearMonth} | ${_fmtKrw(r.expenseTotal)} | ${_fmtKrw(r.revenueTotal)} | ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)} | ${cashCell} |\n`);
+ }
+ const totExp = rows.reduce((s, r) => s + r.expenseTotal, 0);
+ const totRev = rows.reduce((s, r) => s + r.revenueTotal, 0);
+ const totBurn = totExp - totRev;
+ const avgBurn = totBurn / monthsBack;
+ chunk(view, `\n- **${monthsBack}개월 누계**: 지출 ${_fmtKrw(totExp)} · 수입 ${_fmtKrw(totRev)} · 순 burn ${totBurn > 0 ? '+' : ''}${_fmtKrw(totBurn)}\n`);
+ chunk(view, `- **월평균 burn**: ${_fmtKrw(avgBurn)}원/월\n`);
+
+ chunk(view, '\n## 💡 인사이트\n');
+ const insights: string[] = [];
+ if (monthsBack >= 6) {
+ const recent3 = rows.slice(-3);
+ const prior3 = rows.slice(-6, -3);
+ const recentNew = recent3.reduce((s, r) => s + r.newCustomers, 0);
+ const priorNew = prior3.reduce((s, r) => s + r.newCustomers, 0);
+ if (recentNew > priorNew * 1.2) insights.push('🟢 최근 3개월 신규 획득 가속 (이전 3개월 대비 +20%↑)');
+ else if (recentNew < priorNew * 0.8 && priorNew >= 2) insights.push('🟡 최근 3개월 신규 획득 둔화 (이전 3개월 대비 -20%↓)');
+ const recentBurn = recent3.reduce((s, r) => s + (r.expenseTotal - r.revenueTotal), 0);
+ const priorBurn = prior3.reduce((s, r) => s + (r.expenseTotal - r.revenueTotal), 0);
+ if (priorBurn > 0 && recentBurn > priorBurn * 1.3) insights.push('🔴 최근 3개월 burn 가속 (이전 3개월 대비 +30%↑) — 비용 점검 권장');
+ }
+ if (avgBurn > 0 && totRev > 0) {
+ const coverage = totRev / totExp;
+ if (coverage > 0.8) insights.push(`🟢 매출 커버리지 ${(coverage * 100).toFixed(0)}% — 흑자 진입 임박`);
+ else if (coverage < 0.2 && totExp > 0) insights.push(`🟡 매출 커버리지 ${(coverage * 100).toFixed(0)}% — 매출 기반 약함`);
+ }
+ if (insights.length === 0) chunk(view, '- _데이터 부족 또는 추세 신호 약함._ 더 누적되면 인사이트 표시.\n');
+ else for (const i of insights) chunk(view, `- ${i}\n`);
+
+ chunk(view, '\n_데이터 출처: `.astra/customers.jsonl` + `.astra/runway.jsonl`. 더 많은 이벤트 누적 시 추세 정확도↑._\n');
+}
+
+async function runCohort(arg: string, view: any): Promise {
+ const trimmed = arg.trim().toLowerCase();
+ if (trimmed === 'help' || trimmed === '?') {
+ chunk(view, [
+ '\n📈 **/cohort — MoM 추세 분석**',
+ '',
+ '사용법:',
+ ' `/cohort` — 최근 6개월 추세 (기본)',
+ ' `/cohort yearly` — 최근 12개월',
+ ' `/cohort ` — 최근 N개월 (1~24)',
+ '',
+ '데이터 출처: `/customers` events + `/runway` events 의 timestamp 월별 그룹핑.',
+ '표시 항목: 신규/갱신/이탈 + MRR 변화 + 지출/수입/burn + 월말 잔고 + 인사이트 한 줄.\n',
+ ].join('\n'));
+ return true;
+ }
+ let monthsBack = 6;
+ if (trimmed === 'yearly' || trimmed === 'year') monthsBack = 12;
+ else if (/^\d+$/.test(trimmed)) monthsBack = Math.max(1, Math.min(24, parseInt(trimmed, 10)));
+ _cohortDashboard(view, monthsBack);
+ return true;
+}
+
+// ─── /weekly — 주간 리뷰 카드 (CEO 전략 시야) ────────────────────────────
+
+function _isoWeek(d: Date): { year: number; week: number; label: string } {
+ const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
+ const dayNum = (target.getUTCDay() + 6) % 7;
+ target.setUTCDate(target.getUTCDate() - dayNum + 3);
+ const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
+ const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
+ firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
+ const week = 1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000));
+ return { year: target.getUTCFullYear(), week, label: `${target.getUTCFullYear()}-W${String(week).padStart(2, '0')}` };
+}
+
+interface WeeklyWindow {
+ startIso: string;
+ endIso: string;
+ startMs: number;
+ endMs: number;
+ label: string;
+}
+
+function _thisWeekWindow(now: Date = new Date()): WeeklyWindow {
+ const dayOfWeek = now.getDay();
+ const daysFromMonday = (dayOfWeek + 6) % 7;
+ const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysFromMonday);
+ const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6, 23, 59, 59);
+ const { label: yw } = _isoWeek(monday);
+ const startIso = monday.toISOString().slice(0, 10);
+ const endIso = sunday.toISOString().slice(0, 10);
+ const shortStart = `${monday.getMonth() + 1}/${monday.getDate()}`;
+ const shortEnd = `${sunday.getMonth() + 1}/${sunday.getDate()}`;
+ return { startIso, endIso, startMs: monday.getTime(), endMs: sunday.getTime(), label: `${yw} (${shortStart}-${shortEnd})` };
+}
+
+function _priorWeekWindow(thisWeek: WeeklyWindow): WeeklyWindow {
+ const priorMonday = new Date(thisWeek.startMs - 7 * 24 * 60 * 60 * 1000);
+ const priorSunday = new Date(thisWeek.startMs - 1000);
+ const { label: yw } = _isoWeek(priorMonday);
+ const startIso = priorMonday.toISOString().slice(0, 10);
+ const endIso = priorSunday.toISOString().slice(0, 10);
+ const shortStart = `${priorMonday.getMonth() + 1}/${priorMonday.getDate()}`;
+ const shortEnd = `${priorSunday.getMonth() + 1}/${priorSunday.getDate()}`;
+ return { startIso, endIso, startMs: priorMonday.getTime(), endMs: priorSunday.getTime(), label: `${yw} (${shortStart}-${shortEnd})` };
+}
+
+interface WeeklyAggregate {
+ taskCompleted: number;
+ taskByOwner: Map;
+ customerEvents: number;
+ customerNewCount: number;
+ customerRenewCount: number;
+ customerRiskCount: number;
+ customerChurnCount: number;
+ customerNewMrr: number;
+ hireEvents: number;
+ hireMoved: number;
+ hireAdded: number;
+ hireHired: number;
+ runwayExpense: number;
+ runwayRevenue: number;
+ runwayLastCash: number | null;
+ runwayFirstCash: number | null;
+ adrCount: number;
+}
+
+function _aggregateWeek(
+ win: WeeklyWindow,
+ completedTasks: any[],
+ cevs: CustomerEvent[],
+ hevs: HireEvent[],
+ rs: RunwayEntry[],
+ adrs: { date: string; title: string }[],
+): WeeklyAggregate {
+ const agg: WeeklyAggregate = {
+ taskCompleted: 0, taskByOwner: new Map(),
+ customerEvents: 0, customerNewCount: 0, customerRenewCount: 0,
+ customerRiskCount: 0, customerChurnCount: 0, customerNewMrr: 0,
+ hireEvents: 0, hireMoved: 0, hireAdded: 0, hireHired: 0,
+ runwayExpense: 0, runwayRevenue: 0, runwayLastCash: null, runwayFirstCash: null,
+ adrCount: 0,
+ };
+ for (const t of completedTasks) {
+ if (!t.completed) continue;
+ const ms = Date.parse(t.completed);
+ if (ms < win.startMs || ms > win.endMs) continue;
+ agg.taskCompleted++;
+ const { owner } = parseTaskOwner(t.title, t.notes);
+ const k = owner || '(미지정)';
+ agg.taskByOwner.set(k, (agg.taskByOwner.get(k) || 0) + 1);
+ }
+ for (const e of cevs) {
+ const ms = Date.parse(e.timestamp);
+ if (ms < win.startMs || ms > win.endMs) continue;
+ agg.customerEvents++;
+ if (e.type === 'add') { agg.customerNewCount++; if (e.mrr) agg.customerNewMrr += e.mrr; }
+ else if (e.type === 'renew') agg.customerRenewCount++;
+ else if (e.type === 'risk') agg.customerRiskCount++;
+ else if (e.type === 'churn') agg.customerChurnCount++;
+ }
+ for (const e of hevs) {
+ const ms = Date.parse(e.timestamp);
+ if (ms < win.startMs || ms > win.endMs) continue;
+ agg.hireEvents++;
+ if (e.type === 'add') agg.hireAdded++;
+ else if (e.type === 'stage') agg.hireMoved++;
+ else if (e.type === 'hire') agg.hireHired++;
+ }
+ const cashInWin = rs
+ .filter((r) => r.type === 'snapshot')
+ .filter((r) => { const ms = Date.parse(r.timestamp); return ms >= win.startMs && ms <= win.endMs; })
+ .sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
+ if (cashInWin.length > 0) {
+ agg.runwayFirstCash = cashInWin[0].amount;
+ agg.runwayLastCash = cashInWin[cashInWin.length - 1].amount;
+ }
+ for (const e of rs) {
+ const ms = Date.parse(e.timestamp);
+ if (ms < win.startMs || ms > win.endMs) continue;
+ if (e.type === 'expense') agg.runwayExpense += e.amount;
+ else if (e.type === 'revenue') agg.runwayRevenue += e.amount;
+ }
+ for (const a of adrs) {
+ if (a.date >= win.startIso && a.date <= win.endIso) agg.adrCount++;
+ }
+ return agg;
+}
+
+function _deltaSymbol(now: number, prev: number): string {
+ if (prev === 0 && now === 0) return '→';
+ if (prev === 0) return `↑${now}`;
+ const diff = now - prev;
+ if (diff > 0) return `↑${diff}`;
+ if (diff < 0) return `↓${Math.abs(diff)}`;
+ return '→';
+}
+
+async function runWeekly(arg: string, view: any, context?: vscode.ExtensionContext): Promise {
+ const trimmed = arg.trim().toLowerCase();
+ if (trimmed === 'help' || trimmed === '?') {
+ chunk(view, [
+ '\n📅 **/weekly — 주간 리뷰 카드 (대표용)**',
+ '',
+ '사용법:',
+ ' `/weekly` — 이번 주 (월~일) 리뷰 + 지난 주 대비 + 다음 주 준비',
+ '',
+ '`/standup weekly` 와 차이:',
+ '- `/standup weekly` → 팀 공유용 (멤버별 완료/진행/블로커, 슬랙 복붙)',
+ '- `/weekly` → 대표용 전략 시야 (cross-data delta, 회고 프롬프트)',
+ '',
+ '데이터 출처: Google Tasks + /customers + /hire + /runway + Chronicle ADR.\n',
+ ].join('\n'));
+ return true;
+ }
+
+ const thisWeek = _thisWeekWindow();
+ const priorWeek = _priorWeekWindow(thisWeek);
+ chunk(view, `\n📅 **/weekly — 주간 리뷰 ${thisWeek.label}**\n`);
+
+ let completedTasks: any[] = [];
+ let tasksError: string | undefined;
+ if (context) {
+ try {
+ const res = await listTasks(context, { showCompleted: true, maxResults: 500 });
+ if (res.ok) completedTasks = res.tasks.filter((t: any) => t.status === 'completed');
+ else tasksError = res.error;
+ } catch (e: any) { tasksError = e?.message || String(e); }
+ }
+
+ const cevs = readCustomerEvents();
+ const hevs = readHireEvents();
+ const rs = readRunway();
+
+ const adrs: { date: string; title: string }[] = [];
+ try {
+ const folders = vscode.workspace.workspaceFolders;
+ if (folders && folders.length > 0 && context) {
+ const cs = new ChronicleProjectStore(context);
+ const projects = cs.getAll();
+ for (const p of projects) {
+ const decisionsDir = path.join(p.recordRoot, 'decisions');
+ if (!fs.existsSync(decisionsDir)) continue;
+ const files = fs.readdirSync(decisionsDir).filter((f) => f.startsWith('ADR-') && f.endsWith('.md'));
+ for (const f of files) {
+ try {
+ const full = path.join(decisionsDir, f);
+ const stat = fs.statSync(full);
+ const d = new Date(stat.mtimeMs).toISOString().slice(0, 10);
+ const title = f.replace(/^ADR-\d+-/, '').replace(/\.md$/, '').replace(/[-_]/g, ' ');
+ adrs.push({ date: d, title });
+ } catch { /* skip */ }
+ }
+ }
+ }
+ } catch { /* ignore */ }
+
+ const aggNow = _aggregateWeek(thisWeek, completedTasks, cevs, hevs, rs, adrs);
+ const aggPrev = _aggregateWeek(priorWeek, completedTasks, cevs, hevs, rs, adrs);
+
+ chunk(view, '\n## ✅ 이번 주 진척\n');
+ if (tasksError) chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`);
+ else if (aggNow.taskCompleted === 0) chunk(view, '- _완료된 작업 없음._\n');
+ else {
+ chunk(view, `\n### 작업 (${aggNow.taskCompleted}건 완료)\n`);
+ const ranked = [...aggNow.taskByOwner.entries()].sort((a, b) => b[1] - a[1]);
+ for (const [owner, n] of ranked) {
+ const prev = aggPrev.taskByOwner.get(owner) || 0;
+ chunk(view, `- **@${owner}**: ${n}건 ${_deltaSymbol(n, prev)}\n`);
+ }
+ }
+
+ if (aggNow.customerEvents > 0) {
+ chunk(view, `\n### 📒 고객 (${aggNow.customerEvents} 이벤트)\n`);
+ if (aggNow.customerNewCount > 0) chunk(view, `- 신규 ${aggNow.customerNewCount}곳 _(MRR +${_fmtKrw(aggNow.customerNewMrr)})_\n`);
+ if (aggNow.customerRenewCount > 0) chunk(view, `- 갱신 ${aggNow.customerRenewCount}곳\n`);
+ if (aggNow.customerRiskCount > 0) chunk(view, `- ⚠️ 위험 표시 ${aggNow.customerRiskCount}곳\n`);
+ if (aggNow.customerChurnCount > 0) chunk(view, `- 💀 이탈 ${aggNow.customerChurnCount}곳\n`);
+ }
+
+ if (aggNow.hireEvents > 0) {
+ chunk(view, `\n### 🎯 채용 (${aggNow.hireEvents} 이벤트)\n`);
+ if (aggNow.hireAdded > 0) chunk(view, `- 신규 후보 ${aggNow.hireAdded}명\n`);
+ if (aggNow.hireMoved > 0) chunk(view, `- 단계 이동 ${aggNow.hireMoved}건\n`);
+ if (aggNow.hireHired > 0) chunk(view, `- 🎉 합격 ${aggNow.hireHired}명\n`);
+ }
+
+ if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0 || aggNow.runwayLastCash !== null) {
+ chunk(view, '\n### 💰 재무\n');
+ if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0) {
+ const netBurn = aggNow.runwayExpense - aggNow.runwayRevenue;
+ chunk(view, `- 지출 ${_fmtKrw(aggNow.runwayExpense)} · 수입 ${_fmtKrw(aggNow.runwayRevenue)} · 순 burn ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)}\n`);
+ }
+ if (aggNow.runwayLastCash !== null && aggNow.runwayFirstCash !== null) {
+ const delta = aggNow.runwayLastCash - aggNow.runwayFirstCash;
+ chunk(view, `- 잔고: ${_fmtKrw(aggNow.runwayFirstCash)} → ${_fmtKrw(aggNow.runwayLastCash)} _(Δ ${delta >= 0 ? '+' : ''}${_fmtKrw(delta)})_\n`);
+ } else if (aggNow.runwayLastCash !== null) {
+ chunk(view, `- 현재 잔고: ${_fmtKrw(aggNow.runwayLastCash)}\n`);
+ }
+ }
+
+ if (aggNow.adrCount > 0) {
+ chunk(view, `\n### 📋 결정 (ADR ${aggNow.adrCount}건)\n`);
+ const weekAdrs = adrs.filter((a) => a.date >= thisWeek.startIso && a.date <= thisWeek.endIso).slice(0, 5);
+ for (const a of weekAdrs) chunk(view, `- ${a.date} — ${a.title}\n`);
+ }
+
+ chunk(view, `\n## 🔄 지난 주 대비 (${priorWeek.label})\n`);
+ chunk(view, `- 작업: ${aggNow.taskCompleted}건 ${_deltaSymbol(aggNow.taskCompleted, aggPrev.taskCompleted)} _(이번 ${aggNow.taskCompleted} ← 지난 ${aggPrev.taskCompleted})_\n`);
+ chunk(view, `- 신규 고객: ${aggNow.customerNewCount} ${_deltaSymbol(aggNow.customerNewCount, aggPrev.customerNewCount)}\n`);
+ chunk(view, `- 이탈: ${aggNow.customerChurnCount} ${_deltaSymbol(aggNow.customerChurnCount, aggPrev.customerChurnCount)}\n`);
+ const burnNow = aggNow.runwayExpense - aggNow.runwayRevenue;
+ const burnPrev = aggPrev.runwayExpense - aggPrev.runwayRevenue;
+ if (burnNow !== 0 || burnPrev !== 0) {
+ const diff = burnNow - burnPrev;
+ const arrow = diff > 0 ? '↑' : diff < 0 ? '↓' : '→';
+ chunk(view, `- 순 burn: ${_fmtKrw(burnNow)} ${arrow} _(지난 ${_fmtKrw(burnPrev)})_\n`);
+ }
+ chunk(view, `- 채용 이벤트: ${aggNow.hireEvents} ${_deltaSymbol(aggNow.hireEvents, aggPrev.hireEvents)}\n`);
+
+ chunk(view, '\n## 🌅 다음 주 준비\n');
+ const customerStates = computeCustomerStates();
+ const upcoming = Array.from(customerStates.values())
+ .filter((c) => c.status !== 'churned')
+ .map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
+ .filter((x) => x.days !== null && x.days >= 0 && x.days <= 14)
+ .sort((a, b) => (a.days as number) - (b.days as number));
+ if (upcoming.length > 0) {
+ chunk(view, `\n### 🔔 14일 내 갱신 (${upcoming.length}건)\n`);
+ for (const { c, days } of upcoming.slice(0, 5)) {
+ const emoji = (days as number) <= 7 ? '🔴' : '🟡';
+ chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\n`);
+ }
+ }
+
+ let nextWeekDue = 0;
+ if (!tasksError && context) {
+ try {
+ const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
+ if (res.ok) {
+ const startNext = new Date(thisWeek.endMs + 1000);
+ const endNext = new Date(thisWeek.endMs + 7 * 24 * 60 * 60 * 1000);
+ const startIso = startNext.toISOString().slice(0, 10);
+ const endIso = endNext.toISOString().slice(0, 10);
+ nextWeekDue = res.tasks.filter((t: any) => t.due && t.due >= startIso && t.due <= endIso).length;
+ }
+ } catch { /* ignore */ }
+ }
+ if (nextWeekDue > 0) chunk(view, `\n- 📅 다음 주 마감 작업: ${nextWeekDue}건\n`);
+
+ const stalled = Array.from(computeCandidateStates().values())
+ .filter((c) => !TERMINAL_STAGES.has(c.stage))
+ .filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000);
+ if (stalled.length > 0) {
+ chunk(view, `\n### ⏰ 정체 후보 (${stalled.length}명)\n`);
+ for (const c of stalled.slice(0, 3)) {
+ const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
+ chunk(view, `- ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`);
+ }
+ }
+
+ const reflections = [
+ '이번 주 가장 중요한 결정은 무엇이었나? 다음 주에 어떤 결정을 미루면 안 되나?',
+ '이번 주 가장 시간을 많이 쓴 활동이 가장 영향력 있는 활동과 일치했나?',
+ '이번 주 멤버 중 누구와 1:1 시간이 충분치 않았나? 다음 주 일정 잡기.',
+ '이번 주 *안 한 일* 중 다음 주에도 안 해도 되는 일은 무엇인가?',
+ '이번 주 발견한 리스크 한 가지를 꼽는다면? 누구와 이야기해야 하나?',
+ '이번 주 가장 좋았던 30분은 무엇을 하던 때였나? 다음 주에 어떻게 복제할까?',
+ ];
+ const weekKey = Math.floor(thisWeek.startMs / (1000 * 60 * 60 * 24 * 7));
+ const idx = weekKey % reflections.length;
+ chunk(view, `\n## 💭 주간 회고\n> ${reflections[idx]}\n`);
+ chunk(view, '\n_답을 어디 적으면 좋은지:_ `/decisions` (ADR 작성) · `/feedback` (고객 학습) · `/onesie @멤버` (1:1 준비)\n');
+ return true;
+}
+
+// ─── 등록 ─────────────────────────────────────────────────────────────────
+
+registerSlashCommand({ name: '/morning', description: '매일 아침 통합 대시보드 — runway/customers/tasks/hire 핵심 한 화면 + 오늘의 액션', handler: runMorning });
+registerSlashCommand({ name: '/evening', description: '하루 마무리 카드 — 오늘의 진척 + 내일 준비 + 회고 한 줄', handler: runEvening });
+registerSlashCommand({ name: '/cohort', description: 'MoM 추세 분석 — customers + runway events 월별 그룹핑 (신규/이탈/MRR/burn)', handler: runCohort });
+registerSlashCommand({ name: '/weekly', description: '주간 리뷰 카드 (대표용) — 이번 주 진척 + 지난 주 대비 + 다음 주 준비 + 회고', handler: runWeekly });
diff --git a/src/features/teamops/handlers/index.ts b/src/features/teamops/handlers/index.ts
new file mode 100644
index 0000000..9367183
--- /dev/null
+++ b/src/features/teamops/handlers/index.ts
@@ -0,0 +1,14 @@
+/**
+ * TeamOps handlers 배럴 — extension activate 시 1회 import 로 모든 핸들러 등록.
+ *
+ * 각 핸들러 파일은 module scope 에서 `registerSlashCommand({...})` 호출하므로
+ * import 만으로 등록 완료. 새 핸들러 파일은 여기에 한 줄 추가.
+ *
+ * v2.2.196 — trackers (runway/customers/hire) 분리. coordination/communication/
+ * dashboards 는 다음 빌드.
+ */
+
+import './trackers';
+import './dashboards';
+import './coordination';
+import './communication';
diff --git a/src/features/teamops/handlers/trackers.ts b/src/features/teamops/handlers/trackers.ts
new file mode 100644
index 0000000..da45607
--- /dev/null
+++ b/src/features/teamops/handlers/trackers.ts
@@ -0,0 +1,704 @@
+/**
+ * TeamOps Trackers — /runway · /customers · /hire (event-sourced 트래커 3종).
+ *
+ * v2.2.196 에서 slashRouter.ts 에서 분리. 모두 `.astra/*.jsonl` event log 를
+ * 읽고 (createEventStore via 각 store 모듈) 대시보드 / 수정 명령 제공.
+ *
+ * 공통 헬퍼 (fmtKrw / parseAmount / daysUntil / stageEmoji / STAGE_ORDER /
+ * TERMINAL_STAGES) 는 `./_shared.ts` 에서.
+ */
+
+import { registerSlashCommand, chunk } from '../../datacollect/slashRouter';
+import {
+ fmtKrw, parseAmount, daysUntil,
+ stageEmoji, STAGE_ORDER, TERMINAL_STAGES,
+} from './_shared';
+import {
+ appendRunway, readRunway, getRunwayFilePath, computeRunwayStatus,
+ type RunwayEntry, type RunwayEntryType,
+} from '../../runway/runwayStore';
+import {
+ appendEvent as appendCustomerEvent, readEvents as readCustomerEvents,
+ getCustomersFilePath, customerIdFromName, computeCustomerStates,
+ type CustomerEvent, type CustomerState,
+} from '../../customers/customersStore';
+import {
+ appendHireEvent, readHireEvents, getHireFilePath, candidateIdFromName,
+ computeCandidateStates, type HireEvent, type CandidateState,
+} from '../../hire/hireStore';
+
+// ─── /runway ──────────────────────────────────────────────────────────────
+
+function _runwayShowStatus(view: any): void {
+ const s = computeRunwayStatus();
+ chunk(view, '\n💰 **/runway — 현금 / 월 소진율 / 런웨이**\n');
+ if (s.latestCash === null) {
+ chunk(view, '\nℹ️ 잔고 기록 없음. 시작: `/runway cash 5000만` (현재 통장 잔고 입력)\n');
+ return;
+ }
+ const cashDate = (s.latestCashAt || '').slice(0, 10);
+ chunk(view, `\n## 현재 현금\n- **${fmtKrw(s.latestCash)}원** _(기준: ${cashDate})_\n`);
+
+ chunk(view, '\n## 월 소진율 (burn)\n');
+ if (s.explicitBurn !== null) {
+ chunk(view, `- **${fmtKrw(s.explicitBurn)}원/월** _(수동 설정)_\n`);
+ } else if (s.computedBurn !== null) {
+ const ann = s.last30Days < 30 ? ` _(${s.last30Days}일 데이터 → 30일 환산)_` : ' _(최근 30일 실적)_';
+ chunk(view, `- **${fmtKrw(s.computedBurn)}원/월**${ann}\n`);
+ chunk(view, ` · 지출 ${fmtKrw(s.last30Expense)}원 − 수입 ${fmtKrw(s.last30Revenue)}원\n`);
+ } else {
+ chunk(view, '- _데이터 부족_ — `/runway burn 1500만` 또는 `/runway expense 300만 급여` 로 기록\n');
+ }
+
+ chunk(view, '\n## 런웨이\n');
+ if (s.runwayMonths === null) {
+ chunk(view, '- _계산 불가_ (잔고 또는 burn 미정)\n');
+ } else if (!Number.isFinite(s.runwayMonths)) {
+ chunk(view, '- ♾️ **흑자 운영** (지출 ≤ 수입)\n');
+ } else {
+ const m = s.runwayMonths;
+ const emoji = m < 3 ? '🔴' : m < 6 ? '🟡' : '🟢';
+ const months = m.toFixed(1);
+ const exitDate = new Date(Date.now() + m * 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
+ chunk(view, `- ${emoji} **${months}개월** _(예상 소진: ${exitDate})_\n`);
+ if (m < 3) chunk(view, ' · ⚠️ **3개월 미만** — 즉시 자금 조달 또는 비용 절감 필요\n');
+ else if (m < 6) chunk(view, ' · ⚠️ **6개월 미만** — 자금 계획 점검 권장\n');
+ }
+
+ chunk(view, `\n_누적 ${s.totalEntries}건 기록. \`/runway log\` 로 전체 보기, \`/runway path\` 로 파일 위치._\n`);
+}
+
+function _runwayLog(view: any, limit: number): void {
+ const all = readRunway().slice().sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')).slice(0, limit);
+ if (all.length === 0) { chunk(view, '\nℹ️ 기록 없음. `/runway cash 5000만` 으로 시작.\n'); return; }
+ chunk(view, `\n📒 **최근 ${all.length}건** (최신순)\n\n`);
+ const emoji: Record = {
+ snapshot: '💰', expense: '💸', revenue: '💵', burn: '🔥',
+ };
+ for (const e of all) {
+ const date = (e.timestamp || '').slice(0, 10);
+ const cat = e.category ? ` [${e.category}]` : '';
+ const memo = e.memo ? ` — ${e.memo}` : '';
+ const typeLabel = e.type === 'snapshot' ? '잔고' : e.type === 'expense' ? '지출' : e.type === 'revenue' ? '수입' : '월 burn';
+ chunk(view, `- ${emoji[e.type]} \`${date}\` ${typeLabel}: ${fmtKrw(e.amount)}원${cat}${memo}\n`);
+ }
+}
+
+async function runRunway(arg: string, view: any): Promise {
+ const trimmed = arg.trim();
+ if (!trimmed) { _runwayShowStatus(view); return true; }
+
+ const parts = trimmed.split(/\s+/);
+ const sub = parts[0].toLowerCase();
+
+ if (sub === 'help' || sub === '?') {
+ chunk(view, [
+ '\n💰 **/runway — 현금 / 월 소진율 / 런웨이**',
+ '',
+ '사용법:',
+ ' `/runway` — 현재 상태 카드 (현금 / burn / 남은 개월수)',
+ ' `/runway cash <금액> [메모]` — 통장 잔고 스냅샷 기록',
+ ' `/runway expense <금액> [메모]` — 지출 기록 (월 burn 자동 계산에 반영)',
+ ' `/runway revenue <금액> [메모]` — 수입 기록 (burn 상쇄)',
+ ' `/runway burn <월 금액>` — 월 소진율 수동 설정 (자동 계산보다 우선)',
+ ' `/runway log [N]` — 최근 N건 기록 (기본 20)',
+ ' `/runway path` — .jsonl 파일 경로',
+ '',
+ '금액 단위: `5000만` / `1.5억` / `300000` 모두 OK. 소수점·콤마 허용.',
+ '저장 위치: `/.astra/runway.jsonl` (로컬 only, 외부 안 보냄).\n',
+ ].join('\n'));
+ return true;
+ }
+
+ if (sub === 'path') {
+ const p = getRunwayFilePath();
+ if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음 — 저장 위치 결정 불가.\n'); return true; }
+ const count = readRunway().length;
+ chunk(view, `\n📂 \`${p}\`\n · 누적 ${count}건 (.jsonl 형식, 한 줄=한 항목)\n · 사람이 직접 편집 가능.\n`);
+ return true;
+ }
+
+ if (sub === 'log') {
+ const n = parts[1] ? parseInt(parts[1], 10) : 20;
+ _runwayLog(view, Number.isFinite(n) && n > 0 ? n : 20);
+ return true;
+ }
+
+ if (sub === 'cash' || sub === 'expense' || sub === 'revenue' || sub === 'burn') {
+ const amount = parseAmount(parts[1] || '');
+ if (amount === null) {
+ chunk(view, `\n❌ 금액 파싱 실패: "${parts[1] || ''}". 예: \`5000만\` / \`1.5억\` / \`300000\`\n`);
+ return true;
+ }
+ const memo = parts.slice(2).join(' ').trim() || undefined;
+ const typeMap: Record = { cash: 'snapshot', expense: 'expense', revenue: 'revenue', burn: 'burn' };
+ const entry: RunwayEntry = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ timestamp: new Date().toISOString(),
+ type: typeMap[sub],
+ amount,
+ currency: 'KRW',
+ memo,
+ };
+ const res = appendRunway(entry);
+ if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
+ const labels: Record = { cash: '잔고 스냅샷', expense: '지출', revenue: '수입', burn: '월 burn 설정' };
+ chunk(view, `\n✅ ${labels[sub]} 기록: **${fmtKrw(amount)}원**${memo ? ` — ${memo}` : ''}\n`);
+ _runwayShowStatus(view);
+ return true;
+ }
+
+ chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/runway help\` 참조.\n`);
+ return true;
+}
+
+// ─── /customers ───────────────────────────────────────────────────────────
+
+function _customersDashboard(view: any): void {
+ const states = computeCustomerStates();
+ const all = Array.from(states.values());
+ if (all.length === 0) {
+ chunk(view, '\n📒 **/customers — 고객사 / MRR / 갱신**\n\nℹ️ 등록된 고객 없음. 시작: `/customers add <이름> [갱신일] [요금제]`\n예: `/customers add 큐브앤코 200만 2026-12-01 enterprise`\n');
+ return;
+ }
+
+ const active = all.filter((c) => c.status === 'active');
+ const atRisk = all.filter((c) => c.status === 'at-risk');
+ const churned = all.filter((c) => c.status === 'churned');
+ const totalMrr = active.reduce((sum, c) => sum + c.mrr, 0) + atRisk.reduce((sum, c) => sum + c.mrr, 0);
+ const riskMrr = atRisk.reduce((sum, c) => sum + c.mrr, 0);
+
+ chunk(view, '\n📒 **/customers — 고객사 대시보드**\n');
+ chunk(view, '\n## 요약\n');
+ chunk(view, `- **MRR**: ${fmtKrw(totalMrr)}원/월 _(연 ${fmtKrw(totalMrr * 12)}원)_\n`);
+ chunk(view, `- 활성 ${active.length}곳 · 위험 ${atRisk.length}곳 · 이탈 ${churned.length}곳\n`);
+ if (riskMrr > 0) chunk(view, `- ⚠️ **위험 MRR**: ${fmtKrw(riskMrr)}원/월 _(전체의 ${((riskMrr / totalMrr) * 100).toFixed(0)}%)_\n`);
+
+ const upcoming = [...active, ...atRisk]
+ .map((c) => ({ c, days: daysUntil(c.renewalAt) }))
+ .filter((x) => x.days !== null && x.days >= 0 && x.days <= 30)
+ .sort((a, b) => (a.days as number) - (b.days as number));
+ if (upcoming.length > 0) {
+ chunk(view, '\n## 🔔 30일 내 갱신\n');
+ for (const { c, days } of upcoming) {
+ const emoji = c.status === 'at-risk' ? '⚠️' : (days as number) <= 7 ? '🔴' : (days as number) <= 14 ? '🟡' : '🟢';
+ chunk(view, `- ${emoji} **${c.customerName}** — ${c.renewalAt} _(D-${days})_ · ${fmtKrw(c.mrr)}원/월\n`);
+ }
+ }
+
+ if (atRisk.length > 0) {
+ chunk(view, '\n## ⚠️ 위험 고객\n');
+ for (const c of atRisk.sort((a, b) => b.mrr - a.mrr)) {
+ const lastRisk = c.notes.slice().reverse().find((n) => n.type === 'risk');
+ chunk(view, `- **${c.customerName}** — ${fmtKrw(c.mrr)}원/월${lastRisk ? ` · ${lastRisk.memo.slice(0, 60)}` : ''}\n`);
+ }
+ }
+
+ if (active.length > 0) {
+ chunk(view, '\n## 활성 고객 (MRR 순)\n');
+ const top = active.slice().sort((a, b) => b.mrr - a.mrr).slice(0, 10);
+ for (const c of top) {
+ const renewalNote = c.renewalAt ? ` · 갱신 ${c.renewalAt}` : '';
+ chunk(view, `- ${c.customerName} — ${fmtKrw(c.mrr)}원/월${renewalNote}\n`);
+ }
+ if (active.length > 10) chunk(view, `- _…+${active.length - 10}곳_\n`);
+ }
+
+ chunk(view, `\n_누적 이벤트 ${readCustomerEvents().length}건. \`/customers help\` 로 명령어._\n`);
+}
+
+function _customersList(view: any, filter: string | undefined): void {
+ const states = computeCustomerStates();
+ let all = Array.from(states.values());
+ if (filter === 'active' || filter === 'at-risk' || filter === 'risk' || filter === 'churned') {
+ const target = filter === 'risk' ? 'at-risk' : filter;
+ all = all.filter((c) => c.status === target);
+ }
+ if (all.length === 0) { chunk(view, `\nℹ️ 매치 없음${filter ? ` (필터: ${filter})` : ''}.\n`); return; }
+ chunk(view, `\n📋 **고객 목록 (${all.length}곳${filter ? `, ${filter}` : ''})**\n\n`);
+ const sorted = all.slice().sort((a, b) => b.mrr - a.mrr);
+ for (const c of sorted) {
+ const emoji = c.status === 'active' ? '🟢' : c.status === 'at-risk' ? '🟡' : '🔴';
+ const renewalNote = c.renewalAt ? ` · 갱신 ${c.renewalAt}` : '';
+ const planNote = c.plan ? ` · ${c.plan}` : '';
+ chunk(view, `- ${emoji} **${c.customerName}** — ${fmtKrw(c.mrr)}원/월${planNote}${renewalNote}\n`);
+ }
+}
+
+function _customersShow(view: any, name: string): void {
+ const states = computeCustomerStates();
+ const cid = customerIdFromName(name);
+ const c = states.get(cid);
+ if (!c) {
+ const candidates = Array.from(states.values()).filter((x) => x.customerName.toLowerCase().includes(name.toLowerCase()));
+ if (candidates.length === 0) { chunk(view, `\n❌ "${name}" 매치 0건.\n`); return; }
+ if (candidates.length > 1) {
+ chunk(view, `\nℹ️ "${name}" 부분 매치 ${candidates.length}곳:\n`);
+ for (const x of candidates) chunk(view, `- ${x.customerName}\n`);
+ return;
+ }
+ return _customersShow(view, candidates[0].customerName);
+ }
+
+ const emoji = c.status === 'active' ? '🟢' : c.status === 'at-risk' ? '🟡' : '🔴';
+ chunk(view, `\n${emoji} **${c.customerName}** _(${c.status})_\n`);
+ chunk(view, `\n- MRR: **${fmtKrw(c.mrr)}원/월** _(연 ${fmtKrw(c.mrr * 12)}원)_\n`);
+ if (c.plan) chunk(view, `- 요금제: ${c.plan}\n`);
+ if (c.renewalAt) {
+ const d = daysUntil(c.renewalAt);
+ const dn = d !== null ? (d >= 0 ? `D-${d}` : `${-d}일 지남`) : '';
+ chunk(view, `- 갱신일: ${c.renewalAt} _(${dn})_\n`);
+ }
+ chunk(view, `- 시작: ${(c.startedAt || '').slice(0, 10)} · 누적 이벤트 ${c.eventCount}건\n`);
+
+ if (c.notes.length > 0) {
+ chunk(view, `\n## 메모·이벤트 (${c.notes.length}건, 최신순)\n`);
+ const recent = c.notes.slice().reverse().slice(0, 10);
+ for (const n of recent) {
+ const date = (n.timestamp || '').slice(0, 10);
+ const tagEmoji = n.type === 'risk' ? '⚠️' : n.type === 'churn' ? '💀' : '📝';
+ chunk(view, `- ${tagEmoji} \`${date}\` ${n.memo}\n`);
+ }
+ if (c.notes.length > 10) chunk(view, `- _…+${c.notes.length - 10}건_\n`);
+ }
+}
+
+async function runCustomers(arg: string, view: any): Promise {
+ const trimmed = arg.trim();
+ if (!trimmed) { _customersDashboard(view); return true; }
+
+ const parts = trimmed.split(/\s+/);
+ const sub = parts[0].toLowerCase();
+
+ if (sub === 'help' || sub === '?') {
+ chunk(view, [
+ '\n📒 **/customers — 고객사 / MRR / 갱신 트래커**',
+ '',
+ '사용법:',
+ ' `/customers` — 대시보드 (MRR, 위험, 갱신 임박)',
+ ' `/customers add <이름> [갱신일] [요금제]` — 신규 등록',
+ ' `/customers update <이름> mrr=<금액> renewal=<날짜>`— 정보 수정',
+ ' `/customers renew <이름> <새 갱신일> [새 MRR]` — 갱신 처리 (active 로 복귀)',
+ ' `/customers risk <이름> <사유>` — 위험 표시',
+ ' `/customers churn <이름> <사유>` — 이탈 처리 (MRR=0)',
+ ' `/customers note <이름> <텍스트>` — 자유 메모',
+ ' `/customers show <이름>` — 상세 (부분 매치 OK)',
+ ' `/customers list [active/risk/churned]` — 필터 목록',
+ ' `/customers path` — .jsonl 파일 경로',
+ '',
+ 'MRR 금액 단위: `200만` / `1.5억` / `300000` 모두 OK.',
+ '갱신일: `YYYY-MM-DD` (예: `2026-12-01`).',
+ '저장: `/.astra/customers.jsonl` (로컬 only, 외부 안 보냄).\n',
+ ].join('\n'));
+ return true;
+ }
+
+ if (sub === 'path') {
+ const p = getCustomersFilePath();
+ if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음.\n'); return true; }
+ chunk(view, `\n📂 \`${p}\`\n · 누적 이벤트 ${readCustomerEvents().length}건 (.jsonl).\n`);
+ return true;
+ }
+
+ if (sub === 'list') { _customersList(view, parts[1]?.toLowerCase()); return true; }
+
+ if (sub === 'show') {
+ const name = parts.slice(1).join(' ').trim();
+ if (!name) { chunk(view, '\n❌ 사용법: `/customers show <이름>`\n'); return true; }
+ _customersShow(view, name);
+ return true;
+ }
+
+ if (sub === 'add') {
+ const name = parts[1];
+ const mrrToken = parts[2];
+ const renewalToken = parts[3];
+ const planToken = parts[4];
+ if (!name || !mrrToken) {
+ chunk(view, '\n❌ 사용법: `/customers add <이름> [갱신일] [요금제]`\n예: `/customers add 큐브앤코 200만 2026-12-01 enterprise`\n');
+ return true;
+ }
+ const mrr = parseAmount(mrrToken);
+ if (mrr === null) { chunk(view, `\n❌ MRR 파싱 실패: "${mrrToken}"\n`); return true; }
+ const event: CustomerEvent = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ timestamp: new Date().toISOString(),
+ customerId: customerIdFromName(name),
+ customerName: name,
+ type: 'add',
+ mrr,
+ renewalAt: renewalToken && /^\d{4}-\d{2}-\d{2}$/.test(renewalToken) ? renewalToken : undefined,
+ plan: planToken,
+ };
+ const res = appendCustomerEvent(event);
+ if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
+ chunk(view, `\n✅ **${name}** 등록 — MRR ${fmtKrw(mrr)}원/월${event.renewalAt ? ` · 갱신 ${event.renewalAt}` : ''}${event.plan ? ` · ${event.plan}` : ''}\n`);
+ return true;
+ }
+
+ if (sub === 'renew') {
+ const name = parts[1];
+ const newRenewal = parts[2];
+ const newMrrToken = parts[3];
+ if (!name || !newRenewal) { chunk(view, '\n❌ 사용법: `/customers renew <이름> <새 갱신일> [새 MRR]`\n'); return true; }
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(newRenewal)) { chunk(view, `\n❌ 갱신일 형식: YYYY-MM-DD (입력: "${newRenewal}")\n`); return true; }
+ const newMrr = newMrrToken ? parseAmount(newMrrToken) : null;
+ const event: CustomerEvent = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ timestamp: new Date().toISOString(),
+ customerId: customerIdFromName(name),
+ customerName: name,
+ type: 'renew',
+ renewalAt: newRenewal,
+ mrr: newMrr ?? undefined,
+ };
+ const res = appendCustomerEvent(event);
+ if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
+ chunk(view, `\n🔄 **${name}** 갱신 — ${newRenewal}${newMrr !== null ? ` · MRR ${fmtKrw(newMrr)}원/월` : ''}\n`);
+ return true;
+ }
+
+ if (sub === 'risk' || sub === 'churn' || sub === 'note') {
+ const name = parts[1];
+ const memo = parts.slice(2).join(' ').trim();
+ if (!name || !memo) {
+ chunk(view, `\n❌ 사용법: \`/customers ${sub} <이름> <${sub === 'note' ? '텍스트' : '사유'}>\`\n`);
+ return true;
+ }
+ const event: CustomerEvent = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ timestamp: new Date().toISOString(),
+ customerId: customerIdFromName(name),
+ customerName: name,
+ type: sub,
+ memo,
+ };
+ const res = appendCustomerEvent(event);
+ if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
+ const emoji = sub === 'risk' ? '⚠️' : sub === 'churn' ? '💀' : '📝';
+ const label = sub === 'risk' ? '위험 표시' : sub === 'churn' ? '이탈 처리' : '메모 추가';
+ chunk(view, `\n${emoji} **${name}** ${label}: ${memo}\n`);
+ return true;
+ }
+
+ if (sub === 'update') {
+ const name = parts[1];
+ if (!name) { chunk(view, '\n❌ 사용법: `/customers update <이름> mrr=<금액> renewal= plan=<요금제>`\n'); return true; }
+ const rest = parts.slice(2);
+ const event: CustomerEvent = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ timestamp: new Date().toISOString(),
+ customerId: customerIdFromName(name),
+ customerName: name,
+ type: 'update',
+ };
+ let touched = false;
+ for (const kv of rest) {
+ const m = kv.match(/^(\w+)=(.+)$/);
+ if (!m) continue;
+ const k = m[1].toLowerCase();
+ const v = m[2];
+ if (k === 'mrr') {
+ const n = parseAmount(v);
+ if (n !== null) { event.mrr = n; touched = true; }
+ } else if (k === 'renewal' || k === 'renewalat') {
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) { event.renewalAt = v; touched = true; }
+ } else if (k === 'plan') {
+ event.plan = v; touched = true;
+ }
+ }
+ if (!touched) { chunk(view, '\n❌ 변경할 필드 없음. 예: `/customers update 큐브앤코 mrr=300만 renewal=2026-12-15`\n'); return true; }
+ const res = appendCustomerEvent(event);
+ if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
+ const changes: string[] = [];
+ if (event.mrr !== undefined) changes.push(`MRR=${fmtKrw(event.mrr)}원`);
+ if (event.renewalAt) changes.push(`갱신=${event.renewalAt}`);
+ if (event.plan) changes.push(`요금제=${event.plan}`);
+ chunk(view, `\n✏️ **${name}** 업데이트: ${changes.join(' · ')}\n`);
+ return true;
+ }
+
+ chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/customers help\` 참조.\n`);
+ return true;
+}
+
+// ─── /hire ────────────────────────────────────────────────────────────────
+
+function _hireDashboard(view: any): void {
+ const states = computeCandidateStates();
+ const all = Array.from(states.values());
+ if (all.length === 0) {
+ chunk(view, '\n👥 **/hire — 채용 파이프라인**\n\nℹ️ 등록된 후보 없음. 시작: `/hire add <이름> <역할>`\n예: `/hire add 김개발 백엔드`\n');
+ return;
+ }
+
+ const active = all.filter((c) => !TERMINAL_STAGES.has(c.stage));
+ const hired = all.filter((c) => c.stage === 'hired');
+ const rejected = all.filter((c) => c.stage === 'rejected' || c.stage === 'declined');
+
+ chunk(view, '\n👥 **/hire — 채용 파이프라인**\n');
+ chunk(view, '\n## 요약\n');
+ chunk(view, `- 진행 중 **${active.length}명** · 합격 ${hired.length}명 · 종료 ${rejected.length}명\n`);
+
+ const byRole = new Map();
+ for (const c of active) {
+ const role = c.role || '미지정';
+ if (!byRole.has(role)) byRole.set(role, []);
+ byRole.get(role)!.push(c);
+ }
+ if (byRole.size > 0) {
+ chunk(view, '\n## 역할별 진행\n');
+ for (const [role, cs] of byRole) {
+ chunk(view, `- **${role}**: ${cs.length}명\n`);
+ }
+ }
+
+ const byStage = new Map();
+ for (const c of active) {
+ if (!byStage.has(c.stage)) byStage.set(c.stage, []);
+ byStage.get(c.stage)!.push(c);
+ }
+ const sortedStages = Array.from(byStage.keys()).sort((a, b) => (STAGE_ORDER[a] ?? 50) - (STAGE_ORDER[b] ?? 50));
+ if (sortedStages.length > 0) {
+ chunk(view, '\n## 단계별\n');
+ for (const stage of sortedStages) {
+ const list = byStage.get(stage)!;
+ chunk(view, `\n### ${stageEmoji(stage)} ${stage} (${list.length})\n`);
+ for (const c of list.slice().sort((a, b) => (b.lastEventAt || '').localeCompare(a.lastEventAt || ''))) {
+ const daysIn = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
+ const stale = daysIn > 7 ? ` ⏰ ${daysIn}일 정체` : '';
+ const salary = c.salary !== undefined ? ` · ${fmtKrw(c.salary)}원` : '';
+ chunk(view, `- ${c.candidateName} _(${c.role || '미지정'})_${salary}${stale}\n`);
+ }
+ }
+ }
+
+ if (hired.length > 0) {
+ chunk(view, `\n## 🎉 최근 합격 (${hired.length}명)\n`);
+ const recent = hired.slice().sort((a, b) => (b.lastEventAt || '').localeCompare(a.lastEventAt || '')).slice(0, 5);
+ for (const c of recent) {
+ chunk(view, `- ${c.candidateName} _(${c.role})_ — ${(c.lastEventAt || '').slice(0, 10)}\n`);
+ }
+ }
+
+ chunk(view, `\n_누적 이벤트 ${readHireEvents().length}건. \`/hire help\` 로 명령어._\n`);
+}
+
+function _hireList(view: any, filter: string | undefined): void {
+ const states = computeCandidateStates();
+ let all = Array.from(states.values());
+ if (filter) {
+ const f = filter.toLowerCase();
+ if (f === 'active') all = all.filter((c) => !TERMINAL_STAGES.has(c.stage));
+ else if (f === 'closed' || f === 'terminal') all = all.filter((c) => TERMINAL_STAGES.has(c.stage));
+ else all = all.filter((c) => c.stage === f || (c.role || '').toLowerCase() === f);
+ }
+ if (all.length === 0) { chunk(view, `\nℹ️ 매치 없음${filter ? ` (필터: ${filter})` : ''}.\n`); return; }
+ chunk(view, `\n📋 **후보 목록 (${all.length}명${filter ? `, ${filter}` : ''})**\n\n`);
+ const sorted = all.slice().sort((a, b) => (STAGE_ORDER[a.stage] ?? 50) - (STAGE_ORDER[b.stage] ?? 50));
+ for (const c of sorted) {
+ const daysIn = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
+ chunk(view, `- ${stageEmoji(c.stage)} **${c.candidateName}** _(${c.role || '미지정'})_ · ${c.stage} · ${daysIn}일 전\n`);
+ }
+}
+
+function _hireShow(view: any, name: string): void {
+ const states = computeCandidateStates();
+ const cid = candidateIdFromName(name);
+ let c = states.get(cid);
+ if (!c) {
+ const candidates = Array.from(states.values()).filter((x) => x.candidateName.toLowerCase().includes(name.toLowerCase()));
+ if (candidates.length === 0) { chunk(view, `\n❌ "${name}" 매치 0건.\n`); return; }
+ if (candidates.length > 1) {
+ chunk(view, `\nℹ️ "${name}" 부분 매치 ${candidates.length}명:\n`);
+ for (const x of candidates) chunk(view, `- ${x.candidateName} (${x.role})\n`);
+ return;
+ }
+ c = candidates[0];
+ }
+
+ chunk(view, `\n${stageEmoji(c.stage)} **${c.candidateName}** _(${c.role || '미지정'})_\n`);
+ chunk(view, `\n- 단계: **${c.stage}**\n`);
+ if (c.salary !== undefined) chunk(view, `- 제안 연봉: ${fmtKrw(c.salary)}원\n`);
+ chunk(view, `- 시작: ${(c.addedAt || '').slice(0, 10)} · 최근 변경: ${(c.lastEventAt || '').slice(0, 10)} · 이벤트 ${c.eventCount}건\n`);
+
+ if (c.notes.length > 0) {
+ chunk(view, `\n## 메모·이벤트 (${c.notes.length}건)\n`);
+ const recent = c.notes.slice().reverse().slice(0, 10);
+ for (const n of recent) {
+ const date = (n.timestamp || '').slice(0, 10);
+ chunk(view, `- \`${date}\` ${n.memo}\n`);
+ }
+ if (c.notes.length > 10) chunk(view, `- _…+${c.notes.length - 10}건_\n`);
+ }
+}
+
+async function runHire(arg: string, view: any): Promise {
+ const trimmed = arg.trim();
+ if (!trimmed) { _hireDashboard(view); return true; }
+
+ const parts = trimmed.split(/\s+/);
+ const sub = parts[0].toLowerCase();
+
+ if (sub === 'help' || sub === '?') {
+ chunk(view, [
+ '\n👥 **/hire — 채용 파이프라인**',
+ '',
+ '사용법:',
+ ' `/hire` — 파이프라인 대시보드',
+ ' `/hire add <이름> <역할>` — 신규 후보 (inbox 단계)',
+ ' `/hire stage <이름> <새 단계>` — 단계 이동',
+ ' `/hire note <이름> <텍스트>` — 자유 메모',
+ ' `/hire offer <이름> <연봉> [입사일]` — 오퍼 발송 (단계=offer)',
+ ' `/hire hire <이름> [입사일]` — 입사 확정 (단계=hired)',
+ ' `/hire reject <이름> <사유>` — 거절 (회사 측)',
+ ' `/hire decline <이름> <사유>` — 후보 사양',
+ ' `/hire show <이름>` — 상세 + 이력',
+ ' `/hire list [active/closed/단계명/역할]` — 필터 목록',
+ ' `/hire path` — 파일 위치',
+ '',
+ '단계 (기본 파이프라인): inbox → screened → interview → final → offer → accepted → hired',
+ '터미널: rejected · declined',
+ '',
+ '연봉 단위: `4500만` / `1억` / `45000000` 모두 OK.',
+ '입사일: `YYYY-MM-DD`.',
+ '저장: `/.astra/hire.jsonl` (로컬 only).\n',
+ ].join('\n'));
+ return true;
+ }
+
+ if (sub === 'path') {
+ const p = getHireFilePath();
+ if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음.\n'); return true; }
+ chunk(view, `\n📂 \`${p}\`\n · 누적 이벤트 ${readHireEvents().length}건 (.jsonl).\n`);
+ return true;
+ }
+
+ if (sub === 'list') { _hireList(view, parts[1]); return true; }
+ if (sub === 'show') {
+ const name = parts.slice(1).join(' ').trim();
+ if (!name) { chunk(view, '\n❌ 사용법: `/hire show <이름>`\n'); return true; }
+ _hireShow(view, name);
+ return true;
+ }
+
+ if (sub === 'add') {
+ const name = parts[1];
+ const role = parts.slice(2).join(' ').trim();
+ if (!name || !role) { chunk(view, '\n❌ 사용법: `/hire add <이름> <역할>`\n예: `/hire add 김개발 백엔드 시니어`\n'); return true; }
+ const event: HireEvent = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ timestamp: new Date().toISOString(),
+ candidateId: candidateIdFromName(name),
+ candidateName: name,
+ role,
+ type: 'add',
+ stage: 'inbox',
+ };
+ const res = appendHireEvent(event);
+ if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
+ chunk(view, `\n📥 **${name}** 등록 — ${role} (inbox)\n`);
+ return true;
+ }
+
+ if (sub === 'stage') {
+ const name = parts[1];
+ const newStage = parts[2]?.toLowerCase();
+ if (!name || !newStage) { chunk(view, '\n❌ 사용법: `/hire stage <이름> <단계>`\n'); return true; }
+ const existing = computeCandidateStates().get(candidateIdFromName(name));
+ if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음. \`/hire add\` 먼저.\n`); return true; }
+ const event: HireEvent = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ timestamp: new Date().toISOString(),
+ candidateId: existing.candidateId,
+ candidateName: existing.candidateName,
+ role: existing.role,
+ type: 'stage',
+ stage: newStage,
+ };
+ const res = appendHireEvent(event);
+ if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
+ chunk(view, `\n${stageEmoji(newStage)} **${existing.candidateName}**: ${existing.stage} → ${newStage}\n`);
+ return true;
+ }
+
+ if (sub === 'offer') {
+ const name = parts[1];
+ const salaryToken = parts[2];
+ const startDate = parts[3];
+ if (!name || !salaryToken) { chunk(view, '\n❌ 사용법: `/hire offer <이름> <연봉> [입사일]`\n예: `/hire offer 김개발 6000만 2026-07-01`\n'); return true; }
+ const existing = computeCandidateStates().get(candidateIdFromName(name));
+ if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; }
+ const salary = parseAmount(salaryToken);
+ if (salary === null) { chunk(view, `\n❌ 연봉 파싱 실패: "${salaryToken}"\n`); return true; }
+ const event: HireEvent = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ timestamp: new Date().toISOString(),
+ candidateId: existing.candidateId,
+ candidateName: existing.candidateName,
+ role: existing.role,
+ type: 'offer',
+ stage: 'offer',
+ salary,
+ memo: startDate ? `오퍼 발송 (입사 예정: ${startDate})` : '오퍼 발송',
+ };
+ const res = appendHireEvent(event);
+ if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
+ chunk(view, `\n📨 **${existing.candidateName}** 오퍼 — ${fmtKrw(salary)}원${startDate ? ` · 입사 ${startDate}` : ''}\n`);
+ return true;
+ }
+
+ if (sub === 'hire') {
+ const name = parts[1];
+ const startDate = parts[2];
+ if (!name) { chunk(view, '\n❌ 사용법: `/hire hire <이름> [입사일]`\n'); return true; }
+ const existing = computeCandidateStates().get(candidateIdFromName(name));
+ if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; }
+ const event: HireEvent = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ timestamp: new Date().toISOString(),
+ candidateId: existing.candidateId,
+ candidateName: existing.candidateName,
+ role: existing.role,
+ type: 'hire',
+ stage: 'hired',
+ memo: startDate ? `입사 확정 (시작: ${startDate})` : '입사 확정',
+ };
+ const res = appendHireEvent(event);
+ if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
+ chunk(view, `\n🎉 **${existing.candidateName}** 입사 확정 — ${existing.role}${startDate ? ` (${startDate})` : ''}\n`);
+ return true;
+ }
+
+ if (sub === 'reject' || sub === 'decline' || sub === 'note') {
+ const name = parts[1];
+ const memo = parts.slice(2).join(' ').trim();
+ if (!name || !memo) { chunk(view, `\n❌ 사용법: \`/hire ${sub} <이름> <${sub === 'note' ? '텍스트' : '사유'}>\`\n`); return true; }
+ const existing = computeCandidateStates().get(candidateIdFromName(name));
+ if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; }
+ const event: HireEvent = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ timestamp: new Date().toISOString(),
+ candidateId: existing.candidateId,
+ candidateName: existing.candidateName,
+ role: existing.role,
+ type: sub,
+ stage: sub === 'note' ? undefined : sub === 'reject' ? 'rejected' : 'declined',
+ memo,
+ };
+ const res = appendHireEvent(event);
+ if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
+ const labels: Record = { reject: '❌ 거절', decline: '🚪 사양', note: '📝 메모' };
+ chunk(view, `\n${labels[sub]} **${existing.candidateName}**: ${memo}\n`);
+ return true;
+ }
+
+ chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/hire help\` 참조.\n`);
+ return true;
+}
+
+// ─── 등록 ─────────────────────────────────────────────────────────────────
+
+registerSlashCommand({ name: '/runway', description: '현금 / 월 소진율 / 런웨이 — 4인 기업 CEO 의 가장 중요한 숫자 (로컬 .jsonl)', handler: runRunway });
+registerSlashCommand({ name: '/customers', description: '고객사 / MRR / 갱신 / 위험 트래커 — event-sourced 로그 (로컬 .jsonl)', handler: runCustomers });
+registerSlashCommand({ name: '/hire', description: '채용 파이프라인 — 후보자 단계·오퍼·합격 트래커 (로컬 .jsonl)', handler: runHire });
diff --git a/src/lib/contextBuilders/memoryContext.ts b/src/lib/contextBuilders/memoryContext.ts
index f216e50..f5bc808 100644
--- a/src/lib/contextBuilders/memoryContext.ts
+++ b/src/lib/contextBuilders/memoryContext.ts
@@ -63,28 +63,16 @@ export interface TurnContextSink {
lessons: string[];
knowledgeMix: ResolvedKnowledgeMix | null;
/**
- * [CONFLICT WARNINGS] 시스템 프롬프트 블록 — 빈 문자열이면 충돌 없음.
- * buildAstraModeSystemPrompt 가 직접 prompt 에 주입.
+ * 동적 시스템 프롬프트 블록 — id → 본문. memoryContext 가 이 turn 에 채우고,
+ * buildAstraModeSystemPrompt 가 iterate 해서 [CONTEXT] 밖에 join 주입.
+ * Casual conversation 모드면 자동 skip. 빈 본문은 자동 제외.
+ *
+ * 등록 순서대로 prompt 에 join — `intent-clarification` → `terminology` →
+ * `conflict-warnings` → `cove-checklist` → `citation-trace`.
*/
- conflictWarnings: string;
- /**
- * [VERIFICATION CHECKLIST] Chain-of-Verification 블록 — 빈 문자열이면 CoVe 비활성/근거 없음.
- * 모델이 답변 *작성 전* 그라운딩 체크하도록 instructional prompt 주입.
- */
- coveChecklist: string;
- /**
- * [INTENT CLARIFICATION GUIDANCE] 블록 — 질의가 모호한 차원이 감지된 경우 LLM 에게
- * 답변보다 *역질문* 우선 지시. 빈 문자열이면 모호 차원 없음 또는 disable.
- */
- intentClarification: string;
- /**
- * [CITATION TRACE] 블록 — 답변 끝에 사용 출처 한 줄 정리 지시. 검색 결과 있을 때만.
- */
- citationTrace: string;
+ dynamicBlocks: Map;
/** Post-hoc Self-Check 용 — selected chunks 의 (title, excerpt) 요약. */
selfCheckSources: Array<{ title: string; excerpt: string }>;
- /** [TERMINOLOGY DICTIONARY] 시스템 프롬프트 블록 — 글로서리 파일 있을 때만 채워짐. */
- terminology: string;
}
export interface MemoryContextDeps {
@@ -284,47 +272,46 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise c.content);
- // Conflict Surface — selectedChunks 의 per-doc conflictSeverity 신호 + 교차-문서
- // 발산 후보를 LLM 에 노출. 블록은 [CONTEXT] *밖*에 주입돼 token-truncation 보호.
- // 설정으로 disable 가능 — 기본 켜져 있음 (v4 정책이 이미 CONFLICT WARNING 플래그 참조).
- if (config.conflictHighlightingEnabled !== false) {
- const threshold: ConflictThresholdSetting = (config.conflictSeverityThreshold || 'medium') as ConflictThresholdSetting;
- deps.turnCtx.conflictWarnings = buildConflictWarningsBlock(result.selectedChunks, {
- selfFlagThreshold: threshold,
- crossDivergenceEnabled: config.conflictCrossDocEnabled !== false,
- });
- } else {
- deps.turnCtx.conflictWarnings = '';
- }
+ // 동적 시스템 프롬프트 블록 빌드 — 등록 순서대로 turnCtx.dynamicBlocks 에 set.
+ // 옛 named field 5개 (conflictWarnings/coveChecklist/intentClarification/citationTrace/
+ // terminology) 통합. 새 블록 추가 = 여기서 setBlock 한 줄.
+ const blocks = deps.turnCtx.dynamicBlocks;
- // CoVe (Chain-of-Verification) — 답변 *작성 전* 그라운딩 체크리스트를 시스템 프롬프트에
- // 주입. 모델이 한 패스 안에서 self-verify. Conflict Surface 와 보완 관계 — 충돌
- // 데이터를 *어떻게* verify 할지 지시.
- if (config.coveEnabled !== false) {
- deps.turnCtx.coveChecklist = buildCoveChecklistBlock(result.selectedChunks, deps.currentPrompt, {
- topSourcesCount: config.coveTopSourcesCount ?? 5,
- strictMode: config.coveStrictMode === true,
- });
- } else {
- deps.turnCtx.coveChecklist = '';
- }
-
- // Intent Clarification — 모호 차원 감지 시 *역질문 우선* 지시. CoVe / Citation 과
- // 동일 패턴: instructional system prompt block.
+ // Intent Clarification — 답변보다 *역질문 우선*. 모호 아닐 때 빈 문자열 → join 시 자동 제외.
if (config.intentClarificationEnabled !== false) {
const strict = (config.intentClarificationStrictness || 'medium') as IntentStrictness;
const ambig = detectAmbiguity(deps.currentPrompt, strict);
- deps.turnCtx.intentClarification = buildIntentClarificationBlock(ambig);
- } else {
- deps.turnCtx.intentClarification = '';
+ blocks.set('intent-clarification', buildIntentClarificationBlock(ambig));
}
- // Citation Trace — 답변 끝에 출처 한 줄 명시 지시. CoVe Strict 의 가벼운 형제.
- // 검색 결과가 있을 때만 의미 있음.
+ // Terminology Dictionary — 사용자 편집 글로서리. 파일 없으면 빈 문자열.
+ if (config.glossaryEnabled !== false) {
+ blocks.set('terminology', buildTerminologyBlock({
+ relPath: config.glossaryPath || '.astra/glossary.md',
+ maxBodyLength: config.glossaryMaxBodyLength ?? 4000,
+ }));
+ }
+
+ // Conflict Surface — selectedChunks 의 per-doc conflictSeverity + 교차-문서 발산.
+ if (config.conflictHighlightingEnabled !== false) {
+ const threshold: ConflictThresholdSetting = (config.conflictSeverityThreshold || 'medium') as ConflictThresholdSetting;
+ blocks.set('conflict-warnings', buildConflictWarningsBlock(result.selectedChunks, {
+ selfFlagThreshold: threshold,
+ crossDivergenceEnabled: config.conflictCrossDocEnabled !== false,
+ }));
+ }
+
+ // CoVe — 답변 *작성 전* 그라운딩 체크리스트.
+ if (config.coveEnabled !== false) {
+ blocks.set('cove-checklist', buildCoveChecklistBlock(result.selectedChunks, deps.currentPrompt, {
+ topSourcesCount: config.coveTopSourcesCount ?? 5,
+ strictMode: config.coveStrictMode === true,
+ }));
+ }
+
+ // Citation Trace — 답변 끝 출처 한 줄.
if (config.citationTraceEnabled !== false && result.selectedChunks.length > 0) {
- deps.turnCtx.citationTrace = buildCitationTraceBlock(result.selectedChunks);
- } else {
- deps.turnCtx.citationTrace = '';
+ blocks.set('citation-trace', buildCitationTraceBlock(result.selectedChunks));
}
// Self-Check 용 source 미리보기 — agent.ts 가 post-stream 에서 사용.
@@ -333,17 +320,6 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise ({ title: c.title, content: c.content })));
diff --git a/src/lib/mtimeFileCache.ts b/src/lib/mtimeFileCache.ts
new file mode 100644
index 0000000..9209bf9
--- /dev/null
+++ b/src/lib/mtimeFileCache.ts
@@ -0,0 +1,72 @@
+/**
+ * mtime-keyed file cache utility — 파일을 *parse 결과* 까지 캐싱.
+ *
+ * 배경: terminologyBlock.ts 와 termValidator.ts 가 같은 글로서리 파일에 *별도*
+ * 캐시 2개를 보유. "함께 무효화" 가 사람이 손으로 보장하는 invariant — 한쪽만
+ * 잊고 invalidate 안 하면 stale read 위험.
+ *
+ * 이 유틸은:
+ * - 같은 파일을 캐싱하는 모든 consumer 가 같은 cache 인스턴스 사용
+ * - mtime 자동 체크 — 파일이 바뀌면 자동 재read
+ * - `invalidate(filePath)` 가 한 번 호출되면 그 파일의 *모든 parse 결과* 무효화
+ *
+ * 사용:
+ * const cache = createMtimeFileCache('terminology', (raw) => parseMine(raw));
+ * const data = cache.read('/path/to/file.md'); // 캐시 hit or read+parse
+ * cache.invalidate('/path/to/file.md'); // 캐시 비움
+ */
+
+import * as fs from 'fs';
+
+interface CacheEntry {
+ mtimeMs: number;
+ parsed: T;
+}
+
+export interface MtimeFileCache {
+ /** 파일 read + parse + 캐시. mtime 같으면 캐시 hit. 파일 없으면 null. */
+ read(filePath: string): T | null;
+ /** 특정 파일 캐시 무효화. */
+ invalidate(filePath: string): void;
+ /** 모든 캐시 비움. */
+ clear(): void;
+ /** 디버그용 — 현재 캐시 사이즈. */
+ size(): number;
+}
+
+/**
+ * 새 mtime-keyed 캐시 생성. `name` 은 디버그용 라벨 (consumer 식별).
+ * `parse` 는 raw 파일 본문 → 파싱 결과. throw 시 read() 가 null 반환.
+ */
+export function createMtimeFileCache(
+ _name: string,
+ parse: (raw: string, filePath: string) => T,
+): MtimeFileCache {
+ const cache = new Map>();
+
+ return {
+ read(filePath: string): T | null {
+ try {
+ if (!fs.existsSync(filePath)) return null;
+ const stat = fs.statSync(filePath);
+ const cached = cache.get(filePath);
+ if (cached && cached.mtimeMs === stat.mtimeMs) return cached.parsed;
+ const raw = fs.readFileSync(filePath, 'utf-8');
+ const parsed = parse(raw, filePath);
+ cache.set(filePath, { mtimeMs: stat.mtimeMs, parsed });
+ return parsed;
+ } catch {
+ return null;
+ }
+ },
+ invalidate(filePath: string): void {
+ cache.delete(filePath);
+ },
+ clear(): void {
+ cache.clear();
+ },
+ size(): number {
+ return cache.size;
+ },
+ };
+}
diff --git a/src/retrieval/terminologyBlock.ts b/src/retrieval/terminologyBlock.ts
index 9cd1c82..7491290 100644
--- a/src/retrieval/terminologyBlock.ts
+++ b/src/retrieval/terminologyBlock.ts
@@ -16,14 +16,14 @@
* 캐시: 파일 mtime 기반 — 매 turn 디스크 read 안 함.
*/
-import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
+import { createMtimeFileCache } from '../lib/mtimeFileCache';
const DEFAULT_GLOSSARY_REL_PATH = '.astra/glossary.md';
-/** mtime-keyed cache — 사용자가 편집할 때만 다시 읽음. */
-const _cache = new Map();
+/** Raw 본문 캐시 — mtime 기반, 파일 편집 시 자동 재read. */
+const _rawCache = createMtimeFileCache('terminology-raw', (raw) => raw.trim());
export function getGlossaryFilePath(relPath: string = DEFAULT_GLOSSARY_REL_PATH): string | null {
const folders = vscode.workspace.workspaceFolders;
@@ -34,21 +34,11 @@ export function getGlossaryFilePath(relPath: string = DEFAULT_GLOSSARY_REL_PATH)
function readGlossary(relPath: string): string {
const fp = getGlossaryFilePath(relPath);
if (!fp) return '';
- try {
- if (!fs.existsSync(fp)) return '';
- const st = fs.statSync(fp);
- const cached = _cache.get(fp);
- if (cached && cached.mtime === st.mtimeMs) return cached.content;
- const content = fs.readFileSync(fp, 'utf-8').trim();
- _cache.set(fp, { mtime: st.mtimeMs, content });
- return content;
- } catch {
- return '';
- }
+ return _rawCache.read(fp) ?? '';
}
export function clearGlossaryCache(): void {
- _cache.clear();
+ _rawCache.clear();
}
export interface TerminologyBlockOptions {