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
+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';