feat: v2.2.3 - Stability, Self-Reflector & Intent Alignment

- 버전 2.2.3 상향 및 PATCHNOTES.md 업데이트

- [신규] src/features/selfReflector/ - 성찰 실행/검증/프롬프트 모듈 추가

- [신규] intentAlignment.ts, intentClassifier.ts - 의도 정렬 시스템 추가

- [신규] pixelOfficeState.ts - 픽셀 오피스 상태 관리 추가

- sidebarProvider, dispatcher, chatHandlers 핵심 로직 최적화

- astra-2.2.3.vsix 패키지 생성 완료 (298 tests PASS)
This commit is contained in:
2026-05-15 14:16:14 +09:00
parent ed7e497194
commit 72412450c3
33 changed files with 4964 additions and 125 deletions
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1778768831416,
"createdAt": 1778821460579,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1778768831407,
"createdAt": 1778821460577,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"createdAt": 1778768831402,
"createdAt": 1778821460575,
"modelVersion": "unknown"
}
@@ -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"
}
@@ -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": {
+19
View File
@@ -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` 시스템을 구축했습니다.
BIN
View File
Binary file not shown.
+5 -5
View File
@@ -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"
}
+376
View File
@@ -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;
+75 -7
View File
@@ -227,11 +227,19 @@
<div id="addAgentError" class="map-status" style="color:var(--error); margin-top:6px;"></div>
</div>
<!-- Work Pipeline editor. The active pipeline (if any) drives the
dispatcher instead of the CEO planner. Empty list / "기본 (CEO
자유 분배)" → legacy planner behaviour. -->
<div class="map-section">
<div class="map-section-head">
<!--
Work Pipeline editor. 기본은 *접혀 있음* — CEO가 사용자 의도를 보고
자동으로 적합한 작업 흐름을 선택하므로 일반 사용자는 만질 일이 없다.
"고급: 작업 흐름 직접 편집" 토글을 열어야만 보이는 영역으로 옮김.
기능과 데이터는 그대로 유지 (롤백 안전 · 고급 사용자용).
-->
<details class="map-section pipeline-advanced">
<summary class="pipeline-advanced-summary">
<span class="pa-icon">🔧</span>
<span class="pa-title">고급: 작업 흐름 직접 편집</span>
<span class="pa-hint">평소엔 대표가 자동 분배 — 직접 정의하고 싶을 때만 펼치세요.</span>
</summary>
<div class="map-section-head" style="margin-top:8px;">
<div>
<div class="map-section-title">작업 흐름</div>
<div class="map-section-hint">대표에게 맡기거나, 사용자가 정한 순서대로 팀원이 이어서 작업하게 만듭니다.</div>
@@ -247,11 +255,11 @@
<div class="control-row" style="margin-top:8px; gap:8px; align-items:center;">
<label style="font-size:10px; color:var(--text-dim);">현재 흐름:</label>
<select id="activePipelineSel" style="flex:1; padding:4px 8px; background:var(--bg); color:var(--text-primary); border:1px solid var(--border); border-radius:6px; font-size:11px;">
<option value="">대표가 알아서 분배</option>
<option value="">대표가 알아서 분배 (권장)</option>
</select>
</div>
<ul id="companyPipelineList" class="map-list" style="margin-top:8px;"></ul>
</div>
</details>
<!-- Pipeline editor — 카드형 단계 에디터.
각 단계는 역할 그룹 → 담당 cascading + 지시 텍스트 + 재시도 옵션을
@@ -387,6 +395,66 @@
</div>
</div>
<!--
Pixel Office — Agent Work Pipeline 상태 시각화 패널.
백엔드의 SidebarChatProvider.pixelOfficeOn*() 콜이 push하는 `pixelOfficeUpdate`
메시지를 받아 그대로 그린다. dispatcher / agent 로직은 절대 건드리지 않는
read-only 시각화 레이어. data-enabled 속성으로 보이기/숨기기 토글.
-->
<div id="pixelOffice" class="pixel-office" data-enabled="false" data-collapsed="false">
<div class="po-head">
<div class="po-head-left">
<span class="po-title">🏢 Pixel Office</span>
<span class="po-status-label" id="poStatusLabel">idle</span>
</div>
<div class="po-head-actions">
<button class="po-expand" id="poExpandBtn" title="전체 사무실 뷰 열기"></button>
<button class="po-collapse" id="poCollapseBtn" title="접기/펼치기"></button>
</div>
</div>
<div class="po-body" id="poBody">
<div class="po-scene">
<!-- 캐릭터 영역: 단일 캐릭터 + 머리 위 말풍선 슬롯. -->
<div class="po-char-wrap">
<div class="po-bubbles" id="poBubbles"></div>
<div class="po-char" id="poChar">
<div class="po-char-emoji" id="poCharEmoji">🧑‍💼</div>
<div class="po-char-prop" id="poCharProp"></div>
</div>
<div class="po-desk"></div>
</div>
<!-- 진행률 막대. -->
<div class="po-progress" id="poProgress">
<div class="po-progress-bar" id="poProgressBar" style="width:0%"></div>
</div>
</div>
<div class="po-panel" id="poPanel">
<div class="po-row"><span class="po-key">Agent</span><span class="po-val" id="poAgentName"></span></div>
<div class="po-row"><span class="po-key">Status</span><span class="po-val po-val-status" id="poStatusVal">idle</span></div>
<div class="po-row"><span class="po-key">Task</span><span class="po-val" id="poTask"></span></div>
<div class="po-row"><span class="po-key">Step</span><span class="po-val" id="poStep"></span></div>
<div class="po-row" id="poNextStepRow" style="display:none;"><span class="po-key">Next</span><span class="po-val" id="poNextStep"></span></div>
<div class="po-row" id="poMessageRow" style="display:none;"><span class="po-key">Note</span><span class="po-val" id="poMessage"></span></div>
<div class="po-section" id="poNeedInputSection" style="display:none;">
<div class="po-section-head">Need User Input</div>
<ol class="po-need-input" id="poNeedInputList"></ol>
</div>
<div class="po-section" id="poApprovalSection" style="display:none;">
<div class="po-section-head">Awaiting Approval</div>
<div class="po-approval" id="poApprovalText"></div>
</div>
<div class="po-section" id="poContractSection" style="display:none;">
<div class="po-section-head">Requirement Contract</div>
<div class="po-contract" id="poContract"></div>
</div>
<div class="po-section" id="poLogsSection" style="display:none;">
<div class="po-section-head">Recent Logs</div>
<div class="po-logs" id="poLogs"></div>
</div>
</div>
</div>
</div>
<div class="chat" id="chat">
<!-- Dynamic welcome panel — JS의 _renderWelcome()이 두뇌/모델 상태에 맞춰
시작 체크리스트 또는 예시 질문을 채워 넣음. 첫 메시지가 가면 사라짐. -->
+485 -12
View File
@@ -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 = `<span class="cin-label">${escAttr(v.label || '💬 대화')}</span>` +
(v.reason ? ` <span class="cin-reason">${escAttr(v.reason)}</span>` : '');
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 = '<div class="cph-meta">🛑 의도 정렬 취소됨 — 작업이 시작되지 않았습니다</div>';
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 = `<strong>${escAttr(kindLabel)}</strong>`;
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 = `<span class="cal-key">${escAttr(label)}</span><span class="cal-val">${val ? fmt(val) : '<em>(미정)</em>'}</span>`;
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 = `<div class="cph-head">🔁 Stage 재시도</div>
<div class="cph-meta">${escAttr(ev.from)}${escAttr(ev.to)} (반복 ${ev.iteration}회)</div>`;
} 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 = `<div class="cph-head">🔍 <strong>${escAttr(ev.stageLabel || ev.stageId)}</strong> 검수 사이클 시작 <span class="cph-meta">검수자: ${escAttr(ev.inspectorAgentId)} · 최대 ${ev.maxRounds}라운드</span></div>
<div class="rev-rounds"></div>`;
} 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 = `<div class="rev-round-head">라운드 ${ev.round} <span class="cph-meta">${(ev.durationMs/1000).toFixed(1)}s</span></div>
<div class="rev-line"><span class="rev-actor">${inspIcon} 검수</span><span class="rev-body">${fmt((ev.inspectorText || '').slice(0, 1500))}</span></div>
<div class="rev-line"><span class="rev-actor">${ceoIcon} CEO</span><span class="rev-body">${fmt((ev.ceoText || '').slice(0, 1000))}</span></div>`;
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';
+58 -1
View File
@@ -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 <create_file>, 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."
}
}
}
+25
View File
@@ -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.', {
+74
View File
@@ -72,6 +72,67 @@ export interface IAgentConfig {
* true(): Reflector가 plan/research를 critique을 Writer에 .
* false: 3(PlannerResearcherWriter) 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<number>('knowledgeMix.secondBrainWeight', 50)
))),
companyIntentClassifierModel: (cfg.get<string>('company.intentClassifierModel', '') || '').trim(),
companyDisableIntentClassifier: cfg.get<boolean>('company.disableIntentClassifier', false),
companyAutoSelectPipeline: cfg.get<boolean>('company.autoSelectPipeline', true),
companyIntentAlignmentMode: ((): 'off' | 'smart' | 'strict' => {
const v = (cfg.get<string>('company.intentAlignmentMode', 'smart') || 'smart').trim().toLowerCase();
return v === 'off' || v === 'strict' ? v : 'smart';
})(),
companyIntentAlignmentMaxRounds: Math.max(1, Math.min(5, cfg.get<number>('company.intentAlignmentMaxRounds', 3))),
selfReflectorEnabled: cfg.get<boolean>('selfReflector.enabled', true),
selfReflectorExternalEnabled: cfg.get<boolean>('selfReflector.externalVerification', false),
selfReflectorExecutionEnabled: cfg.get<boolean>('selfReflector.executionVerification', false),
companyPixelOfficeEnabled: cfg.get<boolean>('company.pixelOffice.enabled', true),
companyPixelOfficeBubbles: cfg.get<boolean>('company.pixelOffice.bubbles', true),
enableReflection: cfg.get<boolean>('enableReflection', true),
autoLessonFromReflection: cfg.get<boolean>('autoLessonFromReflection', true),
};
+5
View File
@@ -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
+8 -5
View File
@@ -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<PlannerResult> {
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({
+24 -5
View File
@@ -112,21 +112,40 @@ function _normalizeStage(raw: unknown): PipelineStage | null {
const r = raw as Record<string, unknown>;
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:<cat>' / 'agent:<id>' 형태만 허용. 그 외는 무시.
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();
}
+556 -11
View File
@@ -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<ApprovalDecision>;
/**
* 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 `<create_file>`,
// `<run_command>`, `<edit_file>`, 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:<cat>'
* - 'agent:<id>' (/ )
* 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
+15
View File
@@ -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';
+334
View File
@@ -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<typeof _parseAnalysisJson> {
if (!obj || typeof obj !== 'object') return null;
const o = obj as Record<string, unknown>;
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<IntentAnalysisResult> {
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');
}
+348
View File
@@ -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<string, unknown>;
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<IntentResult> {
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,
};
}
+51 -12
View File
@@ -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 {
+280
View File
@@ -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<AgentStatus, string[]> = {
idle: [
'오늘은 무슨 일을 할까?',
'주문 대기 중…',
'커피 한 잔 더 하고 시작할까.',
],
intake: [
'요청서 들어왔다.',
'한번 읽어보자.',
'오케이, 뭘 원하시는지 보자.',
],
analyzing: [
'음… 의도가 조금 모호한데?',
'맥락부터 정리해보자.',
'핵심이 뭐였더라.',
'이거 작업 범위가 어디까지지?',
],
need_clarification: [
'이건 사용자 확인이 먼저야.',
'질문 하나만 하고 가자.',
'추측으로 가면 위험해.',
'핵심 정보가 빠졌네.',
],
contract_ready: [
'좋아, 작업 조건 정리 완료.',
'이제 방향은 잡혔어.',
'계약서 도장 찍었다.',
'이제 진짜 시작.',
],
planning: [
'순서부터 잡아보자.',
'기존 기능은 건드리지 말자.',
'화이트보드 좀 빌릴게.',
'단계 나눠서 가자.',
],
executing: [
'코드 들어간다.',
'이번엔 단순하게 가자.',
'집중 모드 진입.',
'키보드 워밍업 완료.',
],
reviewing: [
'잠깐, 이건 다시 보자.',
'기존 기능 깨지는지 확인해야 해.',
'검수자 시점으로 한 번 더.',
'엣지 케이스 빠진 거 없나.',
],
waiting_approval: [
'이건 승인 없이 못 바꿔.',
'위험 작업 감지. 확인 필요!',
'사장님 결재 부탁드립니다.',
'도장 받기 전엔 멈춤.',
],
error: [
'앗, 이건 예상 못 했는데…',
'조건 하나 놓쳤네.',
'잠깐, 다시 정리.',
'엇, 이게 깨졌네.',
],
done: [
'좋아, 끝났다!',
'이번 작업 깔끔하게 완료.',
'커피 한 잔.',
'오늘치 끝!',
],
};
const EVENT_BUBBLE_POOL: Record<AgentEvent, string[]> = {
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,
};
}
+29
View File
@@ -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');
}
+66 -7
View File
@@ -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:<roleCategory>'` (: `'role:qa'`)
* - `'agent:<agentId>'`
* - / (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. */
@@ -0,0 +1,172 @@
/**
* Self-Reflector Phase C * * .
*
* Phase A/B는 LLM "코드가 실제로 컴파일되는가?"
* . Phase C는 정답: 그냥 * *.
*
* :
* 1. action-tag executor가 report를 `✅ Created: <path>` /
* `✅ Edited: <path>`
* 2. toolchain :
* .py `python -m py_compile <path>`
* .js / .mjs / .cjs `node --check <path>`
* .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<typeof spawn> | 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<string[]> {
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<string>(); // 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;
}
@@ -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 };
}
@@ -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: <Yes / 해당 없음 / 충돌 항목 1줄>
- Completeness: <Yes / 누락 항목 1줄>
- Accuracy: <Checked / 의심 항목 1줄>
\`\`\`
* / / * :
\`\`\`
- References: <Verified / 정의되지 않은 변수·함수 의심 1줄>
- Paths: <Verified / 의심 경로 1줄>
\`\`\`
:
- ( ). .
- "Checked" * * "완벽하다" . .
- . .
- * * .
`.trim();
/**
* . promptBuilder가 stage의
* developer면 specialist prompt에 prepend .
*/
export const SELF_REFLECTOR_CODE_GUARD = `
## [Code Self-Verification ]
/ ** 릿 :
1. ··import가 * * * *
2. ( )
3. \`<read_file>\` 으로 먼저 *현재 내용을 확인*한 뒤 \`<edit_file>\`로 부분 변경
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');
}
@@ -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<string, unknown>;
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<VerifyResult> {
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');
}
+41 -37
View File
@@ -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" (0100) 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;
}
/**
+180 -5
View File
@@ -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? }
+1172 -1
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -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;