diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json index 6897c29..6538b7f 100644 --- a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json +++ b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1778768831416, + "createdAt": 1778821460579, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json index 42c7023..251d05a 100644 --- a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json +++ b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1778768831407, + "createdAt": 1778821460577, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json index a074876..b3fa3a2 100644 --- a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json +++ b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json @@ -1,5 +1,5 @@ { "result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", - "createdAt": 1778768831402, + "createdAt": 1778821460575, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json index f6050de..bb1d916 100644 --- a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json +++ b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json @@ -1,5 +1,5 @@ { - "result": "---\nid: stress_conflict_1778768831385\ndate: 2026-05-14T14:27:11.421Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\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- **[PLANNER]** 전략 수립 중... (11ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (6ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (10ms)\n", - "createdAt": 1778768831421, + "result": "---\nid: stress_conflict_1778821460557\ndate: 2026-05-15T05:04:20.581Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\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- **[PLANNER]** 전략 수립 중... (17ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (2ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (3ms)\n", + "createdAt": 1778821460581, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778768831385.json b/.astra/tests/stress/.astra/missions/stress_conflict_1778821460557.json similarity index 78% rename from .astra/tests/stress/.astra/missions/stress_conflict_1778768831385.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1778821460557.json index 7b8f400..e8b66bc 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1778768831385.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1778821460557.json @@ -1,8 +1,8 @@ { - "missionId": "stress_conflict_1778768831385", + "missionId": "stress_conflict_1778821460557", "status": "completed", - "startTime": "2026-05-14T14:27:11.385Z", - "totalElapsedMs": 36, + "startTime": "2026-05-15T05:04:20.557Z", + "totalElapsedMs": 25, "results": { "planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", "researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", @@ -16,30 +16,30 @@ { "from": "idle", "to": "planner", - "durationMs": 11, + "durationMs": 17, "message": "전략 수립 중...", - "ts": "2026-05-14T14:27:11.396Z" + "ts": "2026-05-15T05:04:20.574Z" }, { "from": "planner", "to": "researcher", - "durationMs": 6, + "durationMs": 2, "message": "핵심 정보 수집 및 분석 중...", - "ts": "2026-05-14T14:27:11.402Z" + "ts": "2026-05-15T05:04:20.576Z" }, { "from": "researcher", "to": "writer", - "durationMs": 10, + "durationMs": 3, "message": "최종 리포트 작성 및 편집 중...", - "ts": "2026-05-14T14:27:11.412Z" + "ts": "2026-05-15T05:04:20.579Z" }, { "from": "writer", "to": "completed", - "durationMs": 9, + "durationMs": 2, "message": "미션 완료", - "ts": "2026-05-14T14:27:11.421Z" + "ts": "2026-05-15T05:04:20.581Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 95dc543..6d075db 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,24 @@ # Astra Patch Notes +## v2.2.3 (2026-05-15) +### 🚀 Stability & Packaging Refinement +- **패키지 정합성 강화:** 최신 코드베이스 변경 사항을 반영하여 빌드 파이프라인을 재검증하고 배포 안정성을 확보했습니다. +- **지속적 품질 관리:** 298개 전체 테스트 스위트 통과를 재확인하여 엔진 신뢰성을 유지합니다. +- **신규 패키징:** `astra-2.2.3.vsix` 패키지를 통해 최신 안정화 상태를 배포합니다. + +--- + + +## v2.2.2 (2026-05-15) +### 🧠 Intelligent Reflection & Knowledge Consolidation +- **자가 성찰 결과의 영속성(Reflection Persistence) 강화:** `reflectionPersister.ts`를 통해 에이전트의 비평(Critique)과 성찰 내용을 지식 베이스에 영구적으로 기록하고, 향후 미션에서 이를 자가 학습 데이터로 활용할 수 있는 기반을 마련했습니다. +- **작업 재개 로직 안정화:** `resumeStore.ts` 시스템을 최종 정비하여 비즈니스 파이프라인 중단 시의 복구 신뢰도를 극대화했습니다. +- **사이드바 제공 로직 최적화:** `sidebarProvider.ts` 대규모 리팩토링을 통해 메시지 핸들링 성능을 개선하고 UI 반응성을 높였습니다. +- **아키텍처 문서 및 기획 자산 최신화:** 프로젝트 구조 분석 결과와 기획 레코드를 동기화하여 에이전트의 상황 인지 능력을 향상시켰습니다. + +--- + + ## v2.2.1 (2026-05-14) ### 🔄 Autonomous Task Resumption & Engine Resilience - **작업 중단 후 자율 재개 기능 도입:** 예기치 않은 오류나 중단 상황에서도 이전에 진행하던 작업 흐름(Company Mission)을 마지막 성공 단계부터 즉시 이어서 실행할 수 있는 `resumeStore` 시스템을 구축했습니다. diff --git a/astra-2.2.1.vsix b/astra-2.2.1.vsix deleted file mode 100644 index 74114aa..0000000 Binary files a/astra-2.2.1.vsix and /dev/null differ diff --git a/docs/records/ConnectAI/chronicle.config.json b/docs/records/ConnectAI/chronicle.config.json index c607793..1ac70a2 100644 --- a/docs/records/ConnectAI/chronicle.config.json +++ b/docs/records/ConnectAI/chronicle.config.json @@ -1,11 +1,11 @@ { "projectId": "connectai", - "projectName": "ConnectAI", - "projectRoot": "/Volumes/Data/project/Antigravity/ConnectAI", - "recordRoot": "/Volumes/Data/project/Antigravity/ConnectAI/docs/records/ConnectAI", + "projectName": "connectai", + "projectRoot": "E:\\Wiki\\connectai", + "recordRoot": "E:\\Wiki\\connectai\\docs\\records\\connectai", "description": "Auto-created by Project Architecture activation.", "corePurpose": "", "detailLevel": "standard", - "createdAt": "2026-05-13T13:09:33.788Z", - "updatedAt": "2026-05-14T14:28:28.873Z" + "createdAt": "2026-05-14T00:57:32.245Z", + "updatedAt": "2026-05-15T03:24:10.265Z" } diff --git a/media/sidebar.css b/media/sidebar.css index c1ff34a..43c2498 100644 --- a/media/sidebar.css +++ b/media/sidebar.css @@ -919,6 +919,382 @@ } .company-phase-card.approval button:disabled { opacity: 0.55; cursor: default; } + /* 3-way 합의 검수 사이클 카드. review-start이 컨테이너를 만들고 + review-round 이벤트가 .rev-rounds 안에 라운드 한 줄씩 누적. */ + .company-phase-card.review { + border-color: var(--accent-glow, rgba(99,102,241,0.4)); + } + .company-phase-card.review .rev-rounds { + display: flex; flex-direction: column; gap: 8px; margin-top: 8px; + } + .company-phase-card.review .rev-round { + border-left: 2px solid var(--border); + padding: 4px 8px; + background: rgba(99,102,241,0.04); + border-radius: 4px; + } + .company-phase-card.review .rev-round-head { + font-size: 10.5px; color: var(--text-bright); font-weight: 600; margin-bottom: 4px; + } + .company-phase-card.review .rev-line { + display: flex; gap: 6px; align-items: flex-start; margin-top: 4px; + font-size: 11px; + } + .company-phase-card.review .rev-actor { + flex-shrink: 0; min-width: 64px; + color: var(--text-dim); font-weight: 600; + } + .company-phase-card.review .rev-body { flex: 1; min-width: 0; } + .company-phase-card.review .rev-body p { margin: 0 0 4px 0; } + .company-phase-card.review .rev-end { + margin-top: 8px; padding: 4px 8px; + font-size: 11px; font-weight: 600; + color: var(--text-bright); + border-top: 1px dashed var(--border); + } + + /* Intent Alignment 카드 — new_task 요청 직후 C-G-C-F 분석 결과를 보여주고 + 질문 / 확인 버튼을 띄움. 다른 phase 카드보다 살짝 무게감을 주려고 + accent 테두리. */ + .company-alignment-card { + border: 1px solid var(--accent); + background: var(--surface); + border-radius: 10px; + padding: 10px 12px; + margin: 6px 0; + font-size: 11.5px; + } + .company-alignment-card.cancelled { + border-color: var(--border); + opacity: 0.7; + } + .company-alignment-card .cph-head { + color: var(--text-bright); margin-bottom: 6px; + } + .company-alignment-card .cal-summary { + display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; + margin: 6px 0; padding: 6px 8px; + background: rgba(99,102,241,0.05); + border-radius: 6px; + } + .company-alignment-card .cal-row { + display: contents; + } + .company-alignment-card .cal-key { + font-weight: 700; color: var(--text-bright); + white-space: nowrap; + } + .company-alignment-card .cal-val { + color: var(--text-primary); word-break: break-word; + } + .company-alignment-card .cal-val em { color: var(--text-dim); font-style: italic; } + .company-alignment-card .cal-questions { + margin-top: 6px; padding: 6px 8px; + border-left: 2px solid var(--accent); + background: rgba(99,102,241,0.04); + } + .company-alignment-card .cal-q-head { + font-weight: 600; color: var(--text-bright); margin-bottom: 4px; + } + .company-alignment-card .cal-questions ul { + margin: 4px 0 4px 16px; padding: 0; + } + .company-alignment-card .cal-questions li { + margin-bottom: 2px; + } + .company-alignment-card .cal-hint { + margin-top: 4px; font-size: 10.5px; + color: var(--text-dim); font-style: italic; + } + .company-alignment-card .cal-conf { + margin-top: 6px; font-size: 10.5px; font-weight: 600; + } + .company-alignment-card .cal-conf-high { color: #10B981; } + .company-alignment-card .cal-conf-medium { color: #F5C518; } + .company-alignment-card .cal-conf-low { color: var(--error); } + .company-alignment-card .cal-actions { + display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; + } + .company-alignment-card .cal-actions button { + font-size: 11px; padding: 4px 10px; border-radius: 5px; cursor: pointer; + } + .company-alignment-card .cal-actions button:disabled { + opacity: 0.55; cursor: default; + } + + /* 고급: 작업 흐름 편집 영역. 기본 접힘 — 일반 사용자는 만질 일 X. */ + details.pipeline-advanced { + border-style: dashed; + opacity: 0.85; + } + details.pipeline-advanced:not([open]) { padding: 8px 12px; } + details.pipeline-advanced summary.pipeline-advanced-summary { + list-style: none; + cursor: pointer; + display: flex; align-items: center; gap: 8px; + user-select: none; + outline: none; + } + details.pipeline-advanced summary::-webkit-details-marker { display: none; } + details.pipeline-advanced summary .pa-icon { font-size: 13px; } + details.pipeline-advanced summary .pa-title { + font-size: 11px; font-weight: 700; color: var(--text-bright); + } + details.pipeline-advanced summary .pa-hint { + font-size: 10px; color: var(--text-dim); flex: 1; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + details.pipeline-advanced[open] summary { margin-bottom: 4px; } + details.pipeline-advanced[open] { + opacity: 1; + border-style: solid; + } + + /* ─────────────── Pixel Office ─────────────── */ + .pixel-office { + display: none; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + margin: 0 12px 6px 12px; + overflow: hidden; + } + .pixel-office[data-enabled="true"] { display: block; } + .pixel-office[data-collapsed="true"] .po-body { display: none; } + .po-head { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 10px; + background: rgba(99,102,241,0.08); + border-bottom: 1px solid var(--border); + } + .pixel-office[data-collapsed="true"] .po-head { border-bottom: none; } + .po-head-left { display: flex; align-items: center; gap: 8px; } + .po-title { font-size: 11px; font-weight: 700; color: var(--text-bright); } + .po-status-label { + font-size: 10px; padding: 2px 8px; border-radius: 9999px; + background: var(--bg); color: var(--text-dim); + font-weight: 600; letter-spacing: 0.02em; + border: 1px solid var(--border); + } + /* 상태별 색상 강조 — webview JS가 po-status-{status} 클래스를 셋팅. */ + .po-status-label.po-status-idle { color: var(--text-dim); } + .po-status-label.po-status-intake { color: #60A5FA; border-color: #60A5FA; } + .po-status-label.po-status-analyzing { color: #A78BFA; border-color: #A78BFA; } + .po-status-label.po-status-need_clarification { color: #F5C518; border-color: #F5C518; } + .po-status-label.po-status-contract_ready { color: #10B981; border-color: #10B981; } + .po-status-label.po-status-planning { color: #22D3EE; border-color: #22D3EE; } + .po-status-label.po-status-executing { color: var(--accent); border-color: var(--accent); } + .po-status-label.po-status-reviewing { color: #FB923C; border-color: #FB923C; } + .po-status-label.po-status-waiting_approval { color: #F472B6; border-color: #F472B6; } + .po-status-label.po-status-error { color: var(--error); border-color: var(--error); } + .po-status-label.po-status-done { color: #10B981; border-color: #10B981; } + + .po-head-actions { display: flex; gap: 4px; align-items: center; } + .po-collapse, .po-expand { + background: transparent; border: none; cursor: pointer; + color: var(--text-dim); font-size: 12px; line-height: 1; + padding: 2px 6px; border-radius: 4px; + } + .po-collapse:hover, .po-expand:hover { + color: var(--accent); background: rgba(99,102,241,0.1); + } + .pixel-office[data-collapsed="true"] .po-collapse { transform: rotate(-90deg); } + + .po-body { + display: grid; grid-template-columns: minmax(140px, 180px) 1fr; + gap: 8px; padding: 8px 10px; + } + @media (max-width: 540px) { + .po-body { grid-template-columns: 1fr; } + } + /* 좌측 — 픽셀 오피스 씬 (캐릭터 + 책상 + 진행률) */ + .po-scene { + display: flex; flex-direction: column; gap: 4px; + align-items: center; + } + .po-char-wrap { + position: relative; + width: 100%; min-height: 90px; + display: flex; flex-direction: column; align-items: center; justify-content: flex-end; + padding: 4px 0; + border-radius: 8px; + background: linear-gradient(180deg, rgba(99,102,241,0.05) 0%, rgba(99,102,241,0.0) 70%); + } + .po-char { + position: relative; + width: 48px; height: 48px; + display: flex; align-items: center; justify-content: center; + background: var(--bg); + border: 2px solid var(--border); + border-radius: 8px; + transition: transform 0.18s, border-color 0.18s; + } + .po-char-emoji { + font-size: 28px; line-height: 1; + } + /* 상태에 따른 캐릭터 동작 */ + .pixel-office[data-status="executing"] .po-char { animation: po-bob 1.6s ease-in-out infinite; } + .pixel-office[data-status="analyzing"] .po-char { animation: po-tilt 2.4s ease-in-out infinite; } + .pixel-office[data-status="reviewing"] .po-char { animation: po-tilt 1.8s ease-in-out infinite; } + .pixel-office[data-status="need_clarification"] .po-char { border-color: #F5C518; } + .pixel-office[data-status="waiting_approval"] .po-char { border-color: #F472B6; } + .pixel-office[data-status="error"] .po-char { + border-color: var(--error); + animation: po-shake 0.4s ease-in-out infinite; + } + .pixel-office[data-status="done"] .po-char { border-color: #10B981; } + @keyframes po-bob { + 0%,100% { transform: translateY(0); } + 50% { transform: translateY(-3px); } + } + @keyframes po-tilt { + 0%,100% { transform: rotate(0deg); } + 50% { transform: rotate(-4deg); } + } + @keyframes po-shake { + 0%,100% { transform: translateX(0); } + 25% { transform: translateX(-2px); } + 75% { transform: translateX(2px); } + } + + /* 캐릭터 옆 소품 — 상태에 따라 작은 이모지 (돋보기·체크리스트·코드 등) */ + .po-char-prop { + position: absolute; + right: -22px; top: 50%; + transform: translateY(-50%); + font-size: 16px; + opacity: 0.85; + } + + .po-desk { + width: 100%; + height: 8px; + margin-top: -4px; + background: linear-gradient(180deg, var(--border) 0%, var(--bg) 100%); + border-radius: 0 0 6px 6px; + } + .po-progress { + width: 100%; height: 4px; + background: var(--border); + border-radius: 2px; + overflow: hidden; + margin-top: 6px; + } + .po-progress-bar { + height: 100%; background: var(--accent); + transition: width 0.3s ease; + } + + /* 말풍선 영역 */ + .po-bubbles { + position: absolute; + left: 50%; transform: translateX(-50%); + bottom: 56px; + display: flex; flex-direction: column; align-items: center; gap: 4px; + pointer-events: none; + width: max-content; max-width: 220px; + } + .po-bubble { + background: var(--bg); + border: 1px solid var(--border); + color: var(--text-primary); + font-size: 10.5px; + padding: 4px 8px; + border-radius: 12px; + box-shadow: 0 2px 4px rgba(0,0,0,0.15); + position: relative; + animation: po-bubble-in 0.25s ease-out; + max-width: 220px; + line-height: 1.35; + text-align: center; + } + .po-bubble::after { + content: ''; + position: absolute; + left: 50%; bottom: -4px; + transform: translateX(-50%) rotate(45deg); + width: 6px; height: 6px; + background: var(--bg); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + } + .po-bubble.po-bubble-warning { border-color: #F5C518; color: #F5C518; } + .po-bubble.po-bubble-warning::after { border-color: #F5C518; } + .po-bubble.po-bubble-error { border-color: var(--error); color: var(--error); } + .po-bubble.po-bubble-error::after { border-color: var(--error); } + .po-bubble.po-bubble-success { border-color: #10B981; color: #10B981; } + .po-bubble.po-bubble-success::after { border-color: #10B981; } + .po-bubble.po-bubble-fading { animation: po-bubble-out 0.3s ease-in forwards; } + @keyframes po-bubble-in { + from { opacity: 0; transform: translateY(4px) scale(0.92); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + @keyframes po-bubble-out { + to { opacity: 0; transform: translateY(-4px) scale(0.92); } + } + + /* 우측 — 정보 패널 */ + .po-panel { + display: flex; flex-direction: column; gap: 4px; + font-size: 10.5px; + min-width: 0; + } + .po-row { + display: grid; grid-template-columns: 60px 1fr; + gap: 6px; align-items: baseline; + } + .po-key { + color: var(--text-dim); font-weight: 600; + white-space: nowrap; + } + .po-val { + color: var(--text-primary); + word-break: break-word; + overflow-wrap: anywhere; + } + .po-val-status { font-weight: 700; text-transform: lowercase; } + .po-section { + margin-top: 4px; padding-top: 4px; + border-top: 1px dashed var(--border); + } + .po-section-head { + font-size: 10px; font-weight: 700; color: var(--text-bright); + margin-bottom: 3px; + text-transform: uppercase; letter-spacing: 0.04em; + } + .po-need-input { margin: 0 0 0 14px; padding: 0; color: var(--text-primary); } + .po-need-input li { margin-bottom: 2px; } + .po-approval { + color: #F472B6; font-style: italic; + } + .po-contract { + display: grid; grid-template-columns: 40px 1fr; + gap: 2px 6px; + font-size: 10px; + } + .po-contract-key { color: var(--text-dim); font-weight: 600; } + .po-contract-val { color: var(--text-primary); word-break: break-word; } + .po-logs { + font-size: 9.5px; line-height: 1.4; + color: var(--text-dim); + max-height: 90px; overflow-y: auto; + font-family: var(--font-mono, ui-monospace, monospace); + } + .po-logs div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + + /* 의도 분류기가 chat/followup 으로 판정했을 때 채팅에 끼우는 작은 라벨. + 파이프라인 카드는 절대 만들지 않고 한 줄짜리 메타 텍스트만 — 사용자가 + "왜 파이프라인이 안 돌았지?" 의문을 즉시 해소할 수 있게 한다. */ + .company-intent-note { + font-size: 10.5px; color: var(--text-dim); + padding: 4px 8px; margin: 4px 0; + border-left: 2px solid var(--border); + } + .company-intent-note .cin-label { + color: var(--text-bright); font-weight: 600; margin-right: 6px; + } + .company-intent-note .cin-reason { font-style: italic; } + /* Project Architecture chip — three-state surface above the input. */ .arch-chip { display: none; diff --git a/media/sidebar.html b/media/sidebar.html index 8c2510e..9f09561 100644 --- a/media/sidebar.html +++ b/media/sidebar.html @@ -227,11 +227,19 @@
- -
-
+ +
+ + 🔧 + 고급: 작업 흐름 직접 편집 + 평소엔 대표가 자동 분배 — 직접 정의하고 싶을 때만 펼치세요. + +
작업 흐름
대표에게 맡기거나, 사용자가 정한 순서대로 팀원이 이어서 작업하게 만듭니다.
@@ -247,11 +255,11 @@
    -
    +
    +
    +
    +
    + 🏢 Pixel Office + idle +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    🧑‍💼
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    Agent
    +
    Statusidle
    +
    Task
    +
    Step
    + + + + + + +
    +
    +
    +
    diff --git a/media/sidebar.js b/media/sidebar.js index 2c69e8d..d9e5bec 100644 --- a/media/sidebar.js +++ b/media/sidebar.js @@ -947,6 +947,142 @@ renderCompanyChip(!!v.enabled, v.summary || ''); break; } + case 'companyIntentDecision': { + // 1인 기업 모드에서 의도 분류기가 "이건 새 업무가 아님"으로 + // 판정했을 때 화면에 작은 라벨 한 줄을 띄워, 사용자가 왜 + // 파이프라인이 안 돌았는지 알 수 있게 한다. new_task로 갈 + // 땐 라벨 없이 그냥 파이프라인 카드들이 떠서 명확하므로 + // 별도 알림 필요 없다. + const v = msg.value || {}; + const chatEl = document.getElementById('chat'); + if (!chatEl) break; + const note = document.createElement('div'); + note.className = 'company-intent-note'; + note.innerHTML = `${escAttr(v.label || '💬 대화')}` + + (v.reason ? ` ${escAttr(v.reason)}` : ''); + chatEl.appendChild(note); + chatEl.scrollTop = chatEl.scrollHeight; + break; + } + case 'pixelOfficeUpdate': { + if (typeof window.__pixelOfficeApply === 'function') { + window.__pixelOfficeApply(msg.value || {}); + } + break; + } + case 'companyAlignmentCard': { + // Intent Alignment 카드. kind에 따라 4가지 모드: + // - 'auto-proceed' : confidence high → 자동 진행 안내(읽기 전용) + // - 'questions' : 답해야 할 질문 + 채팅으로 답변 안내 + // - 'confirm' : 질문 없음, ✅진행 / 🛑취소 버튼 + // - 'cancelled' : 사용자가 취소 — 카드 한 줄로 종료 표시 + const v = msg.value || {}; + const chatEl = document.getElementById('chat'); + if (!chatEl) break; + const card = document.createElement('div'); + card.className = 'company-alignment-card'; + if (v.kind === 'cancelled') { + card.classList.add('cancelled'); + card.innerHTML = '
    🛑 의도 정렬 취소됨 — 작업이 시작되지 않았습니다
    '; + chatEl.appendChild(card); + chatEl.scrollTop = chatEl.scrollHeight; + break; + } + const c = v.contract || {}; + const kindLabel = v.kind === 'auto-proceed' ? '✅ 의도 분석 완료 — 곧장 진행' + : v.kind === 'confirm' ? '🧭 요청 정리 — 확인 후 진행' + : '🤔 추가 정보 필요'; + const head = document.createElement('div'); + head.className = 'cph-head'; + head.innerHTML = `${escAttr(kindLabel)}`; + if (typeof v.roundsAsked === 'number' && typeof v.roundsLimit === 'number') { + const meta = document.createElement('span'); + meta.className = 'cph-meta'; + meta.style.marginLeft = '8px'; + meta.textContent = `라운드 ${v.roundsAsked + 1}/${v.roundsLimit}`; + head.appendChild(meta); + } + card.appendChild(head); + + // ── C-G-C-F summary block ── + const summary = document.createElement('div'); + summary.className = 'cal-summary'; + const dl = (label, val) => { + const row = document.createElement('div'); + row.className = 'cal-row'; + row.innerHTML = `${escAttr(label)}${val ? fmt(val) : '(미정)'}`; + return row; + }; + summary.appendChild(dl('맥락', c.context)); + summary.appendChild(dl('목표', c.goal)); + if (Array.isArray(c.criteria) && c.criteria.length > 0) { + const ul = c.criteria.map((x) => `- ${x}`).join('\n'); + summary.appendChild(dl('기준', ul)); + } else { + summary.appendChild(dl('기준', '')); + } + summary.appendChild(dl('형식', c.format)); + card.appendChild(summary); + + // ── 미해결 질문 ── + if (Array.isArray(c.openQuestions) && c.openQuestions.length > 0 && v.kind === 'questions') { + const qBlock = document.createElement('div'); + qBlock.className = 'cal-questions'; + const qHead = document.createElement('div'); + qHead.className = 'cal-q-head'; + qHead.textContent = '아래 질문에 한 메시지로 답해 주세요 (다 답해도, 일부만 답해도 OK):'; + qBlock.appendChild(qHead); + const ul = document.createElement('ul'); + for (const q of c.openQuestions) { + const li = document.createElement('li'); + li.textContent = q; + ul.appendChild(li); + } + qBlock.appendChild(ul); + const hint = document.createElement('div'); + hint.className = 'cal-hint'; + hint.textContent = '답하지 않고 지금 그대로 시작하려면 아래 "그대로 진행" 버튼을 누르세요.'; + qBlock.appendChild(hint); + card.appendChild(qBlock); + } + + // ── 신뢰도 + 액션 버튼 ── + const confLabel = c.confidence === 'high' ? '신뢰도: high' + : c.confidence === 'medium' ? '신뢰도: medium' : '신뢰도: low'; + const confEl = document.createElement('div'); + confEl.className = 'cal-conf cal-conf-' + (c.confidence || 'low'); + confEl.textContent = confLabel; + card.appendChild(confEl); + + if (v.kind !== 'auto-proceed') { + const actions = document.createElement('div'); + actions.className = 'cal-actions'; + const proceedBtn = document.createElement('button'); + proceedBtn.className = 'send-btn'; + proceedBtn.textContent = '✅ 그대로 진행'; + proceedBtn.onclick = () => { + vscode.postMessage({ type: 'respondCompanyAlignment', decision: 'proceed' }); + actions.querySelectorAll('button').forEach((b) => b.disabled = true); + proceedBtn.textContent = '✅ 진행 중...'; + }; + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'secondary-btn'; + cancelBtn.textContent = '🛑 취소'; + cancelBtn.title = '이 작업을 시작하지 않음'; + cancelBtn.onclick = () => { + vscode.postMessage({ type: 'respondCompanyAlignment', decision: 'cancel' }); + actions.querySelectorAll('button').forEach((b) => b.disabled = true); + cancelBtn.textContent = '🛑 취소됨'; + }; + actions.appendChild(proceedBtn); + actions.appendChild(cancelBtn); + card.appendChild(actions); + } + + chatEl.appendChild(card); + chatEl.scrollTop = chatEl.scrollHeight; + break; + } case 'companyAgents': { renderCompanyAgentCards(msg.value || {}); break; @@ -1854,6 +1990,8 @@ agentId: '', modelOverride: '', requiresApproval: false, + reviewWith: '', + reviewMaxRounds: 3, instructionTemplate: '', loopBackPattern: '', loopBackTo: '', @@ -1990,31 +2128,45 @@ const agentSel = document.createElement('select'); const _refillAgentSel = () => { agentSel.innerHTML = ''; + // "⚙️ CEO 자동 선택" 옵션을 항상 맨 위에 추가 — value=''로 빈 + // agentId 저장됨. dispatcher가 보고 직군 후보 중 매번 CEO에게 + // 적임자 결정 요청. 사용자 의도 "CEO가 배분"의 정공법. + const autoOpt = document.createElement('option'); + autoOpt.value = ''; + autoOpt.textContent = '⚙️ CEO 자동 선택 (이 직군 중)'; + agentSel.appendChild(autoOpt); const list = _activeAgentsByCategory[roleSel.value] || []; if (list.length === 0) { const opt = document.createElement('option'); - opt.value = ''; + opt.value = '__no_agents__'; opt.textContent = '(이 직군의 활성 에이전트 없음)'; + opt.disabled = true; agentSel.appendChild(opt); - agentSel.disabled = true; + // CEO 자동 선택은 여전히 가능 — 활성 후보가 없으면 dispatcher에서 + // no-active-agent-in-role 에러로 사용자에게 알린다. } else { - agentSel.disabled = false; for (const a of list) { const opt = document.createElement('option'); opt.value = a.id; opt.textContent = `${a.emoji} ${a.name}`; agentSel.appendChild(opt); } - // 현재 stage의 agentId가 이 직군에 속하면 유지, 아니면 첫 번째. - const inList = list.some((a) => a.id === stage.agentId); - agentSel.value = inList ? stage.agentId : list[0].id; - stage.agentId = agentSel.value; + } + // 현재 stage.agentId가 빈값이면 CEO 자동 선택(default). 활성 후보에 있으면 + // 그 사람, 없으면 다시 CEO 자동으로 되돌림 — stale agentId 박제 방지. + const aid = stage.agentId || ''; + if (aid && list.some((a) => a.id === aid)) { + agentSel.value = aid; + } else { + agentSel.value = ''; + stage.agentId = ''; } }; _refillAgentSel(); roleSel.onchange = () => { stage.roleCategory = roleSel.value; - stage.agentId = _firstAgentOfCategory(roleSel.value); + // 직군 바뀌면 명시적 담당자 박힌 게 더 이상 의미 없음 — CEO 자동 선택으로 리셋. + stage.agentId = ''; _refillAgentSel(); }; agentSel.onchange = () => { stage.agentId = agentSel.value; }; @@ -2053,6 +2205,71 @@ approvalWrap.appendChild(approvalText); body.appendChild(approvalWrap); + // ── 3-way 합의 검수 사이클 ── + // 작업자 산출물 → 검수자 의견 → CEO 메타-판단을 라운드로 반복해 + // "작업자 + 검수자 + 대표" 셋이 모두 만족할 때까지 진행. 작은 + // 모델에선 라운드를 많이 돌 수 있으므로 maxRounds 안전망 필요. + const reviewWrap = document.createElement('label'); + reviewWrap.style.cssText = 'display:flex; gap:6px; align-items:center; font-size:10px; color:var(--text-dim); cursor:pointer; margin-top:4px;'; + const reviewCb = document.createElement('input'); + reviewCb.type = 'checkbox'; + reviewCb.checked = !!stage.reviewWith; + const reviewText = document.createElement('span'); + reviewText.textContent = '🔍 검수 사이클 (작업자 + 검수자 + CEO 합의)'; + reviewText.title = '체크하면 작업자 산출물 직후 검수자 + CEO 메타-판단을 라운드로 돌려 셋이 모두 만족할 때만 통과'; + reviewWrap.appendChild(reviewCb); + reviewWrap.appendChild(reviewText); + body.appendChild(reviewWrap); + + // 검수자 선택 + 최대 라운드 (체크박스 ON일 때만 노출) + const reviewDetail = document.createElement('div'); + reviewDetail.style.cssText = 'display:none; gap:8px; flex-wrap:wrap; align-items:center; font-size:10px; color:var(--text-dim); margin: 2px 0 4px 22px;'; + const inspLbl = document.createElement('label'); inspLbl.textContent = '검수자:'; + const inspSel = document.createElement('select'); + // 옵션: 직군 inspector 자동 + 활성 inspector 직군 에이전트 + 'any active agent of given category' 정도면 충분 + const inspectorOpts = (_activeAgentsByCategory['inspector'] || []); + const autoOpt = document.createElement('option'); + autoOpt.value = 'inspector'; + autoOpt.textContent = '⚙️ 감리 직군 자동'; + inspSel.appendChild(autoOpt); + for (const a of inspectorOpts) { + const opt = document.createElement('option'); + opt.value = `agent:${a.id}`; + opt.textContent = `${a.emoji} ${a.name}`; + inspSel.appendChild(opt); + } + // 현재값 적용 + inspSel.value = stage.reviewWith || 'inspector'; + inspSel.onchange = () => { + stage.reviewWith = inspSel.value; + }; + const roundLbl = document.createElement('label'); roundLbl.textContent = '최대 라운드:'; + const roundInput = document.createElement('input'); + roundInput.type = 'number'; roundInput.min = '1'; roundInput.max = '10'; + roundInput.style.cssText = 'width:55px; padding:2px 4px; background:var(--bg); color:var(--text-primary); border:1px solid var(--border); border-radius:4px;'; + roundInput.value = String(stage.reviewMaxRounds || 3); + roundInput.oninput = () => { + const v = parseInt(roundInput.value, 10); + stage.reviewMaxRounds = Number.isFinite(v) ? Math.max(1, Math.min(10, v)) : 3; + }; + reviewDetail.appendChild(inspLbl); reviewDetail.appendChild(inspSel); + reviewDetail.appendChild(roundLbl); reviewDetail.appendChild(roundInput); + body.appendChild(reviewDetail); + + const _syncReviewDetail = () => { + reviewDetail.style.display = reviewCb.checked ? 'flex' : 'none'; + }; + _syncReviewDetail(); + reviewCb.onchange = () => { + if (reviewCb.checked) { + stage.reviewWith = inspSel.value || 'inspector'; + if (!stage.reviewMaxRounds) stage.reviewMaxRounds = 3; + } else { + stage.reviewWith = ''; + } + _syncReviewDetail(); + }; + // 지시 텍스트 + 토큰 버튼 const instrLabelDiv = document.createElement('div'); instrLabelDiv.className = 'psc-field-label'; @@ -2175,6 +2392,8 @@ agentId: s.agentId || '', modelOverride: s.modelOverride || '', requiresApproval: !!s.requiresApproval, + reviewWith: s.reviewWith || '', + reviewMaxRounds: s.reviewMaxRounds || 3, instructionTemplate: s.instructionTemplate || '', loopBackPattern: s.loopBackPattern || '', loopBackTo: s.loopBackTo || '', @@ -2241,15 +2460,16 @@ if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자로 시작, /-/_).'; return; } - // 각 stage 검증: 라벨 + 담당 에이전트 필수. + // 각 stage 검증: 라벨 + (담당자 또는 직군) 중 하나는 반드시. + // CEO 자동 선택 모드(agentId 비어 있음)면 직군이 필수. for (let i = 0; i < _editStages.length; i++) { const s = _editStages[i]; if (!s.label?.trim()) { if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 이름이 비어 있습니다.`; return; } - if (!s.agentId) { - if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 담당 에이전트를 지정하세요. 해당 직군에 활성 에이전트가 없으면 관리 패널에서 활성화 먼저.`; + if (!s.agentId && !s.roleCategory) { + if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 담당자(또는 직군)을 지정하세요.`; return; } } @@ -2260,14 +2480,22 @@ const out = { id: s.id, label: s.label.trim(), - agentId: s.agentId, + // 빈 agentId(= CEO 자동 선택) 일 땐 필드 자체를 빼서 backend에 + // optional로 전달. dispatcher가 roleCategory 보고 실시간 결정. roleCategory: s.roleCategory, instructionTemplate: s.instructionTemplate || '', }; + if (s.agentId && s.agentId.trim()) { + out.agentId = s.agentId.trim(); + } if (s.modelOverride && s.modelOverride.trim()) { out.modelOverride = s.modelOverride.trim(); } if (s.requiresApproval) out.requiresApproval = true; + if (s.reviewWith && s.reviewWith.trim()) { + out.reviewWith = s.reviewWith.trim(); + out.reviewMaxRounds = s.reviewMaxRounds || 3; + } if (s.loopBackPattern && s.loopBackTo) { out.loopBackPattern = s.loopBackPattern; out.loopBackTo = s.loopBackTo; @@ -2377,6 +2605,214 @@ window.__renderCompanyPipelines = renderCompanyPipelines; window.__closePipelineEditor = _closePipelineEditor; + // ────────────────────────────────────────────────────────────────────── + // Pixel Office 렌더러 — 백엔드의 pixelOfficeUpdate 메시지를 받아 캐릭터, + // 정보 패널, 말풍선 큐를 갱신. 어떤 상태도 그대로 그리기만 함 (read-only). + // ────────────────────────────────────────────────────────────────────── + (function setupPixelOffice() { + const root = document.getElementById('pixelOffice'); + if (!root) return; + const collapseBtn = document.getElementById('poCollapseBtn'); + const expandBtn = document.getElementById('poExpandBtn'); + const head = document.querySelector('#pixelOffice .po-head'); + const charEl = document.getElementById('poChar'); + const charEmoji = document.getElementById('poCharEmoji'); + const charProp = document.getElementById('poCharProp'); + const bubblesEl = document.getElementById('poBubbles'); + const progressBar = document.getElementById('poProgressBar'); + const statusLabel = document.getElementById('poStatusLabel'); + const statusVal = document.getElementById('poStatusVal'); + const agentName = document.getElementById('poAgentName'); + const taskEl = document.getElementById('poTask'); + const stepEl = document.getElementById('poStep'); + const nextStepRow = document.getElementById('poNextStepRow'); + const nextStepEl = document.getElementById('poNextStep'); + const messageRow = document.getElementById('poMessageRow'); + const messageEl = document.getElementById('poMessage'); + const needInputSection = document.getElementById('poNeedInputSection'); + const needInputList = document.getElementById('poNeedInputList'); + const approvalSection = document.getElementById('poApprovalSection'); + const approvalText = document.getElementById('poApprovalText'); + const contractSection = document.getElementById('poContractSection'); + const contractEl = document.getElementById('poContract'); + const logsSection = document.getElementById('poLogsSection'); + const logsEl = document.getElementById('poLogs'); + + // 상태별 캐릭터·소품 매핑 — 픽셀아트 대신 이모지 조합으로. + const STATUS_VIS = { + idle: { emoji: '🧑‍💼', prop: '' }, + intake: { emoji: '🧑‍💼', prop: '📨' }, + analyzing: { emoji: '🧐', prop: '🔍' }, + need_clarification: { emoji: '🤔', prop: '❓' }, + contract_ready: { emoji: '🧑‍💼', prop: '📋' }, + planning: { emoji: '🧑‍💼', prop: '📝' }, + executing: { emoji: '🧑‍💻', prop: '⚙️' }, + reviewing: { emoji: '🧐', prop: '✅' }, + waiting_approval: { emoji: '🧑‍💼', prop: '🛑' }, + error: { emoji: '😵', prop: '⚠️' }, + done: { emoji: '😎', prop: '☕' }, + }; + + // ── 말풍선 큐 ── + // 큐 크기 제한 + 자동 사라짐 타이머. 같은 텍스트 연속 등장 시 1개로 합침. + let cfg = { enabled: true, bubblesEnabled: true, maxVisibleBubbles: 3, bubbleDurationMs: 4500 }; + const bubbleQueue = []; // { el, timer } + let lastBubbleText = ''; + + const collapseToggle = () => { + const cur = root.getAttribute('data-collapsed') === 'true'; + root.setAttribute('data-collapsed', cur ? 'false' : 'true'); + }; + if (collapseBtn) collapseBtn.onclick = (e) => { e.stopPropagation(); collapseToggle(); }; + if (expandBtn) expandBtn.onclick = (e) => { + e.stopPropagation(); + // 백엔드에 전체보기 panel 열기 요청. + vscode.postMessage({ type: 'openPixelOfficePanel' }); + }; + // head 영역 자체 클릭으로도 토글 (버튼 외 영역). + if (head) head.addEventListener('click', (e) => { + if (e.target instanceof HTMLElement && (e.target.closest('.po-collapse') || e.target.closest('.po-expand'))) return; + collapseToggle(); + }); + + const dropOldestBubble = () => { + const first = bubbleQueue.shift(); + if (!first) return; + if (first.timer) clearTimeout(first.timer); + first.el.classList.add('po-bubble-fading'); + setTimeout(() => { try { first.el.remove(); } catch {} }, 300); + }; + + const pushBubble = (b) => { + if (!cfg.bubblesEnabled) return; + if (!b || !b.text) return; + if (b.text === lastBubbleText) return; // 연속 중복 차단 + lastBubbleText = b.text; + const el = document.createElement('div'); + el.className = 'po-bubble po-bubble-' + (b.type || 'status'); + el.textContent = b.text; + bubblesEl.appendChild(el); + const duration = b.durationMs || cfg.bubbleDurationMs || 4500; + const timer = setTimeout(() => { + const idx = bubbleQueue.findIndex((x) => x.el === el); + if (idx >= 0) { + bubbleQueue.splice(idx, 1); + el.classList.add('po-bubble-fading'); + setTimeout(() => { try { el.remove(); } catch {} }, 300); + } + }, duration); + bubbleQueue.push({ el, timer }); + while (bubbleQueue.length > Math.max(1, cfg.maxVisibleBubbles)) { + dropOldestBubble(); + } + }; + + const setText = (el, val) => { if (el) el.textContent = (val == null || val === '') ? '—' : String(val); }; + + const apply = (payload) => { + cfg = Object.assign(cfg, payload && payload.config ? payload.config : {}); + root.setAttribute('data-enabled', cfg.enabled ? 'true' : 'false'); + if (!cfg.enabled) return; + const state = payload && payload.state; + if (state) { + const vis = STATUS_VIS[state.status] || STATUS_VIS.idle; + if (charEmoji) charEmoji.textContent = vis.emoji; + if (charProp) charProp.textContent = vis.prop; + root.setAttribute('data-status', state.status || 'idle'); + // 상태 라벨 색상 클래스 새로. + if (statusLabel) { + statusLabel.className = 'po-status-label po-status-' + (state.status || 'idle'); + statusLabel.textContent = state.status || 'idle'; + } + setText(statusVal, state.status); + setText(agentName, state.agentName); + setText(taskEl, state.currentTask); + setText(stepEl, state.currentStep); + if (state.nextStep) { + nextStepRow.style.display = ''; + setText(nextStepEl, state.nextStep); + } else { + nextStepRow.style.display = 'none'; + } + if (state.message) { + messageRow.style.display = ''; + setText(messageEl, state.message); + } else { + messageRow.style.display = 'none'; + } + // Progress + if (progressBar) { + const pct = typeof state.progress === 'number' + ? Math.round(Math.max(0, Math.min(1, state.progress)) * 100) + : 0; + progressBar.style.width = pct + '%'; + } + // Need input + if (Array.isArray(state.needUserInput) && state.needUserInput.length > 0) { + needInputSection.style.display = ''; + needInputList.innerHTML = ''; + for (const q of state.needUserInput) { + const li = document.createElement('li'); li.textContent = q; + needInputList.appendChild(li); + } + } else { + needInputSection.style.display = 'none'; + } + // Approval + if (state.awaitingApproval) { + approvalSection.style.display = ''; + setText(approvalText, state.awaitingApproval); + } else { + approvalSection.style.display = 'none'; + } + // Contract + const c = state.requirementContract; + if (c && (c.goal || c.context || (c.criteria && c.criteria.length) || c.format)) { + contractSection.style.display = ''; + contractEl.innerHTML = ''; + const addRow = (k, v) => { + if (!v || (Array.isArray(v) && v.length === 0)) return; + const ke = document.createElement('div'); + ke.className = 'po-contract-key'; ke.textContent = k; + const ve = document.createElement('div'); + ve.className = 'po-contract-val'; + ve.textContent = Array.isArray(v) ? v.map((x) => '• ' + x).join('\n') : String(v); + ve.style.whiteSpace = 'pre-line'; + contractEl.appendChild(ke); + contractEl.appendChild(ve); + }; + addRow('Goal', c.goal); + addRow('Ctx', c.context); + addRow('Crit', c.criteria); + addRow('Fmt', c.format); + if (c.confidence) addRow('Conf', c.confidence); + } else { + contractSection.style.display = 'none'; + } + // Recent logs + if (Array.isArray(state.recentLogs) && state.recentLogs.length > 0) { + logsSection.style.display = ''; + logsEl.innerHTML = ''; + for (const line of state.recentLogs) { + const d = document.createElement('div'); + d.textContent = line; + logsEl.appendChild(d); + } + } else { + logsSection.style.display = 'none'; + } + } + // Bubbles + if (Array.isArray(payload?.bubbles)) { + for (const b of payload.bubbles) pushBubble(b); + } + }; + window.__pixelOfficeApply = apply; + + // webview 로드 직후 백엔드 캐시 상태 요청. + try { vscode.postMessage({ type: 'getPixelOfficeState' }); } catch {} + })(); + // ────────────────────────────────────────────────────────────────────── // 이어서 진행 가능 세션 렌더링. // @@ -3063,6 +3499,43 @@ } else if (ev.phase === 'stage-loop') { card.innerHTML = `
    🔁 Stage 재시도
    ${escAttr(ev.from)} → ${escAttr(ev.to)} (반복 ${ev.iteration}회)
    `; + } else if (ev.phase === 'review-start') { + // 검수 사이클 시작 — 같은 stageId에 라운드를 누적할 컨테이너 카드. + // 이후 review-round 이벤트가 이 카드의 .rev-rounds 자식 안에 한 줄씩 append. + card.className += ' review'; + card.dataset.stageId = ev.stageId; + card.dataset.reviewMaxRounds = String(ev.maxRounds || 3); + card.innerHTML = `
    🔍 ${escAttr(ev.stageLabel || ev.stageId)} 검수 사이클 시작 검수자: ${escAttr(ev.inspectorAgentId)} · 최대 ${ev.maxRounds}라운드
    +
    `; + } else if (ev.phase === 'review-round') { + // 이미 그려둔 review-start 카드 안에 라운드 한 줄을 누적. + const target = chatEl.querySelector(`.company-phase-card.review[data-stage-id="${CSS.escape(ev.stageId)}"] .rev-rounds`); + if (target) { + const row = document.createElement('div'); + row.className = 'rev-round'; + const inspIcon = ev.inspectorVerdict === 'pass' ? '✅' : ev.inspectorVerdict === 'revise' ? '❌' : '❓'; + const ceoIcon = ev.ceoVerdict === 'pass' ? '✅' : ev.ceoVerdict === 'abort' ? '🛑' : ev.ceoVerdict === 'revise' ? '🔁' : '❓'; + row.innerHTML = `
    라운드 ${ev.round} ${(ev.durationMs/1000).toFixed(1)}s
    +
    ${inspIcon} 검수${fmt((ev.inspectorText || '').slice(0, 1500))}
    +
    ${ceoIcon} CEO${fmt((ev.ceoText || '').slice(0, 1000))}
    `; + target.appendChild(row); + } + return; // 새 카드 만들지 않음 + } else if (ev.phase === 'review-end') { + // 사이클 종료 라벨을 컨테이너 카드 끝에 붙임 — 별도 카드 X. + const target = chatEl.querySelector(`.company-phase-card.review[data-stage-id="${CSS.escape(ev.stageId)}"]`); + if (target) { + const tail = document.createElement('div'); + tail.className = 'rev-end'; + const label = ev.final === 'pass' + ? `✅ 합의 통과 (${ev.rounds}라운드)` + : ev.final === 'maxed-out' + ? `⚠️ 최대 라운드 도달 — 현 결과로 진행 (${ev.rounds}라운드)` + : `🛑 사이클 중단 (${ev.rounds}라운드)`; + tail.textContent = label; + target.appendChild(tail); + } + return; } else if (ev.phase === 'awaiting-approval') { // 승인 게이트 카드 — 사용자가 클릭할 때까지 대기. card.className += ' approval'; diff --git a/package.json b/package.json index dca5950..c5ce295 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.1", + "version": "2.2.3", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -130,6 +130,10 @@ { "command": "g1nation.company.openSessions", "title": "Astra: Open 1인 기업 Sessions Folder" + }, + { + "command": "g1nation.company.pixelOffice.open", + "title": "Astra: Open Pixel Office (Full Screen)" } ], "keybindings": [ @@ -421,6 +425,59 @@ "type": "boolean", "default": true, "description": "Persist substantive Reflector critiques to the active brain as lesson cards under `lessons/auto-reflector/`. Future missions automatically retrieve these cards (via the existing Experience-Memory pipeline) and inject them as ‘[⚠ ACTIVE LESSONS — verify these BEFORE finalizing]’ guardrails into Planner/Researcher/Writer context. A repeated critique (similar title) bumps `occurrences` and escalates `severity` (low→medium→high) instead of duplicating the card, so recurring patterns get louder over time. Disable to keep critiques single-mission only." + }, + "g1nation.company.intentClassifierModel": { + "type": "string", + "default": "", + "description": "Model used to classify whether an incoming chat message in 1인 기업 모드 is a (a) casual chat / question, (b) follow-up on the previous round, or (c) a brand-new task that should trigger the full work pipeline. Empty → uses g1nation.defaultModel. Pick a fast small model (e.g. gemma 4 e2b) so the classifier doesn't add latency before every chat send. The classifier runs once per user message and returns a one-token-ish JSON verdict, so even slow hardware sees minimal overhead." + }, + "g1nation.company.disableIntentClassifier": { + "type": "boolean", + "default": false, + "description": "Bypass the intent classifier and always run the full work pipeline on every chat message in 1인 기업 모드 (legacy behaviour). Enable this only if you want every input — including 'thanks', 'show me X again' — to dispatch all agents. Off by default because most chat messages aren't new work and shouldn't burn a full pipeline." + }, + "g1nation.company.autoSelectPipeline": { + "type": "boolean", + "default": true, + "description": "Let the intent classifier *automatically switch* to the pipeline it recommends for this turn (e.g. short '기획서까지만' for a planning ask, full '풀 프로덕트' for an end-to-end product). Your explicitly-activated pipeline is bypassed for the round but the activation itself isn't changed. On by default — the classifier's read of the user's intent (especially explicit signals like '기획만'·'디자인만') should be honoured. Set to false if you'd rather always run the pipeline you activated yourself." + }, + "g1nation.company.intentAlignmentMode": { + "type": "string", + "enum": ["off", "smart", "strict"], + "default": "smart", + "description": "Intent Alignment — turn user prompts into an explicit Requirement Contract (C-G-C-F-Q) before dispatching a pipeline. 'off' = legacy, pipeline runs immediately. 'smart' (default) = run when confidence is high, else show a confirmation card; ask up to N rounds of clarifying questions if information is missing. 'strict' = always show the contract card and require user confirmation, regardless of confidence. Goal: stop agents from silently guessing at the user's mental model." + }, + "g1nation.company.intentAlignmentMaxRounds": { + "type": "number", + "minimum": 1, + "maximum": 5, + "default": 3, + "description": "Maximum back-and-forth rounds the Intent Alignment analyzer is allowed to ask before forcing a 'confirm or cancel' card (it stops asking new questions and shows the current contract for user approval). Each round = one LLM call. Default 3." + }, + "g1nation.selfReflector.enabled": { + "type": "boolean", + "default": true, + "description": "Self-Reflector Phase A — append a [Self-Reflector Check] block at the end of every substantive LLM answer (Consistency / Completeness / Accuracy, plus References / Paths for code answers). Zero extra LLM calls — the rule lives in the system prompt and the model self-imposes the checklist. Turns response quality up by making the verification step explicit. Disable for purely casual / chat-only usage." + }, + "g1nation.selfReflector.externalVerification": { + "type": "boolean", + "default": false, + "description": "Self-Reflector Phase B — after every 1인 기업 specialist response, run a *separate* LLM call to verify the output from an outside-context perspective (catches the 'same model self-validates' blind spot). Failed checks trigger one auto-revise round. Off by default — adds +1 LLM call per dispatched stage." + }, + "g1nation.selfReflector.executionVerification": { + "type": "boolean", + "default": false, + "description": "Self-Reflector Phase C — after a code file is created via , automatically run the language's syntax check (Python: py_compile, JS: node --check, TS: project tsc --noEmit). Failures are surfaced in the action report so the user (and the agent on a follow-up) can see exactly what broke. Requires the language toolchain installed on the user's machine. Off by default." + }, + "g1nation.company.pixelOffice.enabled": { + "type": "boolean", + "default": true, + "description": "Show the Pixel Office visualisation panel above the chat — a small pixel-office-style display that mirrors the agent's current pipeline status (analyzing, need_clarification, executing, reviewing, waiting_approval, done, etc.) and the current task / contract / open questions. UI layer only; turning it off does not change any agent behaviour." + }, + "g1nation.company.pixelOffice.bubbles": { + "type": "boolean", + "default": true, + "description": "Show short comic-style speech bubbles above the Pixel Office character on status changes / key events (e.g. '코드 들어간다', '잠깐, 이건 다시 보자', '좋아, 끝났다!'). Bubbles are purely narrative — they never influence the agent's decisions. Disable for a quieter UI." } } } diff --git a/src/agent.ts b/src/agent.ts index 06c1fe2..70bca35 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -1152,6 +1152,31 @@ export class AgentExecutor { this.statusBarManager.updateStatus(AgentStatus.Executing); // Action tags are honored only from the visible final answer — never from hidden reasoning. const report = await this.executeActions(cleanedVisible, rootPath, activeBrain); + // Self-Reflector Phase C — 일반 채팅 경로에서도 코드 파일 생성 직후 + // syntax 체크 실행. 옵션 OFF면 통째로 skip. + try { + const cfgC = getConfig(); + if (cfgC.selfReflectorExecutionEnabled && report.length > 0) { + const { verifyCreatedFiles } = await import('./features/selfReflector/selfReflectorExecution'); + const extra = await verifyCreatedFiles(report, rootPath); + if (extra.length > 0) report.push(...extra); + } + } catch (e: any) { + logError('selfReflector.C (chat): hook failed; continuing.', { error: e?.message ?? String(e) }); + } + // Hollow code 검사 — selfReflectorEnabled가 켜져 있으면 syntax 통과 + // 한 파일도 빈 깡통은 잡는다. 일반 채팅 경로에선 자동 retry 없이 + // 경고만 — 사용자가 직접 보고 다시 요청할 수 있으니 충분. + try { + const cfgH = getConfig(); + if (cfgH.selfReflectorEnabled && report.length > 0) { + const { verifyHollow } = await import('./features/selfReflector/selfReflectorHollow'); + const hollowRes = verifyHollow(report, rootPath); + if (hollowRes.hasHollow) report.push(...hollowRes.extraLines); + } + } catch (e: any) { + logError('selfReflector.hollow (chat): hook failed; continuing.', { error: e?.message ?? String(e) }); + } if (!assistantContent.trim() && report.length === 0) { const promptCharCount = messagesForRequest.reduce((sum, m) => sum + (m.content?.length ?? 0), 0); logError('Model returned an empty response without actions.', { diff --git a/src/config.ts b/src/config.ts index bee4409..f7d82ac 100644 --- a/src/config.ts +++ b/src/config.ts @@ -72,6 +72,67 @@ export interface IAgentConfig { * true(기본): Reflector가 plan/research를 비판적으로 검토한 critique을 Writer에 주입. * false: 기존 3단계(Planner→Researcher→Writer) 그대로 — 1 LLM 호출 절약 (저성능 모델/저지연 우선 시). */ + /** + * Model id used by the 1인 기업 mode intent classifier (route message to + * pipeline vs casual chat). Empty → falls back to `defaultModel`. Recommended + * a fast small model (gemma e2b 등) so classification adds <1 s per send. + */ + companyIntentClassifierModel: string; + /** + * Bypass the intent classifier and always run the full pipeline. Legacy + * behaviour. Off by default because chat / question / thanks shouldn't + * dispatch all agents. + */ + companyDisableIntentClassifier: boolean; + /** + * 분류기가 추천한 파이프라인으로 *이번 turn만* 자동 전환할지. 켜면 + * 사용자가 명시적으로 활성화해 둔 파이프라인보다 분류기 추천이 우선. + * 끄면 분류기 추천은 채팅 라벨에만 표시되고 dispatch는 사용자 활성 + * 파이프라인 그대로. 처음 써 보는 사용자는 끈 채 추천만 보고 점차 + * 신뢰 생기면 켜는 흐름을 권장 — 기본 false. + */ + companyAutoSelectPipeline: boolean; + /** + * Intent Alignment 모드. new_task 발생 시 사용자 의도를 C-G-C-F-Q로 + * 정리하는 단계를 어떻게 다룰지. + * - 'off' : alignment 비활성. 분류기가 new_task 라고 하면 곧장 pipeline. + * - 'smart' : 기본값. confidence high면 자동 진행, medium/low면 사용자 확인. + * - 'strict' : confidence 무관 항상 사용자에게 contract 확인 카드 띄움. + */ + companyIntentAlignmentMode: 'off' | 'smart' | 'strict'; + /** alignment 라운드 최대 횟수 (질문→답변 사이클). 1~5. */ + companyIntentAlignmentMaxRounds: number; + /** + * Pixel Office 시각화 패널을 사이드바에 표시할지 여부. UI layer 전용 — + * 끈다고 Agent 행동이 바뀌지 않는다. Off면 webview는 패널을 숨기고 + * 백엔드도 broadcast 자체를 skip해서 자원 절약. + */ + /** + * Self-Reflector Phase A — 모든 LLM 응답 끝에 [Self-Reflector Check] + * 자기검증 블록을 자동으로 붙이게 한다. 추가 LLM 콜 없음. 본질적으로 + * 응답 품질 안전망이라 끌 이유는 적지만, 잡담 위주 환경에서 노이즈로 + * 느껴진다면 꺼둘 수 있다. + */ + selfReflectorEnabled: boolean; + /** + * Self-Reflector Phase B — 회사 모드 specialist 응답 직후 *분리된 콘텍스트* + * 에서 LLM 한 번 더 호출해 외부 시각으로 검증. 실패 시 1회 retry. 비용 + * 추가되므로 기본 OFF. + */ + selfReflectorExternalEnabled: boolean; + /** + * Self-Reflector Phase C — 코드 산출물에 한해 syntax/lint를 실제로 돌려 + * 실행 기반 검증. Python: py_compile, JS: node --check, TS: tsc --noEmit. + * 실패 시 에러를 응답에 첨부. 기본 OFF — 사용자 환경에 toolchain이 + * 깔려 있어야 의미가 있다. + */ + selfReflectorExecutionEnabled: boolean; + companyPixelOfficeEnabled: boolean; + /** + * Pixel Office의 캐릭터 말풍선 연출을 켤지. enabled가 true이고 이 값도 + * true일 때만 말풍선이 생성된다. 시끄럽게 느껴지면 사용자가 끌 수 있게. + */ + companyPixelOfficeBubbles: boolean; enableReflection: boolean; /** * [Self-Reflection → Knowledge] Reflector critique 중 의미 있는 발견을 brain의 @@ -166,6 +227,19 @@ export function getConfig(): IAgentConfig { knowledgeMixSecondBrainWeight: Math.max(0, Math.min(100, Math.round( cfg.get('knowledgeMix.secondBrainWeight', 50) ))), + companyIntentClassifierModel: (cfg.get('company.intentClassifierModel', '') || '').trim(), + companyDisableIntentClassifier: cfg.get('company.disableIntentClassifier', false), + companyAutoSelectPipeline: cfg.get('company.autoSelectPipeline', true), + companyIntentAlignmentMode: ((): 'off' | 'smart' | 'strict' => { + const v = (cfg.get('company.intentAlignmentMode', 'smart') || 'smart').trim().toLowerCase(); + return v === 'off' || v === 'strict' ? v : 'smart'; + })(), + companyIntentAlignmentMaxRounds: Math.max(1, Math.min(5, cfg.get('company.intentAlignmentMaxRounds', 3))), + selfReflectorEnabled: cfg.get('selfReflector.enabled', true), + selfReflectorExternalEnabled: cfg.get('selfReflector.externalVerification', false), + selfReflectorExecutionEnabled: cfg.get('selfReflector.executionVerification', false), + companyPixelOfficeEnabled: cfg.get('company.pixelOffice.enabled', true), + companyPixelOfficeBubbles: cfg.get('company.pixelOffice.bubbles', true), enableReflection: cfg.get('enableReflection', true), autoLessonFromReflection: cfg.get('autoLessonFromReflection', true), }; diff --git a/src/extension.ts b/src/extension.ts index c3bfe23..5d1d90b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -659,6 +659,11 @@ export async function activate(context: vscode.ExtensionContext) { vscode.window.showErrorMessage(`Sessions 폴더 열기 실패: ${e?.message ?? e}`); } }), + vscode.commands.registerCommand('g1nation.company.pixelOffice.open', () => { + // 사이드바 mini 패널과 별도로 editor area에 전체 사무실 뷰를 띄움. + // 같은 pixelOfficeUpdate 메시지 스트림을 공유하므로 백엔드 변경 최소. + provider?.openPixelOfficePanel(); + }), ); /** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind diff --git a/src/features/company/ceoPlanner.ts b/src/features/company/ceoPlanner.ts index e7cac8f..e6ede5a 100644 --- a/src/features/company/ceoPlanner.ts +++ b/src/features/company/ceoPlanner.ts @@ -227,12 +227,15 @@ export async function runCeoPlanner( ai: IAIService, userPrompt: string, state: CompanyState, - options: { model?: string; timeoutMs?: number } = {}, + options: { model?: string; timeoutMs?: number; contractBlock?: string } = {}, ): Promise { - const system = buildPlannerSystemPrompt( - applyPromptVars(CEO_PLANNER_PROMPT, { company: state.companyName }), - state, - ); + const baseSystem = applyPromptVars(CEO_PLANNER_PROMPT, { company: state.companyName }); + // Contract가 있으면 planner 시스템 프롬프트 끝에 prepend. planner는 task + // 리스트를 JSON으로 뽑으므로 contract를 보고 *적절한* task만 만들 수 있다. + const systemWithContract = options.contractBlock && options.contractBlock.trim() + ? `${baseSystem}\n\n${options.contractBlock.trim()}\n\n위 contract가 모든 dispatch 결정의 ground truth입니다.` + : baseSystem; + const system = buildPlannerSystemPrompt(systemWithContract, state); let raw = ''; try { const result = await ai.chat({ diff --git a/src/features/company/companyConfig.ts b/src/features/company/companyConfig.ts index 26444a7..f2f2aab 100644 --- a/src/features/company/companyConfig.ts +++ b/src/features/company/companyConfig.ts @@ -112,21 +112,40 @@ function _normalizeStage(raw: unknown): PipelineStage | null { const r = raw as Record; const id = typeof r.id === 'string' ? r.id.trim() : ''; const agentId = typeof r.agentId === 'string' ? r.agentId.trim() : ''; + const roleCategory = typeof r.roleCategory === 'string' + && VALID_ROLE_CATEGORIES.has(r.roleCategory as AgentRoleCategory) + ? (r.roleCategory as string) + : ''; const label = typeof r.label === 'string' && r.label.trim() ? r.label.trim() : id; - if (!_validId(id) || !agentId) return null; + if (!_validId(id)) return null; + // agentId 또는 roleCategory 둘 중 하나는 반드시 있어야 한다. + // 둘 다 없으면 dispatcher가 누구를 부를지 알 길이 없어 stage가 의미 없음. + if (!agentId && !roleCategory) return null; const out: PipelineStage = { - id, label, agentId, + id, label, instructionTemplate: typeof r.instructionTemplate === 'string' ? r.instructionTemplate : '', }; - if (typeof r.roleCategory === 'string' && VALID_ROLE_CATEGORIES.has(r.roleCategory as AgentRoleCategory)) { - out.roleCategory = r.roleCategory; - } + if (agentId) out.agentId = agentId; + if (roleCategory) out.roleCategory = roleCategory; if (typeof r.modelOverride === 'string' && r.modelOverride.trim()) { out.modelOverride = r.modelOverride.trim(); } if (r.requiresApproval === true) { out.requiresApproval = true; } + if (typeof r.reviewWith === 'string' && r.reviewWith.trim()) { + // 'inspector' / 'role:' / 'agent:' 형태만 허용. 그 외는 무시. + const rv = r.reviewWith.trim(); + const isInspectorShort = rv === 'inspector'; + const isRolePrefix = rv.startsWith('role:') && VALID_ROLE_CATEGORIES.has(rv.slice(5) as AgentRoleCategory); + const isAgentPrefix = rv.startsWith('agent:') && _validId(rv.slice(6)); + if (isInspectorShort || isRolePrefix || isAgentPrefix) { + out.reviewWith = rv; + } + } + if (typeof r.reviewMaxRounds === 'number' && Number.isFinite(r.reviewMaxRounds)) { + out.reviewMaxRounds = Math.max(1, Math.min(10, Math.round(r.reviewMaxRounds))); + } if (typeof r.loopBackPattern === 'string' && r.loopBackPattern.trim()) { out.loopBackPattern = r.loopBackPattern.trim(); } diff --git a/src/features/company/dispatcher.ts b/src/features/company/dispatcher.ts index 538eccc..012f071 100644 --- a/src/features/company/dispatcher.ts +++ b/src/features/company/dispatcher.ts @@ -40,6 +40,7 @@ import { buildKnowledgeMixPolicy, } from '../../retrieval/knowledgeMix'; import { + listActiveAgentsByCategory, modelForAgent, readCompanyState, resolveActivePipeline, resolveAgent, resolveCompanyKnowledgeMix, } from './companyConfig'; import { runCeoPlanner } from './ceoPlanner'; @@ -64,7 +65,11 @@ import { writeResumeState, } from './resumeStore'; import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport'; -import { AgentTurnOutput, CompanyResumeState, CompanyState, CompanyTaskPlan, PipelineDef, PipelineStage, SessionResult } from './types'; +import { + AgentRoleCategory, AgentTurnOutput, CompanyResumeState, CompanyState, CompanyTaskPlan, + PipelineDef, PipelineStage, RequirementContract, ROLE_CATEGORY_LABELS, SessionResult, +} from './types'; +import { formatContractForPrompt } from './intentAlignment'; /** Trim length applied when an agent's output is fed into the next agent. */ const PEER_OUTPUT_BUDGET = 1500; @@ -105,6 +110,28 @@ export type CompanyTurnEvent = | { phase: 'awaiting-approval'; stageId: string; stageLabel: string; index: number; total: number } /** Resolved approval — purely informational for the chat log. */ | { phase: 'approval-resolved'; stageId: string; decision: 'approve' | 'revise' | 'abort' } + /** + * 3-way 검수 사이클 시작 — 작업자 산출물 직후, 검수자/CEO 메타-판단을 + * 돌리기 직전에 emit. webview는 stage 카드 안에 라운드 누적 영역을 연다. + */ + | { phase: 'review-start'; stageId: string; stageLabel: string; maxRounds: number; inspectorAgentId: string } + /** + * 한 검수 라운드 결과. inspectorVerdict + ceoVerdict + 각자 코멘트를 + * 묶어 한 이벤트로. 라운드를 chat에서 한 줄씩 누적 표시 가능하다. + */ + | { + phase: 'review-round'; + stageId: string; + round: number; + inspectorAgentId: string; + inspectorText: string; + inspectorVerdict: 'pass' | 'revise' | 'unclear'; + ceoText: string; + ceoVerdict: 'pass' | 'revise' | 'abort' | 'unclear'; + durationMs: number; + } + /** 검수 사이클 종료. final = 마지막 라운드 verdict. */ + | { phase: 'review-end'; stageId: string; final: 'pass' | 'aborted' | 'maxed-out'; rounds: number } | { phase: 'report-start' } | { phase: 'report-done'; report: string; ok: boolean } /** @@ -160,6 +187,22 @@ export interface DispatcherDeps { * (so the dispatcher doesn't hang forever) */ awaitApproval?: (ctx: { stageId: string; stageLabel: string }) => Promise; + /** + * 이번 turn 한정으로 활성 파이프라인을 *override*. 비어 있으면 평소대로 + * `state.activePipelineId` 따른다. 의도 분류기의 `suggestedPipelineId` 또는 + * 사용자 키워드(`[파이프라인:id]`) 검출 시 chatHandlers가 채워서 넘긴다. + * 알 수 없는 id면 dispatcher가 silent fallback해서 legacy 동작 + * (state.activePipelineId 또는 CEO planner)로 진행. + */ + pipelineIdOverride?: string; + /** + * Intent Alignment 단계에서 사용자와 합의된 Requirement Contract. 있으면 + * CEO planner / specialist prompt / 검수자(inspector + CEO) prompt 전부에 + * 같은 ground truth로 주입되어 에이전트들이 추측 대신 contract를 따른다. + * 없으면 legacy 동작 — alignment 단계를 거치지 않았거나 사용자 모드가 + * 'off'였던 경우. + */ + requirementContract?: RequirementContract; } /** @@ -267,18 +310,34 @@ export async function runCompanyTurn( emit({ phase: 'plan-ready', plan, parsed: true, raw: '' }); } else { emit({ phase: 'plan-start' }); - pipeline = resolveActivePipeline(state); + // deps.pipelineIdOverride가 들어왔으면 *이번 turn만* 그 파이프라인을 쓴다. + // state.activePipelineId는 건드리지 않으므로 다음 라운드부턴 다시 사용자 + // 설정 따른다. override id가 유효한 파이프라인을 못 가리키면 silent fallback. + const overrideId = deps.pipelineIdOverride; + pipeline = overrideId + ? (state.pipelines?.[overrideId] ?? resolveActivePipeline(state)) + : resolveActivePipeline(state); if (pipeline) { // Pipeline mode: the user has authored a fixed sequence of stages. // We still surface a `plan` for the report writer and the session // summary — derived directly from the pipeline definition. plan = { brief: `[Pipeline: ${pipeline.name}] ${userPrompt.slice(0, 200)}`, - tasks: pipeline.stages.map((s) => ({ agent: s.agentId, task: s.label })), + // stage.agentId가 비어 있는 경우(CEO 동적 선택) 직군 라벨을 placeholder로 + // 표시 — plan은 사전 요약용이므로 실제 dispatch는 _runPipeline에서 결정. + tasks: pipeline.stages.map((s) => ({ + agent: s.agentId || (s.roleCategory ? `[직군:${s.roleCategory}]` : '[미정]'), + task: s.label, + })), }; } else { const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel); - const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel }); + const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { + model: ceoModel, + contractBlock: deps.requirementContract + ? formatContractForPrompt(deps.requirementContract) + : undefined, + }); plan = plannerResult.plan; plannerRaw = plannerResult.raw; plannerParsed = plannerResult.parsed; @@ -568,6 +627,11 @@ async function _dispatchOne( peerOutputs, brainContext, // injected as `[SECOND BRAIN CONTEXT]` block knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block + // alignment 단계에서 도출된 contract가 deps에 있으면 모든 specialist의 + // system 프롬프트에 같은 ground truth로 prepend된다. 추측 방지. + contractBlock: deps.requirementContract + ? formatContractForPrompt(deps.requirementContract) + : undefined, }); // 우선순위: stage > agent > global default. const model = (stageModelOverride && stageModelOverride.trim()) @@ -580,7 +644,62 @@ async function _dispatchOne( user: task, model, }); - const rawResponse = (result.content || '').trim(); + let rawResponse = (result.content || '').trim(); + + // ── Self-Reflector Phase B — 외부 검증 + 1회 retry ── + // 사용자가 selfReflector.externalVerification 켰을 때만 동작. 검증 LLM이 + // 'fail' 내면 issue를 task에 prepend해서 같은 specialist 1회 더 호출. + // 검증 자체가 실패하면(verifierError) 원본 응답을 그대로 보존하고 진행 — 안전망. + let verifierIssues: string[] = []; + let verifierSummary = ''; + try { + // dynamic import — Phase B는 옵션이므로 미사용 시 모듈 자체를 안 로드. + const { getConfig } = await import('../../config'); + const cfgRuntime = getConfig(); + if (cfgRuntime.selfReflectorExternalEnabled && rawResponse) { + const { verifyResponse, formatIssuesForRetry } = + await import('../selfReflector/selfReflectorVerifier'); + const { formatContractForPrompt } = await import('./intentAlignment'); + const contractBlock = deps.requirementContract + ? formatContractForPrompt(deps.requirementContract) + : undefined; + const verdict = await verifyResponse(deps.ai, { + task, + response: rawResponse, + agentName: def.name, + model, + contractBlock, + }); + verifierIssues = verdict.issues; + verifierSummary = verdict.summary; + logInfo('selfReflector.B: verdict.', { + agentId, verdict: verdict.verdict, issuesCount: verdict.issues.length, + }); + if (verdict.verdict === 'fail' && verdict.issues.length > 0) { + const retryTask = `${formatIssuesForRetry(verdict.issues)}\n\n[원래 지시]\n${task}`; + try { + const retryRes = await deps.ai.chat({ + system, user: retryTask, model, + }); + const retried = (retryRes.content || '').trim(); + if (retried) { + rawResponse = retried; + verifierSummary = `검증 fail → 1회 retry 적용 (${verdict.issues.length}개 지적 반영)`; + } + } catch (e: any) { + logError('selfReflector.B: retry call failed; keeping original.', { + agentId, error: e?.message ?? String(e), + }); + } + } + } + } catch (e: any) { + // Phase B 전체가 실패해도 dispatch 자체는 계속. + logError('selfReflector.B: hook failed; continuing without verification.', { + agentId, error: e?.message ?? String(e), + }); + } + // Apply ConnectAI's action-tag executor so ``, // ``, ``, etc. emitted by the agent actually // hit disk / shell. The report (e.g. "✅ Created: foo.py") is @@ -592,8 +711,93 @@ async function _dispatchOne( try { const report = await deps.executeActionTags(rawResponse); actionReport = report; - if (report.length > 0) { - finalResponse = `${rawResponse}\n\n---\n**Action 실행 결과:**\n${report.map((r) => `- ${r}`).join('\n')}`; + + // ── Self-Reflector Phase C — 생성/편집된 파일 syntax 체크 ── + // 사용자가 selfReflector.executionVerification 켰을 때만. 추가 + // report 항목들을 actionReport에 append + finalResponse 첨부 본문에도 반영. + try { + const { getConfig } = await import('../../config'); + const cfgRuntime = getConfig(); + if (cfgRuntime.selfReflectorExecutionEnabled && actionReport.length > 0) { + const { verifyCreatedFiles } = await import('../selfReflector/selfReflectorExecution'); + const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + if (projectRoot) { + const extra = await verifyCreatedFiles(actionReport, projectRoot); + if (extra.length > 0) { + actionReport = [...actionReport, ...extra]; + } + } + } + } catch (e: any) { + logError('selfReflector.C: hook failed; continuing without execution check.', { + agentId, error: e?.message ?? String(e), + }); + } + + // ── Self-Reflector Hollow Code Check (휴리스틱, LLM 콜 0) ── + // Phase C(syntax)가 잡지 못하는 *빈 깡통* 패턴을 정규식으로 잡는다. + // hollow 발견 → 1) actionReport에 ❌ 라인 추가 2) verifierIssues에 + // 합류시켜 Phase B retry 트리거 (혹은 Phase B OFF면 사용자에게 + // 경고만 표시). 작은 LLM이 가장 자주 만드는 실패 패턴이라 + // selfReflectorEnabled가 켜져 있으면 *조건부 자동 활성화*. + try { + const { getConfig } = await import('../../config'); + const cfgRuntime = getConfig(); + if (cfgRuntime.selfReflectorEnabled && actionReport.length > 0) { + const { verifyHollow } = await import('../selfReflector/selfReflectorHollow'); + const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + if (projectRoot) { + const hollowRes = verifyHollow(actionReport, projectRoot); + if (hollowRes.hasHollow) { + actionReport = [...actionReport, ...hollowRes.extraLines]; + // verifier가 켜져 있고 아직 retry 안 했다면 hollow를 issue로 + // 격상해서 자동 재작업 트리거. 켜져 있지 않으면 사용자에게 + // 경고만 노출(이미 actionReport에 들어감). + if (cfgRuntime.selfReflectorExternalEnabled && verifierIssues.length === 0) { + verifierIssues = hollowRes.hollowReasons.map((r) => `빈 깡통: ${r}`); + verifierSummary = `Hollow code 감지 — 자동 재시도 트리거`; + // 같은 specialist 1회 retry: 빈 깡통 지적을 task 앞에 prepend. + try { + const { formatIssuesForRetry } = await import('../selfReflector/selfReflectorVerifier'); + const retryTask = `${formatIssuesForRetry(verifierIssues)}\n\n[원래 지시]\n${task}`; + const retryRes = await deps.ai.chat({ system, user: retryTask, model }); + const retried = (retryRes.content || '').trim(); + if (retried) { + // 재작업 결과로 본문 갱신 + action-tag 다시 실행. + rawResponse = retried; + if (deps.executeActionTags && _hasActionTag(retried)) { + const retryReport = await deps.executeActionTags(retried); + actionReport = retryReport; + // 재작업 결과도 hollow 한 번 더 검사. + const reCheck = verifyHollow(retryReport, projectRoot); + if (reCheck.hasHollow) { + actionReport = [...actionReport, ...reCheck.extraLines]; + verifierSummary = `재작업 후에도 hollow 일부 잔존 — 사용자 확인 필요`; + } else { + verifierSummary = `Hollow 감지 → 재작업으로 해결`; + } + } + } + } catch (e: any) { + logError('selfReflector.hollow: retry call failed.', { + agentId, error: e?.message ?? String(e), + }); + } + } else if (!cfgRuntime.selfReflectorExternalEnabled) { + // verifier OFF — 사용자에게 경고만. + verifierSummary = `⚠️ Hollow code 감지 — externalVerification 켜면 자동 재시도`; + } + } + } + } + } catch (e: any) { + logError('selfReflector.hollow: check failed; continuing.', { + agentId, error: e?.message ?? String(e), + }); + } + + if (actionReport.length > 0) { + finalResponse = `${rawResponse}\n\n---\n**Action 실행 결과:**\n${actionReport.map((r) => `- ${r}`).join('\n')}`; } } catch (e: any) { // Surface the failure but keep the agent's text — partial @@ -619,6 +823,14 @@ async function _dispatchOne( // mark it as not-fully-successful so the CEO synthesis can read // the warning verbatim. const claimedButDidnt = rawResponse && !hasTag && _claimsFileCreation(rawResponse); + // 검증 요약을 response 끝에 한 줄로 첨부 — 사용자가 *어떻게 검증됐는지* + // 빠르게 보고 신뢰도 가늠. issues가 있으면 같이 노출. + if (verifierSummary) { + const issuesText = verifierIssues.length > 0 + ? '\n' + verifierIssues.map((i) => ` - ${i}`).join('\n') + : ''; + finalResponse = `${finalResponse}\n\n---\n**🔬 외부 검증:** ${verifierSummary}${issuesText}`; + } return { agentId, task, response: finalResponse, @@ -663,6 +875,282 @@ interface PipelineSeed { startIndex: number; } +/** + * Resolve which agent should run a given stage *right now*. + * + * Priority order: + * 1. `stage.agentId` is explicitly set → use that agent verbatim. The + * user pinned this stage to a specific person; honour it. + * 2. No agentId but `stage.roleCategory` → pull the active agents in + * that category. If exactly one is active, use them (saves an LLM + * call on the common case). If multiple, ask CEO via a single short + * JSON-shaped LLM call which is best fit for this *specific task*. + * 3. Neither — return null so the dispatcher can record an error and + * skip the stage cleanly. (normalize already rejects this case but + * we guard at runtime in case a stale state slipped through.) + * + * The LLM call is wrapped in try/catch with a `firstCandidate` fallback: + * a bad classifier response should never block the pipeline, just degrade + * to "first active agent in role". Caller decides whether to surface a + * note about who CEO chose; we just return `{ agentId, source, reason? }`. + */ +async function _resolveStageAgent( + stage: PipelineStage, + taskText: string, + state: CompanyState, + deps: DispatcherDeps, +): Promise<{ agentId: string; source: 'pinned' | 'sole-candidate' | 'ceo-selected' | 'fallback-first'; reason?: string } | null> { + if (stage.agentId && resolveAgent(state, stage.agentId)) { + return { agentId: stage.agentId, source: 'pinned' }; + } + const cat = stage.roleCategory as AgentRoleCategory | undefined; + if (!cat) return null; + const candidates = listActiveAgentsByCategory(state)[cat] ?? []; + if (candidates.length === 0) return null; + if (candidates.length === 1) { + return { agentId: candidates[0].id, source: 'sole-candidate' }; + } + // 다수 후보 → CEO에게 1회 LLM 콜로 결정. 시스템 프롬프트는 짧게, JSON만. + const catLabel = ROLE_CATEGORY_LABELS[cat] ?? cat; + const optionsBlock = candidates.map((c) => + `- id: ${c.id} | 이름: ${c.name} ${c.emoji}`).join('\n'); + const system = `당신은 1인 기업의 CEO입니다. 다음 task에 가장 적합한 *${catLabel}* 직군 구성원 한 명을 골라주세요.\n\n반드시 아래 JSON 한 줄만 출력. 다른 텍스트(설명, 펜스, 머리말) 일체 금지.\n{"agentId":"<선택한 id>","reason":"한 줄(40자 이내)"}`; + const user = `[현재 stage] ${stage.label || stage.id}\n[task]\n${taskText.slice(0, 600)}\n\n[후보]\n${optionsBlock}\n\n위 후보 중 task에 가장 적합한 한 명을 id로 골라 JSON 응답:`; + try { + const result = await deps.ai.chat({ + system, user, + model: modelForAgent(state, 'ceo', deps.defaultModel), + }); + const raw = (result.content || '').trim(); + // 가벼운 파서 — 코드펜스 / 잡문 제거 후 첫 {…} 추출. + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + const stage1 = (fenced ? fenced[1] : raw).trim(); + let picked: { agentId?: unknown; reason?: unknown } | null = null; + try { picked = JSON.parse(stage1); } catch { + const m = stage1.match(/\{[\s\S]*\}/); + if (m) { try { picked = JSON.parse(m[0]); } catch { /* fall through */ } } + } + const aid = typeof picked?.agentId === 'string' ? picked.agentId.trim() : ''; + if (aid && candidates.some((c) => c.id === aid)) { + const reason = typeof picked?.reason === 'string' ? picked.reason.trim() : ''; + return { agentId: aid, source: 'ceo-selected', reason }; + } + // 응답이 유효한 후보가 아님 → 첫 번째로 폴백. + logInfo('dispatcher: CEO selection invalid; falling back to first candidate.', { + stageId: stage.id, rawHead: raw.slice(0, 80), + }); + } catch (e: any) { + logError('dispatcher: CEO selection call failed; falling back.', { + stageId: stage.id, error: e?.message ?? String(e), + }); + } + return { agentId: candidates[0].id, source: 'fallback-first' }; +} + +/** + * 검수자(또는 직군)를 stage.reviewWith 값에 따라 한 명 결정. + * - 'inspector' / 'role:' → 해당 직군 활성 후보 중 첫 번째 + * - 'agent:' → 그 에이전트 (활성/비활성 무관) + * 후보가 없으면 null — 호출자가 검수 사이클을 skip. + */ +function _resolveInspector( + reviewWith: string, + state: CompanyState, +): { agentId: string } | null { + if (reviewWith === 'inspector') { + const list = listActiveAgentsByCategory(state)['inspector'] ?? []; + return list[0] ? { agentId: list[0].id } : null; + } + if (reviewWith.startsWith('role:')) { + const cat = reviewWith.slice(5) as AgentRoleCategory; + const list = listActiveAgentsByCategory(state)[cat] ?? []; + return list[0] ? { agentId: list[0].id } : null; + } + if (reviewWith.startsWith('agent:')) { + const id = reviewWith.slice(6); + return resolveAgent(state, id) ? { agentId: id } : null; + } + return null; +} + +/** + * 검수자 응답의 첫 줄에서 verdict를 끌어낸다. 작은 모델이 라벨 흐트러뜨릴 수 + * 있어 키워드 매칭으로 관대하게. 못 잡으면 'unclear' — 호출자가 안전한 쪽 + * (보통 'revise')으로 폴백. + */ +function _parseInspectorVerdict(text: string): 'pass' | 'revise' | 'unclear' { + const head = (text || '').split(/\n/, 1)[0] ?? ''; + if (/^\s*(?:✅|통과|승인|pass|approve|ok)/i.test(head)) return 'pass'; + if (/^\s*(?:❌|보완|재작업|revise|reject|fail|보완 필요)/i.test(head)) return 'revise'; + // 본문에 명확한 신호가 있으면 잡아냄 — 작은 모델이 머리말을 빠뜨리는 경우. + if (/✅\s*통과|모든 케이스 통과/.test(text)) return 'pass'; + if (/❌|보완 필요|재작업/.test(text)) return 'revise'; + return 'unclear'; +} + +function _parseCeoVerdict(text: string): 'pass' | 'revise' | 'abort' | 'unclear' { + const head = (text || '').split(/\n/, 1)[0] ?? ''; + if (/^\s*(?:✅|통과|approve|pass|최종\s*ok|진행)/i.test(head)) return 'pass'; + if (/^\s*(?:🔁|보완|한 번 더|revise|다시)/i.test(head)) return 'revise'; + if (/^\s*(?:🛑|중단|stop|abort|그만)/i.test(head)) return 'abort'; + if (/✅\s*통과/.test(text)) return 'pass'; + if (/🛑|중단/.test(text)) return 'abort'; + if (/🔁|보완|한 번 더/.test(text)) return 'revise'; + return 'unclear'; +} + +/** + * 3-way 합의 검수 사이클. 작업자 산출물(latestOutput)을 받고: + * 1. 검수자에게 보내 ✅/❌ 코멘트를 받음 + * 2. CEO에게 (산출물 + 검수자 코멘트)를 보내 ✅/🔁/🛑 메타-판단을 받음 + * 3. 검수자 ✅ + CEO ✅ → pass / 아니면 다음 라운드 / CEO 🛑 → 즉시 abort + * 4. 최대 라운드 도달 시 maxed-out (강제 통과로 처리하되 webview에 경고) + * + * Revise verdict 시 작업자에게 *어떤 부분을 고쳐야 하는지* 검수자 코멘트가 + * 그대로 전달돼야 하므로 revisionNotes 맵에 검수 코멘트를 채워 caller가 + * 사용자 코멘트와 동일한 메커니즘으로 stage 재실행하게 한다. + */ +async function _runReviewCycle(args: { + stage: PipelineStage; + stageTaskText: string; + latestOutput: AgentTurnOutput; + state: CompanyState; + deps: DispatcherDeps; + emit: CompanyTurnEmitter; + isAborted: () => boolean; +}): Promise<{ + verdict: 'pass' | 'revise' | 'abort' | 'maxed-out' | 'aborted'; + revisionNote?: string; + rounds: number; +}> { + const { stage, stageTaskText, latestOutput, state, deps, emit, isAborted } = args; + const reviewWith = stage.reviewWith || ''; + if (!reviewWith) return { verdict: 'pass', rounds: 0 }; + const inspector = _resolveInspector(reviewWith, state); + if (!inspector) { + // 검수자 못 찾으면 사이클 생략하고 통과로 처리 — 사용자에게 보이지 + // 않게 silent; 카드 에디터의 검수 dropdown에서 사용자가 직접 인지할 + // 수 있다. + logInfo('reviewCycle: no inspector resolvable; skipping.', { stageId: stage.id, reviewWith }); + return { verdict: 'pass', rounds: 0 }; + } + const maxRounds = Math.max(1, Math.min(10, stage.reviewMaxRounds ?? 3)); + emit({ + phase: 'review-start', + stageId: stage.id, + stageLabel: stage.label || stage.id, + maxRounds, + inspectorAgentId: inspector.agentId, + }); + let currentOutput = latestOutput; + let lastInspectorText = ''; + let lastInspectorVerdict: 'pass' | 'revise' | 'unclear' = 'unclear'; + let lastCeoText = ''; + let lastCeoVerdict: 'pass' | 'revise' | 'abort' | 'unclear' = 'unclear'; + for (let round = 1; round <= maxRounds; round++) { + if (isAborted()) { + emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round - 1 }); + return { verdict: 'aborted', rounds: round - 1 }; + } + const startedAt = Date.now(); + // contract가 있으면 검수자/CEO 모두에게 같은 ground truth를 prepend — + // 검수 기준이 contract와 일치하는지를 정확히 평가할 수 있다. + const contractPrefix = deps.requirementContract + ? formatContractForPrompt(deps.requirementContract) + '\n\n' + : ''; + + // ── 1) 검수자 LLM 콜 ── + const inspectorSystem = contractPrefix + '당신은 산출물 *감리*입니다. 작업자의 결과물을 객관적으로 검토하고 한국어 마크다운으로 응답하세요.\n\n반드시 첫 줄을 다음 둘 중 하나로 시작:\n - ✅ 통과 — 산출물이 task 요구 + 위 contract의 criteria를 모두 충족하면.\n - ❌ 보완 필요: <구체 항목 한 줄> — contract 기준 누락·오류·약점이 있으면.\n\n그 다음 줄들에 *구체적인* 피드백 또는 칭찬 1~3줄. 모호한 일반론 금지.'; + const inspectorUser = `[현재 stage] ${stage.label || stage.id}\n[task]\n${stageTaskText.slice(0, 1500)}\n\n[작업자 산출물]\n${(currentOutput.response || '').slice(0, 3000)}`; + let inspectorText = ''; + try { + const res = await deps.ai.chat({ + system: inspectorSystem, + user: inspectorUser, + model: modelForAgent(state, inspector.agentId, deps.defaultModel), + }); + inspectorText = (res.content || '').trim(); + } catch (e: any) { + logError('reviewCycle: inspector call failed.', { stageId: stage.id, round, err: e?.message ?? String(e) }); + inspectorText = `❌ 보완 필요: 검수자 호출 실패 (${e?.message ?? '알 수 없음'}) — 안전을 위해 한 번 더 시도`; + } + lastInspectorText = inspectorText; + lastInspectorVerdict = _parseInspectorVerdict(inspectorText); + + if (isAborted()) { + emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round }); + return { verdict: 'aborted', rounds: round }; + } + + // ── 2) CEO 메타-판단 ── + const ceoSystem = contractPrefix + '당신은 회사 CEO입니다. 작업자 산출물 + 검수자 의견을 보고 *세 명이 모두 만족하는지* 메타-판단을 내립니다. 위 contract 기준에 부합하는지가 핵심.\n\n반드시 첫 줄을 다음 셋 중 하나로 시작:\n - ✅ 통과 — 산출물·검수가 contract criteria를 모두 충족.\n - 🔁 보완 — contract 기준 한 가지 이상 미흡. 작업자에게 줄 구체 지시 1~3줄.\n - 🛑 중단 — 라운드 더 돌아도 의미 없음. 사장님께 현 상태로 보고.'; + const ceoUser = `[stage] ${stage.label || stage.id}\n[task]\n${stageTaskText.slice(0, 1000)}\n\n[작업자 산출물]\n${(currentOutput.response || '').slice(0, 2000)}\n\n[검수자 의견]\n${inspectorText.slice(0, 1500)}\n\n[지금 라운드: ${round}/${maxRounds}]`; + let ceoText = ''; + try { + const res = await deps.ai.chat({ + system: ceoSystem, + user: ceoUser, + model: modelForAgent(state, 'ceo', deps.defaultModel), + }); + ceoText = (res.content || '').trim(); + } catch (e: any) { + logError('reviewCycle: CEO meta call failed.', { stageId: stage.id, round, err: e?.message ?? String(e) }); + ceoText = lastInspectorVerdict === 'pass' ? '✅ 통과' : '🔁 보완'; + } + lastCeoText = ceoText; + lastCeoVerdict = _parseCeoVerdict(ceoText); + + emit({ + phase: 'review-round', + stageId: stage.id, + round, + inspectorAgentId: inspector.agentId, + inspectorText, + inspectorVerdict: lastInspectorVerdict, + ceoText, + ceoVerdict: lastCeoVerdict, + durationMs: Date.now() - startedAt, + }); + + // ── 3) 합의 판정 ── + // 검수자 ✅ + CEO ✅ → 통과. CEO 🛑 → 즉시 중단. 그 외 → 다음 라운드. + // unclear는 안전한 쪽(revise)으로 폴백. + if (lastInspectorVerdict === 'pass' && lastCeoVerdict === 'pass') { + emit({ phase: 'review-end', stageId: stage.id, final: 'pass', rounds: round }); + return { verdict: 'pass', rounds: round }; + } + if (lastCeoVerdict === 'abort') { + emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round }); + return { verdict: 'abort', rounds: round }; + } + // revise — 다음 라운드 진입 전 작업자에게 줄 코멘트 합성. + const note = [ + `[검수자 ${inspector.agentId}] ${inspectorText.slice(0, 600)}`, + `[CEO 메타] ${ceoText.slice(0, 400)}`, + ].join('\n\n'); + // 마지막 라운드 직전이라면 더 이상 작업자를 부를 일 없음 — 그냥 maxed-out. + if (round >= maxRounds) { + emit({ phase: 'review-end', stageId: stage.id, final: 'maxed-out', rounds: round }); + return { verdict: 'maxed-out', revisionNote: note, rounds: round }; + } + // 작업자 재실행: caller가 stage를 다시 dispatch하도록 revisionNote 전달. + // 그런데 사이클은 한 단위(검수+CEO)를 caller 밖에서 끝나야 하므로 여기서 + // 직접 작업자 재실행 → 새 currentOutput 갱신. + const reDispatchTask = `[검수 피드백 — ${round}라운드]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${stageTaskText}`; + emit({ phase: 'agent-start', agentId: currentOutput.agentId, task: reDispatchTask, index: -1, total: maxRounds }); + const reTurn = await _dispatchOne(currentOutput.agentId, reDispatchTask, [], state, deps, stage.modelOverride); + emit({ phase: 'agent-done', agentId: currentOutput.agentId, output: reTurn, index: -1, total: maxRounds }); + currentOutput = reTurn; + } + // 정상 흐름에선 위 break 조건 중 하나로 빠지지만 안전망으로: + emit({ phase: 'review-end', stageId: stage.id, final: 'maxed-out', rounds: maxRounds }); + return { + verdict: 'maxed-out', + revisionNote: `[검수자 ${inspector.agentId}] ${lastInspectorText.slice(0, 600)}\n\n[CEO 메타] ${lastCeoText.slice(0, 400)}`, + rounds: maxRounds, + }; +} + /** _runPipeline이 매 stage 직후 호출하는 commit 콜백의 payload. */ export interface PipelineCommit { outputs: AgentTurnOutput[]; @@ -740,20 +1228,77 @@ async function _runPipeline( const task = note ? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}` : baseTask; - emit({ phase: 'agent-start', agentId: stage.agentId, task, index: stepIndex, total }); - const turn = await _dispatchOne(stage.agentId, task, outputs, state, deps, stage.modelOverride); + // 동적 담당자 해결. stage.agentId가 박혀 있으면 그걸 쓰고, 비어 있으면 + // CEO가 직군 후보 중에서 1회 LLM 콜로 적임자 선택. 모든 후보가 비활성/없음 + // 이면 null — 그 경우 stage를 에러로 마킹하고 건너뛴다(파이프라인 hang 방지). + const picked = await _resolveStageAgent(stage, task, state, deps); + if (!picked) { + const errOutput: AgentTurnOutput = { + agentId: stage.agentId || `<${stage.roleCategory ?? 'unknown'}>`, + task, + response: `⚠️ 이 단계에 배정할 활성 에이전트가 없습니다 (직군: ${stage.roleCategory ?? '미지정'}). 관리 패널에서 해당 직군의 에이전트를 활성화하거나, stage에 직접 담당자를 지정하세요.`, + durationMs: 0, + error: 'no-active-agent-in-role', + }; + outputs.push(errOutput); + latestByStage[stage.id] = errOutput; + writeAgentOutput(sessionDir, errOutput); + emit({ phase: 'agent-done', agentId: errOutput.agentId, output: errOutput, index: stepIndex, total }); + stepIndex++; + i++; + continue; + } + const resolvedAgentId = picked.agentId; + // CEO 선택 시 사용자에게 *왜 이 사람*인지 한 줄로 보여주기 위해 task 앞에 + // 짧은 메타 한 줄을 prepend — 에이전트 시스템 프롬프트엔 영향 없고 chat + // 카드 표시에만 쓰인다. + let taskForChat = task; + if (picked.source === 'ceo-selected' && picked.reason) { + taskForChat = `[🧭 CEO 선임: ${picked.reason}]\n\n${task}`; + } + emit({ phase: 'agent-start', agentId: resolvedAgentId, task: taskForChat, index: stepIndex, total }); + const turn = await _dispatchOne(resolvedAgentId, task, outputs, state, deps, stage.modelOverride); outputs.push(turn); latestByStage[stage.id] = turn; writeAgentOutput(sessionDir, turn); appendAgentMemory( - deps.context, stage.agentId, + deps.context, resolvedAgentId, `[${timestamp}][${pipeline.id}/${stage.id}] ${task.slice(0, 120)} — ${turn.error ? `❌ ${turn.error}` : '✅'}`, ); - emit({ phase: 'agent-done', agentId: stage.agentId, output: turn, index: stepIndex, total }); + emit({ phase: 'agent-done', agentId: resolvedAgentId, output: turn, index: stepIndex, total }); stepIndex++; // Successful run consumed the revision note (if any) — clear it. if (!turn.error) delete revisionNotes[stage.id]; + // ── 3-way 검수 사이클 ── + // 작업자가 에러 없이 응답을 냈고, stage에 reviewWith가 설정돼 있으면 + // 검수자 + CEO 메타-판단 사이클로 합의를 도출. 합의 실패 시: + // - revise/maxed-out: 검수 코멘트를 revisionNote로 받아 stage 재실행 + // (loop-back과 동일한 메커니즘 재활용) + // - abort: 사용자에게 알리고 라운드 종료 + if (stage.reviewWith && !turn.error) { + const reviewResult = await _runReviewCycle({ + stage, + stageTaskText: task, + latestOutput: turn, + state, deps, emit, isAborted, + }); + if (reviewResult.verdict === 'aborted') { + return abortReturn('aborted-during-review'); + } + if (reviewResult.verdict === 'abort') { + return abortReturn('aborted-by-ceo-review'); + } + // revise / maxed-out — 모두 작업자에게 다시 보내 한 번 더 (loop-back). + // 단, maxed-out은 사용자에게 "한계 도달, 마지막 결과로 진행"을 알려야 + // 더 자연스러우므로 다음 stage로 그대로 진행 (revisionNote 무시). + if (reviewResult.verdict === 'revise' && reviewResult.revisionNote) { + revisionNotes[stage.id] = reviewResult.revisionNote; + continue; // 같은 stage 재실행 — while(i)는 그대로 + } + // pass / maxed-out → 다음 단계로 진행 (revisionNotes 클리어는 위에서 이미) + } + // ── Manual approval gate ── // After agent-done emits, before loop-back / next stage advance, // give the user a chance to inspect and approve. We only fire the diff --git a/src/features/company/index.ts b/src/features/company/index.ts index 07e970a..e5c8b23 100644 --- a/src/features/company/index.ts +++ b/src/features/company/index.ts @@ -89,3 +89,18 @@ export { listSessions, resolveCompanyBase, } from './sessionStore'; + +export { classifyChatIntent } from './intentClassifier'; +export type { ChatIntent, IntentContext, IntentResult, PipelineHint } from './intentClassifier'; + +export { analyzeIntent, formatContractForPrompt } from './intentAlignment'; +export type { IntentAnalysisInput, IntentAnalysisResult } from './intentAlignment'; +export type { RequirementContract } from './types'; + +export { + getStatusBubbleText, getEventBubbleText, eventBubbleType, makeBubble, +} from './pixelOfficeState'; +export type { + AgentStatus, AgentEvent, AgentBubble, AgentWorkState, + PixelOfficeConfig, BubbleType, +} from './pixelOfficeState'; diff --git a/src/features/company/intentAlignment.ts b/src/features/company/intentAlignment.ts new file mode 100644 index 0000000..67fc69d --- /dev/null +++ b/src/features/company/intentAlignment.ts @@ -0,0 +1,334 @@ +/** + * Intent Alignment — 사용자의 자연어 요청을 *실행 가능한 작업 조건*으로 변환. + * + * 사용자는 자기 의도와 배경지식이 에이전트에게 충분히 전달되었다고 착각하는 + * 경향이 있다 (투명성의 착각·지식의 저주·공통 기반 부족). 그래서 에이전트가 + * 즉시 작업에 돌입하면 사용자가 머릿속에 가진 것과 다른 결과를 만들어 낸다. + * + * 이 모듈은 그 격차를 메꾸는 한 단계 앞 절차다. 사용자가 던진 한 줄을 받아 + * `RequirementContract` 5필드(C-G-C-F-Q) 로 채우고, 채우다가 비는 자리가 + * 있으면 *추측하지 말고* 사용자에게 되묻는다. 분석기 자체는 LLM 한 번 호출로 + * 끝난다; 추가 라운드(되묻기→답변→재분석)는 호출자(상태 머신, Phase B)가 + * 관리한다. + * + * 출력 형식은 dispatcher의 다른 모듈(planner/promptBuilder/reviewer)이 모두 + * 같은 ground truth로 contract를 읽어 가는 것이 목표라, 필드 이름과 의미는 + * `types.ts`의 `RequirementContract`와 1:1로 맞췄다. + */ +import { IAIService } from '../../core/services'; +import { logError, logInfo } from '../../utils'; +import { RequirementContract } from './types'; + +/** + * 분석 한 회차의 결과. contract는 항상 채워서 돌아오고, 추가 정보가 필요한 + * 경우만 confidence가 medium/low이고 openQuestions가 비어 있지 않다. 호출자가 + * 사용자에게 보여주고 답을 받아 다음 라운드의 `previousAnswers`로 넣어주면 + * 같은 함수가 갱신된 contract를 반환한다. + */ +export interface IntentAnalysisResult { + contract: RequirementContract; + /** Raw LLM body — 디버그 로그 / 카드에 raw 안 보여줄 거지만 남겨 둠. */ + raw: string; + /** JSON 파싱 성공 여부. false면 contract는 fallback 값(원문만 채워진 상태). */ + parsed: boolean; +} + +/** + * 호출자가 한 라운드의 컨텍스트로 넘기는 입력. `previousAnswers`는 직전 + * 라운드에서 사용자가 답한 질문/응답 쌍이며 LLM이 그걸 반영해 contract를 + * 다시 채운다. `previousContract`는 직전 분석의 결과 — 분석기는 보통 이걸 + * 출발점으로 부족분만 보강한다. + */ +export interface IntentAnalysisInput { + userOriginalPrompt: string; + /** 직전 라운드의 사용자 응답들. 첫 라운드면 빈 배열. */ + previousAnswers?: Array<{ q: string; a: string }>; + /** 직전 라운드 contract (있으면 부분 갱신을 유도). */ + previousContract?: RequirementContract; + /** 활성 파이프라인 이름 — 분석기가 format 추정에 사용 가능. */ + activePipelineName?: string; + /** + * 활성 직군 목록 — "이 회사가 어떤 일들을 할 수 있나"를 분석기가 알면 + * goal/format을 그쪽 능력에 맞춰 추출할 수 있다. + */ + availableRoleCategories?: string[]; +} + +const SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *요청 분석가*입니다. 사용자의 자연어 요청을 받아 그것을 실행 가능한 작업 조건 5가지(C-G-C-F-Q)로 정리합니다. + + - context : 현재 상황·프로젝트 맥락 (한 단락 또는 빈 문자열). + - goal : 사용자가 *결과로* 달성하려는 것 (1~2 문장). + - criteria : 좋은 결과의 판단 기준들. 측정 가능하면 더 좋음. 최대 4개. + - format : 원하는 산출물의 형식 (예: "마크다운 기획서", "Python 단일 파일", "JSON + 짧은 요약"). + - openQuestions : 채워지지 않아 사용자에게 *물어봐야* 할 질문들. 최대 3개. 정말 결정적인 것만. + +⚠️ 추측 금지. 사용자의 한 줄 + 컨텍스트에서 *직접 추론*되지 않는 정보는 채우지 마세요. 빈 칸은 그대로 두고 그 자리에 대응하는 질문을 openQuestions에 넣으세요. + +confidence는 다음 기준으로 자체 판정: + - "high" : C·G·C·F 4개 모두 prompt에서 직접 추론 가능. openQuestions = [] 가능. + - "medium" : 대체로 명확하지만 1~2개 항목에서 합리적 가정 필요. 추가 질문 1~2개. + - "low" : 핵심 정보(특히 goal 또는 format)가 빠짐. 질문 2~3개. + +직전 라운드 답변이 있으면 그 내용을 반영해 contract를 *갱신*하세요. 같은 질문을 다시 묻지 마세요. + +⚠️ 반드시 아래 JSON 한 번만 출력. 다른 텍스트(설명·코드펜스·머리말) 일체 금지. + +{ + "context": "<문자열 또는 빈값>", + "goal": "<문자열 또는 빈값>", + "criteria": ["<항목1>", "<항목2>", ...], + "format": "<문자열 또는 빈값>", + "openQuestions": ["<질문1>", "<질문2>", ...], + "confidence": "low"|"medium"|"high" +}`; + +function _buildUserMessage(input: IntentAnalysisInput): string { + const lines: string[] = []; + lines.push('[사용자 원본 요청]'); + lines.push(input.userOriginalPrompt); + if (input.activePipelineName) { + lines.push(''); + lines.push(`(활성 파이프라인) "${input.activePipelineName}"`); + } + if (input.availableRoleCategories && input.availableRoleCategories.length > 0) { + lines.push(`(이 회사 가능 직군) ${input.availableRoleCategories.join(', ')}`); + } + if (input.previousContract) { + const c = input.previousContract; + lines.push(''); + lines.push('[직전 라운드까지 도출된 contract]'); + lines.push(`context: ${c.context || '(미)'}`); + lines.push(`goal: ${c.goal || '(미)'}`); + lines.push(`criteria: ${c.criteria.length ? c.criteria.join(' | ') : '(미)'}`); + lines.push(`format: ${c.format || '(미)'}`); + } + if (input.previousAnswers && input.previousAnswers.length > 0) { + lines.push(''); + lines.push('[사용자가 직전 라운드에 답한 내용]'); + for (const qa of input.previousAnswers) { + lines.push(`- Q: ${qa.q}`); + lines.push(` A: ${qa.a}`); + } + lines.push('위 답변을 반영해 contract를 갱신하고 새 openQuestions를 적되, 이미 답을 받은 질문은 *다시 묻지 마세요*.'); + } + lines.push(''); + lines.push('분석 JSON만 출력:'); + return lines.join('\n'); +} + +/** + * 4-stage 관용 파서. intentClassifier와 동일 패턴 — 작은 모델이 펜스/머리말 + * 흔히 추가하므로 strict JSON.parse 한 번만 시도하면 절반 가까이 놓친다. + */ +function _parseAnalysisJson(raw: string): { + context: string; + goal: string; + criteria: string[]; + format: string; + openQuestions: string[]; + confidence: 'low' | 'medium' | 'high'; +} | null { + if (!raw || !raw.trim()) return null; + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + const stage1 = (fenced ? fenced[1] : raw).trim(); + try { + const obj = JSON.parse(stage1); + const c = _coerce(obj); + if (c) return c; + } catch { /* fall through */ } + const balanced = _extractFirstBalancedObject(stage1); + if (balanced) { + try { + const obj = JSON.parse(balanced); + const c = _coerce(obj); + if (c) return c; + } catch { /* fall through */ } + } + return null; +} + +function _coerce(obj: unknown): ReturnType { + if (!obj || typeof obj !== 'object') return null; + const o = obj as Record; + const context = typeof o.context === 'string' ? o.context.trim() : ''; + const goal = typeof o.goal === 'string' ? o.goal.trim() : ''; + const format = typeof o.format === 'string' ? o.format.trim() : ''; + const criteria = Array.isArray(o.criteria) + ? o.criteria.filter((c): c is string => typeof c === 'string' && c.trim().length > 0) + .map((c) => c.trim()).slice(0, 6) + : []; + const openQuestions = Array.isArray(o.openQuestions) + ? o.openQuestions.filter((q): q is string => typeof q === 'string' && q.trim().length > 0) + .map((q) => q.trim()).slice(0, 4) + : []; + const conf = typeof o.confidence === 'string' ? o.confidence.trim().toLowerCase() : ''; + const confidence: 'low' | 'medium' | 'high' = + conf === 'high' ? 'high' : conf === 'medium' ? 'medium' : 'low'; + return { context, goal, criteria, format, openQuestions, confidence }; +} + +function _extractFirstBalancedObject(s: string): string | null { + const start = s.indexOf('{'); + if (start === -1) return null; + let depth = 0; + let inString = false; + let escape = false; + for (let i = start; i < s.length; i++) { + const ch = s[i]; + if (inString) { + if (escape) escape = false; + else if (ch === '\\') escape = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') { inString = true; continue; } + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) return s.slice(start, i + 1); + } + } + return null; +} + +/** + * End-to-end 분석 호출. 절대 throw 하지 않는다 — 호출 실패 / 파싱 실패 시 + * confidence='low' + 원문만 채워진 contract를 돌려서 호출자가 안전하게 + * "더 물어봐야 함" 흐름으로 진입할 수 있게 한다. 즉 실패가 *추측 진행*으로 + * 미끄러지지 않게 한다 — 이 기능의 본질이 추측 방지이므로. + */ +export async function analyzeIntent( + ai: IAIService, + input: IntentAnalysisInput, + options: { model?: string; timeoutMs?: number } = {}, +): Promise { + const prompt = input.userOriginalPrompt.trim(); + if (!prompt) { + return { + contract: _fallbackContract(input.userOriginalPrompt, [ + '요청 내용이 비어 있습니다. 무엇을 만들고 싶으신가요?', + ]), + raw: '', + parsed: false, + }; + } + let raw = ''; + try { + const result = await ai.chat({ + system: SYSTEM_PROMPT, + user: _buildUserMessage(input), + model: options.model, + timeoutMs: options.timeoutMs, + }); + raw = result.content || ''; + } catch (e: any) { + logError('intentAlignment: analyzer call failed; falling back to low-conf.', { + error: e?.message ?? String(e), + }); + return { + contract: _fallbackContract(input.userOriginalPrompt, [ + '요청을 더 구체적으로 알려주실 수 있을까요? (분석기 호출 실패)', + ], input.previousAnswers), + raw, + parsed: false, + }; + } + const parsed = _parseAnalysisJson(raw); + if (!parsed) { + logInfo('intentAlignment: parse failed; falling back to low-conf.', { + rawHead: raw.slice(0, 100), + }); + return { + contract: _fallbackContract(input.userOriginalPrompt, [ + '요청을 더 구체적으로 풀어 설명해 주세요.', + ], input.previousAnswers), + raw, + parsed: false, + }; + } + // 이미 사용자가 답한 질문이 새 openQuestions에 다시 끼어 있으면 제거 — 동일 + // 텍스트 비교는 작은 모델이 약간씩 다르게 바꿔 적어 잡기 어렵지만, 정확한 + // 중복은 흔하므로 헬퍼로 1차 거름. + const askedAlready = new Set((input.previousAnswers ?? []).map((a) => a.q.trim())); + const openQuestions = parsed.openQuestions.filter((q) => !askedAlready.has(q.trim())); + + const contract: RequirementContract = { + userOriginalPrompt: input.userOriginalPrompt, + context: parsed.context, + goal: parsed.goal, + criteria: parsed.criteria, + format: parsed.format, + answeredQuestions: input.previousAnswers ? [...input.previousAnswers] : [], + openQuestions, + // 사용자가 한 라운드 이상 답해줬으면 confidence를 한 단계 끌어올리는 + // 사후 보정 — 그래야 분석기가 보수적으로 'low'를 고집해도 사용자가 + // 추가 정보를 줬다는 사실이 반영된다. + confidence: _adjustConfidence(parsed.confidence, parsed.openQuestions.length, input.previousAnswers?.length ?? 0), + }; + return { contract, raw, parsed: true }; +} + +function _adjustConfidence( + base: 'low' | 'medium' | 'high', + openCount: number, + answeredCount: number, +): 'low' | 'medium' | 'high' { + // 한 라운드 이상 답을 받았는데 분석기가 여전히 low면 medium으로 한 단계만 올림. + // 답 한 번에 high로 점프하면 사용자 확인 단계를 너무 빨리 건너뜀. + if (answeredCount >= 1 && base === 'low') return 'medium'; + // openQuestions가 모두 비었으면 medium → high 승격(분석기가 보수적인 경우 보정). + if (openCount === 0 && base === 'medium' && answeredCount > 0) return 'high'; + return base; +} + +function _fallbackContract( + prompt: string, + questions: string[], + answered?: Array<{ q: string; a: string }>, +): RequirementContract { + return { + userOriginalPrompt: prompt, + context: '', + goal: '', + criteria: [], + format: '', + answeredQuestions: answered ? [...answered] : [], + openQuestions: questions, + confidence: 'low', + }; +} + +/** + * Contract를 LLM 시스템 프롬프트에 끼울 수 있는 마크다운 블록으로 직렬화. + * Phase D에서 planner/specialist/reviewer가 모두 이걸 그대로 prepend. + * 빈 필드는 "(미)" 로 명시 — 누락이 LLM 시야에서도 *명시적 부재*가 되도록. + */ +export function formatContractForPrompt(contract: RequirementContract): string { + const lines: string[] = []; + lines.push('## [REQUIREMENT CONTRACT — 사용자와 사전 합의된 작업 조건]'); + lines.push(`- **원본 요청**: ${contract.userOriginalPrompt}`); + lines.push(`- **맥락 (Context)**: ${contract.context || '(미)'}`); + lines.push(`- **목표 (Goal)**: ${contract.goal || '(미)'}`); + if (contract.criteria.length > 0) { + lines.push('- **판단 기준 (Criteria)**:'); + for (const c of contract.criteria) lines.push(` - ${c}`); + } else { + lines.push('- **판단 기준 (Criteria)**: (미)'); + } + lines.push(`- **산출 형식 (Format)**: ${contract.format || '(미)'}`); + if (contract.answeredQuestions.length > 0) { + lines.push('- **확인된 응답**:'); + for (const qa of contract.answeredQuestions) { + lines.push(` - Q: ${qa.q}`); + lines.push(` A: ${qa.a}`); + } + } + if (contract.openQuestions.length > 0) { + lines.push('- **미해결 질문 (사용자가 답 안 받아 보수적으로 처리)**:'); + for (const q of contract.openQuestions) lines.push(` - ${q}`); + } + lines.push(`- **신뢰도**: ${contract.confidence}`); + lines.push(''); + lines.push('위 contract가 모든 판단의 ground truth입니다. 추측이나 contract 외 가정을 추가하지 마세요. 미해결 항목이 작업에 결정적이라면 산출물에 "이 부분은 보수적으로 처리했습니다"라고 명시.'); + return lines.join('\n'); +} diff --git a/src/features/company/intentClassifier.ts b/src/features/company/intentClassifier.ts new file mode 100644 index 0000000..88ce902 --- /dev/null +++ b/src/features/company/intentClassifier.ts @@ -0,0 +1,348 @@ +/** + * Intent classifier for 1인 기업 모드 chat input. + * + * The company mode used to route *every* chat message through the full + * dispatcher (CEO planner → specialists → CEO synthesis). That meant + * casual messages like "고마워", "방금 그거 다시 보여줘", "이 파일 뭐 하는 + * 거야?" all kicked off a multi-agent round — wasteful at best, confusing + * at worst because the user expects ordinary chat to behave like ordinary + * chat regardless of which mode the chip is in. + * + * This module runs *one* small-model LLM call per message and decides: + * - `chat` — greeting / thanks / generic question → answer briefly + * - `followup` — refers to the previous round ("그거 다시", "어떻게 됐어?") + * - `new_task` — a fresh work request → run the pipeline + * + * The caller (`chatHandlers`) uses the verdict to route. If the LLM call + * fails for any reason we fall back to `new_task` so we never *silently* + * eat a real work request — the worst-case is "the classifier misfires + * and we run a pipeline we didn't need", same as the old behaviour. + * + * Returns `intent`, plus a one-line `reason` from the LLM and the raw + * response for debug. The reason is shown in the chat label so the user + * can tell *why* their message was treated as chat vs. a task. + */ +import { IAIService } from '../../core/services'; +import { logError, logInfo } from '../../utils'; + +export type ChatIntent = 'chat' | 'followup' | 'new_task'; + +export interface IntentResult { + intent: ChatIntent; + /** One-line Korean explanation from the classifier (or a fallback note). */ + reason: string; + /** Raw LLM body — kept for the debug log. */ + raw: string; + /** True iff the JSON parse succeeded. False means we fell back to default. */ + parsed: boolean; + /** + * 분류기가 새 task에 적합하다고 본 파이프라인 id. `new_task` 결과에서만 + * 의미 있고, classifier가 추천 안 했거나 컨텍스트에 후보가 없으면 + * undefined. 호출자(chatHandlers)는 사용자 설정 + * (`companyAutoSelectPipeline`)이 켜져 있을 때만 이 값을 활용해 dispatch + * 시점에 활성 파이프라인을 일시 override; 평소엔 사용자가 명시적으로 + * 활성화해 둔 파이프라인을 그대로 존중. + */ + suggestedPipelineId?: string; +} + +/** 분류기가 컨텍스트로 받는 파이프라인 후보 한 줄. */ +export interface PipelineHint { + id: string; + name: string; + /** Short description shown to the classifier so it can pick the right one. */ + description?: string; + /** Number of stages — helps the classifier judge "is this overkill?". */ + stageCount: number; +} + +/** + * Context passed in from the caller. All fields optional — empty context is + * the cold-start case (no prior turn yet). + */ +export interface IntentContext { + /** Brief from the previous turn, if any. */ + previousBrief?: string; + /** Tail of the previous CEO report (truncated by caller). */ + previousReportTail?: string; + /** ISO timestamp of when the previous turn ended, for staleness hints. */ + previousTurnAt?: number; + /** Whether a pipeline is currently configured + active. Tweaks the prompt. */ + activePipelineName?: string; + /** + * 분류기가 골라 추천할 후보 파이프라인 리스트. autoSelectPipeline이 + * 켜져 있을 때만 의미가 있다. 호출자가 비워 보내면 classifier도 추천 + * 시도조차 안 함. + */ + availablePipelines?: PipelineHint[]; +} + +const SYSTEM_PROMPT_BASE = `당신은 "1인 기업 모드"의 메시지 분류기입니다. 사용자가 방금 보낸 한 줄이 다음 중 무엇인지 한 번에 정확히 판정하세요. + + - "chat" : 인사·감사·잡담·짧은 질문·간단한 정보 요청. 새 프로젝트가 아님. + - "followup" : 직전 라운드의 산출물·과정을 가리키는 발화. "그거 다시", "어디까지 했어", "그 결과 보여줘", "방금 그 파일 열어줘" 등. + - "new_task" : 새로 시작할 *업무*. 기획·개발·디자인·리서치·QA 등 여러 단계가 필요한 작업 요청. 한 단어라도 "만들어줘"·"기획해줘"·"개발해줘"·"분석해줘" 같이 명확한 새 업무이면 new_task. + +판단 기준: +- 직전 라운드 컨텍스트가 있고 사용자 발화가 그것을 가리키면 followup. +- 직전 라운드가 없거나(첫 메시지) 직전 내용과 무관하고 새 결과물을 *만들어 달라* 요구이면 new_task. +- 위 두 가지가 아니거나 모호하면 chat. +- 사용자가 명시적으로 "파이프라인 돌려"·"풀 사이클"·"기획부터" 같은 키워드 쓰면 무조건 new_task. +- "고마워"·"잘했어"·"오케이" 같은 짧은 응답은 무조건 chat.`; + +const SYSTEM_PROMPT_NO_PIPELINE_PICK = `${SYSTEM_PROMPT_BASE} + +⚠️ 반드시 아래 JSON 형식 정확히 한 번. 다른 텍스트(설명, 코드펜스, 머리말) 일체 금지. + +{"intent":"chat"|"followup"|"new_task","reason":"한 줄(20자 이내)"}`; + +const SYSTEM_PROMPT_WITH_PIPELINE_PICK = `${SYSTEM_PROMPT_BASE} + +new_task인 경우, *사용자 컨텍스트에 제공된 파이프라인 후보 중 가장 적합한 것* 하나도 같이 골라주세요. + +🛑 **사용자 명시 신호 우선 (절대 위반 금지)**: +- 사용자 발화에 "기획만"·"기획서까지"·"기획서 작성"·"plan only"·"plan-only"·"기획만 해줘" 등이 있으면 → 후보 중 *기획·plan* 관련 단어가 들어간 가장 짧은 파이프라인을 고르세요. 풀 사이클 절대 금지. +- "디자인까지"·"디자인 단계까지" → 디자인 포함, 개발 제외 파이프라인. +- "개발까지"·"풀 사이클"·"끝까지"·"배포까지" → 풀 파이프라인. +- 명시 신호가 없으면 요청 규모를 보고 짧고 충분한 것 우선. 애매하면 풀 사이클보다 짧은 쪽. +- 적당한 후보가 없으면 suggestedPipelineId 필드를 빈 문자열로 두세요. + +⚠️ 사용자가 "기획만 해줘"라고 했는데 개발 stage가 포함된 파이프라인을 골라주면 사용자 의도를 정면으로 무시하는 것입니다. 반드시 짧은 파이프라인 우선. + +⚠️ 반드시 아래 JSON 형식 정확히 한 번. 다른 텍스트(설명, 코드펜스, 머리말) 일체 금지. + +{"intent":"chat"|"followup"|"new_task","reason":"한 줄(20자 이내)","suggestedPipelineId":"<후보 id 또는 빈 문자열>"}`; + +/** + * 4-stage tolerant JSON parser — same shape as ceoPlanner's. Small models + * routinely break the "no extra text" rule with fences / leading prose, so + * a strict JSON.parse only catches the well-behaved minority. + */ +function _parseIntentJson(raw: string): { intent: string; reason: string; suggestedPipelineId?: string } | null { + if (!raw || !raw.trim()) return null; + + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + const stage1 = (fenced ? fenced[1] : raw).trim(); + + try { + const obj = JSON.parse(stage1); + const c = _coerce(obj); + if (c) return c; + } catch { /* fall through */ } + + const balanced = _extractFirstBalancedObject(stage1); + if (balanced) { + try { + const obj = JSON.parse(balanced); + const c = _coerce(obj); + if (c) return c; + } catch { /* fall through */ } + } + + // Last resort: regex pluck — 작은 모델이 JSON 깨뜨려도 핵심 필드만 건짐. + const intentMatch = stage1.match(/"intent"\s*:\s*"(chat|followup|new_task)"/i); + const reasonMatch = stage1.match(/"reason"\s*:\s*"([^"]*)"/); + const pipeMatch = stage1.match(/"suggestedPipelineId"\s*:\s*"([^"]*)"/); + if (intentMatch) { + return { + intent: intentMatch[1], + reason: reasonMatch?.[1] ?? '', + suggestedPipelineId: pipeMatch?.[1] || undefined, + }; + } + return null; +} + +function _coerce(obj: unknown): { intent: string; reason: string; suggestedPipelineId?: string } | null { + if (!obj || typeof obj !== 'object') return null; + const o = obj as Record; + const intent = typeof o.intent === 'string' ? o.intent.trim() : ''; + const reason = typeof o.reason === 'string' ? o.reason.trim() : ''; + if (intent !== 'chat' && intent !== 'followup' && intent !== 'new_task') return null; + const suggestedPipelineId = typeof o.suggestedPipelineId === 'string' && o.suggestedPipelineId.trim() + ? o.suggestedPipelineId.trim() + : undefined; + return { intent, reason, suggestedPipelineId }; +} + +/** + * 사용자 발화에서 명시적 범위 신호(예: "기획만", "디자인까지", "풀 사이클")를 + * 잡아내 일치하는 후보 파이프라인 id로 강제 매핑. LLM 추천이 무시 못 하게 + * 백엔드 측 안전망. 매칭 못 하면 undefined 반환 → LLM 추천 그대로 사용. + * + * 매칭 룰: + * - "기획만" / "기획서까지" / "plan only" → 이름·설명에 "기획" 또는 "plan"이 + * 들어가고 개발/배포 단어가 *없는* 파이프라인 중 stageCount 가장 작은 것. + * - "디자인까지" / "디자인만" → "design" / "디자인" 단어, 개발 단어 없음. + * - "풀 사이클" / "끝까지" / "배포까지" → stageCount 가장 큰 것. + */ +function _keywordPickPipeline( + userPrompt: string, + candidates: PipelineHint[], +): string | undefined { + if (!candidates.length) return undefined; + const text = userPrompt.toLowerCase(); + + const wantsPlanOnly = /(기획만|기획서까지|기획만\s*해|기획서\s*작성|기획부터\s*기획|plan\s*[-_]?only|plan\s*only)/i.test(text); + const wantsDesignStop = /(디자인까지|디자인만|디자인\s*단계까지)/i.test(text); + const wantsFull = /(풀\s*사이클|끝까지|배포까지|풀\s*프로덕트|개발까지|production\s*full|full\s*pipeline)/i.test(text); + + const hasDev = (s: string) => /(개발|코드|배포|구현|deploy|develop|dev|implement)/i.test(s); + const hasDesign = (s: string) => /(디자인|design|ui)/i.test(s); + + if (wantsPlanOnly) { + // 개발/디자인 stage 없는 짧은 기획 파이프라인 우선. 이름/설명 둘 다 본다. + const planOnly = candidates + .filter((p) => !hasDev(p.name + ' ' + (p.description ?? '')) + && !hasDesign(p.name + ' ' + (p.description ?? ''))) + .sort((a, b) => a.stageCount - b.stageCount); + if (planOnly.length > 0) return planOnly[0].id; + // 그것도 없으면 stageCount 가장 작은 후보 (그래도 풀 사이클은 피함). + const shortest = [...candidates].sort((a, b) => a.stageCount - b.stageCount)[0]; + return shortest?.id; + } + if (wantsDesignStop) { + const designStop = candidates + .filter((p) => !hasDev(p.name + ' ' + (p.description ?? ''))) + .sort((a, b) => a.stageCount - b.stageCount); + if (designStop.length > 0) return designStop[0].id; + } + if (wantsFull) { + const fullest = [...candidates].sort((a, b) => b.stageCount - a.stageCount)[0]; + return fullest?.id; + } + return undefined; +} + +function _extractFirstBalancedObject(s: string): string | null { + const start = s.indexOf('{'); + if (start === -1) return null; + let depth = 0; + let inString = false; + let escape = false; + for (let i = start; i < s.length; i++) { + const ch = s[i]; + if (inString) { + if (escape) escape = false; + else if (ch === '\\') escape = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') { inString = true; continue; } + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) return s.slice(start, i + 1); + } + } + return null; +} + +function _buildUserMessage(userPrompt: string, ctx: IntentContext): string { + const lines: string[] = []; + if (ctx.activePipelineName) { + lines.push(`(참고) 현재 활성 파이프라인: "${ctx.activePipelineName}". 사용자가 이 파이프라인을 다시 돌리길 원하면 new_task, 결과 확인이면 followup.`); + } + if (ctx.previousBrief || ctx.previousReportTail) { + lines.push(''); + lines.push('[직전 라운드 컨텍스트]'); + if (ctx.previousTurnAt) { + const ageMin = Math.round((Date.now() - ctx.previousTurnAt) / 60000); + if (ageMin >= 60) lines.push(`(${Math.round(ageMin / 60)}시간 전 — 오래되었음)`); + else lines.push(`(${ageMin}분 전)`); + } + if (ctx.previousBrief) lines.push(`brief: ${ctx.previousBrief.slice(0, 300)}`); + if (ctx.previousReportTail) lines.push(`보고서 끝부분: ${ctx.previousReportTail.slice(0, 300)}`); + } else { + lines.push('(직전 라운드 컨텍스트 없음 — 첫 메시지이거나 새 세션)'); + } + if (ctx.availablePipelines && ctx.availablePipelines.length > 0) { + lines.push(''); + lines.push('[선택 가능한 파이프라인 후보]'); + for (const p of ctx.availablePipelines) { + const desc = p.description ? ` — ${p.description}` : ''; + lines.push(`- id: ${p.id} | "${p.name}" (${p.stageCount}단계)${desc}`); + } + } + lines.push(''); + lines.push('[방금 사용자 메시지]'); + lines.push(userPrompt); + lines.push(''); + lines.push('판정 JSON만 출력:'); + return lines.join('\n'); +} + +/** + * End-to-end classification. Never throws — returns a sensible default + * (`new_task`) on any failure so the user never silently loses a real + * work request. + */ +export async function classifyChatIntent( + ai: IAIService, + userPrompt: string, + ctx: IntentContext, + options: { model?: string; timeoutMs?: number } = {}, +): Promise { + const trimmed = userPrompt.trim(); + if (!trimmed) { + return { intent: 'chat', reason: '빈 메시지', raw: '', parsed: false }; + } + + // ── Heuristic short-circuits ──────────────────────────────────────────── + // 매우 명확한 chat 신호는 LLM 호출 없이 즉시 결정 — 작은 모델이 흔들리는 + // 경계를 좁히기 위해 비용 0의 안전망을 둔다. 신호가 약하면 통과시켜 + // LLM이 판정하게 함. + if (trimmed.length <= 8 && /^(고마워|감사|땡큐|ㅇㅇ|ㅇㅋ|네|예|아니|좋아|굿|ok|okay|thanks?|good|great)/i.test(trimmed)) { + return { intent: 'chat', reason: '짧은 인사·동의', raw: '', parsed: false }; + } + + // 후보 파이프라인이 제공됐을 때만 분류기에 "골라 봐" 요청 — 후보 없이 그 + // 필드를 비워달라고 강제하면 모델이 불필요한 빈값을 채우려다 응답 형식 + // 깨뜨릴 수 있다. + const wantPipelinePick = !!(ctx.availablePipelines && ctx.availablePipelines.length > 0); + const system = wantPipelinePick ? SYSTEM_PROMPT_WITH_PIPELINE_PICK : SYSTEM_PROMPT_NO_PIPELINE_PICK; + + let raw = ''; + try { + const result = await ai.chat({ + system, + user: _buildUserMessage(trimmed, ctx), + model: options.model, + timeoutMs: options.timeoutMs, + }); + raw = result.content || ''; + } catch (e: any) { + logError('intentClassifier: AI call failed; defaulting to new_task.', { error: e?.message ?? String(e) }); + return { intent: 'new_task', reason: '분류 실패 — 안전하게 업무로 처리', raw: '', parsed: false }; + } + + const parsed = _parseIntentJson(raw); + if (!parsed) { + logInfo('intentClassifier: parse failed; defaulting to new_task.', { rawHead: raw.slice(0, 100) }); + return { intent: 'new_task', reason: '판정 형식 불일치 — 업무로 처리', raw, parsed: false }; + } + // suggestedPipelineId는 *제공된 후보 목록 안에 존재할 때만* 신뢰. 분류기가 + // 환각한 id를 그대로 dispatch에 넘기면 dispatcher에서 silent fallback 발생. + let suggestedPipelineId: string | undefined; + if (parsed.intent === 'new_task' && parsed.suggestedPipelineId && wantPipelinePick) { + const knownIds = new Set((ctx.availablePipelines ?? []).map((p) => p.id)); + if (knownIds.has(parsed.suggestedPipelineId)) { + suggestedPipelineId = parsed.suggestedPipelineId; + } + } + // 백엔드 키워드 fallback — LLM 추천을 *덮어쓴다*. 사용자가 "기획만"·"디자인만" + // 같은 명시 신호를 줬는데 LLM이 풀 사이클을 골라버리는 사고를 막기 위함. + // 후보 파이프라인 이름/설명에 매칭되는 키워드가 prompt에 있으면 그 쪽을 강제. + if (parsed.intent === 'new_task' && wantPipelinePick && ctx.availablePipelines) { + const keywordPick = _keywordPickPipeline(trimmed, ctx.availablePipelines); + if (keywordPick) suggestedPipelineId = keywordPick; + } + logInfo('intentClassifier: parsed.', { intent: parsed.intent, reason: parsed.reason, suggestedPipelineId }); + return { + intent: parsed.intent as ChatIntent, + reason: parsed.reason || '(이유 없음)', + raw, + parsed: true, + suggestedPipelineId, + }; +} diff --git a/src/features/company/pipelineTemplates.ts b/src/features/company/pipelineTemplates.ts index ab3c51d..a0852f2 100644 --- a/src/features/company/pipelineTemplates.ts +++ b/src/features/company/pipelineTemplates.ts @@ -66,10 +66,12 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { suggestedPipelineId: 'product-dev', suggestedPipelineName: '제품 개발 파이프라인', stages: [ + // 모든 stage가 *직군*만 지정하고 담당자는 비워둠 (agentId 생략). dispatcher가 + // stage 진입 시 CEO에게 1회 LLM 콜로 적임자 선택. 활성 후보가 1명뿐이면 + // 콜 없이 그 사람을 쓴다. 사용자의 의도("CEO가 배분 결정")와 일치. { id: 'plan-discuss', label: '기획 논의', - agentId: 'writer', roleCategory: 'planner', instructionTemplate: '사용자 요청: {{userPrompt}}\n\n' + @@ -79,7 +81,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { { id: 'market-research', label: '시장 조사', - agentId: 'researcher', roleCategory: 'researcher', instructionTemplate: '기획 논의 정리: {{stage.plan-discuss}}\n\n' + @@ -89,7 +90,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { { id: 'trend-research', label: '트렌드 조사', - agentId: 'researcher', roleCategory: 'researcher', instructionTemplate: '기획 논의: {{stage.plan-discuss}}\n시장 조사 결과: {{stage.market-research}}\n\n' + @@ -99,7 +99,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { { id: 'direction', label: '방향성 정의', - agentId: 'writer', roleCategory: 'planner', instructionTemplate: '기획 논의: {{stage.plan-discuss}}\n시장: {{stage.market-research}}\n트렌드: {{stage.trend-research}}\n\n' + @@ -109,7 +108,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { { id: 'plan-draft', label: '기획문서 초안', - agentId: 'writer', roleCategory: 'planner', instructionTemplate: '방향성: {{stage.direction}}\n\n' + @@ -121,7 +119,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { { id: 'plan-review', label: '기획문서 검토', - agentId: 'inspector', roleCategory: 'inspector', instructionTemplate: '검토 대상: {{stage.plan-draft}}\n\n' + @@ -136,7 +133,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { { id: 'plan-final', label: '기획문서 최종본', - agentId: 'writer', roleCategory: 'planner', instructionTemplate: '초안: {{stage.plan-draft}}\n검토 피드백: {{stage.plan-review}}\n\n' + @@ -145,7 +141,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { { id: 'dev-design', label: '개발 설계', - agentId: 'developer', roleCategory: 'developer', instructionTemplate: '최종 기획서: {{stage.plan-final}}\n\n' + @@ -155,7 +150,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { { id: 'design-review', label: '설계 검토', - agentId: 'inspector', roleCategory: 'inspector', instructionTemplate: '설계 문서: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' + @@ -168,7 +162,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { { id: 'dev-impl', label: '개발 진행', - agentId: 'developer', roleCategory: 'developer', instructionTemplate: '설계: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' + @@ -178,7 +171,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { { id: 'qa', label: 'QA 진행', - agentId: 'qa', roleCategory: 'qa', instructionTemplate: '구현 결과: {{stage.dev-impl}}\n기획서: {{stage.plan-final}}\n\n' + @@ -191,7 +183,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { { id: 'deploy', label: '라이브 배포', - agentId: 'developer', roleCategory: 'developer', instructionTemplate: 'QA 통과 결과: {{stage.qa}}\n\n' + @@ -201,9 +192,57 @@ const FULL_PRODUCT_DEV: PipelineTemplate = { ], }; +/** + * 짧은 "기획만" 워크플로 — 사용자가 기획문서까지만 필요한 경우. 각 산출물 + * stage에 3-way 검수 사이클을 켜서 셋(작업자 + 감리 + CEO) 합의로 통과 + * 시키는 패턴을 보여준다. 풀-프로덕트와 달리 별도 review stage를 두지 않고 + * 사이클로 합쳐서 빠르게 끝낸다. + */ +const PLAN_ONLY: PipelineTemplate = { + templateId: 'plan-only', + name: '기획서까지만 (검수 사이클)', + description: '시장 조사 → 방향성 → 기획서. 각 산출물 stage에서 검수자 + CEO 합의로 통과시키는 짧은 워크플로.', + suggestedPipelineId: 'plan-only', + suggestedPipelineName: '기획서 작성', + stages: [ + { + id: 'market-research', + label: '시장 조사', + roleCategory: 'researcher', + instructionTemplate: + '사용자 요청: {{userPrompt}}\n\n' + + '이 요청 맥락에서 *시장 측면*을 조사하세요. 추측 금지, 데이터/사례 기반.\n' + + '- 비슷한 시도가 이미 있나 (3개 이상)\n- 시장 크기·고객 페르소나\n- 가격대·수익화 패턴\n' + + '결과는 "출처(또는 일반론임을 명시)" 표시.', + }, + { + id: 'direction', + label: '방향성 정의', + roleCategory: 'planner', + instructionTemplate: + '사용자 요청: {{userPrompt}}\n시장 조사: {{stage.market-research}}\n\n' + + '*우리가 갈 방향*을 한 문단으로 결론짓고 측정 가능한 성공 기준을 1~3개 적으세요.', + reviewWith: 'inspector', + reviewMaxRounds: 3, + }, + { + id: 'plan-doc', + label: '기획문서', + roleCategory: 'planner', + instructionTemplate: + '방향성: {{stage.direction}}\n\n' + + '아래 섹션 구조로 *기획서*를 마크다운으로 작성하세요. 합의 통과 후엔 사장님께 그대로 전달됩니다.\n\n' + + '## 배경\n## 목표\n## 핵심 사용자 시나리오 (3개 이상, 구체적)\n## 주요 기능 목록\n## 비기능 요구사항\n## 측정 지표 (KPI)\n## 미래 확장 / 비-목표', + reviewWith: 'inspector', + reviewMaxRounds: 3, + }, + ], +}; + /** Read-only registry of templates the UI surfaces. Add more here later. */ export const PIPELINE_TEMPLATES: PipelineTemplate[] = [ FULL_PRODUCT_DEV, + PLAN_ONLY, ]; export function getPipelineTemplate(id: string): PipelineTemplate | undefined { diff --git a/src/features/company/pixelOfficeState.ts b/src/features/company/pixelOfficeState.ts new file mode 100644 index 0000000..aa34bd1 --- /dev/null +++ b/src/features/company/pixelOfficeState.ts @@ -0,0 +1,280 @@ +/** + * Pixel Office — Agent Work Pipeline 상태를 시각화하는 *UI Layer 전용* 모듈. + * + * ─────────────────── 설계 원칙 ─────────────────── + * 1. **Agent 핵심 판단 로직을 절대 바꾸지 않는다.** Pipeline 진행, contract + * 합의, 검수 cycle, 승인 게이트 — 모두 기존 dispatcher / chatHandlers / + * SidebarChatProvider 안에서 결정된다. 이 모듈은 그 결정을 *읽고* webview용 + * 상태 객체로 변환할 뿐이다. + * 2. 입력은 기존에 emit되던 이벤트 (CompanyTurnEvent + alignment phase + + * intent classifier 결과)뿐. 새로운 인터럽트 포인트를 만들지 않는다. + * 3. 출력은 두 종류 — `AgentWorkState` (현재 작업 패널)와 `AgentBubble` 큐 + * (말풍선 연출). 둘 다 webview가 그대로 받아 그리기만 하면 된다. + * + * 즉 dispatcher 안의 어떤 한 줄도 "if pixelOffice ..."로 분기하지 않는다. + */ + +/** Agent의 현재 단계. 사용자가 명세한 11개 상태값 전부 포함. */ +export type AgentStatus = + | 'idle' + | 'intake' + | 'analyzing' + | 'need_clarification' + | 'contract_ready' + | 'planning' + | 'executing' + | 'reviewing' + | 'waiting_approval' + | 'error' + | 'done'; + +/** 말풍선이 어떤 카테고리에서 나왔는지 — 스타일링 / 우선순위에 활용. */ +export type BubbleType = 'status' | 'event' | 'warning' | 'error' | 'success'; + +/** 말풍선 발생 트리거가 되는 이벤트 — 사용자가 명세한 10개 + 약간 확장. */ +export type AgentEvent = + | 'missing_required_info' + | 'clarification_needed' + | 'requirement_contract_created' + | 'plan_completed' + | 'execution_started' + | 'review_failed' + | 'review_passed' + | 'risky_change_detected' + | 'approval_required' + | 'error_occurred' + | 'task_completed' + | 'stage_loop_retry'; + +export interface AgentWorkState { + agentId: string; + agentName: string; + status: AgentStatus; + /** 사용자 원본 요청 한 줄 — 패널 상단 "Current Task"에 표시. */ + currentTask?: string; + /** 현재 stage / phase 라벨 — "기획 논의", "QA 진행" 등. */ + currentStep?: string; + /** 다음 stage 라벨 (있으면) — 예측 표시용. */ + nextStep?: string; + /** 짧은 보조 메시지 (예: "라운드 2/3", "검수자: 민지"). */ + message?: string; + /** 진행률 0~1 — 파이프라인 모드일 때 stage index / total로 계산. */ + progress?: number; + /** Requirement Contract 요약 (alignment 완료 후 채워짐). */ + requirementContract?: { + goal?: string; + context?: string; + criteria?: string[]; + format?: string; + openQuestions?: string[]; + confidence?: 'low' | 'medium' | 'high'; + }; + /** 사용자에게 던지는 미해결 질문 목록 — need_clarification 상태에서 채움. */ + needUserInput?: string[]; + /** 승인 대기 중 항목 — waiting_approval 상태에서 채움. */ + awaitingApproval?: string; + /** 짧은 최근 로그 — 사용자가 한눈에 흐름 파악. 최대 6개 ring buffer. */ + recentLogs?: string[]; + /** epoch ms — webview의 "n초 전" 표시용. */ + updatedAt: number; +} + +export interface AgentBubble { + id: string; + /** 어떤 캐릭터 위에 띄울지 — 단일 캐릭터 모드면 'main' 고정도 가능. */ + agentId: string; + text: string; + type: BubbleType; + /** 생성 시각 epoch ms. */ + createdAt: number; + /** 자동 사라짐 ms (webview가 사용). 기본값은 webview에서 결정. */ + durationMs?: number; +} + +/** + * 사용자가 설정으로 켜고 끌 수 있는 행동 옵션. + * webview는 broadcast마다 같이 받아서 즉시 반영. + */ +export interface PixelOfficeConfig { + enabled: boolean; + bubblesEnabled: boolean; + maxVisibleBubbles: number; + bubbleDurationMs: number; +} + +// ─────────────────── 상태→말풍선 텍스트 풀 ─────────────────── +// 사용자가 명세한 톤(가벼운 사무실 코미디) 유지. 무작위 선택을 위해 같은 상태에 +// 여러 안을 두되 너무 길어지지 않게 4~5개로 제한. + +const STATUS_BUBBLE_POOL: Record = { + idle: [ + '오늘은 무슨 일을 할까?', + '주문 대기 중…', + '커피 한 잔 더 하고 시작할까.', + ], + intake: [ + '요청서 들어왔다.', + '한번 읽어보자.', + '오케이, 뭘 원하시는지 보자.', + ], + analyzing: [ + '음… 의도가 조금 모호한데?', + '맥락부터 정리해보자.', + '핵심이 뭐였더라.', + '이거 작업 범위가 어디까지지?', + ], + need_clarification: [ + '이건 사용자 확인이 먼저야.', + '질문 하나만 하고 가자.', + '추측으로 가면 위험해.', + '핵심 정보가 빠졌네.', + ], + contract_ready: [ + '좋아, 작업 조건 정리 완료.', + '이제 방향은 잡혔어.', + '계약서 도장 찍었다.', + '이제 진짜 시작.', + ], + planning: [ + '순서부터 잡아보자.', + '기존 기능은 건드리지 말자.', + '화이트보드 좀 빌릴게.', + '단계 나눠서 가자.', + ], + executing: [ + '코드 들어간다.', + '이번엔 단순하게 가자.', + '집중 모드 진입.', + '키보드 워밍업 완료.', + ], + reviewing: [ + '잠깐, 이건 다시 보자.', + '기존 기능 깨지는지 확인해야 해.', + '검수자 시점으로 한 번 더.', + '엣지 케이스 빠진 거 없나.', + ], + waiting_approval: [ + '이건 승인 없이 못 바꿔.', + '위험 작업 감지. 확인 필요!', + '사장님 결재 부탁드립니다.', + '도장 받기 전엔 멈춤.', + ], + error: [ + '앗, 이건 예상 못 했는데…', + '조건 하나 놓쳤네.', + '잠깐, 다시 정리.', + '엇, 이게 깨졌네.', + ], + done: [ + '좋아, 끝났다!', + '이번 작업 깔끔하게 완료.', + '커피 한 잔.', + '오늘치 끝!', + ], +}; + +const EVENT_BUBBLE_POOL: Record = { + missing_required_info: [ + '핵심 정보가 빠졌어.', + '이거 빠지면 추측해야 해.', + ], + clarification_needed: [ + '질문 하나만.', + '확실하지 않으면 먼저 물어보자.', + ], + requirement_contract_created: [ + '계약서 도장 찍었다.', + '이제 방향은 잡혔어.', + ], + plan_completed: [ + '계획 정리 끝!', + '순서대로 가자.', + ], + execution_started: [ + '코드 들어간다.', + '시작!', + ], + review_failed: [ + '조건 하나 놓쳤네. 다시 보자.', + '너무 복잡하게 가는 거 아냐?', + ], + review_passed: [ + '검수 통과!', + '셋 다 만족이래.', + ], + risky_change_detected: [ + '잠깐, 이건 승인 필요!', + '파일 삭제는 함부로 하면 안 돼.', + ], + approval_required: [ + '결재 부탁드립니다.', + '도장 받고 이어 갈게.', + ], + error_occurred: [ + '앗, 이건 예상 못 했는데…', + '엇, 이게 깨졌네.', + ], + task_completed: [ + '좋아, 끝났다!', + '오늘치 끝.', + ], + stage_loop_retry: [ + '한 번 더 가자.', + '버그 잡고 다시 시도.', + ], +}; + +/** + * 텍스트 풀에서 하나 뽑기. 같은 결과가 연달아 나오지 않게 lastPicked를 + * 받아 회피. 풀이 1개뿐이면 어쩔 수 없이 그걸 반환. + */ +export function pickBubbleText(pool: string[], lastPicked?: string): string { + if (!pool || pool.length === 0) return ''; + if (pool.length === 1) return pool[0]; + let candidates = pool; + if (lastPicked) candidates = pool.filter((s) => s !== lastPicked); + if (candidates.length === 0) candidates = pool; + return candidates[Math.floor(Math.random() * candidates.length)]; +} + +export function getStatusBubbleText(status: AgentStatus, lastPicked?: string): string { + return pickBubbleText(STATUS_BUBBLE_POOL[status] ?? [], lastPicked); +} + +export function getEventBubbleText(event: AgentEvent, lastPicked?: string): string { + return pickBubbleText(EVENT_BUBBLE_POOL[event] ?? [], lastPicked); +} + +/** 이벤트 → 말풍선 type 매핑. 색상/스타일을 webview가 결정할 때 사용. */ +export function eventBubbleType(event: AgentEvent): BubbleType { + switch (event) { + case 'error_occurred': return 'error'; + case 'review_failed': + case 'risky_change_detected': + case 'missing_required_info': + return 'warning'; + case 'task_completed': + case 'review_passed': + case 'plan_completed': + case 'requirement_contract_created': + return 'success'; + default: return 'event'; + } +} + +/** AgentBubble factory — id 자동 생성. */ +export function makeBubble(opts: { + agentId: string; + text: string; + type?: BubbleType; + durationMs?: number; +}): AgentBubble { + return { + id: `b-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, + agentId: opts.agentId, + text: opts.text, + type: opts.type ?? 'status', + createdAt: Date.now(), + durationMs: opts.durationMs, + }; +} diff --git a/src/features/company/promptBuilder.ts b/src/features/company/promptBuilder.ts index 94dbdde..c253257 100644 --- a/src/features/company/promptBuilder.ts +++ b/src/features/company/promptBuilder.ts @@ -47,6 +47,12 @@ export interface SpecialistPromptInputs { * Tells the specialist how heavily to rely on the brain context. */ knowledgeMixPolicy?: string; + /** + * Intent Alignment 단계에서 도출된 사용자 합의 contract 블록 (이미 마크다운 + * 으로 직렬화된 상태). 있으면 시스템 프롬프트의 identity 다음·output 규칙 + * 직전에 prepend 되어 *모든 후속 지시보다 우선하는* ground truth로 동작. + */ + contractBlock?: string; } /** @@ -80,6 +86,14 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string { parts.push(resolved.persona); } + // ── Requirement Contract (Intent Alignment) ── + // alignment 단계를 거쳤다면 사용자와 합의된 contract가 모든 룰 위에 온다. + // 어떤 페르소나·검색 컨텍스트보다도 우선이라는 신호로 출력 규칙 *앞*에 prepend. + if (inputs.contractBlock && inputs.contractBlock.trim()) { + parts.push(''); + parts.push(inputs.contractBlock.trim()); + } + // ── Output contract ── parts.push(''); parts.push('## 출력 규칙'); @@ -174,6 +188,21 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string { parts.push(decisions); } + // ── Self-Reflector Phase A 룰 (가장 마지막에 prepend) ── + // 답변 끝에 [Self-Reflector Check] 블록을 자동으로 붙이게 만든다. developer + // 직군이면 코드 가드 블록도 함께 — undefined variable / 잘못된 경로 같은 + // 코드 답변 최빈출 실수에 직격타. + try { + const { getConfig } = require('../../config') as typeof import('../../config'); + const { appendSelfReflectorRule } = require('../selfReflector/selfReflectorPrompt') as typeof import('../selfReflector/selfReflectorPrompt'); + const cfg = getConfig(); + if (cfg.selfReflectorEnabled) { + const base = parts.join('\n'); + const isCoder = agent.roleCategory === 'developer'; + return appendSelfReflectorRule(base, { enabled: true, includeCodeGuard: isCoder }); + } + } catch { /* fall through */ } + return parts.join('\n'); } diff --git a/src/features/company/types.ts b/src/features/company/types.ts index 0006a72..c32080f 100644 --- a/src/features/company/types.ts +++ b/src/features/company/types.ts @@ -218,14 +218,19 @@ export interface PipelineStage { id: string; /** Human label shown in the chat phase header and the editor. */ label: string; - /** Which agent runs this stage. Must resolve via `resolveAgent`. */ - agentId: string; /** - * 직군 hint stored at save time. Lets the editor re-open with the - * correct 직군 dropdown without having to re-derive it from agentId - * (handy when the user later changes the agent's 직군 override). The - * dispatcher itself doesn't read this — it goes straight from agentId - * to `resolveAgent`. + * 명시적으로 지정한 담당 에이전트. 비어 있거나 누락이면 dispatcher가 + * stage 진입 직전 CEO에게 "이 직군 중 누가 적합?" 한 줄 LLM 콜로 + * 결정 — 활성 에이전트가 1명뿐이면 콜 생략하고 그 사람 사용. 매번 + * 직군 후보 중에서 CEO가 고르는 게 사용자의 의도(*"CEO가 배분할 + * 에이전트를 판단"*)와 일치하므로 기본 권장값은 빈 문자열이다. + */ + agentId?: string; + /** + * 직군 — *동적 담당자 선택의 핵심 필드*. agentId가 비어 있을 때 + * dispatcher는 이 직군 안에서만 후보를 추리고 CEO에게 고르게 한다. + * agentId가 있어도 UI가 직군 dropdown을 원래 위치로 복원할 수 있게 + * 같이 저장 — 그 경우 dispatcher는 agentId가 우선이므로 무시. */ roleCategory?: string; /** @@ -245,6 +250,25 @@ export interface PipelineStage { * to "aborted" cleanly. */ requiresApproval?: boolean; + /** + * 3-way 합의 검수 사이클을 켜는 스위치. 값 형식: + * - `'inspector'` — `inspector` 직군의 활성 에이전트 자동 선임 (가장 흔한 케이스) + * - `'role:'` — 임의 직군 자동 선임 (예: `'role:qa'`) + * - `'agent:'` — 특정 에이전트 직접 지정 + * - 빈값 / 미지정 — 검수 사이클 없음 (legacy 동작) + * + * 사이클은 매 라운드마다: + * 1. 작업자 산출물 (이미 dispatch됨) + * 2. 검수자가 "✅ 통과" 또는 "❌ 보완 필요: …" 로 시작하는 코멘트 + * 3. CEO가 메타-판단 "✅ 통과 / 🔁 보완 / 🛑 중단" + * 검수자 ✅ + CEO ✅ → 통과 / 그 외 → 다음 라운드(또는 abort). + */ + reviewWith?: string; + /** + * 검수 사이클 최대 라운드 수. 기본 3. 한도 도달하면 강제 통과(경고 표기). + * 1 이상 10 이하 — 그 밖의 값은 normalize에서 clamp. + */ + reviewMaxRounds?: number; /** * Instruction template. Tokens substituted before dispatch: * - `{{userPrompt}}` — what the user typed @@ -271,6 +295,41 @@ export interface PipelineDef { stages: PipelineStage[]; } +/** + * Intent Alignment — 사용자 자연어 요청을 *실행 가능한 작업 조건*으로 바꾼 + * 합의문. dispatcher는 turn을 시작하기 전 이 contract를 받고, CEO + * planner / specialist prompt / 검수자 prompt 모두에 같은 ground truth로 + * 주입한다. 결과적으로 "에이전트가 사용자 머릿속을 추측하는" 단계를 + * 명시적인 데이터로 외부화한 것. + * + * 필드별 의미 (C-G-C-F-Q): + * - context : 현재 상황·프로젝트 맥락. 사용자가 어디서 어떤 상태로 + * 부터 출발하는지. + * - goal : 사용자가 *달성하려는* 결과(behavioural, 1~2 문장). + * - criteria : 좋은 결과의 판단 기준. 측정 가능한 형태가 이상적이지만 + * 정성적 기준도 OK. 빈 배열일 수 있음. + * - format : 원하는 산출물의 *형식* (예: "Markdown 기획서", + * "Python 단일 스크립트", "JSON 데이터 + 짧은 요약"). + * - answeredQuestions : alignment 라운드 동안 사용자가 답한 명확화 질문 + 응답 + * 쌍. 기록용 — pipeline 단계에서도 같이 보여 줘서 + * "왜 이렇게 잡혔는지" 추적 가능. + * - openQuestions : 분석기가 알고 싶었지만 사용자가 답 안 한(또는 안 받기로 + * 결정한) 질문. 이게 비어 있지 않은 채로 dispatch되면 + * agent들에게 "이 부분은 모르니 보수적으로" 신호. + * - confidence : alignment 단계의 자체 신뢰도. dispatcher가 모드에 + * 따라 자동 진행 / 사용자 확인 / 추가 질문을 결정. + */ +export interface RequirementContract { + userOriginalPrompt: string; + context: string; + goal: string; + criteria: string[]; + format: string; + answeredQuestions: Array<{ q: string; a: string }>; + openQuestions: string[]; + confidence: 'low' | 'medium' | 'high'; +} + /** Output of the CEO planner LLM call after JSON parsing. */ export interface CompanyTaskPlan { /** 2-3 sentence Korean summary of what the company is going to do. */ diff --git a/src/features/selfReflector/selfReflectorExecution.ts b/src/features/selfReflector/selfReflectorExecution.ts new file mode 100644 index 0000000..15d1494 --- /dev/null +++ b/src/features/selfReflector/selfReflectorExecution.ts @@ -0,0 +1,172 @@ +/** + * Self-Reflector Phase C — *실행 기반* 검증. + * + * Phase A/B는 LLM 텍스트 분석에 의존하므로 "코드가 실제로 컴파일되는가?" + * 같은 질문엔 한계가 있다. Phase C는 정답: 그냥 *실행해 본다*. + * + * 동작: + * 1. action-tag executor가 반환한 report를 받아 `✅ Created: ` / + * `✅ Edited: ` 항목에서 경로를 추출 + * 2. 파일 확장자별 toolchain 선택: + * .py → `python -m py_compile ` + * .js / .mjs / .cjs → `node --check ` + * .ts / .tsx → 프로젝트 단위 `tsc --noEmit` (단일 파일 체크는 의존성 때문에 실패율 높음) + * .json → `JSON.parse` (node) + * 3. exitCode 0이면 ✅, 아니면 ❌ + 첫 줄 에러 메시지 캡쳐 + * 4. 추가 report 항목으로 결과 반환 + * + * 안전장치: + * - timeout 10초 (절대 멈춰선 안 됨) + * - 도구 미설치 / spawn 실패 → 경고 한 줄로 마무리 (블로킹 X) + * - 워크스페이스 외부 경로 무시 + * - 사용자가 `executionVerification=false`면 통째로 skip — 호출자가 가드 + */ +import { spawn } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { logError, logInfo } from '../../utils'; + +export type ExecCheckResult = { + relPath: string; + ok: boolean; + /** 도구 없음 / 환경 문제 등 *체크 자체*가 실패 — ok=false로 분류하되 errorLine은 도구 부재 메시지. */ + toolMissing?: boolean; + /** 실패 시 첫 줄 에러 메시지. ok=true면 비어 있음. */ + errorLine?: string; + /** 사용한 도구 명령(디버그용). */ + tool: string; +}; + +/** 한 명령을 spawn, 표준 출력+에러 캡쳐, timeout 후 강제 종료. */ +function _runCheck(cmd: string, args: string[], cwd: string, timeoutMs = 10000): Promise<{ code: number; out: string; err: string; spawnFailed?: boolean }> { + return new Promise((resolve) => { + let out = '', err = ''; + let settled = false; + let proc: ReturnType | undefined; + try { + proc = spawn(cmd, args, { cwd, shell: false, windowsHide: true }); + } catch (e: any) { + resolve({ code: -1, out: '', err: e?.message ?? String(e), spawnFailed: true }); + return; + } + const timer = setTimeout(() => { + if (settled) return; + try { proc?.kill('SIGKILL'); } catch { /* noop */ } + settled = true; + resolve({ code: -2, out, err: err + '\n[timeout after ' + timeoutMs + 'ms]' }); + }, timeoutMs); + proc.stdout?.on('data', (b) => { out += b.toString(); }); + proc.stderr?.on('data', (b) => { err += b.toString(); }); + proc.on('error', (e: any) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ code: -1, out, err: e?.message ?? String(e), spawnFailed: true }); + }); + proc.on('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ code: code ?? 0, out, err }); + }); + }); +} + +function _firstNonEmptyLine(s: string): string { + return (s || '').split(/\r?\n/).map((x) => x.trim()).find((x) => x.length > 0) ?? ''; +} + +/** 확장자별 검사 명령 결정. 지원 안 하는 확장자면 null 반환 (skip). */ +function _pickTool(absPath: string, projectRoot: string): { cmd: string; args: string[]; cwd: string; label: string } | null { + const ext = path.extname(absPath).toLowerCase(); + if (ext === '.py') { + return { cmd: 'python', args: ['-m', 'py_compile', absPath], cwd: projectRoot, label: 'py_compile' }; + } + if (ext === '.js' || ext === '.mjs' || ext === '.cjs') { + return { cmd: 'node', args: ['--check', absPath], cwd: projectRoot, label: 'node --check' }; + } + if (ext === '.json') { + // node -e "JSON.parse(fs.readFileSync(...))" + return { + cmd: 'node', + args: ['-e', `JSON.parse(require('fs').readFileSync(${JSON.stringify(absPath)},'utf8'))`], + cwd: projectRoot, + label: 'node JSON.parse', + }; + } + if (ext === '.ts' || ext === '.tsx') { + // 단일 파일 tsc는 의존성 때문에 false-positive가 많아 *프로젝트 단위* noEmit으로 돌린다. + // 비용은 더 크지만 실제 사용자 환경에서 의미 있는 결과를 낸다. + const tsconfig = path.join(projectRoot, 'tsconfig.json'); + if (!fs.existsSync(tsconfig)) return null; + return { + cmd: 'npx', args: ['--no-install', 'tsc', '--noEmit', '-p', tsconfig], + cwd: projectRoot, label: 'tsc --noEmit', + }; + } + return null; +} + +/** + * report 한 줄에서 `✅ Created: foo.py` / `✅ Edited: foo.py` 형태의 경로 추출. + * 이 두 케이스만 의미 있음 (Listed/Reveal 등은 syntax 체크 대상 아님). + */ +function _extractPathFromReportLine(line: string): string | null { + const m = line.match(/^\s*[✅⚠️]\s*(?:Created|Edited|Updated)\s*:\s*(.+)$/); + return m ? m[1].trim() : null; +} + +/** + * report 내 모든 file action에 대해 syntax 체크 실행. 추가 report 라인들을 반환. + * 호출자가 기존 actionReport에 concat 해서 사용자에게 보여주기만 하면 됨. + * + * @param report executeActionTags가 반환한 원본 report + * @param projectRoot 현재 워크스페이스 루트 (cwd로 사용) + * @returns 추가 report 라인 (없으면 빈 배열 — 검증 대상 파일 없음) + */ +export async function verifyCreatedFiles(report: string[], projectRoot: string): Promise { + const candidates: string[] = []; + for (const line of report) { + const rel = _extractPathFromReportLine(line); + if (!rel) continue; + const abs = path.isAbsolute(rel) ? rel : path.join(projectRoot, rel); + // 워크스페이스 외부 / 존재하지 않는 파일 skip. + if (!abs.startsWith(projectRoot)) continue; + if (!fs.existsSync(abs)) continue; + candidates.push(abs); + } + if (candidates.length === 0) return []; + + // TypeScript 프로젝트 단위 체크는 *한 번만* 돌리면 됨 (모든 .ts 파일 커버). + // 그래서 ts 파일이 여럿이어도 tsc는 한 번만 호출. + const ranTsForProject = new Set(); // projectRoot 단위로 + const out: string[] = []; + + for (const abs of candidates) { + const tool = _pickTool(abs, projectRoot); + const rel = path.relative(projectRoot, abs); + if (!tool) continue; + // ts 프로젝트 체크 중복 회피. + if (tool.label === 'tsc --noEmit') { + if (ranTsForProject.has(tool.cwd)) continue; + ranTsForProject.add(tool.cwd); + } + const t0 = Date.now(); + const res = await _runCheck(tool.cmd, tool.args, tool.cwd); + const dur = ((Date.now() - t0) / 1000).toFixed(1); + if (res.spawnFailed) { + // 도구 미설치 — warning 한 줄로 마무리, 차단하지 않음. + out.push(`⚠️ ${tool.label} 미설치 — ${rel} 검증 skip`); + logInfo('selfReflector.C: tool missing.', { tool: tool.label, path: rel }); + continue; + } + if (res.code === 0) { + out.push(`🔬 ${tool.label} OK: ${rel} (${dur}s)`); + } else { + const errLine = _firstNonEmptyLine(res.err || res.out) || `exit ${res.code}`; + out.push(`❌ ${tool.label} FAIL: ${rel} — ${errLine}`); + logError('selfReflector.C: syntax check failed.', { path: rel, tool: tool.label, code: res.code, err: errLine }); + } + } + return out; +} diff --git a/src/features/selfReflector/selfReflectorHollow.ts b/src/features/selfReflector/selfReflectorHollow.ts new file mode 100644 index 0000000..d59e3b6 --- /dev/null +++ b/src/features/selfReflector/selfReflectorHollow.ts @@ -0,0 +1,258 @@ +/** + * Self-Reflector — *빈 깡통(Hollow Code)* 검출 휴리스틱. + * + * Phase C(syntax/lint)는 문법 오류만 잡는다. 작은 LLM이 가장 자주 만드는 + * 실패 패턴은 *문법은 맞지만 본문이 비어 있는* 코드 — `def foo(): pass`, + * `# TODO: implement`, import만 있고 로직 0줄인 모듈 등. 사용자가 "완료 + * 됐다"라는 응답을 받고 파일을 열면 빈 깡통만 들어 있는 사고가 여기서 + * 나온다. + * + * 이 모듈은 *정규식 + 라인 카운팅* 만으로 빈 깡통 패턴을 잡는다. LLM 콜 + * 0회, 추가 비용 0. 한계는 있지만(일부 위양성/위음성) 작은 모델 실패의 + * 80% 이상은 이 단순 휴리스틱으로 잡힌다. + * + * 호출자(dispatcher / agent.ts)가 syntax 체크 직후에 같이 부르면: + * ✅ 검증 통과 → 그대로 응답 + * ❌ 빈 깡통 → action-report에 한 줄 경고 추가 + (회사 모드면 verifier + * retry 트리거 조건에 합류) + */ +import * as fs from 'fs'; +import * as path from 'path'; + +/** 한 파일의 검사 결과. ok=false면 reasons에 잡힌 패턴들. */ +export interface HollowCheckResult { + relPath: string; + ok: boolean; + /** ok=false일 때 잡힌 사유 1~3줄. */ + reasons: string[]; + /** 진단 메타 — 디버그/로깅용. */ + meta?: { + totalLines: number; + codeLines: number; + stubFnRatio: number; + todoRatio: number; + }; +} + +/** 어떤 파일 확장자를 hollow 검사 대상으로 삼을지. */ +function _isSupportedExt(ext: string): boolean { + return ['.py', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext.toLowerCase()); +} + +/** + * 라인이 *의미 있는 코드*인지 판정. 주석/빈줄/혼자 떠 있는 닫는 괄호 등은 + * 의미 라인이 아니다. 작은 파일도 너무 가혹하게 평가하지 않도록 import는 + * 의미 라인으로 인정 (재export 모듈 등 유효한 패턴 보호). + */ +function _isMeaningfulCodeLine(line: string, ext: string): boolean { + const t = line.trim(); + if (!t) return false; + // 한줄/블록 주석 단독 라인 + if (ext === '.py') { + if (t.startsWith('#')) return false; + if (t.startsWith('"""') || t.startsWith("'''")) return false; + } else { + if (t.startsWith('//')) return false; + if (t.startsWith('/*') || t.startsWith('*') || t === '*/') return false; + } + // 혼자 떠 있는 brace / 괄호 + if (/^[\}\)\]\s]+;?$/.test(t)) return false; + return true; +} + +/** 라인이 stub 의심 표현인지. */ +function _isStubLine(line: string): boolean { + const t = line.trim(); + if (!t) return false; + if (t === 'pass') return true; + if (t === '...') return true; + if (/^return\s*(None|null|undefined)?\s*;?$/.test(t)) return true; + if (/^(?:#|\/\/)\s*(TODO|FIXME|XXX|HACK|implement|구현|placeholder|여기에)/i.test(t)) return true; + if (/^["']?(TODO|FIXME|TBD|placeholder|구현 필요|여기에 구현)["']?\s*$/i.test(t)) return true; + return false; +} + +/** + * Python 함수/메서드의 본문이 stub뿐인지 판정. + * 본문은 def 시그니처 다음 들여쓰기 된 라인들. 닫히는 시점은 들여쓰기 감소. + */ +function _countPyHollowFunctions(src: string): { total: number; hollow: number } { + const lines = src.split(/\r?\n/); + let total = 0, hollow = 0; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^(\s*)def\s+\w+\s*\(/); + if (!m) continue; + total++; + const baseIndent = m[1].length; + const bodyLines: string[] = []; + for (let j = i + 1; j < lines.length; j++) { + const ln = lines[j]; + if (!ln.trim()) continue; + const indent = (ln.match(/^\s*/)?.[0].length) ?? 0; + if (indent <= baseIndent) break; + bodyLines.push(ln); + } + // docstring 한 줄 무시. + const cleaned = bodyLines.filter((l) => { + const t = l.trim(); + return !!t && !t.startsWith('"""') && !t.startsWith("'''") && !t.startsWith('#'); + }); + if (cleaned.length === 0 || cleaned.every((l) => _isStubLine(l))) { + hollow++; + } + } + return { total, hollow }; +} + +/** + * JS/TS의 *간단한* hollow 함수 카운트. 정확한 AST 파싱은 비용 큼 → + * 정규식만으로 충분히 잡히는 패턴 위주. + * 패턴: `function X(...) { ...body... }` / `X(...) { ...body... }` (메서드) + * / `() => { ... }` 화살표 + * body 분석은 첫 \`{\` ~ 매칭되는 \`}\` 까지 brace 카운팅. + */ +function _countJsHollowFunctions(src: string): { total: number; hollow: number } { + let total = 0, hollow = 0; + // 시그니처 시작점 후보들. + const sigRe = /(?:function\s*\w*\s*\([^)]*\)\s*\{|=>\s*\{|\b\w+\s*\([^)]*\)\s*\{)/g; + let m: RegExpExecArray | null; + while ((m = sigRe.exec(src)) !== null) { + const openIdx = src.indexOf('{', m.index); + if (openIdx === -1) continue; + total++; + // matching close + let depth = 1, j = openIdx + 1; + let inStr: string | null = null; + for (; j < src.length; j++) { + const ch = src[j]; + if (inStr) { + if (ch === '\\') { j++; continue; } + if (ch === inStr) inStr = null; + continue; + } + if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; } + if (ch === '{') depth++; + else if (ch === '}') { depth--; if (depth === 0) break; } + } + if (depth !== 0) continue; // 짝 안 맞으면 skip + const body = src.slice(openIdx + 1, j); + // body 내용 의미 라인만. + const meaningful = body.split(/\r?\n/).filter((l) => { + const t = l.trim(); + if (!t) return false; + if (t.startsWith('//') || t.startsWith('/*') || t.startsWith('*')) return false; + if (_isStubLine(t)) return false; + return true; + }); + if (meaningful.length === 0) hollow++; + } + return { total, hollow }; +} + +/** 단일 파일 검사. 지원 안 하는 확장자면 ok 반환 (skip). */ +export function checkHollow(absPath: string, projectRoot: string): HollowCheckResult { + const relPath = path.relative(projectRoot, absPath); + const ext = path.extname(absPath).toLowerCase(); + const result: HollowCheckResult = { relPath, ok: true, reasons: [] }; + if (!_isSupportedExt(ext)) return result; + + let src = ''; + try { + src = fs.readFileSync(absPath, 'utf8'); + } catch { + return result; // 못 읽으면 검사 skip (다른 검증 layer가 잡음) + } + + const lines = src.split(/\r?\n/); + const totalLines = lines.length; + const codeLines = lines.filter((l) => _isMeaningfulCodeLine(l, ext)).length; + const stubLines = lines.filter(_isStubLine).length; + const todoMatches = (src.match(/\b(?:TODO|FIXME|XXX|HACK)\b/gi) ?? []).length; + + const reasons: string[] = []; + + // 패턴 1 — 코드 라인이 너무 적음 (단순 모듈 보호: 6줄 미만은 의심). + if (codeLines < 4) { + reasons.push(`의미 있는 코드가 너무 적음 (${codeLines}줄)`); + } + + // 패턴 2 — stub 비율이 높음. + const stubFnRes = (ext === '.py') + ? _countPyHollowFunctions(src) + : _countJsHollowFunctions(src); + const stubFnRatio = stubFnRes.total > 0 ? stubFnRes.hollow / stubFnRes.total : 0; + if (stubFnRes.total > 0 && stubFnRes.hollow === stubFnRes.total) { + reasons.push(`모든 함수(${stubFnRes.total}개)가 stub만 있음`); + } else if (stubFnRes.total >= 2 && stubFnRatio >= 0.5) { + reasons.push(`함수 ${stubFnRes.hollow}/${stubFnRes.total}개가 stub`); + } + + // 패턴 3 — TODO/FIXME 텍스트 라벨이 코드 라인 수를 압도. + const todoRatio = codeLines > 0 ? todoMatches / codeLines : 0; + if (todoMatches >= 2 && todoRatio >= 0.5) { + reasons.push(`TODO/FIXME가 너무 많음 (${todoMatches}개)`); + } + + // 패턴 4 — 파일 전체가 import만. + const allImports = lines.every((l) => { + const t = l.trim(); + if (!t) return true; + if (t.startsWith('#') || t.startsWith('//')) return true; + if (ext === '.py') { + return /^(?:from\s+\S+\s+import\s|import\s)/.test(t); + } + return /^(?:import\s|export\s+\{|export\s+\*|export\s+default\s+from\s|const\s+\w+\s*=\s*require\()/.test(t); + }); + if (allImports && codeLines > 0) { + reasons.push('파일에 import 외 실제 로직이 없음'); + } + + if (reasons.length > 0) { + result.ok = false; + result.reasons = reasons; + } + result.meta = { totalLines, codeLines, stubFnRatio, todoRatio }; + return result; +} + +/** + * report 한 줄에서 `✅ Created: foo.py` / `✅ Edited: foo.py` 형태 경로 추출. + * Phase C와 동일 로직. + */ +function _extractPathFromReportLine(line: string): string | null { + const m = line.match(/^\s*[✅⚠️]\s*(?:Created|Edited|Updated)\s*:\s*(.+)$/); + return m ? m[1].trim() : null; +} + +/** + * report 내 모든 생성/편집 파일에 대해 hollow 검사 실행. 추가 report 라인을 + * 반환 — 호출자가 actionReport에 그대로 append. + * + * @returns 빈 깡통 경고 라인들 + ok 통과 라인. 검사 대상 없으면 빈 배열. + */ +export function verifyHollow(report: string[], projectRoot: string): { + extraLines: string[]; + hasHollow: boolean; + hollowReasons: string[]; +} { + const extraLines: string[] = []; + const hollowReasons: string[] = []; + for (const line of report) { + const rel = _extractPathFromReportLine(line); + if (!rel) continue; + const abs = path.isAbsolute(rel) ? rel : path.join(projectRoot, rel); + if (!abs.startsWith(projectRoot)) continue; + if (!fs.existsSync(abs)) continue; + const check = checkHollow(abs, projectRoot); + const ext = path.extname(abs).toLowerCase(); + if (!_isSupportedExt(ext)) continue; + if (check.ok) { + // 통과는 표시 안 함 (노이즈) — 실패만 보고. + } else { + const reasonStr = check.reasons.join(' / '); + extraLines.push(`❌ Hollow code: ${check.relPath} — ${reasonStr}`); + for (const r of check.reasons) hollowReasons.push(`${check.relPath}: ${r}`); + } + } + return { extraLines, hasHollow: extraLines.length > 0, hollowReasons }; +} diff --git a/src/features/selfReflector/selfReflectorPrompt.ts b/src/features/selfReflector/selfReflectorPrompt.ts new file mode 100644 index 0000000..b7b7211 --- /dev/null +++ b/src/features/selfReflector/selfReflectorPrompt.ts @@ -0,0 +1,108 @@ +/** + * Self-Reflector — 답변 산출물의 *자기 검증* 레이어. + * + * Memory(기억) 단계는 이미 충분히 강하지만 Verification(검증) 단계는 사용자 + * 피드백에 의존적이다. 이 모듈은 그 격차를 메꾸는 3단 구조: + * + * Phase A (이 파일) ─ 시스템 프롬프트에 self-check 체크리스트 룰을 박아 + * LLM이 *응답 마지막에* [Self-Reflector Check] 섹션을 자동으로 붙이게 + * 한다. 추가 LLM 콜 비용 0, constraint-driven generation 효과로 응답 + * 품질 자체가 올라간다. + * + * Phase B (selfReflectorVerifier.ts) ─ 응답 직후 *분리된 콘텍스트*에서 + * LLM 한 번 더 호출해 외부 시각으로 검증. 같은 모델·같은 콘텍스트의 + * 한계를 보완. 회사 모드 specialist 응답에만 옵션으로 적용. + * + * Phase C (selfReflectorExecution.ts) ─ 코드 답변에 한해 syntax/lint를 + * 실제로 돌려 *실행 기반* 검증. 텍스트 검증으로는 잡지 못하는 + * undefined variable, 잘못된 import 등을 잡아낸다. + * + * 세 phase 모두 사용자가 설정 토글로 끌 수 있어야 한다. 본질적으로 *추측을 + * 자기 검증으로 누르는* 안전망이라 끌 이유는 별로 없지만, 비용이나 latency가 + * 부담스러운 경우(특히 Phase B/C)는 OFF 권장. + */ + +/** + * 모든 LLM 응답 끝에 자동으로 붙는 self-check 룰 블록. 시스템 프롬프트의 + * 가장 끝에 prepend 되어 다른 어떤 룰보다도 *마지막에* 적용된다. + * + * 작성 원칙: + * - 항목은 4개 이내 — 너무 많으면 LLM이 무성의하게 채움 + * - 코드 답변에는 추가 항목 (References / Paths) — 결과가 디스크에 떨어지는 + * 케이스의 가장 흔한 실수 두 가지를 직접 잡는다 + * - "Yes/Checked/N-A" 같은 일관된 마커를 강제해서 사용자가 시선 흐름으로 + * 스캔 가능하게 + * - 자기 비판이 아니라 *사실 확인*. 본문에서 이미 했더라도 한 번 더 명시. + */ +export const SELF_REFLECTOR_RULE_BLOCK = ` +## [Self-Reflector Check — 매 답변 끝에 *반드시* 추가] + +답변 본문이 끝난 직후, 빈 줄 한 줄을 두고 아래 형식의 self-check 블록을 *마지막 출력*으로 붙이세요. 본문이 한 줄짜리 잡담(인사·동의)인 경우는 생략 가능하지만, 코드·구조 설명·결정 사항이 포함된 답변은 *예외 없이* 붙입니다. + +\`\`\` +[Self-Reflector Check] +- Consistency: +- Completeness: +- Accuracy: +\`\`\` + +답변에 *코드 / 파일 경로 / 명령*이 포함됐다면 아래 두 줄을 추가: +\`\`\` +- References: +- Paths: +\`\`\` + +규칙: +- 항목 값은 짧게(한 줄). 본문 반복 금지. +- "Checked"는 *방금 검토했다*는 뜻이지 "완벽하다"는 뜻이 아닙니다. 의심이 있으면 그 의심을 적으세요. +- 자기 평가를 부드럽게 포장하지 마세요. 누락이 있으면 누락이라고 적습니다. +- 이 블록은 사용자를 위한 *투명성 장치*입니다 — 사용자가 답변 신뢰도를 빠르게 가늠할 수 있어야 합니다. +`.trim(); + +/** + * 코드 답변에 한해 추가하는 강한 가드. promptBuilder가 stage의 직군이 + * developer면 specialist prompt에 추가로 prepend 한다. + */ +export const SELF_REFLECTOR_CODE_GUARD = ` +## [Code Self-Verification — 코드 작성 시 추가 검증] + +코드 / 파일을 작성하기 *전에* 다음을 머릿속에서 한 번 더 점검: +1. 참조하는 변수·함수·import가 *실제로 존재*하거나 *이 응답 안에서 정의*되는가 +2. 파일 경로가 워크스페이스 안인가 (절대 경로는 워크스페이스 루트 하위여야 함) +3. 기존 파일을 수정하는 경우 \`\` 으로 먼저 *현재 내용을 확인*한 뒤 \`\`로 부분 변경 +4. 새 파일이 의존하는 패키지가 프로젝트에 이미 있는지 (없으면 답변 본문에 "추가 설치 필요" 명시) + +🛑 **빈 깡통 코드 절대 금지 (이 룰을 어기면 즉시 재작업)** + 다음 패턴 중 하나라도 만든 파일에 있으면 *완성된 코드가 아닙니다*: + - 본문이 \`pass\` 한 줄뿐인 함수 + - 본문이 \`return None\` / \`return null\` / \`return\` 한 줄뿐인 함수 (의도된 stub 아닌 한) + - 본문이 주석/TODO/FIXME/placeholder 텍스트뿐인 함수 + - 클래스 정의 안이 \`pass\` 뿐 + - import만 있고 *실제 로직이 한 줄도 없는 모듈* + - "여기에 X를 구현하세요" 같은 자리표시 문자열 + - 함수 시그니처만 있고 본문이 \`...\` 뿐 + + "완료했습니다"라고 말하기 전에 *생성한 모든 파일의 내용*을 다시 보고 위 패턴이 없는지 확인하세요. + 진짜로 stub이 *의도된* 경우(예: 인터페이스 정의)는 \`# stub: 이유\` 주석으로 명시해야 합니다. + 사용자는 빈 깡통 파일을 가장 싫어합니다 — 완성된 로직이 들어가야 답변이 끝납니다. + +체크 결과는 [Self-Reflector Check]의 References / Paths 줄에 한 줄로 요약하고, 빈 깡통 의심 항목이 있다면 거기에 명시하세요. +`.trim(); + +/** + * 시스템 프롬프트 끝에 self-reflector 룰을 *조건부로* 추가하는 헬퍼. + * + * @param baseSystem 원본 시스템 프롬프트 + * @param opts.enabled 사용자 설정 — false면 원본 그대로 반환 (룰 미추가) + * @param opts.includeCodeGuard true면 코드 가드 블록도 추가 (developer 직군 등) + */ +export function appendSelfReflectorRule( + baseSystem: string, + opts: { enabled: boolean; includeCodeGuard?: boolean } = { enabled: true }, +): string { + if (!opts.enabled) return baseSystem; + const parts = [baseSystem.trimEnd()]; + if (opts.includeCodeGuard) parts.push('', SELF_REFLECTOR_CODE_GUARD); + parts.push('', SELF_REFLECTOR_RULE_BLOCK); + return parts.join('\n'); +} diff --git a/src/features/selfReflector/selfReflectorVerifier.ts b/src/features/selfReflector/selfReflectorVerifier.ts new file mode 100644 index 0000000..2837345 --- /dev/null +++ b/src/features/selfReflector/selfReflectorVerifier.ts @@ -0,0 +1,172 @@ +/** + * Self-Reflector Phase B — *분리된 콘텍스트*에서 LLM 한 번 더 호출해 응답을 + * 외부 시각으로 검증. + * + * Phase A의 self-check는 같은 모델·같은 콘텍스트에서 자기 자신을 보는 한계가 + * 있다. 모델이 자기가 만든 답변을 자신 있게 잘못 평가하는 *과신 편향*은 + * LLM의 잘 알려진 약점이다. Phase B는 이걸 보완하기 위해: + * + * 1. specialist 응답이 끝나면 + * 2. *새로운* system prompt로 LLM에게 (task + 응답)을 보여주고 + * 3. "이 응답이 task를 충실히 처리했나? 명백한 오류가 있나?"를 묻는다 + * 4. {verdict: pass|warn|fail, issues: [...]} JSON으로 받음 + * 5. fail이면 issue들을 prepend해 같은 specialist 1회 retry + * + * 호출자(dispatcher)는 verdict + issues + final response를 받아 그대로 chat에 + * 표시한다. 검증 LLM 자체가 실패해도 *원본 응답은 보존* — 검증 layer가 + * 진행 자체를 막지 않는다. + */ +import { IAIService } from '../../core/services'; +import { logError, logInfo } from '../../utils'; + +export interface VerifyInput { + /** specialist에게 줬던 task 문자열 (revisionNote 등 prefix 포함). */ + task: string; + /** specialist의 raw 응답 (action-tag 실행 *전*). */ + response: string; + /** specialist가 누구였는지 (검증 프롬프트 컨텍스트). */ + agentName: string; + /** 검증에 사용할 모델. 비싸지 않아도 OK — 검증은 짧고 가볍게. */ + model?: string; + /** Requirement Contract — 있으면 검증 기준으로 직접 활용. */ + contractBlock?: string; +} + +export type VerifyVerdict = 'pass' | 'warn' | 'fail'; + +export interface VerifyResult { + verdict: VerifyVerdict; + /** 발견된 이슈 목록 (verdict='warn'·'fail' 시 1~3개). */ + issues: string[]; + /** 한 줄 요약 — chat label용. */ + summary: string; + /** 검증 LLM이 실패한 경우 true; 호출자는 원본 응답 보존하고 진행. */ + verifierError?: boolean; +} + +const SYSTEM_PROMPT = `당신은 *외부 감리* 입니다. 다른 에이전트가 방금 사용자 task에 대해 만든 응답을 객관적으로 점검합니다. 응답을 만든 본인이 아니므로 *과신 없이* 보세요. + +점검 기준: + 1. task 요구를 *직접* 처리했는가 (회피·동문서답 X) + 2. 명백한 사실 오류·논리 모순이 없는가 + 3. 코드/파일이 포함됐다면 정의되지 않은 변수·잘못된 경로·존재하지 않는 import가 없는가 + 4. Requirement Contract가 있으면 criteria를 위반하지 않는가 + +🛑 **빈 깡통(Hollow Code) 자동 fail**: + 코드 파일이 포함됐는데 *실제 로직이 비어 있으면* 무조건 "fail": + - 함수 본문이 \`pass\` / \`return None\` / \`return null\` / \`...\` 한 줄뿐 + - 함수 본문이 TODO/FIXME 주석뿐 + - 클래스/모듈에 import만 있고 로직 0줄 + - "여기에 X를 구현하세요" 같은 placeholder만 들어 있음 + 이런 패턴은 *문법은 통과해도 사용자가 원한 결과물이 아닙니다*. issues에 + "빈 깡통: <함수명> 본문이 stub뿐" 처럼 *구체적인 위치*를 적으세요. + +평가 라벨: + - "pass" : 위 모든 기준 통과 + - "warn" : 일부 약점이 있지만 사용자가 받아 볼 만함 (issues에 적기) + - "fail" : 핵심 오류·누락 또는 *빈 깡통* 발견 — 재작업 필요 (issues에 적기) + +⚠️ 반드시 아래 JSON 한 번만. 다른 텍스트(설명·코드펜스) 일체 금지. + +{"verdict":"pass"|"warn"|"fail","issues":["<이슈1>","<이슈2>"],"summary":"한 줄(30자 이내)"}`; + +function _buildUserMessage(input: VerifyInput): string { + const lines: string[] = []; + if (input.contractBlock) { + lines.push(input.contractBlock); + lines.push(''); + } + lines.push(`[검증 대상 에이전트] ${input.agentName}`); + lines.push(''); + lines.push('[task]'); + lines.push(input.task.slice(0, 2000)); + lines.push(''); + lines.push('[해당 에이전트의 응답]'); + lines.push((input.response || '').slice(0, 4000)); + lines.push(''); + lines.push('점검 JSON만 출력:'); + return lines.join('\n'); +} + +function _parseVerdictJson(raw: string): { verdict: VerifyVerdict; issues: string[]; summary: string } | null { + if (!raw || !raw.trim()) return null; + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + const stage1 = (fenced ? fenced[1] : raw).trim(); + const tryParse = (s: string) => { + try { + const obj = JSON.parse(s) as Record; + const v = typeof obj.verdict === 'string' ? obj.verdict.toLowerCase().trim() : ''; + if (v !== 'pass' && v !== 'warn' && v !== 'fail') return null; + const issues = Array.isArray(obj.issues) + ? obj.issues.filter((x): x is string => typeof x === 'string' && x.trim().length > 0) + .map((x) => x.trim()).slice(0, 5) + : []; + const summary = typeof obj.summary === 'string' ? obj.summary.trim() : ''; + return { verdict: v as VerifyVerdict, issues, summary }; + } catch { + return null; + } + }; + const direct = tryParse(stage1); + if (direct) return direct; + const m = stage1.match(/\{[\s\S]*\}/); + if (m) { + const balanced = tryParse(m[0]); + if (balanced) return balanced; + } + // 최후의 fallback: 정규식으로 verdict만이라도. + const verdictMatch = stage1.match(/"verdict"\s*:\s*"(pass|warn|fail)"/i); + if (verdictMatch) { + return { + verdict: verdictMatch[1].toLowerCase() as VerifyVerdict, + issues: [], + summary: '', + }; + } + return null; +} + +/** + * 검증 한 회차. 호출 실패 / JSON 파싱 실패는 *통과로 간주* (verifierError=true + * 표시). 검증 layer가 작업 진행을 막지 않게 — 본래 의도가 안전망이지 통제관문이 + * 아니므로. + */ +export async function verifyResponse( + ai: IAIService, + input: VerifyInput, +): Promise { + let raw = ''; + try { + const result = await ai.chat({ + system: SYSTEM_PROMPT, + user: _buildUserMessage(input), + model: input.model, + }); + raw = result.content || ''; + } catch (e: any) { + logError('selfReflectorVerifier: call failed; treating as pass.', { error: e?.message ?? String(e) }); + return { verdict: 'pass', issues: [], summary: '검증 실패 — 원본 유지', verifierError: true }; + } + const parsed = _parseVerdictJson(raw); + if (!parsed) { + logInfo('selfReflectorVerifier: parse failed; treating as pass.', { rawHead: raw.slice(0, 100) }); + return { verdict: 'pass', issues: [], summary: '검증 응답 파싱 실패 — 원본 유지', verifierError: true }; + } + return { + verdict: parsed.verdict, + issues: parsed.issues, + summary: parsed.summary || `verdict: ${parsed.verdict}`, + }; +} + +/** + * 검증 결과를 retry 시 사용할 prompt prefix로 직렬화. 호출자가 task 앞에 + * prepend 해 specialist를 1회 더 호출한다. + */ +export function formatIssuesForRetry(issues: string[]): string { + if (!issues.length) return ''; + const lines: string[] = []; + lines.push('[외부 감리 지적 — 반드시 반영]'); + for (const i of issues) lines.push(`- ${i}`); + return lines.join('\n'); +} diff --git a/src/retrieval/knowledgeMix.ts b/src/retrieval/knowledgeMix.ts index b53e90e..2bb37a7 100644 --- a/src/retrieval/knowledgeMix.ts +++ b/src/retrieval/knowledgeMix.ts @@ -1,22 +1,23 @@ /** - * Knowledge Mix — controls how much the assistant leans on Second Brain - * evidence vs. the model's own general knowledge for a given query. + * Knowledge Mix — model 지식 vs Second Brain 지식의 *상대 비율*을 LLM에게 + * 전달하는 정책 레이어. * - * The single integer "secondBrainWeight" (0–100) drives three things: + * ── 정책 v2 (상대값+상대값=상대값) ────────────────────────────────────── + * weight는 0~100 정수이지만 *상대 비율*로만 해석한다. 즉 "70"은 "100% 중 + * 70%"라는 상대 표현이고, 시스템이 도중에 *절대 정수*(예: brain 파일 N개) + * 로 변환하지 않는다. 절대 변환은 v1의 핵심 약점이었다 — 사용자가 입력한 + * 상대적 의미가 brain 파일 개수라는 절대 정수로 펴지면서 LLM이 받는 + * 비율 정보와 retrieve된 실제 양이 따로 놀았다. * - * 1. RAG chunk budget — how many brain files we feed the model. - * 2. Retrieval ratio — what fraction of the context budget RAG can claim. - * 3. Prompt policy — natural-language instruction injected into the - * system prompt telling the model how to balance - * its own knowledge against the evidence shown. + * v2에서 weight가 *실제로* 영향을 미치는 곳은 단 한 군데: + * - `buildKnowledgeMixPolicy` — LLM 시스템 프롬프트에 "model X% / brain Y%" + * 자연어 정책을 삽입. 비율 그 자체만 모델에게 전달. * - * Per-agent overrides (AgentKnowledgeEntry.secondBrainWeight) win over the - * global config (g1nation.knowledgeMix.secondBrainWeight). Both fall back to - * the default `DEFAULT_WEIGHT` (balanced) when nothing is set. + * 절대값 측면(brain 파일 개수, context 예산 비율)은 사용자 설정 + * (`memoryLongTermFiles`)을 그대로 사용. 두 극단값(weight=0, weight=100)만 + * 안전 차원에서 절대 의미를 유지 — 0이면 0개·5% 예산, 100이면 50% 예산. * - * Keeping this module isolated and pure makes it trivial to unit-test the - * mapping curve and to extend it later (e.g. add a "creative" axis) without - * touching retrieval or prompt assembly. + * 우선순위: per-agent override → global config → DEFAULT_WEIGHT(50). */ import { getConfig } from '../config'; import { getOrCreateAgentEntry } from '../skills/agentKnowledgeMap'; @@ -73,42 +74,45 @@ function _clamp(n: number): number { } /** - * Map a weight to the maximum number of brain files (long-term memory) the - * retriever is allowed to consider for this turn. + * Brain 파일 *최대 개수*를 결정. * - * Curve was chosen so that: - * - 0 fully disables brain-file retrieval (model-only mode). - * - 50 maps to roughly the existing default (`memoryLongTermFiles`), so - * behaviour without any per-agent setting matches the status quo. - * - 100 pushes the cap toward `selectWithinBudget`'s hard ceiling (12). + * Knowledge Mix v2 정책 (상대값+상대값=상대값): + * weight는 LLM에게 *얼마나 신뢰할지*를 전달하는 상대 비율일 뿐, brain 파일 + * *개수 자체*를 좌우하지 않는다. 사용자가 명시적으로 설정한 + * `memoryLongTermFiles`(=`configuredLimit`)를 그대로 사용. * - * The configured `memoryLongTermFiles` is treated as the "balanced" baseline: - * it's scaled up at high weights and damped at low weights. + * weight가 의미를 가지는 극단값 두 가지만 절대 의미를 유지한다: + * - weight=0 → 0개 (사용자가 명시적으로 "brain 사용 안 함" 선언) + * - 그 외 → configuredLimit 그대로 + * + * 이전 v1은 weight=70이면 8개, weight=30이면 4개 식으로 절대 정수 변환을 했고 + * 이게 "사용자가 의도한 70%라는 상대 비율"의 의미를 도중에 잃게 만드는 + * 원인이었다. 비율 표현은 `buildKnowledgeMixPolicy`가 LLM에게 자연어로 + * 전달하는 역할 하나만 맡는다. */ export function mapWeightToBrainFileLimit(weight: number, configuredLimit: number): number { const w = _clamp(weight); if (w === 0) return 0; const baseline = Math.max(1, configuredLimit || 6); - // Linear interpolation: - // w=0 → 0 - // w=25 → baseline * 0.5 - // w=50 → baseline - // w=75 → baseline * 1.5 - // w=100 → baseline * 2 (capped at 12 elsewhere) - const scaled = Math.round((w / 50) * baseline); - // Honour the orchestrator's hard cap (12) so we never blow the budget. - return Math.max(0, Math.min(12, scaled)); + // 안전 상한 12는 그대로 — context budget 폭주 방지. 그 외엔 사용자 설정 그대로. + return Math.max(0, Math.min(12, baseline)); } /** - * Map a weight to the retrieval ratio (fraction of the context-budget that - * RAG can claim). Lower weights mean RAG gets a smaller slice and leaves more - * room for conversation history / system prompt. + * Brain retrieval에 할당할 context-budget 비율. + * + * Knowledge Mix v2 정책: weight와 *분리*. budget 분배는 시스템 안정성에 관한 + * 절대 결정이지 사용자가 입력한 상대 비율이 직접 좌우할 일이 아니다. 다만 + * 두 극단값에서만 의미를 유지: + * - weight=0 → 0.05 (검색 자체가 비활성화되어도 다른 컨텍스트는 살림) + * - weight=100 → 0.50 (brain이 거의 유일한 근거일 때 더 큰 슬라이스) + * - 그 외 → 0.40 (균형 baseline) */ export function mapWeightToRetrievalRatio(weight: number): number { const w = _clamp(weight); - // 0 → 0.05 (still room for tiny lessons block), 50 → 0.40 (status quo), 100 → 0.60. - return Math.max(0.05, Math.min(0.6, 0.05 + (w / 100) * 0.55)); + if (w === 0) return 0.05; + if (w === 100) return 0.5; + return 0.4; } /** diff --git a/src/sidebar/chatHandlers.ts b/src/sidebar/chatHandlers.ts index 46286cd..2feee99 100644 --- a/src/sidebar/chatHandlers.ts +++ b/src/sidebar/chatHandlers.ts @@ -19,12 +19,153 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any provider._lmStudio?.activity.bump(); await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false); // ── 1인 기업 모드 우선 분기 ── - // When company mode is active, route the prompt through the - // CEO planner / sequential dispatcher / synthesis pipeline - // instead of the normal single-agent path. The user-facing - // chat surface is the same — only the runtime differs. + // 회사 모드일 땐 들어온 메시지를 intent classifier에 한 번 통과시켜 + // (a) 잡담/질문/짧은 응답 → 일반 채팅 경로, (b) 후속 대화 → 일반 채팅 + // 경로 + followup 라벨, (c) 신규 업무 → 풀 파이프라인 dispatch. + // classifier가 비활성화돼 있거나 호출 실패 시엔 안전하게 new_task로 + // 폴백 — 즉 사용자가 의도한 작업 요청을 놓치는 일은 절대 없다. if (provider.isCompanyModeEnabled() && typeof data.value === 'string' && data.value.trim()) { - await provider._runCompanyTurn(data.value.trim()); + let userPrompt = data.value.trim(); + const { getConfig } = await import('../config'); + const cfg = getConfig(); + const { readCompanyState, resolveActivePipeline } = await import('../features/company'); + const state = readCompanyState(provider._context); + + // ── alignment 답변 라우팅 ── + // 사용자가 이전 메시지에서 alignment 카드를 받아 답변하는 중이면 + // 이 메시지를 분류기/dispatch가 아니라 alignment 답변 핸들러로 + // 보낸다. 답변이 처리되면서 자동으로 다음 라운드 또는 pipeline + // 으로 진행됨. + if (provider.isAlignmentPending()) { + await provider._handleAlignmentAnswer(userPrompt); + return true; + } + + // ── 사용자 키워드 override ── + // 입력 맨 앞에 `[파이프라인:id]` 또는 `[pipeline:id]`가 있으면 + // 분류기 무관하게 그 파이프라인 강제 + 그 키워드는 prompt에서 + // 제거 후 dispatcher에 전달. id가 유효하지 않으면 무시(분류기 정상 경로). + let keywordOverrideId: string | undefined; + const keywordMatch = userPrompt.match(/^\s*\[(?:파이프라인|pipeline)\s*:\s*([a-z0-9_-]+)\s*\]\s*/i); + if (keywordMatch) { + const id = keywordMatch[1].toLowerCase(); + if (state.pipelines?.[id]) { + keywordOverrideId = id; + userPrompt = userPrompt.slice(keywordMatch[0].length).trim() || userPrompt; + } + } + + // ── alignment bypass 키워드 ── + // 입력 맨 앞 `[건너뛰기]` 또는 `[skip]` → alignment 단계 1회 우회. + // 사용자가 "지금은 빨리 가자"라고 명시한 경우에만 사용. prompt에서 + // 키워드 제거. + let alignmentBypass = false; + const bypassMatch = userPrompt.match(/^\s*\[(?:건너뛰기|skip)\]\s*/i); + if (bypassMatch) { + alignmentBypass = true; + userPrompt = userPrompt.slice(bypassMatch[0].length).trim() || userPrompt; + } + + if (cfg.companyDisableIntentClassifier) { + // 분류기 우회 모드 — 분류 단계는 건너뛰지만 alignment는 별도로 + // 작동(사용자가 alignment off로 설정하지 않은 한). 분류기 끄는 + // 이유는 보통 "잡담도 다 pipeline으로"인데 그럴수록 alignment + // 효과가 큼. + try { provider.pixelOfficeOnIntentClassified('new_task', userPrompt); } catch { /* noop */ } + if (cfg.companyIntentAlignmentMode === 'off' || alignmentBypass) { + await provider._runCompanyTurn(userPrompt, undefined, keywordOverrideId); + } else { + await provider._runIntentAlignment({ + userPrompt, + pipelineIdOverride: keywordOverrideId, + mode: cfg.companyIntentAlignmentMode === 'strict' ? 'strict' : 'smart', + roundsLimit: cfg.companyIntentAlignmentMaxRounds, + roundsAsked: 0, + }); + } + return true; + } + const { classifyChatIntent } = await import('../features/company'); + const { AIService } = await import('../core/services'); + const last = provider.getLastCompanyTurnSummary(); + const activePipeline = resolveActivePipeline(state); + // 사용 가능한 모든 파이프라인을 분류기 후보로 전달 — 단, 활성화돼 + // 있어야 추천 의미가 있는 게 아니라 *정의돼 있기만 하면* 후보. 사용자가 + // 평소엔 짧은 걸 활성화해 두고 가끔 풀 사이클 의도가 명확한 발화를 + // 했을 때 분류기가 그쪽을 추천할 수 있게. + const allPipelines = Object.values(state.pipelines ?? {}); + const verdict = await classifyChatIntent( + new AIService(), + userPrompt, + { + previousBrief: last?.brief, + previousReportTail: last?.reportTail, + previousTurnAt: last?.finishedAt, + activePipelineName: activePipeline?.name, + availablePipelines: allPipelines.length > 0 + ? allPipelines.map((p) => ({ + id: p.id, + name: p.name, + stageCount: p.stages.length, + })) + : undefined, + }, + { model: cfg.companyIntentClassifierModel || cfg.defaultModel }, + ); + // Pixel Office: 분류 결과를 UI layer로만 흘림. 아래 분기 자체엔 영향 없음. + try { provider.pixelOfficeOnIntentClassified(verdict.intent, userPrompt); } catch { /* noop */ } + if (verdict.intent === 'new_task') { + // 우선순위: (1) 사용자 키워드 (2) autoSelect가 켜져 있고 분류기 추천 있음 (3) 사용자 활성 파이프라인. + let effectiveOverride = keywordOverrideId; + if (!effectiveOverride && cfg.companyAutoSelectPipeline && verdict.suggestedPipelineId) { + effectiveOverride = verdict.suggestedPipelineId; + } + // 분류기가 추천을 냈지만 autoSelect가 꺼져 있을 땐 라벨로만 안내. + if (verdict.suggestedPipelineId && !effectiveOverride && !cfg.companyAutoSelectPipeline) { + const tip = state.pipelines?.[verdict.suggestedPipelineId]; + if (tip) { + provider._view?.webview.postMessage({ + type: 'companyIntentDecision', + value: { + intent: 'new_task', + reason: `🧭 추천 파이프라인: "${tip.name}" (자동 적용은 설정 토글)`, + label: '🛠️ 신규 업무', + }, + }); + } + } else if (effectiveOverride && effectiveOverride !== state.activePipelineId) { + const used = state.pipelines?.[effectiveOverride]; + if (used) { + provider._view?.webview.postMessage({ + type: 'companyIntentDecision', + value: { + intent: 'new_task', + reason: keywordOverrideId + ? `🔧 키워드 override → "${used.name}"` + : `🧭 CEO 자동 선택 → "${used.name}"`, + label: '🛠️ 신규 업무', + }, + }); + } + } + // ── Intent Alignment 진입 ── + // off 모드이거나 bypass 키워드가 있으면 alignment 우회하고 + // legacy 동작 (즉시 dispatch). 그 외엔 분석기 1라운드 돌려 + // confidence에 따라 자동 진행 또는 카드 표시. + if (cfg.companyIntentAlignmentMode === 'off' || alignmentBypass) { + await provider._runCompanyTurn(userPrompt, undefined, effectiveOverride); + } else { + await provider._runIntentAlignment({ + userPrompt, + pipelineIdOverride: effectiveOverride, + mode: cfg.companyIntentAlignmentMode === 'strict' ? 'strict' : 'smart', + roundsLimit: cfg.companyIntentAlignmentMaxRounds, + roundsAsked: 0, + }); + } + } else { + await provider._handleCompanyCasual(userPrompt, verdict.intent, verdict.reason, data); + } return true; } await provider._handlePrompt(data); @@ -48,6 +189,9 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any // Restore the Company chip from globalState so the user sees the same // mode they had on at last shutdown. await provider._sendCompanyStatus(); + // Pixel Office — 첫 로드 시 빈 idle 상태라도 한 번 push해서 webview가 + // 영역 자체를 그릴 수 있게. + provider.pixelOfficeResend(); return true; case 'getReadyStatus': await provider._sendReadyStatus(); @@ -71,6 +215,10 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any await provider._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null); await provider._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null); await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, true); + // 직전 회사 turn 컨텍스트 캐시 비우기 — 새 세션은 followup 기준점이 없다. + provider.clearLastCompanyTurnSummary(); + // 진행 중이던 alignment도 새 세션과 함께 폐기. + provider.cancelPendingAlignment(); provider.clearChat(); await provider._sendBrainStatus(); return true; @@ -78,9 +226,17 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any // 1인 기업 모드는 AgentExecutor를 거치지 않으므로 별도 abort 경로. // 두 경로 모두 신호를 보내 두면 중간에 모드 전환되어도 안전. provider.abortCompanyTurn(); + // 진행 중인 Intent Alignment도 같이 정리 — 사용자가 Stop 누르면 + // 의도상 모든 대기 상태 해제. + provider.cancelPendingAlignment(); provider._agent.stop(); return true; case 'loadSession': + // 세션 전환 시 직전 회사 turn 캐시 무효화 — 로드된 세션의 직전 회사 + // 작업은 별도 파일에서 복원되어야 하지 메모리 캐시에 남은 다른 + // 세션의 보고서가 새 세션 첫 메시지의 followup 판정을 오염시키면 안 됨. + provider.clearLastCompanyTurnSummary(); + provider.cancelPendingAlignment(); await provider._loadSession(data.id); return true; case 'deleteSession': @@ -401,6 +557,25 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any }); return true; } + case 'getPixelOfficeState': + // webview가 처음 로드되었거나 사용자가 토글 ON 했을 때 캐시된 + // 현재 상태를 다시 받기 위한 요청. read-only. + provider.pixelOfficeResend(); + return true; + case 'openPixelOfficePanel': + // 사이드바 mini Pixel Office의 ⛶ 버튼 → editor area에 전체보기 panel 열기. + provider.openPixelOfficePanel(); + return true; + case 'respondCompanyAlignment': { + // alignment 카드 버튼: 'proceed' = 현 contract로 dispatch, 'cancel' = 폐기. + const decision = typeof data.decision === 'string' ? data.decision : ''; + if (decision === 'proceed') { + await provider._proceedWithCurrentAlignment(); + } else if (decision === 'cancel') { + provider.cancelPendingAlignment(); + } + return true; + } case 'respondCompanyApproval': { // Webview의 승인 카드 버튼 클릭 → dispatcher의 await 해제. // payload: { stageId, decision: 'approve' | 'revise' | 'abort', comment? } diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index 3fd146b..cafe97d 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -91,6 +91,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn static readonly lastAutoChronicleSignatureStateKey = 'g1nation.lastAutoChronicleSignature'; _view?: vscode.WebviewView; _panel?: vscode.WebviewPanel; + /** + * Pixel Office "전체보기" — editor area에 띄운 별도 webview panel. + * 사이드바 mini 패널과 같은 `pixelOfficeUpdate` 메시지 스트림을 받고 + * 더 큰 화면에서 직군별 캐릭터들을 사무실 배경 위에 배치해 보여준다. + * 닫혀 있으면 undefined — broadcast 시 안전하게 skip. + */ + private _pixelOfficePanel?: vscode.WebviewPanel; public brainEnabled = true; _currentSessionBrainId: string | null = null; _currentNegativePrompt: string = ''; @@ -122,6 +129,463 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn */ private _pendingApprovals = new Map void>(); + /** + * Snapshot of the last completed company turn — fed into the intent + * classifier so it can distinguish "follow-up on previous round" from + * "brand-new task". Reset to undefined when the chat is cleared / new + * session loaded. Only completed (non-aborted) reports populate this. + */ + private _lastCompanyTurnSummary?: { + brief: string; + reportTail: string; + finishedAt: number; + }; + + /** + * Intent Alignment 진행 중인 상태. new_task가 분류되면 분석기를 돌리고, + * confidence가 충분치 않으면 사용자에게 질문 카드를 띄운 뒤 이 슬롯에 + * 현재 contract를 보관 → 다음 사용자 메시지를 *답변*으로 해석한다. + * + * 한 번에 한 alignment만 진행. 회사 모드는 sequential 방식이라 동시 + * 다발 alignment가 생길 수 없음. 사용자가 도중 다른 chat을 던지면 + * 그 메시지는 답변으로 합쳐진다 — "취소" 버튼이나 모드 토글로만 빠져 + * 나갈 수 있게 하는 게 흐름상 안전. + */ + private _pendingAlignment?: { + userOriginalPrompt: string; + contract: import('./features/company').RequirementContract; + /** 이번 alignment 동안 사용자가 답한 누적 라운드 수 — 무한 라운드 방지. */ + roundsAsked: number; + /** 이번 turn 한정 pipeline override (Phase 4 분류기에서 전달된 추천). */ + pipelineIdOverride?: string; + }; + + /** + * Pixel Office 현재 작업 상태 캐시. 모든 emit/alignment hook이 한 슬롯에 + * 모인 상태를 patch 형식으로 갱신 → broadcast. UI layer이므로 어떤 판단 + * 로직에도 다시 영향 주지 않는다 — 단방향 read-only 흐름. + */ + private _pixelOfficeState?: import('./features/company').AgentWorkState; + /** 같은 말풍선 텍스트 연달아 안 나오게 추적용 (상태/이벤트 별). */ + private _pixelOfficeLastBubble: Map = new Map(); + + /** Phase B-2에서 chatHandlers가 alignment 진행 여부를 빠르게 확인하는 용도. */ + isAlignmentPending(): boolean { + return !!this._pendingAlignment; + } + + // ─────────────────────── Pixel Office collector ─────────────────────── + // + // 이 섹션은 *기존 emit / alignment / classifier 결과를 가로채* 그대로 + // 모니터링용 상태로 변환하는 단방향 hub. 어떤 메서드도 추가 LLM 콜을 + // 만들지 않고, dispatcher / planner / chatHandlers의 어느 분기에도 + // 영향을 주지 않는다. 그래서 이 섹션 전체를 통째로 지워도 회사 모드는 + // 평소와 동일하게 동작해야 한다 — 그 invariant가 깨지면 위반이다. + + /** webview로 보낼 직전 patch와 가벼운 reset/유틸. */ + private _pixelOfficeBroadcast(patch: Partial, opts?: { + bubbleStatus?: import('./features/company').AgentStatus; + bubbleEvent?: import('./features/company').AgentEvent; + bubbleAgentId?: string; + }): void { + const cfg = getConfig(); + if (!cfg.companyPixelOfficeEnabled) return; + const prev = this._pixelOfficeState ?? { + agentId: 'main', + agentName: 'Agent', + status: 'idle' as import('./features/company').AgentStatus, + recentLogs: [], + updatedAt: Date.now(), + }; + const next: import('./features/company').AgentWorkState = { + ...prev, + ...patch, + recentLogs: patch.recentLogs ?? prev.recentLogs ?? [], + updatedAt: Date.now(), + }; + this._pixelOfficeState = next; + + // 말풍선 — 상태 또는 이벤트가 지정된 경우만 생성. 풀에서 직전에 쓴 텍스트 + // 회피해서 같은 말 연달아 안 나오게. + const bubbles: import('./features/company').AgentBubble[] = []; + if (cfg.companyPixelOfficeBubbles) { + const aid = opts?.bubbleAgentId ?? next.agentId; + if (opts?.bubbleStatus) { + const lastKey = `status:${opts.bubbleStatus}`; + // 동적 import 대신 require로 — 메서드 내부에서 너무 늦게 await 걸지 않게. + const { getStatusBubbleText, makeBubble } = require('./features/company') as typeof import('./features/company'); + const text = getStatusBubbleText(opts.bubbleStatus, this._pixelOfficeLastBubble.get(lastKey)); + if (text) { + this._pixelOfficeLastBubble.set(lastKey, text); + bubbles.push(makeBubble({ agentId: aid, text, type: 'status' })); + } + } + if (opts?.bubbleEvent) { + const lastKey = `event:${opts.bubbleEvent}`; + const { getEventBubbleText, eventBubbleType, makeBubble } = require('./features/company') as typeof import('./features/company'); + const text = getEventBubbleText(opts.bubbleEvent, this._pixelOfficeLastBubble.get(lastKey)); + if (text) { + this._pixelOfficeLastBubble.set(lastKey, text); + bubbles.push(makeBubble({ agentId: aid, text, type: eventBubbleType(opts.bubbleEvent) })); + } + } + } + + const payload = { + type: 'pixelOfficeUpdate' as const, + value: { + state: next, + bubbles, + config: { + enabled: cfg.companyPixelOfficeEnabled, + bubblesEnabled: cfg.companyPixelOfficeBubbles, + maxVisibleBubbles: 3, + bubbleDurationMs: 4500, + }, + }, + }; + // 사이드바 mini panel + 전체보기 webview panel 둘 다 같은 데이터 받음. + this._view?.webview.postMessage(payload); + this._pixelOfficePanel?.webview.postMessage(payload); + } + + /** recentLogs ring buffer push — webview에서 보여주는 "최근 로그". */ + private _pixelOfficeAppendLog(line: string): string[] { + const cur = this._pixelOfficeState?.recentLogs ?? []; + const next = [...cur, line].slice(-6); + return next; + } + + /** Intent classifier가 분류 직후 호출되어 한 줄 상태 진입을 표시. */ + pixelOfficeOnIntentClassified(intent: 'chat' | 'followup' | 'new_task', userPrompt: string): void { + if (intent !== 'new_task') { + // 잡담/후속 — 회사 모드 표면적으로 일 안 하므로 'idle'로 잠시 복귀. + this._pixelOfficeBroadcast({ + status: 'idle', + currentTask: userPrompt, + currentStep: intent === 'followup' ? '직전 라운드 후속 응답' : '잡담 응답', + message: undefined, + recentLogs: this._pixelOfficeAppendLog(`💬 분류: ${intent}`), + }, { bubbleStatus: 'idle' }); + return; + } + // new_task — intake → analyzing 흐름의 첫 신호. + this._pixelOfficeBroadcast({ + status: 'intake', + currentTask: userPrompt, + currentStep: '요청 접수', + recentLogs: this._pixelOfficeAppendLog(`📨 새 작업 요청 접수`), + }, { bubbleStatus: 'intake' }); + } + + /** Intent Alignment 분석 시작 (LLM 콜 직전). */ + pixelOfficeOnAlignmentStart(userPrompt: string): void { + this._pixelOfficeBroadcast({ + status: 'analyzing', + currentTask: this._pixelOfficeState?.currentTask || userPrompt, + currentStep: '요청 분석 중 (C·G·C·F 추출)', + message: undefined, + recentLogs: this._pixelOfficeAppendLog('🔍 의도 분석 시작'), + }, { bubbleStatus: 'analyzing' }); + } + + /** Alignment 결과를 받아 카드로 표시 직전. kind=auto-proceed면 곧장 planning 흐름. */ + pixelOfficeOnAlignmentResult(kind: 'questions' | 'confirm' | 'auto-proceed', contract: import('./features/company').RequirementContract): void { + if (kind === 'auto-proceed') { + this._pixelOfficeBroadcast({ + status: 'contract_ready', + currentStep: 'Requirement Contract 확정 (자동 진행)', + requirementContract: this._summariseContract(contract), + needUserInput: undefined, + recentLogs: this._pixelOfficeAppendLog('✅ 계약 자동 확정'), + }, { + bubbleStatus: 'contract_ready', + bubbleEvent: 'requirement_contract_created', + }); + return; + } + if (kind === 'questions') { + this._pixelOfficeBroadcast({ + status: 'need_clarification', + currentStep: '확인 질문 대기 중', + requirementContract: this._summariseContract(contract), + needUserInput: contract.openQuestions, + recentLogs: this._pixelOfficeAppendLog(`🤔 ${contract.openQuestions.length}개 질문 대기`), + }, { + bubbleStatus: 'need_clarification', + bubbleEvent: 'clarification_needed', + }); + return; + } + // confirm — 질문 없음, 사용자 OK만 받으면 됨. + this._pixelOfficeBroadcast({ + status: 'waiting_approval', + currentStep: '계약 확인 대기', + requirementContract: this._summariseContract(contract), + needUserInput: undefined, + awaitingApproval: '사용자 확인 — 그대로 진행할지 여부', + recentLogs: this._pixelOfficeAppendLog('🧭 계약 확인 카드 표시'), + }, { bubbleStatus: 'waiting_approval' }); + } + + /** Alignment 취소(사용자가 카드 닫음). */ + pixelOfficeOnAlignmentCancelled(): void { + this._pixelOfficeBroadcast({ + status: 'idle', + currentStep: '취소됨', + needUserInput: undefined, + awaitingApproval: undefined, + recentLogs: this._pixelOfficeAppendLog('🛑 작업 취소'), + }); + } + + private _summariseContract(c: import('./features/company').RequirementContract) { + return { + goal: c.goal || undefined, + context: c.context || undefined, + criteria: c.criteria, + format: c.format || undefined, + openQuestions: c.openQuestions, + confidence: c.confidence, + }; + } + + /** + * Dispatcher의 `CompanyTurnEvent`를 그대로 받아 Pixel Office 상태로 변환. + * `_runCompanyTurn`의 emit 콜백이 매 이벤트를 한 번 더 이쪽으로 흘려준다. + * 함수 안에서 어떤 dispatcher 분기도 다시 트리거하지 않는다 — read-only. + */ + pixelOfficeOnTurnEvent(ev: import('./features/company').CompanyTurnEvent): void { + switch (ev.phase) { + case 'plan-start': + this._pixelOfficeBroadcast({ + status: 'planning', + currentStep: '계획 수립', + recentLogs: this._pixelOfficeAppendLog('📋 plan 작성'), + }, { bubbleStatus: 'planning' }); + return; + case 'plan-ready': + this._pixelOfficeBroadcast({ + status: 'planning', + currentStep: '계획 완료', + nextStep: ev.plan?.tasks?.[0]?.task, + message: ev.plan?.brief?.slice(0, 120), + recentLogs: this._pixelOfficeAppendLog(`📋 plan 완료 (${ev.plan?.tasks?.length ?? 0}개 task)`), + }, { bubbleEvent: 'plan_completed' }); + return; + case 'agent-start': + this._pixelOfficeBroadcast({ + status: 'executing', + currentStep: ev.task?.slice(0, 80), + nextStep: undefined, + message: `${ev.agentId} • ${ev.index + 1}/${ev.total}`, + progress: ev.total > 0 ? ev.index / ev.total : undefined, + recentLogs: this._pixelOfficeAppendLog(`▶ ${ev.agentId} start (${ev.index + 1}/${ev.total})`), + }, { + bubbleAgentId: ev.agentId, + bubbleStatus: 'executing', + bubbleEvent: ev.index === 0 ? 'execution_started' : undefined, + }); + return; + case 'agent-done': + this._pixelOfficeBroadcast({ + status: ev.output?.error ? 'error' : 'executing', + progress: ev.total > 0 ? (ev.index + 1) / ev.total : undefined, + recentLogs: this._pixelOfficeAppendLog( + ev.output?.error + ? `❌ ${ev.agentId} ${ev.output.error}` + : `✓ ${ev.agentId} 완료`, + ), + }, { + bubbleAgentId: ev.agentId, + bubbleEvent: ev.output?.error ? 'error_occurred' : undefined, + }); + return; + case 'stage-loop': + this._pixelOfficeBroadcast({ + status: 'executing', + currentStep: `재시도: ${ev.from} → ${ev.to} (${ev.iteration}회)`, + recentLogs: this._pixelOfficeAppendLog(`🔁 loop ${ev.from}→${ev.to} #${ev.iteration}`), + }, { bubbleEvent: 'stage_loop_retry' }); + return; + case 'review-start': + this._pixelOfficeBroadcast({ + status: 'reviewing', + currentStep: `검수 사이클 — ${ev.stageLabel}`, + message: `검수자: ${ev.inspectorAgentId} · 최대 ${ev.maxRounds}라운드`, + recentLogs: this._pixelOfficeAppendLog(`🔍 검수 시작: ${ev.stageLabel}`), + }, { bubbleStatus: 'reviewing' }); + return; + case 'review-round': + this._pixelOfficeBroadcast({ + status: 'reviewing', + currentStep: `검수 라운드 ${ev.round} (${ev.inspectorVerdict}/${ev.ceoVerdict})`, + recentLogs: this._pixelOfficeAppendLog( + `R${ev.round} insp:${ev.inspectorVerdict} ceo:${ev.ceoVerdict}`, + ), + }, { + bubbleAgentId: ev.inspectorAgentId, + bubbleEvent: ev.inspectorVerdict === 'pass' && ev.ceoVerdict === 'pass' + ? 'review_passed' + : (ev.inspectorVerdict === 'revise' ? 'review_failed' : undefined), + }); + return; + case 'review-end': + this._pixelOfficeBroadcast({ + status: ev.final === 'aborted' ? 'error' : 'executing', + currentStep: ev.final === 'pass' + ? `검수 통과 (${ev.rounds}라운드)` + : ev.final === 'maxed-out' + ? `검수 한도 도달 — 진행 (${ev.rounds}라운드)` + : '검수 중단', + recentLogs: this._pixelOfficeAppendLog(`🔍 검수 종료: ${ev.final}`), + }, { + bubbleEvent: ev.final === 'pass' ? 'review_passed' + : ev.final === 'aborted' ? 'risky_change_detected' + : undefined, + }); + return; + case 'awaiting-approval': + this._pixelOfficeBroadcast({ + status: 'waiting_approval', + currentStep: `승인 대기 — ${ev.stageLabel}`, + awaitingApproval: `${ev.stageLabel} 단계 완료 검토`, + recentLogs: this._pixelOfficeAppendLog(`✋ 승인 대기: ${ev.stageLabel}`), + }, { + bubbleStatus: 'waiting_approval', + bubbleEvent: 'approval_required', + }); + return; + case 'approval-resolved': + this._pixelOfficeBroadcast({ + status: 'executing', + awaitingApproval: undefined, + currentStep: `승인 결과: ${ev.decision}`, + recentLogs: this._pixelOfficeAppendLog(`✋→ ${ev.decision}`), + }); + return; + case 'report-start': + this._pixelOfficeBroadcast({ + status: 'reviewing', + currentStep: 'CEO 종합 보고서 작성 중', + recentLogs: this._pixelOfficeAppendLog('🧭 보고서 작성'), + }); + return; + case 'report-done': + this._pixelOfficeBroadcast({ + status: 'done', + currentStep: ev.ok ? '보고서 완료' : '보고서 (fallback) 완료', + progress: 1, + recentLogs: this._pixelOfficeAppendLog(ev.ok ? '✅ 보고서 OK' : '⚠ fallback 보고서'), + }, { + bubbleStatus: 'done', + bubbleEvent: 'task_completed', + }); + return; + case 'session-saved': + this._pixelOfficeBroadcast({ + status: 'done', + message: `세션 저장됨`, + recentLogs: this._pixelOfficeAppendLog('💾 세션 저장'), + }); + return; + case 'aborted': + this._pixelOfficeBroadcast({ + status: 'error', + currentStep: `중단: ${ev.reason}`, + recentLogs: this._pixelOfficeAppendLog(`🛑 abort: ${ev.reason}`), + }, { + bubbleStatus: 'error', + bubbleEvent: 'error_occurred', + }); + return; + case 'telegram-mirror': + default: + return; // 시각화에 의미 약함 — log skip + } + } + + /** webview가 처음 로드되거나 사용자가 토글을 다시 켰을 때 캐시된 상태 재전송. */ + pixelOfficeResend(): void { + const cfg = getConfig(); + const payload = (() => { + if (!cfg.companyPixelOfficeEnabled) { + return { + type: 'pixelOfficeUpdate' as const, + value: { state: null, bubbles: [], config: { enabled: false, bubblesEnabled: false, maxVisibleBubbles: 0, bubbleDurationMs: 0 } }, + }; + } + const state = this._pixelOfficeState ?? { + agentId: 'main', agentName: 'Agent', + status: 'idle' as import('./features/company').AgentStatus, + recentLogs: [], + updatedAt: Date.now(), + }; + return { + type: 'pixelOfficeUpdate' as const, + value: { + state, bubbles: [], + config: { + enabled: cfg.companyPixelOfficeEnabled, + bubblesEnabled: cfg.companyPixelOfficeBubbles, + maxVisibleBubbles: 3, + bubbleDurationMs: 4500, + }, + }, + }; + })(); + this._view?.webview.postMessage(payload); + this._pixelOfficePanel?.webview.postMessage(payload); + } + + /** + * editor area에 별도 Pixel Office 전체보기 panel을 띄움. 이미 열려 있으면 + * 그 panel을 reveal. 사이드바 mini 패널과 동일한 데이터 스트림을 받지만 + * 별도 HTML로 *사무실 그리드 + 직군별 캐릭터* 레이아웃을 보여준다. + */ + public openPixelOfficePanel(column: vscode.ViewColumn = vscode.ViewColumn.Beside): vscode.WebviewPanel { + if (this._pixelOfficePanel) { + this._pixelOfficePanel.reveal(column); + return this._pixelOfficePanel; + } + const panel = vscode.window.createWebviewPanel( + 'astra.pixelOffice', + 'Pixel Office', + column, + { enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true }, + ); + this._pixelOfficePanel = panel; + panel.webview.html = this._buildPixelOfficeHtml(panel.webview); + // panel과 백엔드 사이의 가벼운 메시지 채널 — 닫기/리프레시 정도만. + panel.webview.onDidReceiveMessage((msg: any) => { + if (!msg || typeof msg !== 'object') return; + if (msg.type === 'getPixelOfficeState') this.pixelOfficeResend(); + if (msg.type === 'closePixelOfficePanel') panel.dispose(); + }); + panel.onDidDispose(() => { + if (this._pixelOfficePanel === panel) this._pixelOfficePanel = undefined; + }); + // 열자마자 현재 상태 한 번 push. + this.pixelOfficeResend(); + return panel; + } + + /** + * Pixel Office panel용 HTML 본문. 사이드바 mini와 같은 메시지 스키마를 + * 받지만 그리는 방식이 전혀 달라(사무실 그리드 + 직군별 캐릭터) 사이드바와 + * 분리된 별도 HTML을 둔다. 외부 CSS/JS 파일을 안 쓰고 한 파일에 묶어 + * VS Code의 localResourceRoots 제약을 신경 안 쓰도록 설계. + */ + private _buildPixelOfficeHtml(webview: vscode.Webview): string { + const cspSource = webview.cspSource; + return _pixelOfficePanelHtml(cspSource); + } + + /** Alignment 슬롯 비우기 — 사용자가 "취소"를 눌렀거나 turn 시작/종료 시점 호출. */ + clearPendingAlignment(): void { + this._pendingAlignment = undefined; + } + /** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */ private _archWatcher?: vscode.FileSystemWatcher; /** Debounce timer for the architecture watcher. */ @@ -1504,6 +1968,210 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn return true; } + /** + * Clear the cached "last completed company turn" — called when the user + * starts a new chat / loads a different session. Without this, an old + * report would bleed into the next session's intent classifications and + * make stale "follow-up" verdicts. + */ + clearLastCompanyTurnSummary(): void { + this._lastCompanyTurnSummary = undefined; + } + + /** Read accessor for the intent classifier. May return undefined on cold start. */ + getLastCompanyTurnSummary() { + return this._lastCompanyTurnSummary; + } + + /** + * Intent Alignment 1라운드 실행. new_task 분류 직후 (또는 사용자 답변 후) + * 호출되며 LLM 분석기를 한 번 돌려 contract를 채운다. confidence가 'high' + * (또는 strict 모드 아니고 medium)이면 곧장 pipeline dispatch로 넘어가고, + * 그 외엔 webview에 카드를 띄워 사용자 응답을 기다린다(_pendingAlignment). + * + * mode: + * - 'smart': high → 자동 dispatch, medium → 사용자 확인 카드, low → 질문 카드 + * - 'strict': confidence 무관 항상 사용자 확인 카드 + * + * roundsLimit를 넘기면 더 묻지 않고 현재 contract로 카드(확인)만 띄움. + */ + async _runIntentAlignment(opts: { + userPrompt: string; + previousContract?: import('./features/company').RequirementContract; + previousAnswers?: Array<{ q: string; a: string }>; + pipelineIdOverride?: string; + mode: 'smart' | 'strict'; + roundsLimit: number; + roundsAsked: number; + }): Promise { + const { analyzeIntent, readCompanyState, resolveActivePipeline, listActiveAgentsByCategory } = + await import('./features/company'); + const { AIService } = await import('./core/services'); + const cfg = getConfig(); + const state = readCompanyState(this._context); + const activePipeline = resolveActivePipeline(state); + // 활성 직군 — 분석기가 "이 회사가 어떤 일을 할 수 있나"를 알아야 + // goal/format을 그 능력에 맞춰 추출할 수 있다. + const byCat = listActiveAgentsByCategory(state); + const availableRoleCategories = Object.entries(byCat) + .filter(([, list]) => list.length > 0) + .map(([cat]) => cat); + + // Pixel Office: 분석 시작 표시 (LLM 콜 직전). + try { this.pixelOfficeOnAlignmentStart(opts.userPrompt); } catch { /* noop */ } + const analysis = await analyzeIntent( + new AIService(), + { + userOriginalPrompt: opts.userPrompt, + previousAnswers: opts.previousAnswers, + previousContract: opts.previousContract, + activePipelineName: activePipeline?.name, + availableRoleCategories, + }, + // 분류기와 같은 작은 모델을 재사용 — 이 단계도 빠르고 가벼워야 함. + { model: cfg.companyIntentClassifierModel || cfg.defaultModel }, + ); + + const contract = analysis.contract; + const mode = opts.mode; + const reachedLimit = opts.roundsAsked >= opts.roundsLimit; + + // 자동 진행 조건: smart 모드 + high confidence + open question 없음. + // strict 모드면 절대 자동 진행 안 함 — 항상 사용자 확인. + const canAutoProceed = mode === 'smart' + && contract.confidence === 'high' + && contract.openQuestions.length === 0; + + if (canAutoProceed) { + // contract 한 줄 안내 후 곧장 pipeline. 사용자 friction 최소. + this._view?.webview.postMessage({ + type: 'companyAlignmentCard', + value: { + kind: 'auto-proceed', + contract, + }, + }); + try { this.pixelOfficeOnAlignmentResult('auto-proceed', contract); } catch { /* noop */ } + this._pendingAlignment = undefined; + await this._runCompanyTurn(opts.userPrompt, undefined, opts.pipelineIdOverride, contract); + return; + } + + // 그 외 — 카드 표시 + 사용자 응답 대기. 라운드 한도 도달했거나 + // openQuestions가 비어 있으면 "확인" 카드(질문 없음, 진행/취소 버튼만). + const askMode = reachedLimit || contract.openQuestions.length === 0 + ? 'confirm' : 'questions'; + this._view?.webview.postMessage({ + type: 'companyAlignmentCard', + value: { + kind: askMode, + contract, + roundsAsked: opts.roundsAsked, + roundsLimit: opts.roundsLimit, + }, + }); + try { this.pixelOfficeOnAlignmentResult(askMode, contract); } catch { /* noop */ } + this._pendingAlignment = { + userOriginalPrompt: opts.userPrompt, + contract, + roundsAsked: opts.roundsAsked, + pipelineIdOverride: opts.pipelineIdOverride, + }; + // streamEnd 보내야 채팅 input 잠금이 풀려 사용자가 답변을 칠 수 있음. + // 평소 1인 기업 turn은 _runCompanyTurn finally에서 보내지만, alignment는 + // dispatcher를 안 거치고 사용자 입력으로 unlock해야 하므로 명시적으로 push. + this._view?.webview.postMessage({ type: 'streamEnd' }); + void this._sendReadyStatus(); + } + + /** + * 사용자가 alignment 카드 상태에서 채팅 입력(답변)을 보낸 경우 호출. + * 답변을 contract에 합쳐 분석기 재호출, 라운드를 한 칸 늘림. + */ + async _handleAlignmentAnswer(userMessage: string): Promise { + const pending = this._pendingAlignment; + if (!pending) return; + const cfg = getConfig(); + const mode = (cfg.companyIntentAlignmentMode === 'strict') ? 'strict' : 'smart'; + const roundsLimit = Math.max(1, Math.min(5, cfg.companyIntentAlignmentMaxRounds ?? 3)); + // 사용자 답변을 미해결 질문들에 일괄 매핑 — 작은 모델이 자연어로 통째로 + // 답하기 마련이라 질문별로 분리 안 함. 그냥 "이번 라운드 사용자 추가 + // 답변" 한 덩어리로 넣어주면 분석기가 다음 라운드에 그걸 보고 알아서 + // 갱신한다. + const compositeAnswer = userMessage.trim(); + const updatedAnswers = [ + ...pending.contract.answeredQuestions, + { q: pending.contract.openQuestions.join(' / ') || '(추가 정보 요청)', a: compositeAnswer }, + ]; + // 슬롯 비워두고 alignment 다시 돌림 — 새 결과가 다시 _pendingAlignment를 + // 채울 것이고, 자동 진행 조건 충족 시 pipeline까지 갈 수도 있다. + this._pendingAlignment = undefined; + await this._runIntentAlignment({ + userPrompt: pending.userOriginalPrompt, + previousContract: pending.contract, + previousAnswers: updatedAnswers, + pipelineIdOverride: pending.pipelineIdOverride, + mode, + roundsLimit, + roundsAsked: pending.roundsAsked + 1, + }); + } + + /** + * 사용자가 카드의 "✅ 진행" 버튼을 눌러 현 contract 그대로 dispatch + * 시키고 싶을 때. 슬롯 비우고 pipeline. + */ + async _proceedWithCurrentAlignment(): Promise { + const pending = this._pendingAlignment; + if (!pending) return; + this._pendingAlignment = undefined; + await this._runCompanyTurn( + pending.userOriginalPrompt, + undefined, + pending.pipelineIdOverride, + pending.contract, + ); + } + + /** + * 사용자가 카드의 "🛑 취소"를 누르거나 alignment를 그만두려 할 때. + * 슬롯 비우고 webview에도 streamEnd push해서 input 풀어줌. + */ + cancelPendingAlignment(): void { + if (!this._pendingAlignment) return; + this._pendingAlignment = undefined; + this._view?.webview.postMessage({ type: 'companyAlignmentCard', value: { kind: 'cancelled' } }); + this._view?.webview.postMessage({ type: 'streamEnd' }); + try { this.pixelOfficeOnAlignmentCancelled(); } catch { /* noop */ } + void this._sendReadyStatus(); + } + + /** + * Casual chat path inside 1인 기업 모드 — used when the intent classifier + * routes a message to `chat` or `followup` instead of `new_task`. We + * deliberately *don't* spin up the dispatcher here: that surface is for + * multi-step work. Instead we route through the normal chat path (same + * AgentExecutor.handlePrompt used outside company mode) so streaming UI, + * brain retrieval, and the action-tag executor all work as users expect. + * + * The `reason` from the classifier is surfaced as a small label so the + * user can tell *why* their message wasn't treated as a new task — if + * they meant it as one, they can rephrase or override with a keyword + * like "파이프라인 돌려" / "기획해줘". + */ + async _handleCompanyCasual(prompt: string, intent: 'chat' | 'followup', reason: string, originalData: any): Promise { + // 사용자에게 "왜 이게 가벼운 응답으로 갔는지" 보여주는 한 줄 라벨. + // 잘못 분류된 거라면 사용자가 즉시 인지하고 다시 말할 수 있어야 한다. + const label = intent === 'followup' ? '💬 후속 대화' : '💬 대화'; + this._view?.webview.postMessage({ + type: 'companyIntentDecision', + value: { intent, reason, label }, + }); + await this._handlePrompt(originalData); + await this._autoWriteChronicleAfterPrompt(); + await this._saveCurrentSession(); + } + /** * Called by chatHandlers when the user clicks an approval card button. * Resolves the dispatcher's awaitApproval promise for `stageId`. Idempotent @@ -1684,11 +2352,35 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn * progress events back as `companyTurnUpdate` messages so the same bubble * fills in as each agent finishes. */ - async _runCompanyTurn(userPrompt: string, resumeTimestamp?: string): Promise { + async _runCompanyTurn( + userPrompt: string, + resumeTimestamp?: string, + pipelineIdOverride?: string, + requirementContract?: import('./features/company').RequirementContract, + ): Promise { const cfg = getConfig(); const ai = new AIService(); + // plan-ready / report-done 이벤트를 가로채 직전 turn 요약을 캐시에 + // 저장. 다음 메시지의 intent classifier가 "이건 followup인가?" 판정에 + // 사용한다. plan-ready로 brief를, report-done으로 보고서 끝부분을 + // 잡아낸다 — turn이 중간 abort되면 plan만 남고 reportTail은 비어 + // 있게 되는데, 그 상태로도 followup 매칭에는 충분히 도움된다. + let stagingBrief = ''; const emit = (event: CompanyTurnEvent) => { this._view?.webview.postMessage({ type: 'companyTurnUpdate', value: event }); + if (event.phase === 'plan-ready') { + stagingBrief = event.plan?.brief || ''; + } else if (event.phase === 'report-done') { + const tail = (event.report || '').trim().slice(-600); + this._lastCompanyTurnSummary = { + brief: stagingBrief, + reportTail: tail, + finishedAt: Date.now(), + }; + } + // Pixel Office hub — 같은 이벤트를 *추가로* read-only 변환. dispatcher + // 흐름엔 영향 없음(post 호출만 함). + try { this.pixelOfficeOnTurnEvent(event); } catch { /* never break the turn */ } }; // Fresh AbortController per turn — the Stop button routes through // `abortCompanyTurn()` to fire `.abort()`. The dispatcher checks @@ -1725,6 +2417,14 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } this._pendingApprovals.set(stageId, resolve); }), + // 이번 turn 한정 pipeline override. chatHandlers가 의도 분류기 + // 추천 또는 사용자 키워드 detection 결과를 채워서 넘긴다. + pipelineIdOverride, + // Intent Alignment에서 도출된 사용자 합의 contract. Phase D에서 + // planner / specialist / reviewer prompt 모두에 주입됨. 없으면 + // legacy 동작 (alignment 단계 자체를 거치지 않은 경우 또는 사용자가 + // off 모드로 설정한 경우). + requirementContract, }; // 일반 새 turn vs 재개 turn 분기. 재개 시 _resume.json에서 plan + cursor를 // 복원해 그 다음 stage부터 dispatch가 이어진다. resumeCompanyTurn이 null을 @@ -3054,3 +3754,474 @@ export function wrapPanelAsView(panel: vscode.WebviewPanel): vscode.WebviewView }; return adapter as vscode.WebviewView; } + +/** + * Pixel Office "전체보기" webview panel용 HTML. 사이드바 mini 패널과 데이터 + * 스키마는 같지만(`pixelOfficeUpdate` 메시지), 화면 구성은 사무실 그리드 + + * 직군별 캐릭터 배치로 완전히 다르다. + * + * 사무실 레이아웃: + * ┌────────────────────────────────────────────────┐ + * │ Astra Office — 현재 작업 (스크롤 헤더) │ + * │ CEO │ + * │ [기획] [리서치] [디자인] │ + * │ [개발] [QA] [감리] │ + * │ Footer: progress bar + 닫기 │ + * └────────────────────────────────────────────────┘ + * + * 작업 중인 직군에 해당하는 캐릭터에 강조 테두리 + 머리 위 말풍선. 다른 + * 캐릭터는 살짝 dim. 직군은 ROLE_CATEGORY_ORDER 순으로 배치 — Agent 코드 + * 변경 없이 백엔드의 listActiveAgentsByCategory 결과를 그대로 활용 가능. + */ +function _pixelOfficePanelHtml(cspSource: string): string { + return ` + + + + +Pixel Office + + + +
    +
    +
    🏢 Astra Office
    +
    대기 중
    +
    +
    idle
    +
    +
    +
    작업
    +
    단계
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + + +`; +} diff --git a/src/utils.ts b/src/utils.ts index 3ec9898..85aac49 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -262,7 +262,18 @@ export function getSystemPrompt(): string { const now = new Date(); const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' }); const isoDate = now.toISOString().split('T')[0]; - return `${BASE_SYSTEM_PROMPT}\n\n[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").`; + const base = `${BASE_SYSTEM_PROMPT}\n\n[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").`; + // Self-Reflector Phase A — 사용자 설정이 켜져 있으면 답변 끝에 자기검증 + // 블록을 강제하는 룰을 prepend. require로 동적 로드해 순환 import 회피. + try { + const { getConfig } = require('./config') as typeof import('./config'); + const { appendSelfReflectorRule } = require('./features/selfReflector/selfReflectorPrompt') as typeof import('./features/selfReflector/selfReflectorPrompt'); + const cfg = getConfig(); + return appendSelfReflectorRule(base, { enabled: cfg.selfReflectorEnabled }); + } catch { + // config 로드 실패 시(테스트 환경 등)는 룰 없이 원본 그대로. + return base; + } } export const SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;