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