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