From 75d7e6b83a71de56990f1e466cf8bc6c3322bdfb Mon Sep 17 00:00:00 2001 From: g1nation Date: Thu, 14 May 2026 17:36:15 +0900 Subject: [PATCH] feat: Implement Pipeline Templates for Company Suite and refine orchestration logic --- ...d46d2ca2057b05c488be1dcf439166ac5a9a1.json | 2 +- ...9f4f39d2bc368f77456c37b5eef9a94a66b5c.json | 2 +- ...5c7a44d7661af673b24e3f49551a7a2e50280.json | 2 +- ...adc543795e4b427b64540a49c9ab27c7fe213.json | 4 +- ...son => stress_conflict_1778742370793.json} | 16 +- PATCHNOTES.md | 10 + media/sidebar.css | 124 ++++ media/sidebar.html | 24 +- media/sidebar.js | 544 +++++++++++++++++- package.json | 2 +- src/features/company/ceoPlanner.ts | 49 +- src/features/company/companyConfig.ts | 9 + src/features/company/dispatcher.ts | 86 ++- src/features/company/index.ts | 4 + src/features/company/pipelineTemplates.ts | 211 +++++++ src/features/company/promptBuilder.ts | 31 +- src/features/company/types.ts | 25 + src/sidebar/chatHandlers.ts | 29 + src/sidebarProvider.ts | 57 ++ 19 files changed, 1181 insertions(+), 50 deletions(-) rename .astra/tests/stress/.astra/missions/{stress_conflict_1778740761013.json => stress_conflict_1778742370793.json} (82%) create mode 100644 src/features/company/pipelineTemplates.ts diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json index f2646e2..86bf884 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": 1778740761033, + "createdAt": 1778742370814, "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 f9b04b7..5cd1838 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": 1778740761031, + "createdAt": 1778742370811, "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 6f22ebe..c7cf37b 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": 1778740761028, + "createdAt": 1778742370809, "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 550bcfe..8310ef7 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_1778740761013\ndate: 2026-05-14T06:39:21.035Z\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]** 전략 수립 중... (13ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (3ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (3ms)\n", - "createdAt": 1778740761035, + "result": "---\nid: stress_conflict_1778742370793\ndate: 2026-05-14T07:06:10.815Z\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]** 전략 수립 중... (14ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (3ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (3ms)\n", + "createdAt": 1778742370816, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778740761013.json b/.astra/tests/stress/.astra/missions/stress_conflict_1778742370793.json similarity index 82% rename from .astra/tests/stress/.astra/missions/stress_conflict_1778740761013.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1778742370793.json index 1725d51..315a962 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1778740761013.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1778742370793.json @@ -1,8 +1,8 @@ { - "missionId": "stress_conflict_1778740761013", + "missionId": "stress_conflict_1778742370793", "status": "completed", - "startTime": "2026-05-14T06:39:21.013Z", - "totalElapsedMs": 23, + "startTime": "2026-05-14T07:06:10.793Z", + "totalElapsedMs": 24, "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": 13, + "durationMs": 14, "message": "전략 수립 중...", - "ts": "2026-05-14T06:39:21.026Z" + "ts": "2026-05-14T07:06:10.807Z" }, { "from": "planner", "to": "researcher", "durationMs": 3, "message": "핵심 정보 수집 및 분석 중...", - "ts": "2026-05-14T06:39:21.029Z" + "ts": "2026-05-14T07:06:10.810Z" }, { "from": "researcher", "to": "writer", "durationMs": 3, "message": "최종 리포트 작성 및 편집 중...", - "ts": "2026-05-14T06:39:21.032Z" + "ts": "2026-05-14T07:06:10.813Z" }, { "from": "writer", "to": "completed", "durationMs": 4, "message": "미션 완료", - "ts": "2026-05-14T06:39:21.036Z" + "ts": "2026-05-14T07:06:10.817Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 29e5b64..32f2034 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,15 @@ # Astra Patch Notes +## v2.1.4 (2026-05-14) +### 🏢 Enterprise Multi-Agent Suite & Reflector Stabilization +- **Company Suite 고도화:** `CEO Planner`, `CEO Reporter` 등 1인 기업 모드를 위한 비즈니스 오케스트레이션 기능을 안정화하고, 부서 간 협업 로직을 강화했습니다. +- **Self-Reflection (Reflector) 공식 도입:** Researcher와 Writer 사이에 비평(Critique) 단계를 추가하여, 리서치 결과의 누락이나 모순을 사전에 검증하는 인텔리전스 루프를 완성했습니다. +- **지식 자산(ADR/Bug) 체계화:** 신규 ADR 및 버그 레코드를 통합하여 프로젝트의 의사결정 이력과 기술적 부채 관리 능력을 향상시켰습니다. +- **인프라 환경 정비:** 프로젝트 루트 및 하위 모듈 간의 환경 설정 충돌을 해결하고 빌드 파이프라인의 견고함을 확보했습니다. + +--- + + ## v2.1.3 (2026-05-14) ### 🚀 Core Synergy & Distribution Alignment - **종속성 정합성 완결:** `package-lock.json`을 최신 엔진 사양에 맞춰 동기화하여 빌드 시의 의존성 충돌 가능성을 원천 차단했습니다. diff --git a/media/sidebar.css b/media/sidebar.css index 2cedcf3..65ee44e 100644 --- a/media/sidebar.css +++ b/media/sidebar.css @@ -550,6 +550,121 @@ background: var(--accent); border-color: var(--accent); color: #fff; } + /* ── Pipeline stage cards ───────────────────────────────────── */ + .pipeline-stage-list { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 8px; + } + .pipeline-stage-list:empty::before { + content: '아직 단계가 없습니다. "+ Stage 추가" 로 첫 단계를 만드세요.'; + font-size: 11px; color: var(--text-dim); font-style: italic; + padding: 12px 4px; display: block; text-align: center; + } + .pipeline-stage-card { + border: 1px solid var(--border); + background: var(--surface); + border-radius: 8px; + padding: 10px; + transition: opacity 0.15s, border-color 0.15s; + } + .pipeline-stage-card.dragging { + opacity: 0.4; + } + .pipeline-stage-card.drop-above { + border-top: 2px solid var(--accent); + } + .pipeline-stage-card.drop-below { + border-bottom: 2px solid var(--accent); + } + .psc-head { + display: flex; align-items: center; gap: 8px; + margin-bottom: 8px; + } + .psc-num { + display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; border-radius: 50%; + background: var(--accent); color: #fff; + font-size: 11px; font-weight: 700; flex-shrink: 0; + cursor: grab; + user-select: none; + } + .psc-num:active { cursor: grabbing; } + .psc-num::after { + /* 드래그 가능 힌트. 마우스 호버 시만 살짝 보이게. */ + content: ''; opacity: 0; + } + .pipeline-stage-card:hover .psc-num { + box-shadow: 0 0 0 2px var(--accent-glow, rgba(99,102,241,0.25)); + } + .psc-label { + flex: 1; min-width: 0; + font-size: 12px; font-weight: 600; + padding: 5px 8px; border-radius: 6px; + background: var(--bg); color: var(--text-bright); + border: 1px solid var(--border); font-family: inherit; + } + .psc-controls { display: flex; gap: 4px; flex-shrink: 0; } + .psc-controls button { + font-size: 11px; padding: 3px 8px; border-radius: 5px; + background: var(--surface); color: var(--text-primary); + border: 1px solid var(--border); cursor: pointer; line-height: 1; + } + .psc-controls button:hover { border-color: var(--accent); color: var(--accent); } + .psc-controls button:disabled { opacity: 0.3; cursor: not-allowed; } + .psc-controls button.del:hover { border-color: var(--error); color: var(--error); } + + .psc-body { display: flex; flex-direction: column; gap: 8px; } + .psc-row { + display: flex; gap: 8px; align-items: center; flex-wrap: wrap; + font-size: 10px; color: var(--text-dim); + } + .psc-row > label { white-space: nowrap; } + .psc-row select { + font-size: 11px; padding: 4px 6px; border-radius: 5px; + background: var(--bg); color: var(--text-primary); + border: 1px solid var(--border); min-width: 100px; + } + .psc-row select option { color: var(--text-primary); background: var(--bg); } + + .psc-field-label { font-size: 10px; color: var(--text-dim); margin-bottom: 2px; } + .psc-tokens { display: flex; gap: 4px; flex-wrap: wrap; } + .psc-tokens button { + font-size: 10px; padding: 2px 8px; border-radius: 10px; + background: var(--bg); color: var(--text-dim); + border: 1px dashed var(--border); cursor: pointer; + } + .psc-tokens button:hover { color: var(--accent); border-color: var(--accent); } + .psc-instr { + width: 100%; box-sizing: border-box; + font-size: 11px; padding: 6px 8px; border-radius: 6px; + background: var(--bg); color: var(--text-primary); + border: 1px solid var(--border); font-family: inherit; + resize: vertical; min-height: 50px; + } + .psc-loop { + font-size: 10px; color: var(--text-dim); + border-top: 1px dashed var(--border); + padding-top: 6px; + } + .psc-loop summary { + cursor: pointer; user-select: none; + padding: 2px 0; + } + .psc-loop summary:hover { color: var(--accent); } + .psc-loop[open] summary { color: var(--text-bright); margin-bottom: 6px; } + .psc-loop-grid { + display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; + align-items: center; padding-left: 14px; + } + .psc-loop-grid input[type="text"], + .psc-loop-grid input[type="number"], + .psc-loop-grid select { + font-size: 11px; padding: 4px 6px; border-radius: 5px; + background: var(--bg); color: var(--text-primary); + border: 1px solid var(--border); + } + .psc-loop-grid input[type="number"] { max-width: 60px; } + /* Per-phase company turn header in chat. */ .company-phase-card { border: 1px solid var(--border); @@ -566,6 +681,15 @@ } .company-phase-card .cph-meta { color: var(--text-dim); font-size: 10px; } .company-phase-card.report .cph-head { color: var(--accent); } + .company-phase-card.approval { + border-color: var(--accent); + background: var(--accent-glow, rgba(99,102,241,0.08)); + } + .company-phase-card.approval .cph-head { color: var(--accent); } + .company-phase-card.approval button { + font-size: 11px; padding: 4px 10px; border-radius: 5px; cursor: pointer; + } + .company-phase-card.approval button:disabled { opacity: 0.55; cursor: default; } /* Project Architecture chip — three-state surface above the input. */ .arch-chip { diff --git a/media/sidebar.html b/media/sidebar.html index b754769..f660c91 100644 --- a/media/sidebar.html +++ b/media/sidebar.html @@ -224,7 +224,11 @@
CEO 자유 분배 대신 사용자가 정한 stage 순서대로 dispatch합니다. loop-back 정규식이 매칭되면 이전 stage로 되돌아갑니다 (최대 maxIterations 회).
- + +
@@ -236,26 +240,24 @@
- +
파이프라인 편집
-
-
- 예시: - [{"id":"plan","label":"기획","agentId":"writer","instructionTemplate":"{{userPrompt}} 에 대한 기획서 작성"},{"id":"dev","label":"개발","agentId":"developer","instructionTemplate":"다음 기획대로 구현: {{stage.plan}}","loopBackPattern":"버그|오류|fail","loopBackTo":"plan","maxIterations":3}] +
+
단계 (Stages)
+
+
    diff --git a/media/sidebar.js b/media/sidebar.js index f6da940..e02e235 100644 --- a/media/sidebar.js +++ b/media/sidebar.js @@ -827,6 +827,15 @@ } break; } + case 'companyPipelineTemplateContent': { + const tpl = msg.value; + if (!tpl) { showToast('템플릿을 찾을 수 없습니다.', 'warn'); break; } + // 에디터를 미리 채워서 연다 — 사용자는 ID/name/지시문만 다듬으면 됨. + if (typeof window.__openPipelineEditorWithTemplate === 'function') { + window.__openPipelineEditorWithTemplate(tpl); + } + break; + } case 'upsertCompanyPipelineResult': { const v = msg.value || {}; const errEl = document.getElementById('pipelineEditError'); @@ -1627,18 +1636,350 @@ }; } - // ── Work Pipeline editor ── + // ── Work Pipeline editor (card-based) ── const _activePipelineSel = document.getElementById('activePipelineSel'); const _pipelineList = document.getElementById('companyPipelineList'); const _addPipelineBtn = document.getElementById('addCompanyPipelineBtn'); const _pipelineEditForm = document.getElementById('pipelineEditForm'); const _pipelineEditId = document.getElementById('pipelineEditId'); const _pipelineEditName = document.getElementById('pipelineEditName'); - const _pipelineEditStages = document.getElementById('pipelineEditStages'); + const _pipelineStageList = document.getElementById('pipelineStageList'); + const _addStageBtn = document.getElementById('addStageBtn'); const _pipelineEditError = document.getElementById('pipelineEditError'); const _cancelPipelineEditBtn = document.getElementById('cancelPipelineEditBtn'); const _savePipelineEditBtn = document.getElementById('savePipelineEditBtn'); + // pipeline 페이로드에 같이 오는 직군별 활성 에이전트 캐시. + // 카드의 "직군 → 담당" cascading dropdown이 이걸 참조한다. + let _activeAgentsByCategory = {}; + + // 현재 편집 중인 stages의 in-memory 표현. 카드 UI는 이 배열만 보고 + // 다시 그려지므로 텍스트 입력 외 모든 상태 변경(추가/삭제/순서변경/ + // 직군 변경)이 즉시 re-render를 통해 화면과 데이터를 동기화한다. + let _editStages = []; + // 드래그 중인 stage의 원본 인덱스. -1 = 드래그 중 아님. + // 한 페이지에 에디터 인스턴스는 하나뿐이라 모듈 스코프로 충분. + let _draggedStageIndex = -1; + + const _genStageId = (taken) => { + const used = new Set(taken); + let n = 1; + while (used.has(`stage-${n}`)) n++; + return `stage-${n}`; + }; + + const _emptyStage = () => ({ + id: _genStageId(_editStages.map((s) => s.id)), + label: '', + roleCategory: 'planner', + agentId: '', + modelOverride: '', + requiresApproval: false, + instructionTemplate: '', + loopBackPattern: '', + loopBackTo: '', + maxIterations: 3, + }); + + const _firstAgentOfCategory = (cat) => { + const list = _activeAgentsByCategory[cat] || []; + return list[0]?.id || ''; + }; + + const _buildStageCard = (stage, index, total) => { + const li = document.createElement('li'); + li.className = 'pipeline-stage-card'; + li.dataset.stageId = stage.id; + li.dataset.index = String(index); + + // ── head: number + label input + ↑/↓/🗑 ── + const head = document.createElement('div'); + head.className = 'psc-head'; + const num = document.createElement('span'); + num.className = 'psc-num'; + num.textContent = String(index + 1); + num.title = '드래그하여 순서 변경 (↑/↓ 버튼도 가능)'; + + // ── 드래그 핸들: 번호 칩만 드래그 시작점이 되도록. + // textarea·input 영역에서 드래그가 시작되면 텍스트 선택과 충돌함. + // li 자체는 드래그 가능하되 dragstart는 핸들 클릭 후에만 허용. + li.draggable = false; + let _dragArmed = false; + num.addEventListener('mousedown', () => { + li.draggable = true; + _dragArmed = true; + }); + // mouseup이 어디서든 일어나면 드래그 해제 (drop 후엔 dragend가 처리). + const _disarm = () => { + if (_dragArmed) { _dragArmed = false; li.draggable = false; } + }; + li.addEventListener('mouseup', _disarm); + li.addEventListener('mouseleave', _disarm); + + li.addEventListener('dragstart', (e) => { + if (!_dragArmed) { e.preventDefault(); return; } + e.dataTransfer.effectAllowed = 'move'; + // 텍스트 페이로드는 필요 없지만 일부 브라우저가 비어 있으면 무시. + try { e.dataTransfer.setData('text/plain', stage.id); } catch {} + li.classList.add('dragging'); + // li reference를 모듈 스코프 변수에 기록 — dragover/drop이 참조. + _draggedStageIndex = index; + }); + li.addEventListener('dragend', () => { + li.classList.remove('dragging'); + li.draggable = false; + _dragArmed = false; + _draggedStageIndex = -1; + // 모든 카드의 drop indicator 제거. + _pipelineStageList?.querySelectorAll('.drop-above, .drop-below') + .forEach((el) => el.classList.remove('drop-above', 'drop-below')); + }); + li.addEventListener('dragover', (e) => { + if (_draggedStageIndex < 0 || _draggedStageIndex === index) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + // 카드의 위/아래 절반에 따라 indicator 위치 결정. + const rect = li.getBoundingClientRect(); + const above = (e.clientY - rect.top) < rect.height / 2; + li.classList.toggle('drop-above', above); + li.classList.toggle('drop-below', !above); + }); + li.addEventListener('dragleave', () => { + li.classList.remove('drop-above', 'drop-below'); + }); + li.addEventListener('drop', (e) => { + e.preventDefault(); + li.classList.remove('drop-above', 'drop-below'); + if (_draggedStageIndex < 0 || _draggedStageIndex === index) return; + const rect = li.getBoundingClientRect(); + const above = (e.clientY - rect.top) < rect.height / 2; + const from = _draggedStageIndex; + let to = above ? index : index + 1; + // splice 보정: from < to 인 경우 to를 한 칸 당겨야 의도한 위치. + if (from < to) to -= 1; + if (from === to) return; + const [moved] = _editStages.splice(from, 1); + _editStages.splice(to, 0, moved); + _draggedStageIndex = -1; + _renderStages(); + }); + const lbl = document.createElement('input'); + lbl.type = 'text'; + lbl.className = 'psc-label'; + lbl.placeholder = '단계 이름 (예: 기획 논의)'; + lbl.value = stage.label || ''; + lbl.oninput = () => { stage.label = lbl.value; }; + const ctrls = document.createElement('div'); + ctrls.className = 'psc-controls'; + const upBtn = document.createElement('button'); + upBtn.textContent = '↑'; upBtn.title = '위로'; upBtn.disabled = index === 0; + upBtn.onclick = () => { if (index > 0) { [_editStages[index - 1], _editStages[index]] = [_editStages[index], _editStages[index - 1]]; _renderStages(); } }; + const downBtn = document.createElement('button'); + downBtn.textContent = '↓'; downBtn.title = '아래로'; downBtn.disabled = index === total - 1; + downBtn.onclick = () => { if (index < _editStages.length - 1) { [_editStages[index + 1], _editStages[index]] = [_editStages[index], _editStages[index + 1]]; _renderStages(); } }; + const delBtn = document.createElement('button'); + delBtn.className = 'del'; delBtn.textContent = '🗑'; delBtn.title = '단계 삭제'; + delBtn.onclick = () => { + if (!confirm(`'${stage.label || stage.id}' 단계를 삭제할까요?`)) return; + _editStages.splice(index, 1); + _renderStages(); + }; + ctrls.appendChild(upBtn); ctrls.appendChild(downBtn); ctrls.appendChild(delBtn); + head.appendChild(num); head.appendChild(lbl); head.appendChild(ctrls); + li.appendChild(head); + + // ── body ── + const body = document.createElement('div'); + body.className = 'psc-body'; + + // row: 직군 → 담당 + const row = document.createElement('div'); + row.className = 'psc-row'; + const roleLabel = document.createElement('label'); + roleLabel.textContent = '직군:'; + const roleSel = document.createElement('select'); + for (const cat of _roleCategoryOrder) { + if (cat === 'ceo') continue; // stage agent로 CEO는 안 씀 + const opt = document.createElement('option'); + opt.value = cat; + opt.textContent = _roleCategoryLabels[cat] || cat; + roleSel.appendChild(opt); + } + roleSel.value = stage.roleCategory || 'planner'; + const agentLabel = document.createElement('label'); + agentLabel.textContent = '담당:'; + const agentSel = document.createElement('select'); + const _refillAgentSel = () => { + agentSel.innerHTML = ''; + const list = _activeAgentsByCategory[roleSel.value] || []; + if (list.length === 0) { + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = '(이 직군의 활성 에이전트 없음)'; + agentSel.appendChild(opt); + agentSel.disabled = true; + } 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; + } + }; + _refillAgentSel(); + roleSel.onchange = () => { + stage.roleCategory = roleSel.value; + stage.agentId = _firstAgentOfCategory(roleSel.value); + _refillAgentSel(); + }; + agentSel.onchange = () => { stage.agentId = agentSel.value; }; + row.appendChild(roleLabel); row.appendChild(roleSel); + row.appendChild(agentLabel); row.appendChild(agentSel); + + // 모델 select — 비워두면 에이전트의 모델 override → 글로벌 default 사용. + const modelLbl = document.createElement('label'); modelLbl.textContent = '모델:'; + const stageModelSel = document.createElement('select'); + stageModelSel.title = '비워두면 담당 에이전트의 모델 설정(또는 글로벌 기본)을 사용'; + populateAgentModelSelect(stageModelSel, stage.modelOverride || ''); + // populateAgentModelSelect는 첫 옵션 라벨이 "default (global)"인데 + // stage 맥락에선 "기본 (에이전트 설정 사용)"이 더 정확. 첫 옵션 텍스트만 교체. + if (stageModelSel.options.length > 0 && stageModelSel.options[0].value === '') { + stageModelSel.options[0].text = '기본 (에이전트 설정)'; + } + stageModelSel.onchange = () => { stage.modelOverride = stageModelSel.value || ''; }; + row.appendChild(modelLbl); row.appendChild(stageModelSel); + + body.appendChild(row); + + // ── 승인 게이트 체크박스 ── + // 한 stage가 끝난 뒤 사용자가 직접 ✅승인 / ✎수정요청 / 🛑중단 을 + // 누를 때까지 dispatcher가 대기. 검토 stage(inspector)나 중요 결정 + // 직전에 켜두면 자동 진행을 막을 수 있다. + const approvalWrap = document.createElement('label'); + approvalWrap.style.cssText = 'display:flex; gap:6px; align-items:center; font-size:10px; color:var(--text-dim); cursor:pointer;'; + const approvalCb = document.createElement('input'); + approvalCb.type = 'checkbox'; + approvalCb.checked = !!stage.requiresApproval; + approvalCb.onchange = () => { stage.requiresApproval = approvalCb.checked; }; + approvalWrap.appendChild(approvalCb); + const approvalText = document.createElement('span'); + approvalText.textContent = '✋ 이 단계 후 내 승인 받기'; + approvalText.title = '체크하면 stage 완료 후 자동 진행하지 않고 승인/수정요청/중단 버튼이 채팅창에 뜸'; + approvalWrap.appendChild(approvalText); + body.appendChild(approvalWrap); + + // 지시 텍스트 + 토큰 버튼 + const instrLabelDiv = document.createElement('div'); + instrLabelDiv.className = 'psc-field-label'; + instrLabelDiv.textContent = '지시 내용 (담당자에게 전달될 메시지)'; + body.appendChild(instrLabelDiv); + + const tokens = document.createElement('div'); + tokens.className = 'psc-tokens'; + const instr = document.createElement('textarea'); + instr.className = 'psc-instr'; + instr.placeholder = '예: {{userPrompt}} 에 대한 기획서 초안을 작성해주세요. 시장 조사 결과({{stage.research}})를 참고하세요.'; + instr.value = stage.instructionTemplate || ''; + instr.oninput = () => { stage.instructionTemplate = instr.value; }; + + const _insertToken = (token) => { + const start = instr.selectionStart ?? instr.value.length; + const end = instr.selectionEnd ?? instr.value.length; + instr.value = instr.value.slice(0, start) + token + instr.value.slice(end); + stage.instructionTemplate = instr.value; + instr.focus(); + instr.selectionStart = instr.selectionEnd = start + token.length; + }; + const mkTokenBtn = (label, token) => { + const b = document.createElement('button'); + b.textContent = label; + b.onclick = (e) => { e.preventDefault(); _insertToken(token); }; + return b; + }; + tokens.appendChild(mkTokenBtn('+ 사용자 요청', '{{userPrompt}}')); + tokens.appendChild(mkTokenBtn('+ CEO 브리프', '{{brief}}')); + // 이전 stages의 출력을 참조하는 토큰들 — 자기 자신 이후는 제외. + for (let i = 0; i < index; i++) { + const prev = _editStages[i]; + const label = prev.label || prev.id; + tokens.appendChild(mkTokenBtn(`+ ${label}`, `{{stage.${prev.id}}}`)); + } + body.appendChild(tokens); + body.appendChild(instr); + + // ── loop-back details ── + const loop = document.createElement('details'); + loop.className = 'psc-loop'; + if (stage.loopBackPattern || stage.loopBackTo) loop.open = true; + const summary = document.createElement('summary'); + const hasLoop = !!(stage.loopBackPattern && stage.loopBackTo); + summary.textContent = hasLoop + ? `🔁 재시도 활성: "${stage.loopBackPattern}" 발견 시 → ${_editStages.find((s) => s.id === stage.loopBackTo)?.label || stage.loopBackTo}` + : '🔁 재시도 조건 설정하기 (선택)'; + loop.appendChild(summary); + const grid = document.createElement('div'); + grid.className = 'psc-loop-grid'; + // condition + const condLbl = document.createElement('label'); condLbl.textContent = '조건 (regex):'; + const condInput = document.createElement('input'); + condInput.type = 'text'; + condInput.placeholder = '예: 버그|오류|fail|재작업'; + condInput.value = stage.loopBackPattern || ''; + condInput.oninput = () => { stage.loopBackPattern = condInput.value; }; + // target + const tgtLbl = document.createElement('label'); tgtLbl.textContent = '되돌아갈 단계:'; + const tgtSel = document.createElement('select'); + const emptyOpt = document.createElement('option'); + emptyOpt.value = ''; emptyOpt.textContent = '(선택 안 함)'; + tgtSel.appendChild(emptyOpt); + for (let i = 0; i < index; i++) { + const prev = _editStages[i]; + const opt = document.createElement('option'); + opt.value = prev.id; + opt.textContent = `${i + 1}. ${prev.label || prev.id}`; + tgtSel.appendChild(opt); + } + tgtSel.value = (stage.loopBackTo && index > 0) ? stage.loopBackTo : ''; + tgtSel.onchange = () => { stage.loopBackTo = tgtSel.value; }; + // max + const maxLbl = document.createElement('label'); maxLbl.textContent = '최대 반복:'; + const maxInput = document.createElement('input'); + maxInput.type = 'number'; maxInput.min = '1'; maxInput.max = '10'; + maxInput.value = String(stage.maxIterations || 3); + maxInput.oninput = () => { + const v = parseInt(maxInput.value, 10); + stage.maxIterations = Number.isFinite(v) ? Math.max(1, Math.min(10, v)) : 3; + }; + grid.appendChild(condLbl); grid.appendChild(condInput); + grid.appendChild(tgtLbl); grid.appendChild(tgtSel); + grid.appendChild(maxLbl); grid.appendChild(maxInput); + if (index === 0) { + const note = document.createElement('div'); + note.style.cssText = 'grid-column:1/-1; font-style:italic; opacity:0.7;'; + note.textContent = '첫 단계는 되돌아갈 곳이 없어 재시도 설정이 적용되지 않습니다.'; + grid.appendChild(note); + } + loop.appendChild(grid); + body.appendChild(loop); + + li.appendChild(body); + return li; + }; + + const _renderStages = () => { + if (!_pipelineStageList) return; + _pipelineStageList.innerHTML = ''; + for (let i = 0; i < _editStages.length; i++) { + _pipelineStageList.appendChild(_buildStageCard(_editStages[i], i, _editStages.length)); + } + }; + const _openPipelineEditor = (pipeline) => { if (!_pipelineEditForm) return; _pipelineEditForm.setAttribute('data-open', 'true'); @@ -1646,17 +1987,39 @@ if (pipeline) { if (_pipelineEditId) { _pipelineEditId.value = pipeline.id; _pipelineEditId.disabled = true; } if (_pipelineEditName) _pipelineEditName.value = pipeline.name || ''; - if (_pipelineEditStages) _pipelineEditStages.value = JSON.stringify(pipeline.stages || [], null, 2); + // stages 데이터를 깊은 복사 — 사용자 수정이 cancel 시 안 새도록. + _editStages = (pipeline.stages || []).map((s) => ({ + id: s.id, + label: s.label || '', + // 기존 데이터에 roleCategory가 없으면 담당 agentId의 직군으로 추정. + roleCategory: s.roleCategory || _deriveRoleFromAgent(s.agentId) || 'planner', + agentId: s.agentId || '', + modelOverride: s.modelOverride || '', + requiresApproval: !!s.requiresApproval, + instructionTemplate: s.instructionTemplate || '', + loopBackPattern: s.loopBackPattern || '', + loopBackTo: s.loopBackTo || '', + maxIterations: s.maxIterations || 3, + })); } else { if (_pipelineEditId) { _pipelineEditId.value = ''; _pipelineEditId.disabled = false; } if (_pipelineEditName) _pipelineEditName.value = ''; - if (_pipelineEditStages) _pipelineEditStages.value = '[]'; + _editStages = []; } + _renderStages(); + }; + + // 빌트인 + 커스텀 에이전트의 직군 매핑 캐시 (lastCompanyAgentsPayload 활용) + const _deriveRoleFromAgent = (agentId) => { + if (!agentId || !_lastCompanyAgentsPayload) return null; + const agent = (_lastCompanyAgentsPayload.agents || []).find((a) => a.id === agentId); + return agent?.roleCategory || null; }; const _closePipelineEditor = () => { if (_pipelineEditForm) _pipelineEditForm.setAttribute('data-open', 'false'); if (_pipelineEditId) _pipelineEditId.disabled = false; + _editStages = []; }; if (_addPipelineBtn) { @@ -1665,31 +2028,75 @@ _pipelineEditId?.focus(); }; } + + // 템플릿에서 새 pipeline 만들기. 선택 시 백엔드에 stages를 요청 → + // companyPipelineTemplateContent 응답이 오면 에디터를 미리 채워 연다. + const _pipelineTemplateSel = document.getElementById('pipelineTemplateSel'); + if (_pipelineTemplateSel) { + _pipelineTemplateSel.onchange = () => { + const tplId = _pipelineTemplateSel.value; + if (!tplId) return; + vscode.postMessage({ type: 'getCompanyPipelineTemplate', templateId: tplId }); + // 선택 후 즉시 초기화 — 사용자가 같은 템플릿을 다시 찍어도 onchange가 다시 발화하도록. + _pipelineTemplateSel.value = ''; + }; + } + if (_addStageBtn) { + _addStageBtn.onclick = () => { + const ns = _emptyStage(); + ns.agentId = _firstAgentOfCategory(ns.roleCategory); + _editStages.push(ns); + _renderStages(); + // 새로 추가한 카드의 라벨 입력으로 포커스 — 이름부터 적게 유도. + const newCard = _pipelineStageList?.lastElementChild; + newCard?.querySelector('.psc-label')?.focus(); + }; + } if (_cancelPipelineEditBtn) { _cancelPipelineEditBtn.onclick = _closePipelineEditor; } if (_savePipelineEditBtn) { _savePipelineEditBtn.onclick = () => { - let stages; - try { - stages = JSON.parse(_pipelineEditStages?.value || '[]'); - } catch (e) { - if (_pipelineEditError) _pipelineEditError.textContent = `Stages JSON 파싱 실패: ${e.message}`; + const id = (_pipelineEditId?.value || '').trim().toLowerCase(); + if (!id) { + if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자로 시작, /-/_).'; return; } - if (!Array.isArray(stages)) { - if (_pipelineEditError) _pipelineEditError.textContent = 'Stages는 배열이어야 합니다.'; - return; + // 각 stage 검증: 라벨 + 담당 에이전트 필수. + 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}번 단계: 담당 에이전트를 지정하세요. 해당 직군에 활성 에이전트가 없으면 관리 패널에서 활성화 먼저.`; + return; + } } const def = { - id: (_pipelineEditId?.value || '').trim().toLowerCase(), - name: (_pipelineEditName?.value || '').trim(), - stages, + id, + name: (_pipelineEditName?.value || '').trim() || id, + stages: _editStages.map((s) => { + const out = { + id: s.id, + label: s.label.trim(), + agentId: s.agentId, + roleCategory: s.roleCategory, + instructionTemplate: s.instructionTemplate || '', + }; + if (s.modelOverride && s.modelOverride.trim()) { + out.modelOverride = s.modelOverride.trim(); + } + if (s.requiresApproval) out.requiresApproval = true; + if (s.loopBackPattern && s.loopBackTo) { + out.loopBackPattern = s.loopBackPattern; + out.loopBackTo = s.loopBackTo; + out.maxIterations = s.maxIterations || 3; + } + return out; + }), }; - if (!def.id) { - if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자/숫자/-/_).'; - return; - } vscode.postMessage({ type: 'upsertCompanyPipeline', def }); }; } @@ -1702,10 +2109,35 @@ }; } + let _lastCompanyPipelinesPayload = null; const renderCompanyPipelines = (payload) => { if (!_pipelineList || !_activePipelineSel) return; + _lastCompanyPipelinesPayload = payload || null; const pipelines = (payload && payload.pipelines && typeof payload.pipelines === 'object') ? payload.pipelines : {}; const activeId = payload && payload.activePipelineId ? payload.activePipelineId : ''; + // 직군별 에이전트 + 라벨 캐시 갱신. 에디터가 열려 있는 동안 + // 새 페이로드가 오면 카드의 담당 dropdown도 새 목록 반영. + if (payload && payload.activeAgentsByCategory) { + _activeAgentsByCategory = payload.activeAgentsByCategory; + } + if (payload && payload.roleCategoryLabels) _roleCategoryLabels = payload.roleCategoryLabels; + if (payload && Array.isArray(payload.roleCategoryOrder)) _roleCategoryOrder = payload.roleCategoryOrder; + // 템플릿 드롭다운 채우기. + const tplSel = document.getElementById('pipelineTemplateSel'); + if (tplSel && payload && Array.isArray(payload.templates)) { + tplSel.innerHTML = ''; + for (const t of payload.templates) { + const opt = document.createElement('option'); + opt.value = t.templateId; + opt.textContent = `${t.name} (${t.stageCount}단계)`; + opt.title = t.description || ''; + tplSel.appendChild(opt); + } + } + // 에디터가 열려 있으면 stages 다시 그려서 새로운 담당 옵션 반영. + if (_pipelineEditForm?.getAttribute('data-open') === 'true' && _editStages.length > 0) { + _renderStages(); + } // active dropdown _activePipelineSel.innerHTML = ''; for (const p of Object.values(pipelines)) { @@ -1759,6 +2191,25 @@ // expose for the message handler below window.__renderCompanyPipelines = renderCompanyPipelines; window.__closePipelineEditor = _closePipelineEditor; + // 템플릿 stamp 시 호출 — id/name 제안값 + stages를 카드 에디터에 미리 채움. + window.__openPipelineEditorWithTemplate = (tpl) => { + if (!tpl) return; + // suggested id가 이미 존재하면 -2, -3 식으로 충돌 회피. + const taken = new Set(Object.keys((_lastCompanyPipelinesPayload?.pipelines) || {})); + let id = tpl.suggestedPipelineId || 'pipeline'; + if (taken.has(id)) { + let n = 2; + while (taken.has(`${id}-${n}`)) n++; + id = `${id}-${n}`; + } + _openPipelineEditor({ + id, + name: tpl.suggestedPipelineName || id, + stages: tpl.stages || [], + }); + // suggested id는 새로 만드는 것이므로 잠가두지 않고 사용자가 바꿀 수 있게. + if (_pipelineEditId) _pipelineEditId.disabled = false; + }; /** * Keep the last payload around so we can re-render whenever the @@ -2172,6 +2623,61 @@ } else if (ev.phase === 'stage-loop') { card.innerHTML = `
    🔁 Stage 재시도
    ${escAttr(ev.from)} → ${escAttr(ev.to)} (반복 ${ev.iteration}회)
    `; + } else if (ev.phase === 'awaiting-approval') { + // 승인 게이트 카드 — 사용자가 클릭할 때까지 대기. + card.className += ' approval'; + card.dataset.stageId = ev.stageId; + const head = document.createElement('div'); + head.className = 'cph-head'; + head.innerHTML = `✋ ${escAttr(ev.stageLabel || ev.stageId)} 완료 — 검토 후 승인해 주세요`; + const actions = document.createElement('div'); + actions.style.cssText = 'display:flex; gap:6px; margin-top:8px; flex-wrap:wrap;'; + const approveBtn = document.createElement('button'); + approveBtn.className = 'send-btn'; + approveBtn.textContent = '✅ 승인 (다음으로)'; + approveBtn.onclick = () => { + vscode.postMessage({ type: 'respondCompanyApproval', stageId: ev.stageId, decision: 'approve' }); + actions.querySelectorAll('button').forEach((b) => b.disabled = true); + approveBtn.textContent = '✅ 승인됨'; + }; + const reviseBtn = document.createElement('button'); + reviseBtn.className = 'secondary-btn'; + reviseBtn.textContent = '✎ 수정 요청'; + reviseBtn.onclick = () => { + const comment = prompt('어떤 부분을 수정하면 좋을까요? (담당 에이전트에게 전달됩니다)', ''); + if (comment === null) return; // 취소 + vscode.postMessage({ type: 'respondCompanyApproval', stageId: ev.stageId, decision: 'revise', comment }); + actions.querySelectorAll('button').forEach((b) => b.disabled = true); + reviseBtn.textContent = '✎ 수정 요청 전송됨'; + }; + const abortBtn = document.createElement('button'); + abortBtn.className = 'secondary-btn'; + abortBtn.textContent = '🛑 중단'; + abortBtn.title = '여기서 파이프라인 중단'; + abortBtn.onclick = () => { + if (!confirm('이 라운드를 여기서 중단할까요? 이미 완료된 stage 결과는 보존됩니다.')) return; + vscode.postMessage({ type: 'respondCompanyApproval', stageId: ev.stageId, decision: 'abort' }); + actions.querySelectorAll('button').forEach((b) => b.disabled = true); + abortBtn.textContent = '🛑 중단 요청'; + }; + actions.appendChild(approveBtn); + actions.appendChild(reviseBtn); + actions.appendChild(abortBtn); + card.innerHTML = ''; + card.appendChild(head); + card.appendChild(actions); + } else if (ev.phase === 'approval-resolved') { + // 이전에 그려둔 awaiting-approval 카드를 갱신 — 별도 카드를 또 만들지 않음. + const prev = chatEl.querySelector(`.company-phase-card.approval[data-stage-id="${CSS.escape(ev.stageId)}"]`); + if (prev) { + const tail = document.createElement('div'); + tail.className = 'cph-meta'; + tail.style.marginTop = '6px'; + const label = ev.decision === 'approve' ? '✅ 승인됨' : ev.decision === 'revise' ? '✎ 수정 요청됨' : '🛑 사용자가 중단'; + tail.textContent = label; + prev.appendChild(tail); + } + return; // 새 카드 안 만듦 } else if (ev.phase === 'report-start') { card.innerHTML = '
    🧭 CEO 종합 보고서 작성 중…
    '; } else if (ev.phase === 'report-done') { diff --git a/package.json b/package.json index 7518551..f3a040c 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.1.3", + "version": "2.1.4", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/features/company/ceoPlanner.ts b/src/features/company/ceoPlanner.ts index aea0a60..e7cac8f 100644 --- a/src/features/company/ceoPlanner.ts +++ b/src/features/company/ceoPlanner.ts @@ -145,6 +145,51 @@ function _extractFirstBalancedObject(s: string): string | null { * state. Tasks targeting unknown / inactive agents are dropped, and Korean * nicknames are rewritten to canonical ids. */ +/** + * Post-hoc 직군 가드. The LLM is supposed to follow the "planner before + * developer" rule encoded in the planner prompt, but small models break it + * routinely. We re-check the parsed plan and inject a planner task at the + * front when build/design tasks were scheduled without any prior planning + * step. Returns the original plan when no fix is needed. + * + * This is best-effort, not authoritative — for a strict guarantee, the user + * should activate a pipeline (which is deterministic). The fallback agent + * for the auto-inserted planner step is the highest-priority active + * planner; if no planner direction is active we leave the plan as-is so + * the user isn't silently force-fed into a workflow they didn't set up. + */ +function _applyRoleGuard( + plan: CompanyTaskPlan, + state: CompanyState, + userPrompt: string, +): CompanyTaskPlan { + if (plan.tasks.length === 0) return plan; + const roleOf = (agentId: string): string => resolveAgent(state, agentId)?.roleCategory ?? 'support'; + // 가드: 첫 build/design task 이전에 planner / researcher / inspector step이 하나도 없다. + const buildIdx = plan.tasks.findIndex((t) => { + const r = roleOf(t.agent); + return r === 'developer' || r === 'designer'; + }); + if (buildIdx === -1) return plan; // build/design task 없음 → 가드 불필요 + const hasPriorContext = plan.tasks.slice(0, buildIdx).some((t) => { + const r = roleOf(t.agent); + return r === 'planner' || r === 'researcher' || r === 'inspector'; + }); + if (hasPriorContext) return plan; // 이미 사전 정리 task 존재 + // 사용자가 명시적으로 "기획 없이 바로", "지금 당장 코드만" 라고 했다면 가드 skip + if (/(기획\s*없이|바로\s*코드|기획\s*X|간단히|빨리)/i.test(userPrompt)) return plan; + // planner 직군의 첫 활성 에이전트를 골라 정리 task 한 줄 prepend. + const plannerAgent = listAllAgents(state) + .find((a) => a.roleCategory === 'planner' && (a.id === 'ceo' || state.activeAgentIds.includes(a.id))); + if (!plannerAgent) return plan; // planner 활성 없음 → 강제 못 함 + const inserted = { + agent: plannerAgent.id, + task: `사용자 요청을 한 번 정리한 뒤 다음 단계로 넘기세요. 요청: "${userPrompt.slice(0, 200)}". 다음 사항을 한 단락으로 정리: (1) 무엇을 만드는가, (2) 누가 쓰는가, (3) 성공 기준 1~2개.`, + }; + logInfo('ceoPlanner: role guard inserted planner task.', { agent: plannerAgent.id }); + return { brief: plan.brief, tasks: [inserted, ...plan.tasks] }; +} + export function normalizePlan(plan: CompanyTaskPlan, state: CompanyState): CompanyTaskPlan { const out: CompanyTaskPlan = { brief: plan.brief, tasks: [] }; const dropped: string[] = []; @@ -209,11 +254,13 @@ export async function runCeoPlanner( return { plan: { brief: raw.trim(), tasks: [] }, parsed: false, raw }; } - const plan = normalizePlan(parsed, state); + const normalized = normalizePlan(parsed, state); + const plan = _applyRoleGuard(normalized, state, userPrompt); logInfo('ceoPlanner: parsed plan.', { briefChars: plan.brief.length, taskCount: plan.tasks.length, agents: plan.tasks.map((t) => t.agent), + roleGuardApplied: plan.tasks.length !== normalized.tasks.length, }); return { plan, parsed: true, raw }; } diff --git a/src/features/company/companyConfig.ts b/src/features/company/companyConfig.ts index d6a5f2d..c1ad28f 100644 --- a/src/features/company/companyConfig.ts +++ b/src/features/company/companyConfig.ts @@ -116,6 +116,15 @@ function _normalizeStage(raw: unknown): PipelineStage | null { id, label, agentId, 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 (typeof r.modelOverride === 'string' && r.modelOverride.trim()) { + out.modelOverride = r.modelOverride.trim(); + } + if (r.requiresApproval === true) { + out.requiresApproval = true; + } 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 93f0e35..2408fd8 100644 --- a/src/features/company/dispatcher.ts +++ b/src/features/company/dispatcher.ts @@ -69,6 +69,17 @@ const PEER_OUTPUT_BUDGET = 1500; * the same channel can carry CEO/agent/report messages without per-type * postMessage plumbing. */ +/** + * User's decision after a stage with `requiresApproval=true` finishes. + * - 'approve' → proceed to next stage as-is + * - 'revise' → re-run the same stage; comment is prepended to its instruction + * - 'abort' → end the turn (same as hitting Stop) + */ +export type ApprovalDecision = + | { kind: 'approve' } + | { kind: 'revise'; comment: string } + | { kind: 'abort' }; + export type CompanyTurnEvent = | { phase: 'plan-start' } | { phase: 'plan-ready'; plan: CompanyTaskPlan; parsed: boolean; raw: string } @@ -81,6 +92,13 @@ export type CompanyTurnEvent = * (재시도 N차)" in the chat. */ | { phase: 'stage-loop'; from: string; to: string; iteration: number } + /** + * Manual approval gate. Emitted right before the dispatcher awaits the + * user's decision. Webview surfaces 승인/수정요청/중단 buttons. + */ + | { 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' } | { phase: 'report-start' } | { phase: 'report-done'; report: string; ok: boolean } /** @@ -125,6 +143,17 @@ export interface DispatcherDeps { signal?: AbortSignal; /** Optional event sink for the webview. Receives events synchronously. */ onEvent?: CompanyTurnEmitter; + /** + * Manual-approval bridge. When a pipeline stage has `requiresApproval`, + * the dispatcher emits `phase: 'awaiting-approval'` and then *awaits* + * this promise. The host (SidebarChatProvider) is responsible for: + * 1. Storing a resolver tied to the emitted stageId + * 2. Surfacing approval buttons in the webview chat + * 3. Resolving the promise when the user clicks one of them + * 4. Resolving with `{ kind: 'abort' }` if the turn is cancelled + * (so the dispatcher doesn't hang forever) + */ + awaitApproval?: (ctx: { stageId: string; stageLabel: string }) => Promise; } /** @@ -287,6 +316,8 @@ async function _dispatchOne( earlierOutputs: AgentTurnOutput[], state: ReturnType, deps: DispatcherDeps, + /** Pipeline stage override — wins over the agent's own model override. */ + stageModelOverride?: string, ): Promise { const startedAt = Date.now(); const def = resolveAgent(state, agentId); @@ -360,7 +391,10 @@ async function _dispatchOne( brainContext, // injected as `[SECOND BRAIN CONTEXT]` block knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block }); - const model = modelForAgent(state, agentId, deps.defaultModel); + // 우선순위: stage > agent > global default. + const model = (stageModelOverride && stageModelOverride.trim()) + ? stageModelOverride.trim() + : modelForAgent(state, agentId, deps.defaultModel); try { const result = await deps.ai.chat({ @@ -455,14 +489,22 @@ async function _runPipeline( const latestByStage: Record = {}; const iterations: Record = {}; const total = pipeline.stages.length; + // Per-stage extra instruction injected by user revision requests. Cleared + // after the stage re-runs successfully so it doesn't pollute the rest of + // the pipeline. + const revisionNotes: Record = {}; let i = 0; let stepIndex = 0; while (i < pipeline.stages.length) { if (isAborted()) return { outputs, aborted: 'aborted-mid-pipeline' }; const stage = pipeline.stages[i]; - const task = _renderStageInstruction(stage, userPrompt, brief, latestByStage); + const baseTask = _renderStageInstruction(stage, userPrompt, brief, latestByStage); + const note = revisionNotes[stage.id]; + 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); + const turn = await _dispatchOne(stage.agentId, task, outputs, state, deps, stage.modelOverride); outputs.push(turn); latestByStage[stage.id] = turn; writeAgentOutput(sessionDir, turn); @@ -472,6 +514,44 @@ async function _runPipeline( ); emit({ phase: 'agent-done', agentId: stage.agentId, output: turn, index: stepIndex, total }); stepIndex++; + // Successful run consumed the revision note (if any) — clear it. + if (!turn.error) delete revisionNotes[stage.id]; + + // ── 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 + // gate when (a) the stage opted in, (b) we have an awaitApproval + // bridge from the host, and (c) the stage didn't error out + // (errored stages would loop forever waiting for "approval" of a + // failure — the user should just hit Stop). + if (stage.requiresApproval && deps.awaitApproval && !turn.error) { + emit({ + phase: 'awaiting-approval', + stageId: stage.id, + stageLabel: stage.label || stage.id, + index: stepIndex - 1, + total, + }); + let decision: ApprovalDecision; + try { + decision = await deps.awaitApproval({ stageId: stage.id, stageLabel: stage.label || stage.id }); + } catch { + // 호스트가 에러를 던지면 안전하게 중단 — 무한 대기 방지. + decision = { kind: 'abort' }; + } + if (isAborted()) return { outputs, aborted: 'aborted-mid-approval' }; + emit({ phase: 'approval-resolved', stageId: stage.id, decision: decision.kind }); + if (decision.kind === 'abort') { + return { outputs, aborted: 'aborted-by-user-at-approval' }; + } + if (decision.kind === 'revise') { + revisionNotes[stage.id] = decision.comment || '(추가 코멘트 없음)'; + // 같은 stage 재실행 — i를 그대로 두고 continue. + continue; + } + // 'approve' → 아래 loop-back/다음 stage 진행 로직으로 자연히 fall-through. + } + // Loop-back evaluation. We only loop on *successful* responses with // non-empty body — an error or empty response would loop forever. if (stage.loopBackTo && stage.loopBackPattern && !turn.error && turn.response.trim()) { diff --git a/src/features/company/index.ts b/src/features/company/index.ts index 96dab71..d7be8fa 100644 --- a/src/features/company/index.ts +++ b/src/features/company/index.ts @@ -50,6 +50,9 @@ export type { export { ROLE_CATEGORY_LABELS, ROLE_CATEGORY_ORDER } from './types'; +export { PIPELINE_TEMPLATES, getPipelineTemplate } from './pipelineTemplates'; +export type { PipelineTemplate } from './pipelineTemplates'; + export type { CompanyAgentDef, CompanyState, @@ -64,6 +67,7 @@ export { } from './dispatcher'; export type { + ApprovalDecision, CompanyTurnEvent, CompanyTurnEmitter, DispatcherDeps, diff --git a/src/features/company/pipelineTemplates.ts b/src/features/company/pipelineTemplates.ts new file mode 100644 index 0000000..ab3c51d --- /dev/null +++ b/src/features/company/pipelineTemplates.ts @@ -0,0 +1,211 @@ +/** + * Built-in pipeline templates for 1인 기업 모드. + * + * These are *blueprints*, not data — they're surfaced in the manage panel's + * "템플릿에서 추가" dropdown so a non-developer user can stamp out a + * working pipeline in one click and then tweak the labels / 지시 / agent + * assignments in the card editor. Once stamped, the pipeline lives in + * `state.pipelines` like any other; templates themselves stay read-only in + * code so a future Astra version can ship improved defaults without + * trampling user edits. + * + * Why ship a template at all: the user's own description of the desired + * workflow ("작업 수락 → 기획 → 시장 조사 → … → QA → 배포") is exactly the + * shape this codifies. Without the template they'd have to author 13 + * cards from scratch the first time they open the editor — which is + * exactly the friction we're trying to remove. + */ +import { PipelineDef } from './types'; + +export interface PipelineTemplate { + /** Stable id used by the UI dropdown and `applyTemplate` call. */ + templateId: string; + /** Korean display name shown in the dropdown. */ + name: string; + /** One-line description of when this template fits. */ + description: string; + /** + * Default pipeline id when stamping. The UI will suggest this and let + * the user override before saving so two stamps don't collide. + */ + suggestedPipelineId: string; + /** Default human-readable name for the stamped pipeline. */ + suggestedPipelineName: string; + /** Stage definitions — same shape as a saved PipelineDef.stages. */ + stages: PipelineDef['stages']; +} + +/** + * "풀 프로덕트 개발" — the user's described workflow, codified: + * 1. 작업 수락 — CEO 브리프 (자동, 별도 stage 아님) + * 2. 기획 논의 — planner + * 3. 시장 조사 — researcher + * 4. 트렌드 조사 — researcher + * 5. 방향성 정의 — planner + * 6. 기획문서 작성 — planner + * 7. 기획문서 검토 — inspector (재작업 발견 시 → 6번 loop-back) + * 8. 기획문서 최종본 — planner + * 9. 개발 설계 — developer + * 10. 설계 검토 — inspector (재작업 발견 시 → 9번 loop-back) + * 11. 개발 진행 — developer + * 12. QA 진행 — qa (버그 발견 시 → 11번 loop-back) + * 13. 라이브 배포 — developer + * + * 1번 "작업 수락"은 별도 stage가 아니라 CEO의 브리프 발신 — 모든 pipeline + * turn은 자동으로 brief를 생성해 `{{brief}}` 토큰으로 사용 가능하다. + * + * 지시 템플릿은 작은 LLM이 가장 자주 어기는 두 가지 — (a) 기획 문맥 잊기, + * (b) 단순 코드만 던지기 — 를 강하게 누른다. 사용자가 처음 보면 길어 + * 보일 수 있는데, "이 단계에서 정확히 뭘 해야 하나" 가이드가 필요한 + * 작은 모델 (gemma e2b·4b 등)에서 결과 품질을 크게 좌우한다. + */ +const FULL_PRODUCT_DEV: PipelineTemplate = { + templateId: 'full-product-dev', + name: '풀 프로덕트 개발 (13단계)', + description: '기획 → 리서치 → 기획서 → 검토 → 설계 → 개발 → QA → 배포. 기획 누락 없이 풀 사이클을 도는 표준 워크플로.', + suggestedPipelineId: 'product-dev', + suggestedPipelineName: '제품 개발 파이프라인', + stages: [ + { + id: 'plan-discuss', + label: '기획 논의', + agentId: 'writer', + roleCategory: 'planner', + instructionTemplate: + '사용자 요청: {{userPrompt}}\n\n' + + '이번 작업의 목표·사용자·성공 기준을 정리하세요. 결정 사항이 아니라 *논의 정리* 단계입니다.\n' + + '- 누구를 위한 결과물인가\n- 핵심 가치 한 줄\n- 우리가 모르는 것 / 더 알아봐야 할 것\n- 위험 요소', + }, + { + id: 'market-research', + label: '시장 조사', + agentId: 'researcher', + roleCategory: 'researcher', + instructionTemplate: + '기획 논의 정리: {{stage.plan-discuss}}\n\n' + + '위 맥락에서 *시장 측면*을 조사하세요. 추측 금지, 데이터/사례 기반.\n' + + '- 비슷한 시도가 이미 있나 (3개 이상)\n- 시장 크기·고객 페르소나\n- 가격대·수익화 패턴\n결과는 "출처(또는 일반론임을 명시)" 표시.', + }, + { + id: 'trend-research', + label: '트렌드 조사', + agentId: 'researcher', + roleCategory: 'researcher', + instructionTemplate: + '기획 논의: {{stage.plan-discuss}}\n시장 조사 결과: {{stage.market-research}}\n\n' + + '*최근 트렌드*를 조사하세요. 6개월 이내 변화를 우선.\n' + + '- 떠오르는 키워드/패턴\n- 사용자 기대치 변화\n- 우리가 활용할 수 있는 기술/문화 신호', + }, + { + id: 'direction', + label: '방향성 정의', + agentId: 'writer', + roleCategory: 'planner', + instructionTemplate: + '기획 논의: {{stage.plan-discuss}}\n시장: {{stage.market-research}}\n트렌드: {{stage.trend-research}}\n\n' + + '위 3개를 종합해 *우리가 갈 방향*을 한 문단으로 결론지어요.\n포함:\n' + + '- 무엇을 만들 것인가 (제품 한 줄 설명)\n- 누가 첫 사용자인가\n- 안 만들 것 (out of scope)\n- 성공 판단 기준 1~3가지', + }, + { + id: 'plan-draft', + label: '기획문서 초안', + agentId: 'writer', + roleCategory: 'planner', + instructionTemplate: + '방향성: {{stage.direction}}\n\n' + + '이 방향에 맞는 *기획서 초안*을 마크다운으로 작성하세요.\n' + + '필수 섹션:\n' + + '## 배경\n## 목표\n## 핵심 사용자 시나리오 (3개 이상, 구체적)\n## 주요 기능 목록\n## 비기능 요구사항\n## 측정 지표 (KPI)\n## 미래 확장 / 비-목표\n\n' + + '아직 *최종본 아님* — 검토자가 피드백 줄 수 있도록 가정·미확정 사항을 명시하세요.', + }, + { + id: 'plan-review', + label: '기획문서 검토', + agentId: 'inspector', + roleCategory: 'inspector', + instructionTemplate: + '검토 대상: {{stage.plan-draft}}\n\n' + + '*감리* 관점에서 기획서를 검토하세요. 칭찬 X, 구체적 피드백 O.\n' + + '확인 사항:\n- 시나리오가 구체적인가 ("사용자가 X를 한다" vs "사용자가 잘 쓴다")\n' + + '- 기능 vs 시나리오 매핑이 1:1로 되는가\n- 측정 가능한 성공 기준이 있는가\n- 빠진 케이스/엣지 케이스\n\n' + + '결론은 반드시 "✅ 승인" 또는 "❌ 재작업 필요: <항목 나열>" 로 시작.', + loopBackPattern: '재작업 필요|reject|보완 필요', + loopBackTo: 'plan-draft', + maxIterations: 3, + }, + { + id: 'plan-final', + label: '기획문서 최종본', + agentId: 'writer', + roleCategory: 'planner', + instructionTemplate: + '초안: {{stage.plan-draft}}\n검토 피드백: {{stage.plan-review}}\n\n' + + '피드백을 모두 반영해 *최종 기획서*를 다시 쓰세요. 다음 단계(개발 설계)에서 그대로 사양서로 쓸 정도로 명확해야 합니다.', + }, + { + id: 'dev-design', + label: '개발 설계', + agentId: 'developer', + roleCategory: 'developer', + instructionTemplate: + '최종 기획서: {{stage.plan-final}}\n\n' + + '이 기획서 기준으로 *개발 설계 문서*를 작성하세요. 아직 코드는 쓰지 않습니다.\n' + + '포함:\n## 데이터 모델\n## 컴포넌트/모듈 분할\n## 외부 의존성\n## 단계별 구현 순서 (체크리스트)\n## 테스트 전략\n## 알려진 리스크', + }, + { + id: 'design-review', + label: '설계 검토', + agentId: 'inspector', + roleCategory: 'inspector', + instructionTemplate: + '설계 문서: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' + + '설계가 기획 의도를 모두 커버하는지 *감리*하세요. 누락된 시나리오·과도한 over-engineering이 있나.\n' + + '결론은 "✅ 승인" 또는 "❌ 재작업 필요: ..." 로 시작.', + loopBackPattern: '재작업 필요|reject|보완 필요', + loopBackTo: 'dev-design', + maxIterations: 2, + }, + { + id: 'dev-impl', + label: '개발 진행', + agentId: 'developer', + roleCategory: 'developer', + instructionTemplate: + '설계: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' + + '설계대로 *실제 코드를 작성*하세요. 반드시 ConnectAI 액션 태그(``, ``, ``)를 사용해 디스크에 떨어지도록.\n' + + '코드 블록만 보여주고 "생성 완료"라고 말하면 디스크엔 아무것도 안 만들어집니다. 작성 후 자가 검증 한 줄.', + }, + { + id: 'qa', + label: 'QA 진행', + agentId: 'qa', + roleCategory: 'qa', + instructionTemplate: + '구현 결과: {{stage.dev-impl}}\n기획서: {{stage.plan-final}}\n\n' + + '*테스트 시나리오를 직접 실행*해 기능을 검증하세요. 케이스별로 PASS/FAIL 명확히 적고, 실패 시 재현 방법을 적어요.\n' + + '결론은 "✅ 모든 케이스 통과" 또는 "❌ 버그 발견: ..." 로 시작 (loop-back regex가 이걸 봅니다).', + loopBackPattern: '버그 발견|❌|버그|오류|실패', + loopBackTo: 'dev-impl', + maxIterations: 4, + }, + { + id: 'deploy', + label: '라이브 배포', + agentId: 'developer', + roleCategory: 'developer', + instructionTemplate: + 'QA 통과 결과: {{stage.qa}}\n\n' + + '배포 절차를 *실행*하세요. README 갱신, 버전 태깅, 배포 스크립트 실행 등 필요한 명령은 `` 로.\n' + + '마지막에 배포된 상태 요약과 사용자에게 안내할 한 줄 (📝 다음).', + }, + ], +}; + +/** Read-only registry of templates the UI surfaces. Add more here later. */ +export const PIPELINE_TEMPLATES: PipelineTemplate[] = [ + FULL_PRODUCT_DEV, +]; + +export function getPipelineTemplate(id: string): PipelineTemplate | undefined { + return PIPELINE_TEMPLATES.find((t) => t.templateId === id); +} diff --git a/src/features/company/promptBuilder.ts b/src/features/company/promptBuilder.ts index 8ec4448..94dbdde 100644 --- a/src/features/company/promptBuilder.ts +++ b/src/features/company/promptBuilder.ts @@ -16,8 +16,8 @@ * memory/decisions and passes them in), which keeps it trivial to test. */ import { COMPANY_AGENTS } from './agents'; -import { listAllAgents, resolveAgent, resolveAgentPrompt } from './companyConfig'; -import { CompanyState } from './types'; +import { listAllAgents, listActiveAgentsByCategory, resolveAgent, resolveAgentPrompt } from './companyConfig'; +import { CompanyState, ROLE_CATEGORY_LABELS } from './types'; export interface SpecialistPromptInputs { /** Active agent id. Must exist in `COMPANY_AGENTS`. */ @@ -192,6 +192,33 @@ export function buildPlannerSystemPrompt( const allIds = listAllAgents(state).map((a) => a.id); const inactive = allIds.filter((id) => !active.has(id)); const tail: string[] = []; + + // ── 직군별 활성 에이전트 카탈로그 + 직군 가드 ─────────────────────────── + // 작은 LLM (gemma e2b/4b 등) 은 사용자가 "코딩해줘" 한 마디만 해도 + // developer로 직행하는 anchor bias가 강하다. 직군 가드 룰을 명시해서 + // "개발 task가 있으려면 그 전에 planner task가 반드시 있어야 한다" + // 를 박아둔다. 100% 작동하진 않지만 도움이 됨. 더 확실히 가드하려면 + // 사용자가 pipeline을 켜면 된다 — 그쪽은 deterministic. + const buckets = listActiveAgentsByCategory(state); + const usedCats = (Object.keys(buckets) as (keyof typeof buckets)[]) + .filter((cat) => cat !== 'ceo' && buckets[cat].length > 0); + if (usedCats.length > 0) { + tail.push(''); + tail.push('## 직군(role) 기반 작업 분배 규칙'); + tail.push('이번 라운드에 활성화된 직군별 에이전트:'); + for (const cat of usedCats) { + const label = ROLE_CATEGORY_LABELS[cat] || cat; + const names = buckets[cat].map((a) => `${a.emoji}${a.name}(${a.id})`).join(', '); + tail.push(`- **${label}** [${cat}]: ${names}`); + } + tail.push(''); + tail.push('🛑 **직군 순서 가드 (반드시 준수)**:'); + tail.push('1. `developer` 직군에게 코드 작성 task를 줄 때는 그 *전에* `planner` 직군의 task가 tasks 배열 안에 반드시 하나 이상 있어야 합니다. 사용자가 "지금 당장 만들어"라고 명시했어도 마찬가지 — 한 줄짜리 기획 정리도 planner task로 먼저 넣으세요.'); + tail.push('2. `developer`·`designer`에게 task를 주는데 사용자 요청이 모호하다면, 그 전에 `researcher` 또는 `planner` task로 *맥락을 정리*하는 step을 먼저 넣으세요.'); + tail.push('3. `qa`·`inspector` 직군은 *반드시 검토 대상이 되는 산출물 task가 같은 라운드에 존재할 때만* 호출하세요. 검토할 게 없는데 QA만 호출하면 빈 검토가 됩니다.'); + tail.push('4. 사용자가 "기획 없이 바로 코드"라고 명시적으로 말한 경우에만 1번 규칙을 우회 가능 — 그 외엔 위반 금지.'); + } + if (inactive.length > 0) { tail.push(''); tail.push('현재 비활성화된 에이전트 (절대 dispatch 금지):'); diff --git a/src/features/company/types.ts b/src/features/company/types.ts index 53a4dc7..d6062f6 100644 --- a/src/features/company/types.ts +++ b/src/features/company/types.ts @@ -182,6 +182,31 @@ export interface PipelineStage { 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`. + */ + roleCategory?: string; + /** + * Stage-level model override. Empty / missing → fall back to the + * agent's own model override → global default. Use case: light planning + * stages on a fast model (e.g. gemma e2b), heavy implementation stages + * on a strong model (e.g. qwen 14b). LM Studio's lifecycle manager + * swaps the model when the next stage's effective model differs. + */ + modelOverride?: string; + /** + * Manual approval gate. When true, the dispatcher pauses after this + * stage completes and waits for the user to click 승인 / 수정요청 / + * 중단 in the chat. 수정요청은 같은 stage를 사용자 코멘트와 함께 + * 다시 실행. The pause is bounded by the existing turn-abort signal + * — if the user kills the turn instead of clicking, the wait resolves + * to "aborted" cleanly. + */ + requiresApproval?: boolean; /** * Instruction template. Tokens substituted before dispatch: * - `{{userPrompt}}` — what the user typed diff --git a/src/sidebar/chatHandlers.ts b/src/sidebar/chatHandlers.ts index efb4993..fa6f266 100644 --- a/src/sidebar/chatHandlers.ts +++ b/src/sidebar/chatHandlers.ts @@ -316,6 +316,35 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any if (result.ok) await provider._sendCompanyPipelines(); return true; } + case 'getCompanyPipelineTemplate': { + // Returns a template's stages so the editor can pre-fill the form. + const { getPipelineTemplate } = await import('../features/company'); + const tplId = typeof data.templateId === 'string' ? data.templateId : ''; + const tpl = getPipelineTemplate(tplId); + provider._view?.webview.postMessage({ + type: 'companyPipelineTemplateContent', + value: tpl ? { + templateId: tpl.templateId, + suggestedPipelineId: tpl.suggestedPipelineId, + suggestedPipelineName: tpl.suggestedPipelineName, + stages: tpl.stages, + } : null, + }); + return true; + } + case 'respondCompanyApproval': { + // Webview의 승인 카드 버튼 클릭 → dispatcher의 await 해제. + // payload: { stageId, decision: 'approve' | 'revise' | 'abort', comment? } + const stageId = typeof data.stageId === 'string' ? data.stageId : ''; + const decision = typeof data.decision === 'string' ? data.decision : ''; + if (!stageId || !['approve', 'revise', 'abort'].includes(decision)) return true; + let payload: any; + if (decision === 'approve') payload = { kind: 'approve' }; + else if (decision === 'abort') payload = { kind: 'abort' }; + else payload = { kind: 'revise', comment: typeof data.comment === 'string' ? data.comment : '' }; + provider.resolveApprovalGate(stageId, payload); + return true; + } case 'setActiveCompanyPipeline': { const { setActivePipeline } = await import('../features/company'); const pid = typeof data.pipelineId === 'string' && data.pipelineId.trim() diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index 1718edc..009564e 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -45,6 +45,7 @@ import { ROLE_CATEGORY_LABELS, ROLE_CATEGORY_ORDER, resolveAgent, + PIPELINE_TEMPLATES, } from './features/company'; import { AIService } from './core/services'; @@ -102,6 +103,21 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn */ private _companyAbort?: AbortController; + /** + * Open approval gates. The dispatcher emits `phase: 'awaiting-approval'` + * for stages with `requiresApproval`, and waits on a Promise this map + * stores. The webview surfaces 승인 / 수정요청 / 중단 buttons; clicks + * route through `chatHandlers.respondCompanyApproval` which calls + * `resolveApprovalGate(stageId, decision)` here. + * + * Keyed by stageId — only one approval may be pending per stage at a + * time (sequential dispatch), but multiple stages across the same turn + * each get their own entry as they hit their gate. On turn abort we + * resolve all outstanding entries with `{ kind: 'abort' }` so the + * dispatcher unblocks cleanly. + */ + private _pendingApprovals = new Map void>(); + /** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */ private _archWatcher?: vscode.FileSystemWatcher; /** Debounce timer for the architecture watcher. */ @@ -1475,6 +1491,25 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn abortCompanyTurn(): boolean { if (!this._companyAbort) return false; this._companyAbort.abort(); + // 승인 게이트 대기 중인 모든 stage를 'abort'로 해제. 안 하면 dispatcher가 + // 영원히 await 상태로 남아 turn이 절대 종료 안 됨. + for (const resolve of this._pendingApprovals.values()) { + try { resolve({ kind: 'abort' }); } catch { /* noop */ } + } + this._pendingApprovals.clear(); + return true; + } + + /** + * Called by chatHandlers when the user clicks an approval card button. + * Resolves the dispatcher's awaitApproval promise for `stageId`. Idempotent + * — extra clicks after the first one are silently dropped. + */ + resolveApprovalGate(stageId: string, decision: import('./features/company/dispatcher').ApprovalDecision): boolean { + const resolve = this._pendingApprovals.get(stageId); + if (!resolve) return false; + this._pendingApprovals.delete(stageId); + try { resolve(decision); } catch { /* noop */ } return true; } @@ -1495,6 +1530,16 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn for (const [cat, defs] of Object.entries(byCategory)) { slimByCategory[cat] = defs.map((d) => ({ id: d.id, name: d.name, emoji: d.emoji })); } + // 템플릿 카탈로그 — 가벼운 메타데이터만 (stages는 stamp 시점에 한 번 + // 더 요청). UI는 dropdown 옵션 텍스트만 필요. + const templates = PIPELINE_TEMPLATES.map((t) => ({ + templateId: t.templateId, + name: t.name, + description: t.description, + stageCount: t.stages.length, + suggestedPipelineId: t.suggestedPipelineId, + suggestedPipelineName: t.suggestedPipelineName, + })); this._view.webview.postMessage({ type: 'companyPipelines', value: { @@ -1503,6 +1548,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn roleCategoryLabels: ROLE_CATEGORY_LABELS, roleCategoryOrder: ROLE_CATEGORY_ORDER, activeAgentsByCategory: slimByCategory, + templates, }, }); } @@ -1630,6 +1676,17 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn executeActionTags: (text) => this._agent.executeActionTagsOnText(text), signal: abort.signal, onEvent: emit, + // 승인 게이트 bridge — dispatcher가 호출하면 Promise를 만들어 + // resolver를 _pendingApprovals에 보관 후 await. 사용자가 카드 버튼을 + // 누르면 chatHandlers가 resolveApprovalGate(stageId, decision)을 호출 + // 하고 그 resolve가 이 await을 풀어준다. + awaitApproval: ({ stageId }) => new Promise((resolve) => { + if (abort.signal.aborted) { + resolve({ kind: 'abort' }); + return; + } + this._pendingApprovals.set(stageId, resolve); + }), }); } catch (e: any) { logError('company.runTurn: unexpected failure.', { error: e?.message ?? String(e) });