Chat History
diff --git a/media/sidebar.js b/media/sidebar.js
index 19f2ca7..c37635b 100644
--- a/media/sidebar.js
+++ b/media/sidebar.js
@@ -777,6 +777,25 @@
vscode.postMessage({ type: 'getKnowledgeScope', agentPath: msg.selected });
syncContextBar();
break;
+ case 'companyStatus': {
+ const v = msg.value || {};
+ renderCompanyChip(!!v.enabled, v.summary || '');
+ break;
+ }
+ case 'companyAgents': {
+ renderCompanyAgentCards(msg.value || {});
+ break;
+ }
+ case 'openCompanyManageOverlay': {
+ // Triggered by the Command Palette `Manage 1인 기업 Agents`.
+ document.getElementById('companyOverlay')?.classList.add('visible');
+ vscode.postMessage({ type: 'getCompanyAgents' });
+ break;
+ }
+ case 'companyTurnUpdate': {
+ if (msg.value) renderCompanyPhase(msg.value);
+ break;
+ }
case 'architectureStatus': {
// Show / hide the chip + reflect current state.
const chip = document.getElementById('archChip');
@@ -1350,6 +1369,7 @@
vscode.postMessage({ type: 'getChronicleRecords' });
vscode.postMessage({ type: 'getKnowledgeMix' });
vscode.postMessage({ type: 'getArchitectureStatus' });
+ vscode.postMessage({ type: 'getCompanyStatus' });
vscode.postMessage({ type: 'ready' });
// ── Project Architecture chip buttons ─────────────────────────────────
@@ -1360,6 +1380,180 @@
if (_archRefreshBtn) _archRefreshBtn.onclick = () => vscode.postMessage({ type: 'refreshArchitecture' });
if (_archDetachBtn) _archDetachBtn.onclick = () => vscode.postMessage({ type: 'detachArchitecture' });
+ // ── 1인 기업 (Company) Mode chip + manage overlay ─────────────────────
+ // The chip itself toggles enabled/disabled. The ▾ button opens the
+ // manage overlay where the user picks active agents + per-agent
+ // model overrides. State round-trips through `companyStatus` /
+ // `companyAgents` messages so the webview and extension stay in sync.
+ const _companyChip = document.getElementById('companyChip');
+ const _companyChipLabel = document.getElementById('companyChipLabel');
+ const _companyManageBtn = document.getElementById('companyManageBtn');
+ const _companyOverlay = document.getElementById('companyOverlay');
+ const _closeCompanyBtns = [
+ document.getElementById('closeCompanyOverlayBtn'),
+ document.getElementById('closeCompanyOverlayBtn2'),
+ ].filter(Boolean);
+ const _companyNameInput = document.getElementById('companyNameInput');
+ const _saveCompanyNameBtn = document.getElementById('saveCompanyNameBtn');
+ const _companyAgentList = document.getElementById('companyAgentList');
+ const _companyStatusEl = document.getElementById('companyStatus');
+
+ const renderCompanyChip = (active, summary) => {
+ if (!_companyChip || !_companyChipLabel) return;
+ _companyChip.setAttribute('data-active', active ? 'true' : 'false');
+ _companyChipLabel.textContent = active ? (summary || 'Company ON') : 'Company OFF';
+ };
+
+ if (_companyChip) {
+ _companyChip.onclick = () => {
+ const isActive = _companyChip.getAttribute('data-active') === 'true';
+ // Optimistic flip — backend echoes the canonical state back.
+ renderCompanyChip(!isActive, _companyChipLabel?.textContent || '');
+ vscode.postMessage({ type: 'setCompanyEnabled', value: !isActive });
+ };
+ }
+ if (_companyManageBtn) {
+ _companyManageBtn.onclick = () => {
+ if (!_companyOverlay) return;
+ _companyOverlay.classList.add('visible');
+ _companyStatusEl.textContent = '불러오는 중...';
+ vscode.postMessage({ type: 'getCompanyAgents' });
+ };
+ }
+ for (const btn of _closeCompanyBtns) {
+ btn.onclick = () => _companyOverlay?.classList.remove('visible');
+ }
+ if (_saveCompanyNameBtn && _companyNameInput) {
+ _saveCompanyNameBtn.onclick = () => {
+ vscode.postMessage({ type: 'setCompanyName', value: _companyNameInput.value });
+ };
+ }
+
+ /**
+ * Render the agent cards in the manage overlay. Each card has a
+ * toggle (active on/off) and a model input (per-agent override).
+ * CEO is rendered but locked-on; clicking its toggle is a no-op.
+ */
+ function renderCompanyAgentCards(payload) {
+ if (!_companyAgentList) return;
+ _companyAgentList.innerHTML = '';
+ if (_companyNameInput && payload && typeof payload.companyName === 'string') {
+ _companyNameInput.value = payload.companyName;
+ }
+ const agents = (payload && Array.isArray(payload.agents)) ? payload.agents : [];
+ for (const a of agents) {
+ const li = document.createElement('li');
+ li.className = 'company-agent-card';
+ li.setAttribute('data-active', a.active ? 'true' : 'false');
+ if (a.alwaysOn) li.setAttribute('data-locked', 'true');
+
+ const emoji = document.createElement('span');
+ emoji.className = 'company-agent-emoji';
+ emoji.textContent = a.emoji;
+
+ const body = document.createElement('div');
+ body.className = 'company-agent-body';
+ const name = document.createElement('div');
+ name.className = 'company-agent-name';
+ name.innerHTML = `${escAttr(a.name)}
${escAttr(a.role)}`;
+ const tag = document.createElement('div');
+ tag.className = 'company-agent-tagline';
+ tag.textContent = a.tagline || '';
+ tag.title = a.specialty || '';
+ body.appendChild(name);
+ body.appendChild(tag);
+
+ const controls = document.createElement('div');
+ controls.className = 'company-agent-controls';
+
+ const modelInput = document.createElement('input');
+ modelInput.type = 'text';
+ modelInput.className = 'company-agent-model';
+ modelInput.placeholder = 'default';
+ modelInput.value = a.modelOverride || '';
+ modelInput.title = '비워두면 글로벌 기본 모델 사용';
+ modelInput.onchange = () => {
+ vscode.postMessage({
+ type: 'setCompanyAgentModel',
+ agentId: a.id,
+ model: modelInput.value.trim(),
+ });
+ };
+
+ const toggle = document.createElement('button');
+ toggle.className = 'company-agent-toggle';
+ toggle.textContent = a.active ? 'ON' : 'OFF';
+ if (a.alwaysOn) {
+ toggle.disabled = true;
+ toggle.textContent = 'LOCKED';
+ } else {
+ toggle.onclick = () => {
+ // Optimistic update + send the full new list so the
+ // backend has a single canonical replace operation.
+ const wantActive = !(li.getAttribute('data-active') === 'true');
+ li.setAttribute('data-active', wantActive ? 'true' : 'false');
+ toggle.textContent = wantActive ? 'ON' : 'OFF';
+ const nextIds = Array.from(_companyAgentList.querySelectorAll('.company-agent-card'))
+ .filter(el => el.getAttribute('data-active') === 'true')
+ .map(el => el.dataset.agentId)
+ .filter(Boolean);
+ vscode.postMessage({ type: 'setCompanyActiveAgents', value: nextIds });
+ };
+ }
+ li.dataset.agentId = a.id;
+ controls.appendChild(modelInput);
+ controls.appendChild(toggle);
+
+ li.appendChild(emoji);
+ li.appendChild(body);
+ li.appendChild(controls);
+ _companyAgentList.appendChild(li);
+ }
+ if (_companyStatusEl) _companyStatusEl.textContent = '';
+ }
+
+ /**
+ * Render one phase event from the dispatcher. The chat gets a
+ * card per phase so the user can follow progress in real time —
+ * "🧭 CEO 작업 분배 중..." → "📺 레오 작업 수행 중..." → final report.
+ */
+ function renderCompanyPhase(ev) {
+ const chatEl = document.getElementById('chat');
+ if (!chatEl) return;
+ const card = document.createElement('div');
+ card.className = 'company-phase-card';
+ if (ev.phase === 'plan-start') {
+ card.innerHTML = '
🧭 CEO
작업 분배 중…
';
+ } else if (ev.phase === 'plan-ready') {
+ const tasks = (ev.plan?.tasks || []).map((t, i) => `${i + 1}.
${escAttr(t.agent)} — ${escAttr(t.task)}`).join('
');
+ card.innerHTML = `
🧭 CEO 브리프
+
${escAttr(ev.plan?.brief || '(brief 없음)')}
+
${tasks || '(no tasks — chat reply)'}
`;
+ } else if (ev.phase === 'agent-start') {
+ card.innerHTML = `
${escAttr(ev.agentId)} 작업 수행 중…
+
${escAttr(ev.task)} (${ev.index + 1}/${ev.total})
`;
+ } else if (ev.phase === 'agent-done') {
+ const o = ev.output || {};
+ const body = (o.response || '').slice(0, 4000);
+ card.innerHTML = `
${escAttr(ev.agentId)} 완료 ${(o.durationMs/1000).toFixed(1)}s${o.error ? ' · ⚠️ ' + escAttr(o.error) : ''}
+
${fmt(body)}
`;
+ } else if (ev.phase === 'report-start') {
+ card.innerHTML = '
🧭 CEO 종합 보고서 작성 중…
';
+ } else if (ev.phase === 'report-done') {
+ card.className += ' report';
+ card.innerHTML = `
🧭 CEO 보고서${ev.ok ? '' : ' (fallback)'}
+
${fmt(ev.report || '')}
`;
+ } else if (ev.phase === 'session-saved') {
+ card.innerHTML = `
세션 저장 완료 — 클릭하여 열기
`;
+ card.style.cursor = 'pointer';
+ card.onclick = () => vscode.postMessage({ type: 'openCompanySession', sessionDir: ev.sessionDir });
+ } else if (ev.phase === 'aborted') {
+ card.innerHTML = `
⛔ 회사 모드 중단
${escAttr(ev.reason)}
`;
+ }
+ chatEl.appendChild(card);
+ chatEl.scrollTop = chatEl.scrollHeight;
+ }
+
// ── Knowledge Mix: global slider ──────────────────────────────────────
// Mirrors `g1nation.knowledgeMix.secondBrainWeight`. The hint label updates
// live as the user drags; the value is committed (postMessage) on `change`
diff --git a/package.json b/package.json
index ae43d92..0afb3c0 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.0.2",
+ "version": "2.0.3",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
@@ -114,6 +114,18 @@
{
"command": "g1nation.architecture.open",
"title": "Astra: Open Project Architecture Doc"
+ },
+ {
+ "command": "g1nation.company.toggle",
+ "title": "Astra: Toggle 1인 기업 Mode"
+ },
+ {
+ "command": "g1nation.company.manage",
+ "title": "Astra: Manage 1인 기업 Agents"
+ },
+ {
+ "command": "g1nation.company.openSessions",
+ "title": "Astra: Open 1인 기업 Sessions Folder"
}
],
"keybindings": [
diff --git a/src/extension.ts b/src/extension.ts
index fba393e..e8b1c41 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -164,6 +164,7 @@ export async function activate(context: vscode.ExtensionContext) {
logError('Failed to start bridge server.', err);
}
+
// 5. Register Core Commands
context.subscriptions.push(
vscode.commands.registerCommand('g1nation.focusInput', () => {
@@ -449,6 +450,35 @@ export async function activate(context: vscode.ExtensionContext) {
if (!provider) return;
await provider._openArchitectureDoc();
}),
+ // ── 1인 기업 (Company) Mode commands ──────────────────────────────────
+ // Thin shells over sidebar-provider methods so the runtime owns all
+ // state mutation (chip status, watcher lifecycle, agent persistence).
+ vscode.commands.registerCommand('g1nation.company.toggle', async () => {
+ if (!provider) return;
+ const { readCompanyState, setCompanyEnabled } = await import('./features/company');
+ const cur = readCompanyState(context);
+ const next = await setCompanyEnabled(context, !cur.enabled);
+ await provider._sendCompanyStatus();
+ vscode.window.showInformationMessage(`Astra: 1인 기업 모드 ${next.enabled ? 'ON' : 'OFF'}`);
+ }),
+ vscode.commands.registerCommand('g1nation.company.manage', async () => {
+ if (!provider) return;
+ // Reveal the sidebar then ask the webview to open the overlay.
+ await vscode.commands.executeCommand('g1nation-v2-view.focus');
+ provider._view?.webview.postMessage({ type: 'openCompanyManageOverlay' });
+ await provider._sendCompanyAgents();
+ }),
+ vscode.commands.registerCommand('g1nation.company.openSessions', async () => {
+ const { resolveCompanyBase } = await import('./features/company');
+ const base = resolveCompanyBase(context);
+ const target = path.join(base, 'sessions');
+ try {
+ if (!fs.existsSync(target)) fs.mkdirSync(target, { recursive: true });
+ await vscode.env.openExternal(vscode.Uri.file(target));
+ } catch (e: any) {
+ vscode.window.showErrorMessage(`Sessions 폴더 열기 실패: ${e?.message ?? e}`);
+ }
+ }),
);
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
diff --git a/src/features/company/agents.ts b/src/features/company/agents.ts
new file mode 100644
index 0000000..5983e1e
--- /dev/null
+++ b/src/features/company/agents.ts
@@ -0,0 +1,136 @@
+/**
+ * The 9-agent roster for 1인 기업 모드.
+ *
+ * Each entry is a *static* description — persona, role, specialty — used to
+ * build the specialist's system prompt at dispatch time. The set was adopted
+ * from Connect_origin's `src/agents.ts` and pruned to focus on the personas
+ * + specialties; per-machine state (active flag, model override) is kept
+ * separately in `CompanyState` so the roster itself stays code-shaped and
+ * easy to review.
+ *
+ * Editing rules:
+ * - `id` is a stable key — change only with a migration plan.
+ * - `persona` is *optional*. When set it nudges the agent's voice but
+ * never overrides the system prompt's core rules (file/command tags,
+ * output format).
+ * - Keep `specialty` task-oriented (verbs + nouns), not adjective-heavy —
+ * the CEO planner matches user keywords against it.
+ */
+import { CompanyAgentDef } from './types';
+
+export const COMPANY_AGENTS: Record
= {
+ ceo: {
+ id: 'ceo',
+ name: 'CEO',
+ role: 'Chief Executive Agent',
+ emoji: '🧭',
+ color: '#F8FAFC',
+ specialty: '오케스트레이션, 작업 분해, 종합 판단, 다음 액션 결정',
+ tagline: '회사 전체 의사결정과 작업 분배를 맡습니다',
+ alwaysOn: true,
+ },
+ youtube: {
+ id: 'youtube',
+ name: '레오',
+ role: 'Head of YouTube',
+ emoji: '📺',
+ color: '#FF4444',
+ specialty: '유튜브 채널 운영, 영상 기획서(제목·후크·구조), 트렌드 분석, 썸네일 브리프, 업로드 메타데이터, 시청자 유지율 전략',
+ tagline: '유튜브 채널 기획·운영 전반을 책임집니다',
+ persona: '데이터 중심·솔직·자신감 있는 톤. 결론을 먼저 말한 뒤 데이터 근거로 뒷받침. 추측보다 숫자. 가끔 직설적이지만 따뜻함은 잃지 않음. 이모지는 자제하되 "🔥"·"📊"·"🎯" 같은 핵심 강조용은 OK.',
+ },
+ instagram: {
+ id: 'instagram',
+ name: 'Instagram',
+ role: 'Head of Instagram',
+ emoji: '📷',
+ color: '#E1306C',
+ specialty: '인스타그램 릴스/피드 콘셉트, 캡션, 해시태그 전략, 게시 시간, 스토리, 팔로워 인게이지먼트',
+ tagline: '인스타 콘텐츠 기획과 인게이지먼트를 끌어올립니다',
+ },
+ designer: {
+ id: 'designer',
+ name: 'Designer',
+ role: 'Lead Designer',
+ emoji: '🎨',
+ color: '#A78BFA',
+ specialty: '브랜드 디자인 브리프(컬러·타이포·레퍼런스), 썸네일 컨셉 3안, 비주얼 시스템, 디자인 가이드',
+ tagline: '브랜드와 시각 자산 디자인을 담당합니다',
+ },
+ developer: {
+ id: 'developer',
+ name: '코다리',
+ role: '시니어 풀스택 엔지니어',
+ emoji: '💻',
+ color: '#22D3EE',
+ specialty: '코드 작성·편집·디버깅, 자동화 스크립트, API 통합, 웹사이트/봇, 데이터 파이프라인, git 워크플로, 자기 검증 루프',
+ tagline: '읽고·생각하고·짜고·검증한다 — 시니어 엔지니어',
+ persona: '시니어 풀스택 엔지니어. 코드 한 줄도 그냥 안 넘김. "왜?·어떻게?·이게 깨지나?" 늘 묻고 검증. 친근하지만 프로페셔널 톤. "확인 후 진행할게요"·"테스트 통과 확인했어요" 같은 책임감 있는 표현. 이모지는 💻·⚙️·🔧·✅·🐛 정도만.',
+ },
+ business: {
+ id: 'business',
+ name: '현빈',
+ role: '비즈니스 전략가 · Head of Business',
+ emoji: '💼',
+ color: '#F5C518',
+ specialty: '수익화 모델, 가격 전략, 시장·경쟁 분석, ROI/KPI 설계, 비즈니스 의사결정',
+ tagline: '수익화·가격·전략 의사결정을 같이 봅니다',
+ },
+ secretary: {
+ id: 'secretary',
+ name: '영숙',
+ role: '비서 · Personal Assistant',
+ emoji: '📱',
+ color: '#84CC16',
+ specialty: '일정·할 일 관리, 다른 에이전트 작업 요약·보고, 데일리 브리핑, 알림',
+ tagline: '일정·할 일·연락을 챙기고 소통을 정리합니다',
+ persona: '친근하고 정중한 톤. 짧고 정리된 문장. 이모지 적당히 (😊·📅·✅ 정도). 보고할 땐 한눈에 보이게 불릿 포인트 + 핵심만.',
+ },
+ editor: {
+ id: 'editor',
+ name: '루나',
+ role: 'Sound Director & Composer',
+ emoji: '🎵',
+ color: '#F472B6',
+ specialty: '영상 BGM 기획, 사운드 디자인, 영상-음악 매칭, 자막·타이틀 동기화 가이드',
+ tagline: '영상의 톤에 맞는 사운드 방향을 잡습니다',
+ persona: '음악·사운드 감각이 좋고 영상의 톤을 한 마디로 잡아냄. "이 영상은 [장르/분위기]가 어울릴 것 같아요" 식으로 제안. BPM·키·길이를 정확히 표기. 데이터 중심이지만 창작자 감수성도 있음. 이모지는 🎵·🎼·🎚 정도만.',
+ },
+ writer: {
+ id: 'writer',
+ name: 'Writer',
+ role: 'Copywriter',
+ emoji: '✍️',
+ color: '#FBBF24',
+ specialty: '카피라이팅, 영상 스크립트 초안, 인스타 캡션, 블로그 글, 메일 톤앤매너, 후크 작성',
+ tagline: '카피·스크립트·후크를 글로 풀어냅니다',
+ },
+ researcher: {
+ id: 'researcher',
+ name: 'Researcher',
+ role: 'Trend & Data Researcher',
+ emoji: '🔍',
+ color: '#60A5FA',
+ specialty: '트렌드 리서치, 경쟁사 분석, 데이터 수집·요약, 인용 자료 정리, 사실 확인',
+ tagline: '트렌드와 데이터를 모아 사실 확인까지 끝냅니다',
+ },
+};
+
+/** Display order for the manage panel. CEO first, then specialists. */
+export const COMPANY_AGENT_ORDER: string[] = [
+ 'ceo', 'youtube', 'instagram', 'designer', 'developer',
+ 'business', 'secretary', 'editor', 'writer', 'researcher',
+];
+
+/** Specialists only (everything except the CEO). */
+export const COMPANY_SPECIALIST_IDS: string[] = COMPANY_AGENT_ORDER.filter((id) => id !== 'ceo');
+
+/** Default activation set used when a user first opens the company panel. */
+export const DEFAULT_ACTIVE_AGENTS: string[] = [
+ 'ceo', 'developer', 'writer', 'researcher', 'designer', 'business',
+];
+
+/** Lookup helper. Returns `undefined` for unknown ids instead of throwing. */
+export function getCompanyAgent(id: string): CompanyAgentDef | undefined {
+ return COMPANY_AGENTS[id];
+}
diff --git a/src/features/company/ceoPlanner.ts b/src/features/company/ceoPlanner.ts
new file mode 100644
index 0000000..4a2e999
--- /dev/null
+++ b/src/features/company/ceoPlanner.ts
@@ -0,0 +1,219 @@
+/**
+ * CEO planner — turns a user prompt into a `CompanyTaskPlan`.
+ *
+ * Lifecycle of one planner call:
+ * 1. Build the planner system prompt (template + active-agent list).
+ * 2. Hit the AI service with the user prompt as the user message.
+ * 3. Parse the response through a 4-stage JSON pipeline that tolerates
+ * ```json fences, leading thoughts, truncated outputs, and minor key
+ * misspellings. Smaller local models violate "no extra text" rules
+ * *constantly*, so a permissive parser is required.
+ * 4. Normalize agent ids: accept Korean nicknames (`레오` → `youtube`,
+ * `코다리` → `developer`) and filter out tasks for inactive agents.
+ *
+ * The function never throws — it always returns a `CompanyTaskPlan`. If
+ * everything fails we surface an empty plan with a brief that explains what
+ * happened, and the dispatcher treats that as "nothing to dispatch, just
+ * relay the chat-style reply".
+ */
+import { IAIService } from '../../core/services';
+import { logError, logInfo } from '../../utils';
+import { COMPANY_AGENTS } from './agents';
+import { isAgentActive } from './companyConfig';
+import { applyPromptVars, CEO_PLANNER_PROMPT } from './promptAssets';
+import { buildPlannerSystemPrompt } from './promptBuilder';
+import { CompanyState, CompanyTaskPlan } from './types';
+
+export interface PlannerResult {
+ plan: CompanyTaskPlan;
+ /** True iff JSON parsing succeeded — false means we fell back to empty. */
+ parsed: boolean;
+ /** Raw LLM output (kept for the chat / debug log). */
+ raw: string;
+}
+
+const EMPTY_PLAN: CompanyTaskPlan = { brief: '', tasks: [] };
+
+/**
+ * Map Korean agent nicknames + likely typos to canonical ids. Built once
+ * from the static AGENTS map so it stays in sync with renames.
+ */
+const NAME_TO_ID: Record = (() => {
+ const out: Record = {};
+ for (const [id, def] of Object.entries(COMPANY_AGENTS)) {
+ out[id.toLowerCase()] = id;
+ out[def.name.toLowerCase()] = id;
+ // Also catch the role keyword (e.g. "designer", "writer")
+ const roleHead = def.role.split(/[\s·]+/)[0]?.toLowerCase();
+ if (roleHead && !out[roleHead]) out[roleHead] = id;
+ }
+ return out;
+})();
+
+function _canonicalAgentId(raw: unknown): string | null {
+ if (typeof raw !== 'string') return null;
+ const key = raw.trim().toLowerCase();
+ return NAME_TO_ID[key] ?? (COMPANY_AGENTS[key] ? key : null);
+}
+
+/**
+ * 4-stage JSON extractor — same idea as Connect_origin's planner but built
+ * fresh here so we don't carry over its 21K-line file. Each stage is a fall-
+ * through: we keep trying until something gives us a parseable object.
+ */
+function _parsePlanJson(raw: string): CompanyTaskPlan | null {
+ if (!raw || !raw.trim()) return null;
+
+ // Stage 1 — strip ```json … ``` fence + leading "okay let me think" prose.
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
+ const stage1 = (fenced ? fenced[1] : raw).trim();
+
+ // Stage 2 — direct JSON.parse.
+ try {
+ const obj = JSON.parse(stage1);
+ const plan = _coercePlan(obj);
+ if (plan) return plan;
+ } catch { /* fall through */ }
+
+ // Stage 3 — find the first balanced `{ … }` and parse just that. Smaller
+ // models love to prepend explanations or append trailing notes.
+ const balanced = _extractFirstBalancedObject(stage1);
+ if (balanced) {
+ try {
+ const obj = JSON.parse(balanced);
+ const plan = _coercePlan(obj);
+ if (plan) return plan;
+ } catch { /* fall through */ }
+ }
+
+ // Stage 4 — regex recovery. If JSON is truncated mid-task we still try
+ // to pull `brief` + any complete `{agent, task}` pairs from the text.
+ const briefMatch = stage1.match(/"brief"\s*:\s*"([\s\S]*?)"/);
+ const brief = briefMatch ? briefMatch[1] : '';
+ const tasks: CompanyTaskPlan['tasks'] = [];
+ const taskRe = /\{\s*"agent"\s*:\s*"([^"]+)"\s*,\s*"task"\s*:\s*"([\s\S]*?)"\s*\}/g;
+ let m: RegExpExecArray | null;
+ while ((m = taskRe.exec(stage1))) {
+ tasks.push({ agent: m[1].trim(), task: m[2].trim() });
+ }
+ if (brief || tasks.length > 0) return { brief: brief.trim(), tasks };
+ return null;
+}
+
+function _coercePlan(obj: unknown): CompanyTaskPlan | null {
+ if (!obj || typeof obj !== 'object') return null;
+ const o = obj as Record;
+ const brief = typeof o.brief === 'string' ? o.brief : '';
+ const rawTasks = Array.isArray(o.tasks) ? o.tasks : [];
+ const tasks: CompanyTaskPlan['tasks'] = [];
+ for (const t of rawTasks) {
+ if (!t || typeof t !== 'object') continue;
+ const tt = t as Record;
+ if (typeof tt.agent === 'string' && typeof tt.task === 'string') {
+ tasks.push({ agent: tt.agent.trim(), task: tt.task.trim() });
+ }
+ }
+ return { brief: brief.trim(), tasks };
+}
+
+/** Find the first complete `{ … }` block respecting brace nesting. */
+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;
+}
+
+/**
+ * Filter + normalize a freshly-parsed plan against the current company
+ * state. Tasks targeting unknown / inactive agents are dropped, and Korean
+ * nicknames are rewritten to canonical ids.
+ */
+export function normalizePlan(plan: CompanyTaskPlan, state: CompanyState): CompanyTaskPlan {
+ const out: CompanyTaskPlan = { brief: plan.brief, tasks: [] };
+ const dropped: string[] = [];
+ for (const t of plan.tasks) {
+ const canonical = _canonicalAgentId(t.agent);
+ if (!canonical) {
+ dropped.push(`unknown:${t.agent}`);
+ continue;
+ }
+ if (canonical === 'ceo') {
+ // CEO is the orchestrator — it never receives a task in `tasks`
+ // (the report phase calls it separately). Drop silently.
+ dropped.push('ceo:self-dispatch');
+ continue;
+ }
+ if (!isAgentActive(state, canonical)) {
+ dropped.push(`inactive:${canonical}`);
+ continue;
+ }
+ out.tasks.push({ agent: canonical, task: t.task });
+ }
+ if (dropped.length > 0) {
+ logInfo('ceoPlanner: dropped tasks during normalization.', { dropped });
+ }
+ return out;
+}
+
+/**
+ * Run the CEO planner end-to-end. Never throws. The caller decides what to
+ * do with `{ parsed: false, plan: { tasks: [] } }` — usually we surface the
+ * raw text as a casual CEO reply.
+ */
+export async function runCeoPlanner(
+ ai: IAIService,
+ userPrompt: string,
+ state: CompanyState,
+ options: { model?: string; timeoutMs?: number } = {},
+): Promise {
+ const system = buildPlannerSystemPrompt(
+ applyPromptVars(CEO_PLANNER_PROMPT, { company: state.companyName }),
+ state,
+ );
+ let raw = '';
+ try {
+ const result = await ai.chat({
+ system,
+ user: userPrompt,
+ model: options.model,
+ timeoutMs: options.timeoutMs,
+ });
+ raw = result.content || '';
+ } catch (e: any) {
+ logError('ceoPlanner: AI call failed.', { error: e?.message ?? String(e) });
+ return { plan: EMPTY_PLAN, parsed: false, raw: '' };
+ }
+
+ const parsed = _parsePlanJson(raw);
+ if (!parsed) {
+ // No JSON found — treat as a casual chat reply. The dispatcher's
+ // empty-plan branch will surface `raw` as the CEO's spoken response.
+ return { plan: { brief: raw.trim(), tasks: [] }, parsed: false, raw };
+ }
+
+ const plan = normalizePlan(parsed, state);
+ logInfo('ceoPlanner: parsed plan.', {
+ briefChars: plan.brief.length,
+ taskCount: plan.tasks.length,
+ agents: plan.tasks.map((t) => t.agent),
+ });
+ return { plan, parsed: true, raw };
+}
diff --git a/src/features/company/ceoReporter.ts b/src/features/company/ceoReporter.ts
new file mode 100644
index 0000000..3df2464
--- /dev/null
+++ b/src/features/company/ceoReporter.ts
@@ -0,0 +1,120 @@
+/**
+ * CEO synthesis pass — runs after all specialists have finished.
+ *
+ * Given the per-agent outputs, this asks the CEO model to produce the final
+ * markdown report (✅ 완료 / 🚀 다음 / 💡 인사이트) that the user actually
+ * reads. The function deliberately doesn't try to *parse* the response —
+ * we trust the prompt to keep the structure and surface the text as-is.
+ *
+ * Failure mode: when the CEO call errors out we still return whatever raw
+ * text we managed to collect (typically empty). The dispatcher then
+ * concatenates the per-agent outputs into a fallback report so the user
+ * never sees a blank screen.
+ */
+import { IAIService } from '../../core/services';
+import { logError } from '../../utils';
+import { getCompanyAgent } from './agents';
+import { applyPromptVars, CEO_REPORT_PROMPT } from './promptAssets';
+import { AgentTurnOutput, CompanyState, CompanyTaskPlan } from './types';
+
+/** Max characters of per-agent output to feed back into the CEO synthesis. */
+const PER_AGENT_REPORT_BUDGET = 2000;
+
+export interface ReportResult {
+ /** Generated markdown. Empty string on hard failure. */
+ report: string;
+ /** True when the LLM call succeeded with non-empty content. */
+ ok: boolean;
+}
+
+/**
+ * Build the user-message payload the CEO sees: the brief, plus each agent's
+ * task + output, lightly trimmed so the planner-model's context window
+ * doesn't blow up on a verbose specialist.
+ */
+function _buildReportUserMessage(
+ plan: CompanyTaskPlan,
+ outputs: AgentTurnOutput[],
+): string {
+ const lines: string[] = [];
+ if (plan.brief) {
+ lines.push('## 이번 작업 브리프');
+ lines.push(plan.brief);
+ lines.push('');
+ }
+ lines.push('## 에이전트별 산출물');
+ if (outputs.length === 0) {
+ lines.push('_(no agent dispatched this turn — produce a brief acknowledgement instead)_');
+ } else {
+ for (const out of outputs) {
+ const def = getCompanyAgent(out.agentId);
+ const head = def ? `### ${def.emoji} ${def.name}` : `### ${out.agentId}`;
+ lines.push('');
+ lines.push(head);
+ lines.push(`**Task:** ${out.task}`);
+ if (out.error) {
+ lines.push(`**Note:** dispatch failed — \`${out.error}\`. 사용 가능한 부분만 인용해서 보고.`);
+ }
+ lines.push('');
+ const body = out.response.length > PER_AGENT_REPORT_BUDGET
+ ? out.response.slice(0, PER_AGENT_REPORT_BUDGET) + '\n…(truncated)'
+ : out.response;
+ lines.push(body);
+ }
+ }
+ return lines.join('\n');
+}
+
+/** Build a fallback report by concatenating agent outputs verbatim. Used when the LLM synthesis fails. */
+export function buildFallbackReport(
+ plan: CompanyTaskPlan,
+ outputs: AgentTurnOutput[],
+): string {
+ const parts: string[] = ['## ✅ 완료된 작업'];
+ if (outputs.length === 0) {
+ parts.push('- _(no agents ran this turn)_');
+ } else {
+ for (const out of outputs) {
+ const def = getCompanyAgent(out.agentId);
+ const head = def ? `**${def.emoji} ${def.name}**` : `**${out.agentId}**`;
+ const firstLine = (out.response.split(/\n/).find((l) => l.trim()) || out.task).trim();
+ parts.push(`- ${head} — ${firstLine.slice(0, 120)}`);
+ }
+ }
+ parts.push('');
+ parts.push('## 🚀 다음 액션');
+ parts.push('_(CEO 합성 실패 — 위 산출물을 직접 확인하세요)_');
+ parts.push('');
+ parts.push('## 💡 인사이트');
+ parts.push(`- 이번 턴은 ${outputs.length}명의 에이전트가 작업했습니다.`);
+ if (plan.brief) parts.push(`- 브리프: ${plan.brief}`);
+ return parts.join('\n');
+}
+
+/** End-to-end synthesis call. Never throws — returns `{ ok: false, … }` on error. */
+export async function runCeoReporter(
+ ai: IAIService,
+ plan: CompanyTaskPlan,
+ outputs: AgentTurnOutput[],
+ state: CompanyState,
+ options: { model?: string; timeoutMs?: number } = {},
+): Promise {
+ const system = applyPromptVars(CEO_REPORT_PROMPT, { company: state.companyName });
+ const user = _buildReportUserMessage(plan, outputs);
+ try {
+ const result = await ai.chat({
+ system,
+ user,
+ model: options.model,
+ timeoutMs: options.timeoutMs,
+ });
+ const text = (result.content || '').trim();
+ if (!text) {
+ return { report: buildFallbackReport(plan, outputs), ok: false };
+ }
+ return { report: text, ok: true };
+ } catch (e: any) {
+ logError('ceoReporter: AI call failed.', { error: e?.message ?? String(e) });
+ return { report: buildFallbackReport(plan, outputs), ok: false };
+ }
+}
diff --git a/src/features/company/companyConfig.ts b/src/features/company/companyConfig.ts
new file mode 100644
index 0000000..4c21f30
--- /dev/null
+++ b/src/features/company/companyConfig.ts
@@ -0,0 +1,182 @@
+/**
+ * State + config plumbing for 1인 기업 모드.
+ *
+ * Two surfaces:
+ *
+ * - **`CompanyState`** (runtime data: enabled flag, company name, which
+ * agents are active, per-agent model overrides). Persisted in VS Code's
+ * `globalState` so it survives reloads. Mutating it always goes through
+ * `update*()` helpers so the webview can re-render after the change.
+ *
+ * - **Read-only helpers** that derive useful data from the current state +
+ * the static `COMPANY_AGENTS` roster (active list, model-for-agent lookup,
+ * etc.). Keeping these in one module means the planner, dispatcher, and
+ * UI all consult one place.
+ *
+ * The choice of `globalState` over `workspaceState` is deliberate: the user
+ * wants the same company / agent set / nicknames available across every
+ * project they open. Per-workspace overrides can be added later as a layer
+ * on top without breaking this API.
+ */
+import * as vscode from 'vscode';
+import { COMPANY_AGENTS, DEFAULT_ACTIVE_AGENTS, getCompanyAgent } from './agents';
+import { CompanyState, COMPANY_STATE_KEY } from './types';
+
+/** Default state for a brand-new user. CEO is always on. */
+function _defaultState(): CompanyState {
+ return {
+ enabled: false,
+ companyName: '1인 기업',
+ activeAgentIds: DEFAULT_ACTIVE_AGENTS.slice(),
+ modelOverrides: {},
+ };
+}
+
+/**
+ * Normalize a state value loaded from globalState. Guards against schema
+ * drift (e.g. unknown agent ids that no longer exist, missing fields).
+ */
+function _normalize(raw: Partial | undefined): CompanyState {
+ const def = _defaultState();
+ if (!raw || typeof raw !== 'object') return def;
+ const enabled = typeof raw.enabled === 'boolean' ? raw.enabled : def.enabled;
+ const companyName = typeof raw.companyName === 'string' && raw.companyName.trim()
+ ? raw.companyName.trim()
+ : def.companyName;
+ const validIds = Array.isArray(raw.activeAgentIds)
+ ? raw.activeAgentIds.filter((id): id is string => typeof id === 'string' && !!getCompanyAgent(id))
+ : def.activeAgentIds;
+ // CEO is *implicitly* always active — keep it out of the persisted list
+ // so we never accidentally drop it, but the public reader re-includes it.
+ const withoutCeo = validIds.filter((id) => id !== 'ceo');
+ const overrides: Record = {};
+ if (raw.modelOverrides && typeof raw.modelOverrides === 'object') {
+ for (const [k, v] of Object.entries(raw.modelOverrides)) {
+ if (typeof v === 'string' && v.trim() && getCompanyAgent(k)) {
+ overrides[k] = v.trim();
+ }
+ }
+ }
+ return { enabled, companyName, activeAgentIds: withoutCeo, modelOverrides: overrides };
+}
+
+/** Read the current company state. Always returns a fully-populated object. */
+export function readCompanyState(context: vscode.ExtensionContext): CompanyState {
+ const raw = context.globalState.get>(COMPANY_STATE_KEY);
+ return _normalize(raw);
+}
+
+/** Persist a complete state object. Callers usually go through the `update*`
+ * helpers below; direct use is fine when you want to write multiple fields
+ * atomically. */
+export async function writeCompanyState(
+ context: vscode.ExtensionContext,
+ next: CompanyState,
+): Promise {
+ await context.globalState.update(COMPANY_STATE_KEY, _normalize(next));
+}
+
+/**
+ * Toggle the whole mode on/off. Returns the new state so callers can
+ * immediately broadcast it to the webview without a re-read.
+ */
+export async function setCompanyEnabled(
+ context: vscode.ExtensionContext,
+ enabled: boolean,
+): Promise {
+ const cur = readCompanyState(context);
+ const next: CompanyState = { ...cur, enabled };
+ await writeCompanyState(context, next);
+ return next;
+}
+
+/** Rename the company. Empty / whitespace input falls back to the default. */
+export async function setCompanyName(
+ context: vscode.ExtensionContext,
+ name: string,
+): Promise {
+ const cur = readCompanyState(context);
+ const trimmed = (name || '').trim();
+ const next: CompanyState = { ...cur, companyName: trimmed || '1인 기업' };
+ await writeCompanyState(context, next);
+ return next;
+}
+
+/** Replace the active-agent set. Order is preserved; unknown ids are dropped. */
+export async function setActiveAgents(
+ context: vscode.ExtensionContext,
+ ids: string[],
+): Promise {
+ const cur = readCompanyState(context);
+ const next: CompanyState = { ...cur, activeAgentIds: ids };
+ await writeCompanyState(context, next);
+ return next;
+}
+
+/**
+ * Set / clear a per-agent model override. Passing empty string removes the
+ * override (the agent will fall back to the global default).
+ */
+export async function setAgentModelOverride(
+ context: vscode.ExtensionContext,
+ agentId: string,
+ model: string,
+): Promise {
+ const cur = readCompanyState(context);
+ const overrides = { ...cur.modelOverrides };
+ if (model && model.trim()) {
+ overrides[agentId] = model.trim();
+ } else {
+ delete overrides[agentId];
+ }
+ const next: CompanyState = { ...cur, modelOverrides: overrides };
+ await writeCompanyState(context, next);
+ return next;
+}
+
+// ── Derived helpers (no I/O) ────────────────────────────────────────────────
+
+/**
+ * Resolve the full set of agent ids that should be available to the CEO
+ * planner on this turn. CEO is always included regardless of `activeAgentIds`.
+ */
+export function activeAgentIds(state: CompanyState): string[] {
+ const set = new Set(['ceo']);
+ for (const id of state.activeAgentIds) {
+ if (getCompanyAgent(id)) set.add(id);
+ }
+ return Array.from(set);
+}
+
+/** Returns true when an agent is currently active (CEO always returns true). */
+export function isAgentActive(state: CompanyState, agentId: string): boolean {
+ if (agentId === 'ceo') return true;
+ return state.activeAgentIds.includes(agentId);
+}
+
+/**
+ * The model to use when dispatching `agentId`. Returns the override when
+ * configured, otherwise `fallbackDefault` (typically the global
+ * `g1nation.defaultModel`). Empty string is treated as "no override".
+ */
+export function modelForAgent(
+ state: CompanyState,
+ agentId: string,
+ fallbackDefault: string,
+): string {
+ const override = state.modelOverrides[agentId];
+ return override && override.trim() ? override.trim() : fallbackDefault;
+}
+
+/**
+ * Human-readable summary for the chip tooltip / status bar:
+ * "🏢 My Company · 5 agents · default model"
+ */
+export function summarizeForChip(state: CompanyState): string {
+ const count = activeAgentIds(state).length;
+ return `${state.companyName} · ${count} agents`;
+}
+
+// Re-export the static catalogue so callers only have to import from one
+// module to get the full picture.
+export { COMPANY_AGENTS, getCompanyAgent };
diff --git a/src/features/company/dispatcher.ts b/src/features/company/dispatcher.ts
new file mode 100644
index 0000000..068171b
--- /dev/null
+++ b/src/features/company/dispatcher.ts
@@ -0,0 +1,252 @@
+/**
+ * Sequential dispatcher for 1인 기업 모드.
+ *
+ * Drives one company "turn":
+ *
+ * user prompt
+ * → CEO planner (JSON {brief, tasks})
+ * → for each task in plan: dispatch one specialist (sequentially)
+ * - build specialist prompt (incl. peer context from earlier agents)
+ * - call the AI service
+ * - persist its output to disk
+ * - append its output to the peer-context buffer for the next agent
+ * → CEO reporter (synthesis markdown)
+ * → persist `_report.md`, update agent memory + decisions
+ * → emit `companyTurnUpdate` events to the webview at each phase
+ *
+ * Why sequential? The user runs Astra on a single GPU/CPU with limited RAM,
+ * and parallel agents would force us to keep multiple models loaded
+ * simultaneously. Sequential dispatch keeps "exactly one model resident at
+ * a time" — the LM Studio lifecycle manager unloads the previous model and
+ * loads the next when an agent has its own override.
+ *
+ * Why not use `AgentExecutor.handlePrompt` here? Because `handlePrompt` is
+ * built for the *interactive* chat path: it owns the conversation history,
+ * streaming UI, agent-mode injection, and a dozen other things we don't
+ * want triggered by a company turn. The company dispatcher needs a clean
+ * "one system + one user → one string back" primitive — `AIService.chat()`
+ * fits that perfectly. Specialists can still emit action tags
+ * (``, ``); we route their *raw* output through
+ * the existing action-tag executor afterwards so file/command tools work
+ * exactly as in chat.
+ */
+import * as vscode from 'vscode';
+import { IAIService } from '../../core/services';
+import { logError, logInfo } from '../../utils';
+import { getCompanyAgent } from './agents';
+import { modelForAgent, readCompanyState } from './companyConfig';
+import { runCeoPlanner } from './ceoPlanner';
+import { runCeoReporter } from './ceoReporter';
+import { buildSpecialistPrompt } from './promptBuilder';
+import {
+ appendAgentMemory,
+ appendDecision,
+ createSessionDir,
+ newSessionTimestamp,
+ readAgentMemory,
+ readDecisions,
+ writeAgentOutput,
+ writeBrief,
+ writeReport,
+ writeSessionJson,
+} from './sessionStore';
+import { AgentTurnOutput, CompanyTaskPlan, SessionResult } from './types';
+
+/** Trim length applied when an agent's output is fed into the next agent. */
+const PEER_OUTPUT_BUDGET = 1500;
+
+/**
+ * Events emitted during a turn. The sidebar webview subscribes to render
+ * progress (chips, headers, streamed agent replies). The shape is generic so
+ * the same channel can carry CEO/agent/report messages without per-type
+ * postMessage plumbing.
+ */
+export type CompanyTurnEvent =
+ | { phase: 'plan-start' }
+ | { phase: 'plan-ready'; plan: CompanyTaskPlan; parsed: boolean; raw: string }
+ | { phase: 'agent-start'; agentId: string; task: string; index: number; total: number }
+ | { phase: 'agent-done'; agentId: string; output: AgentTurnOutput; index: number; total: number }
+ | { phase: 'report-start' }
+ | { phase: 'report-done'; report: string; ok: boolean }
+ | { phase: 'session-saved'; sessionDir: string }
+ | { phase: 'aborted'; reason: string };
+
+export type CompanyTurnEmitter = (event: CompanyTurnEvent) => void;
+
+export interface DispatcherDeps {
+ context: vscode.ExtensionContext;
+ ai: IAIService;
+ /** Default model to fall back to when an agent has no override. */
+ defaultModel: string;
+ /** Per-call cancellation. The sidebar's Stop button flips this. */
+ signal?: AbortSignal;
+ /** Optional event sink for the webview. Receives events synchronously. */
+ onEvent?: CompanyTurnEmitter;
+}
+
+/**
+ * Run a single company turn. Returns a fully-populated `SessionResult` even
+ * on partial failure (so callers can always render *something* in chat).
+ */
+export async function runCompanyTurn(
+ userPrompt: string,
+ deps: DispatcherDeps,
+): Promise {
+ const startedAt = Date.now();
+ const state = readCompanyState(deps.context);
+ const timestamp = newSessionTimestamp();
+ const sessionDir = createSessionDir(deps.context, timestamp);
+
+ const emit: CompanyTurnEmitter = deps.onEvent ?? (() => { /* noop */ });
+ const isAborted = () => deps.signal?.aborted === true;
+ const fail = (reason: string): SessionResult => {
+ emit({ phase: 'aborted', reason });
+ return {
+ timestamp, sessionDir,
+ userPrompt,
+ plan: { brief: '', tasks: [] },
+ agentOutputs: [],
+ report: '',
+ totalDurationMs: Date.now() - startedAt,
+ };
+ };
+ if (isAborted()) return fail('signal-aborted');
+
+ // ── Phase 1: planner ──
+ emit({ phase: 'plan-start' });
+ const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
+ const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel });
+ if (isAborted()) return fail('aborted-after-plan');
+ emit({
+ phase: 'plan-ready',
+ plan: plannerResult.plan,
+ parsed: plannerResult.parsed,
+ raw: plannerResult.raw,
+ });
+ writeBrief(sessionDir, userPrompt, plannerResult.plan);
+
+ // ── Phase 2: sequential dispatch ──
+ const outputs: AgentTurnOutput[] = [];
+ const total = plannerResult.plan.tasks.length;
+ for (let i = 0; i < total; i++) {
+ if (isAborted()) return fail('aborted-mid-dispatch');
+ const task = plannerResult.plan.tasks[i];
+ emit({ phase: 'agent-start', agentId: task.agent, task: task.task, index: i, total });
+ const turn = await _dispatchOne(task.agent, task.task, outputs, state, deps);
+ outputs.push(turn);
+ writeAgentOutput(sessionDir, turn);
+ // Best-effort: append a one-line memory entry so the agent "remembers"
+ // having done this task. Verbose successes are summarized in the CEO
+ // report — memory is just the breadcrumb trail.
+ appendAgentMemory(
+ deps.context,
+ task.agent,
+ `[${timestamp}] ${task.task} — ${turn.error ? `❌ ${turn.error}` : '✅'}`,
+ );
+ emit({ phase: 'agent-done', agentId: task.agent, output: turn, index: i, total });
+ }
+
+ // ── Phase 3: synthesis ──
+ if (isAborted()) return fail('aborted-before-report');
+ emit({ phase: 'report-start' });
+ const reportModel = modelForAgent(state, 'ceo', deps.defaultModel);
+ const reportResult = await runCeoReporter(
+ deps.ai,
+ plannerResult.plan,
+ outputs,
+ state,
+ { model: reportModel },
+ );
+ writeReport(sessionDir, reportResult.report);
+ emit({ phase: 'report-done', report: reportResult.report, ok: reportResult.ok });
+
+ // ── Phase 4: persist + side effects ──
+ const result: SessionResult = {
+ timestamp, sessionDir,
+ userPrompt,
+ plan: plannerResult.plan,
+ agentOutputs: outputs,
+ report: reportResult.report,
+ totalDurationMs: Date.now() - startedAt,
+ };
+ writeSessionJson(sessionDir, result);
+ // Heuristic: if the report mentions a 🚀 line, extract it as a decision.
+ const decisionLine = reportResult.report.split(/\n/).find((l) => /^\d+\.\s+/.test(l.trim()));
+ if (decisionLine) appendDecision(deps.context, decisionLine.trim());
+ emit({ phase: 'session-saved', sessionDir });
+
+ logInfo('company.dispatcher: turn complete.', {
+ sessionDir, agents: outputs.length, ok: reportResult.ok,
+ durationMs: result.totalDurationMs,
+ });
+ return result;
+}
+
+/**
+ * Dispatch one specialist. Wraps the AI call with try/catch so a single
+ * agent's failure never aborts the whole turn — we record the error and
+ * keep going so the user still gets the other agents' outputs.
+ */
+async function _dispatchOne(
+ agentId: string,
+ task: string,
+ earlierOutputs: AgentTurnOutput[],
+ state: ReturnType,
+ deps: DispatcherDeps,
+): Promise {
+ const startedAt = Date.now();
+ const def = getCompanyAgent(agentId);
+ if (!def) {
+ return {
+ agentId, task, response: '', durationMs: 0,
+ error: `Unknown agent id: ${agentId}`,
+ };
+ }
+ const memory = readAgentMemory(deps.context, agentId);
+ const decisions = readDecisions(deps.context, 2000);
+ const peerOutputs = earlierOutputs
+ .filter((o) => !o.error) // skip failed peers — they'd just confuse the next agent
+ .map((o) => {
+ const peerDef = getCompanyAgent(o.agentId);
+ const body = o.response.length > PEER_OUTPUT_BUDGET
+ ? o.response.slice(0, PEER_OUTPUT_BUDGET) + '\n…(truncated)'
+ : o.response;
+ return {
+ agentId: o.agentId,
+ agentName: peerDef?.name ?? o.agentId,
+ emoji: peerDef?.emoji ?? '🤖',
+ content: body,
+ };
+ });
+
+ const system = buildSpecialistPrompt({
+ agentId, state,
+ agentMemory: memory, sharedDecisions: decisions,
+ peerOutputs,
+ });
+ const model = modelForAgent(state, agentId, deps.defaultModel);
+
+ try {
+ const result = await deps.ai.chat({
+ system,
+ user: task,
+ model,
+ });
+ const response = (result.content || '').trim();
+ return {
+ agentId, task,
+ response: response || '_(empty response)_',
+ durationMs: Date.now() - startedAt,
+ error: response ? undefined : 'empty-response',
+ };
+ } catch (e: any) {
+ const err = e?.message ?? String(e);
+ logError('company.dispatcher: agent dispatch failed.', { agentId, err });
+ return {
+ agentId, task,
+ response: `⚠️ 호출 실패: ${err}`,
+ durationMs: Date.now() - startedAt,
+ error: err,
+ };
+ }
+}
diff --git a/src/features/company/index.ts b/src/features/company/index.ts
new file mode 100644
index 0000000..77e4513
--- /dev/null
+++ b/src/features/company/index.ts
@@ -0,0 +1,50 @@
+/**
+ * Public API for 1인 기업 모드.
+ *
+ * Consumers (sidebarProvider, chatHandlers, command handlers) import from
+ * this barrel so internal layout can move around without touching every
+ * call site.
+ */
+export {
+ COMPANY_AGENTS,
+ COMPANY_AGENT_ORDER,
+ COMPANY_SPECIALIST_IDS,
+ DEFAULT_ACTIVE_AGENTS,
+ getCompanyAgent,
+} from './agents';
+
+export {
+ readCompanyState,
+ writeCompanyState,
+ setCompanyEnabled,
+ setCompanyName,
+ setActiveAgents,
+ setAgentModelOverride,
+ activeAgentIds,
+ isAgentActive,
+ modelForAgent,
+ summarizeForChip,
+} from './companyConfig';
+
+export type {
+ CompanyAgentDef,
+ CompanyState,
+ CompanyTaskPlan,
+ AgentTurnOutput,
+ SessionResult,
+} from './types';
+
+export {
+ runCompanyTurn,
+} from './dispatcher';
+
+export type {
+ CompanyTurnEvent,
+ CompanyTurnEmitter,
+ DispatcherDeps,
+} from './dispatcher';
+
+export {
+ listSessions,
+ resolveCompanyBase,
+} from './sessionStore';
diff --git a/src/features/company/promptAssets.ts b/src/features/company/promptAssets.ts
new file mode 100644
index 0000000..db1d465
--- /dev/null
+++ b/src/features/company/promptAssets.ts
@@ -0,0 +1,114 @@
+/**
+ * Inlined prompt assets for the 1인 기업 mode.
+ *
+ * The CEO planner / reporter / casual-chat prompts are kept as TS string
+ * constants rather than loaded from `prompts/*.md` at runtime, for two reasons:
+ *
+ * 1. **Bundling.** esbuild collapses the whole extension into one file,
+ * so resolving a markdown path via `__dirname` would point inside
+ * `out/extension.js` and fail. Inlining sidesteps that entirely.
+ * 2. **Tamper resistance.** The planner prompt encodes the multi-agent
+ * contract (JSON shape, minimum-dispatch rule). Embedding it in code
+ * means a workspace can't quietly swap it for a malicious version.
+ *
+ * The `.md` files under `./prompts/` are kept as **reference copies** so
+ * developers can read/diff them in any editor — `promptAssets.ts` is the
+ * source of truth the runtime actually uses.
+ */
+
+/**
+ * CEO planner prompt. The `{{COMPANY}}` placeholder is substituted with the
+ * user-configured company name before sending to the LLM. The model is
+ * required to return a single JSON object — see `ceoPlanner.ts` for the
+ * 4-stage parser that tolerates fenced/leading-noise variants.
+ */
+export const CEO_PLANNER_PROMPT = `당신은 "{{COMPANY}}"의 CEO입니다. 1인 AI 기업의 사령관이자 오케스트레이터입니다.
+
+당신의 팀(전문 에이전트):
+- youtube (Head of YouTube) : 유튜브 채널 운영, 영상 기획, 트렌드, 썸네일 브리프
+- instagram (Head of Instagram) : 릴스/피드, 캡션, 해시태그, 게시 시간, 인게이지먼트
+- designer (Lead Designer) : 디자인 브리프, 썸네일·브랜드 비주얼, 컬러/타이포
+- developer (코다리 · 시니어 풀스택 엔지니어): 코드 작성·편집·디버깅, 자동화 스크립트, API 통합, 웹사이트, 테스트, git, 자기 검증 루프
+- business (Head of Business) : 수익화, 가격, 비즈니스 전략·분석, KPI
+- secretary (Personal Assistant) : 일정·할 일, 작업 요약, 데일리 브리핑
+- editor (루나 · 사운드 감독) : BGM 기획, 사운드 디자인, 영상-음악 매칭
+- writer (Copywriter) : 카피라이팅, 영상 스크립트, 캡션, 블로그, 후크
+- researcher(Trend & Data Researcher) : 트렌드/경쟁사 리서치, 데이터 수집·요약, 사실 확인
+
+사용자가 한 줄 명령을 내리면, 당신은 어떤 에이전트들을 어떤 순서로 동원할지 결정합니다.
+
+⚠️ 반드시 아래 JSON 형식으로만 출력하세요. 다른 텍스트(설명, \`\`\`json 펜스, 머리말, 꼬리말)는 절대 포함 금지.
+
+{
+ "brief": "이번 작업이 무엇인지 2~3줄 한국어 요약",
+ "tasks": [
+ {"agent": "youtube", "task": "구체적이고 실행 가능한 한국어 지시"}
+ ]
+}
+
+🛑 **최소 동원 원칙 — 절대 위반 금지**:
+1. **단순 데이터 조회·정보 확인 명령은 데이터 에이전트 1명만**. 예: "내 채널 분석", "구독자 수", "오늘 일정", "최근 영상" → tasks 배열에 1명. 추가 분석 에이전트(researcher/business/designer/writer) 절대 추가 금지. 사용자가 추가 분석을 *명시적으로* 요청해야만 추가.
+2. **창작·기획 명령일 때만 multi-agent**. 예: "영상 기획해줘", "썸네일 만들어", "수익화 전략 짜줘" → 관련 에이전트 2~3명. 5명 이상 절대 금지.
+3. **상관없는 에이전트 끌어오지 마라**. 사용자 명령이 유튜브 데이터인데 designer/writer 부르는 건 즉시 금지. 사용자가 "디자인"·"카피"·"썸네일" 같은 단어를 *직접* 썼을 때만.
+
+데이터 수집 키워드 매칭 (해당 에이전트만 1명):
+- "유튜브"·"YouTube"·"내 채널"·"구독자"·"조회수"·"영상 분석" → youtube 1명만
+- "인스타"·"릴스"·"피드" → instagram 1명만
+- "캘린더"·"일정"·"오늘 미팅" → secretary 1명만
+
+기타 규칙:
+- 논리적 순서로 정렬 (예: 데이터 수집 → 분석 → 창작 — 사용자가 그 모두를 요청한 경우에만)
+- 각 task는 모호함 없이 구체적·실행가능하게
+- JSON 외 텍스트는 단 한 글자도 출력 금지
+- 데이터 수집 없이 researcher/business만 호출하면 LLM이 가짜 분석을 출력합니다 — 절대 금지
+`;
+
+/**
+ * CEO synthesis prompt — runs at the end of every turn after all specialists
+ * have replied. Output is plain markdown (no JSON), structured into ✅/🚀/💡
+ * sections that the chat surfaces verbatim.
+ */
+export const CEO_REPORT_PROMPT = `당신은 {{COMPANY}}의 CEO입니다. 방금 팀이 작업을 끝냈습니다.
+각 에이전트의 산출물을 읽고 사장님께 올릴 종합 보고서를 작성하세요.
+
+형식 (한국어 마크다운, 정확히 이대로):
+
+## ✅ 완료된 작업
+- (에이전트별 핵심 산출물 1줄씩, 굵은 글씨로 에이전트명)
+
+## 🚀 다음 액션 (Top 3)
+1. **(에이전트명)** — 무엇을
+2. **(에이전트명)** — 무엇을
+3. **(에이전트명)** — 무엇을
+
+## 💡 인사이트
+- 이번 작업에서 발견한 핵심 통찰 1~2개
+
+규칙: 간결, 사족 금지, 사과·면책 금지. 가능하면 300자 내외.
+
+⚠️ 데이터 우선 규칙 (반드시 준수):
+- 산출물에 **실제 숫자/데이터**가 있으면 **그 데이터를 직접 인용**해 보고하세요. 추상적인 "분석 진행됨" 같은 말로 대체 금지.
+- 추측·일반론·placeholder 절대 금지. 산출물에 없는 사실 만들어내지 마세요.
+- 어떤 에이전트의 산출물에 에러 메시지가 있어도 다른 에이전트의 실제 결과는 정상적으로 인용하세요.
+`;
+
+/**
+ * Fallback "casual chat" prompt used when the planner's JSON parse fails
+ * entirely (typically because the user wrote a greeting instead of a work
+ * command). Replies in 1–3 sentences without trying to dispatch agents.
+ */
+export const CEO_CHAT_PROMPT = `당신은 {{COMPANY}}의 CEO입니다. 사용자(사장님)와 짧게 인사·안부·잡담을 주고받습니다.
+- 한국어로 1~3문장. 친근하지만 사장-CEO 관계는 유지.
+- 인사·안부 질문이면 자연스럽게 응답하세요. 작업 지시가 아니면 굳이 작업 분배 제안 X.
+- 회사 정체성·최근 결정이 컨텍스트에 있으면 자연스럽게 활용.
+- JSON 출력 금지. 그냥 평문으로 짧게.
+`;
+
+/**
+ * Substitute the `{{COMPANY}}` placeholder. Trivial today, but isolating it
+ * here keeps the door open for additional templating later (e.g. company
+ * mission statement, brand voice) without touching every call site.
+ */
+export function applyPromptVars(template: string, vars: { company: string }): string {
+ return template.replace(/\{\{COMPANY\}\}/g, vars.company || '1인 기업');
+}
diff --git a/src/features/company/promptBuilder.ts b/src/features/company/promptBuilder.ts
new file mode 100644
index 0000000..2d56c47
--- /dev/null
+++ b/src/features/company/promptBuilder.ts
@@ -0,0 +1,140 @@
+/**
+ * System-prompt construction for company-mode agents.
+ *
+ * Each specialist needs a prompt that includes:
+ * - Their identity (name, role, specialty) + optional persona.
+ * - The action-tag contract (``, ``, etc.) so
+ * ConnectAI's existing `_executeActions()` can handle tool calls
+ * transparently after the LLM responds.
+ * - The *peer context* — earlier agents' outputs in the same turn, so the
+ * second/third agent can build on what came before.
+ * - The agent's long-term memory (`memory.md`) when available.
+ * - Company-wide decisions, if recorded.
+ *
+ * Build-once-per-dispatch: the dispatcher calls `buildSpecialistPrompt()` for
+ * every task. Each call is pure (no I/O of its own — the caller fetches
+ * memory/decisions and passes them in), which keeps it trivial to test.
+ */
+import { COMPANY_AGENTS, getCompanyAgent } from './agents';
+import { CompanyState } from './types';
+
+export interface SpecialistPromptInputs {
+ /** Active agent id. Must exist in `COMPANY_AGENTS`. */
+ agentId: string;
+ /** Current persisted company state (used for company name + context). */
+ state: CompanyState;
+ /** Long-term agent memory text (may be empty). Pre-read by caller. */
+ agentMemory?: string;
+ /** Tail of `_shared/decisions.md` (may be empty). */
+ sharedDecisions?: string;
+ /**
+ * Peer outputs from earlier agents in *this* dispatch, in execution order.
+ * Truncated by the dispatcher before passing — this builder doesn't trim
+ * again so we don't double-pay tokens for one transformation.
+ */
+ peerOutputs?: Array<{ agentId: string; agentName: string; emoji: string; content: string }>;
+}
+
+/**
+ * Build the full system prompt for one specialist. Returns plain markdown.
+ *
+ * The structure favours *short headed sections* over one giant blob because
+ * smaller local models (≤7B) respect markdown-headed blocks better than
+ * dense paragraphs. Order matters: identity first, then rules, then context.
+ */
+export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
+ const agent = getCompanyAgent(inputs.agentId);
+ if (!agent) {
+ // Defensive fallback — should never happen because the dispatcher
+ // filters tasks against the active agent set before calling us.
+ return `You are an agent named "${inputs.agentId}". Respond in Korean.`;
+ }
+ const company = inputs.state.companyName || '1인 기업';
+ const parts: string[] = [];
+
+ // ── Identity ──
+ parts.push(`# ${agent.emoji} ${agent.name} — ${agent.role}`);
+ parts.push(`당신은 ${company}의 ${agent.role}입니다.`);
+ parts.push(`전문 분야: ${agent.specialty}`);
+ if (agent.persona) {
+ parts.push('');
+ parts.push('## 페르소나');
+ parts.push(agent.persona);
+ }
+
+ // ── Output contract ──
+ parts.push('');
+ parts.push('## 출력 규칙');
+ parts.push('- 한국어 마크다운으로 답변. 사장님(사용자)에게 보고하는 톤.');
+ parts.push('- 작업이 끝나면 마지막에 두 줄로 자기 평가를 붙이세요:');
+ parts.push(' - `📊 평가:` 한 줄로 산출물의 가치(데이터 기반·완성도·아이디어 신선도).');
+ parts.push(' - `📝 다음:` 사장님 입장에서 다음에 할 만한 한 가지 액션 한 줄.');
+ parts.push('- 추측·일반론·placeholder 금지. 가진 정보만 인용.');
+
+ // ── Tool contract ──
+ // ConnectAI's existing AgentExecutor parses these tags automatically
+ // after the streaming response completes. Keeping the syntax identical
+ // means specialists can write files / run commands the same way the
+ // base chat already does — no new plumbing on the agent side.
+ parts.push('');
+ parts.push('## 도구 사용 규칙 (필요할 때만)');
+ parts.push('실제 파일 생성·명령 실행이 필요하면 ConnectAI의 액션 태그를 사용하세요.');
+ parts.push('예) `내용`, `npm test` 등.');
+ parts.push('태그 없이 평문으로만 답해도 됩니다 — 기획·분석·아이디어 작업은 보통 태그가 필요 없습니다.');
+
+ // ── Peer context (this turn) ──
+ const peers = inputs.peerOutputs ?? [];
+ if (peers.length > 0) {
+ parts.push('');
+ parts.push('## 같은 세션의 동료 산출물');
+ parts.push('아래는 당신보다 먼저 작업한 동료들의 결과입니다. 인용·참조해서 일관된 흐름을 만드세요.');
+ for (const p of peers) {
+ parts.push('');
+ parts.push(`### ${p.emoji} ${p.agentName}`);
+ parts.push(p.content);
+ }
+ }
+
+ // ── Long-term memory ──
+ const memory = (inputs.agentMemory ?? '').trim();
+ if (memory) {
+ parts.push('');
+ parts.push('## 당신의 장기 기억 (memory.md)');
+ parts.push('과거 작업에서 누적된 학습입니다. 지금 task와 충돌하면 *현재 task가 우선*입니다.');
+ parts.push(memory);
+ }
+
+ // ── Company-wide decisions ──
+ const decisions = (inputs.sharedDecisions ?? '').trim();
+ if (decisions) {
+ parts.push('');
+ parts.push('## 회사 공통 결정 사항 (decisions.md)');
+ parts.push(decisions);
+ }
+
+ return parts.join('\n');
+}
+
+/**
+ * Build the planner system prompt. The base template is in `promptAssets.ts`;
+ * this helper layers on the currently active agent list so the planner can't
+ * dispatch to a disabled specialist.
+ */
+export function buildPlannerSystemPrompt(
+ baseTemplate: string,
+ state: CompanyState,
+): string {
+ const active = new Set(state.activeAgentIds);
+ active.add('ceo');
+ const inactive = Object.keys(COMPANY_AGENTS).filter((id) => !active.has(id));
+ const tail: string[] = [];
+ if (inactive.length > 0) {
+ tail.push('');
+ tail.push('현재 비활성화된 에이전트 (절대 dispatch 금지):');
+ for (const id of inactive) {
+ const def = COMPANY_AGENTS[id];
+ tail.push(`- ${id} (${def?.name ?? id})`);
+ }
+ }
+ return baseTemplate + tail.join('\n');
+}
diff --git a/src/features/company/prompts/ceo-chat.md b/src/features/company/prompts/ceo-chat.md
new file mode 100644
index 0000000..ff94a3a
--- /dev/null
+++ b/src/features/company/prompts/ceo-chat.md
@@ -0,0 +1,5 @@
+당신은 {{COMPANY}}의 CEO입니다. 사용자(사장님)와 짧게 인사·안부·잡담을 주고받습니다.
+- 한국어로 1~3문장. 친근하지만 사장-CEO 관계는 유지.
+- 인사·안부 질문이면 자연스럽게 응답하세요. 작업 지시가 아니면 굳이 작업 분배 제안 X.
+- 회사 정체성·최근 결정·추적기 상태가 컨텍스트에 있으면 자연스럽게 활용.
+- JSON 출력 금지. 그냥 평문으로 짧게.
\ No newline at end of file
diff --git a/src/features/company/prompts/ceo-planner.md b/src/features/company/prompts/ceo-planner.md
new file mode 100644
index 0000000..f04f2a3
--- /dev/null
+++ b/src/features/company/prompts/ceo-planner.md
@@ -0,0 +1,39 @@
+당신은 "{{COMPANY}}"의 CEO입니다. 1인 AI 기업의 사령관이자 오케스트레이터입니다.
+
+당신의 팀(전문 에이전트):
+- youtube (Head of YouTube) : 유튜브 채널 운영, 영상 기획, 트렌드, 썸네일 브리프
+- instagram (Head of Instagram) : 릴스/피드, 캡션, 해시태그, 게시 시간, 인게이지먼트
+- designer (Lead Designer) : 디자인 브리프, 썸네일·브랜드 비주얼, 컬러/타이포
+- developer (코다리 · 시니어 풀스택 엔지니어): 코드 작성·편집·디버깅, 자동화 스크립트, API 통합, 웹사이트, 테스트, git, 자기 검증 루프 (Claude Code 수준)
+- business (Head of Business) : 수익화, 가격, 비즈니스 전략·분석, KPI
+- secretary (Personal Assistant) : 일정·할 일, 작업 요약, 텔레그램 보고, 데일리 브리핑
+- editor (루나 · 사운드 감독) : BGM 자동 생성(MusicGen/ACE-Step), 사운드 디자인, 영상-음악 합성, 오디오 후처리
+- writer (Copywriter) : 카피라이팅, 영상 스크립트, 캡션, 블로그, 후크
+- researcher(Trend & Data Researcher) : 트렌드/경쟁사 리서치, 데이터 수집·요약, 사실 확인
+
+사용자가 한 줄 명령을 내리면, 당신은 어떤 에이전트들을 어떤 순서로 동원할지 결정합니다.
+
+⚠️ 반드시 아래 JSON 형식으로만 출력하세요. 다른 텍스트(설명, ```json 펜스, 머리말, 꼬리말)는 절대 포함 금지.
+
+{
+ "brief": "이번 작업이 무엇인지 2~3줄 한국어 요약",
+ "tasks": [
+ {"agent": "youtube", "task": "구체적이고 실행 가능한 한국어 지시"}
+ ]
+}
+
+🛑 **최소 동원 원칙 — 절대 위반 금지**:
+1. **단순 데이터 조회·정보 확인 명령은 데이터 에이전트 1명만**. 예: "내 채널 분석", "구독자 수", "오늘 일정", "최근 영상" → tasks 배열에 1명. 추가 분석 에이전트(researcher/business/designer/writer) 절대 추가 금지. 사용자가 추가 분석을 *명시적으로* 요청해야만 추가.
+2. **창작·기획 명령일 때만 multi-agent**. 예: "영상 기획해줘", "썸네일 만들어", "수익화 전략 짜줘" → 관련 에이전트 2~3명. 5명 이상 절대 금지.
+3. **상관없는 에이전트 끌어오지 마라**. 사용자 명령이 유튜브 데이터인데 designer/writer 부르는 건 즉시 금지. 사용자가 "디자인"·"카피"·"썸네일" 같은 단어를 *직접* 썼을 때만.
+
+데이터 수집 키워드 매칭 (해당 에이전트만 1명):
+- "유튜브"·"YouTube"·"내 채널"·"구독자"·"조회수"·"영상 분석" → youtube 1명만
+- "인스타"·"릴스"·"피드" → instagram 1명만
+- "캘린더"·"일정"·"오늘 미팅" → secretary 1명만
+
+기타 규칙:
+- 논리적 순서로 정렬 (예: 데이터 수집 → 분석 → 창작 — 사용자가 그 모두를 요청한 경우에만)
+- 각 task는 모호함 없이 구체적·실행가능하게
+- JSON 외 텍스트는 단 한 글자도 출력 금지
+- 데이터 수집 없이 researcher/business만 호출하면 LLM이 가짜 분석을 출력합니다 — 절대 금지
\ No newline at end of file
diff --git a/src/features/company/prompts/ceo-report.md b/src/features/company/prompts/ceo-report.md
new file mode 100644
index 0000000..3c3e2d0
--- /dev/null
+++ b/src/features/company/prompts/ceo-report.md
@@ -0,0 +1,22 @@
+당신은 {{COMPANY}}의 CEO입니다. 방금 팀이 작업을 끝냈습니다.
+각 에이전트의 산출물을 읽고 사장님께 올릴 종합 보고서를 작성하세요.
+
+형식 (한국어 마크다운, 정확히 이대로):
+
+## ✅ 완료된 작업
+- (에이전트별 핵심 산출물 1줄씩, 굵은 글씨로 에이전트명)
+
+## 🚀 다음 액션 (Top 3)
+1. **(에이전트명)** — 무엇을
+2. **(에이전트명)** — 무엇을
+3. **(에이전트명)** — 무엇을
+
+## 💡 인사이트
+- 이번 작업에서 발견한 핵심 통찰 1~2개
+
+규칙: 간결, 사족 금지, 사과·면책 금지. 200자 이내가 이상적.
+
+⚠️ 데이터 우선 규칙 (반드시 준수):
+- 산출물에 **실제 숫자/데이터**가 있으면(예: "조회수 중간값 49,931", "영상 6개", "구독자 1,234") **그 데이터를 직접 인용**해 보고하세요. 추상적인 "분석 진행됨" 같은 말로 대체 금지.
+- 산출물에 `⚠️ LLM 호출 실패` 헤더가 있어도 그 안에 `📊 LLM 실패에도 시스템이 가져온 실데이터` 섹션이 있으면 **데이터는 살아있는 것**입니다. "데이터 로드 실패"로 오해해서 보고하지 마세요. LLM 분석은 못했지만 데이터는 확보했다고 정확히 표시.
+- 추측·일반론·placeholder 절대 금지. 산출물에 없는 사실 만들어내지 마세요.
\ No newline at end of file
diff --git a/src/features/company/sessionStore.ts b/src/features/company/sessionStore.ts
new file mode 100644
index 0000000..f8d8417
--- /dev/null
+++ b/src/features/company/sessionStore.ts
@@ -0,0 +1,231 @@
+/**
+ * Disk persistence for company-mode session artefacts.
+ *
+ * Each company turn produces a timestamped directory:
+ *
+ * /.astra/company/sessions/2026-05-13T21-29/
+ * ├─ _brief.md ← CEO's task decomposition
+ * ├─ .md ← Each specialist's raw output
+ * ├─ _report.md ← CEO's final synthesis
+ * └─ _session.json ← Structured copy of the SessionResult
+ *
+ * Long-lived per-agent memory + shared decisions live one level up under
+ * `_agents//memory.md` and `_shared/decisions.md`. The store is
+ * intentionally dumb — markdown files, append-only — so the user can read,
+ * grep, or git-commit them by hand without any tooling.
+ *
+ * Path resolution: we always prefer the **workspace root**. When the user
+ * opens Astra without a workspace (very rare), we fall back to the
+ * extension's globalStorage path so the feature still works rather than
+ * silently swallowing writes.
+ */
+import * as fs from 'fs';
+import * as path from 'path';
+import * as vscode from 'vscode';
+import { logError, logInfo } from '../../utils';
+import {
+ AgentTurnOutput,
+ COMPANY_AGENTS_REL,
+ COMPANY_SESSIONS_REL,
+ COMPANY_SHARED_REL,
+ CompanyTaskPlan,
+ SessionResult,
+} from './types';
+
+/**
+ * Resolve the base directory for company data. Falls back to globalStorage
+ * when no workspace is open so the mode still works in a fresh window.
+ */
+export function resolveCompanyBase(context: vscode.ExtensionContext): string {
+ const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+ if (ws) return path.join(ws, '.astra', 'company');
+ return path.join(context.globalStorageUri.fsPath, 'company');
+}
+
+function _ensureDir(p: string): void {
+ try {
+ fs.mkdirSync(p, { recursive: true });
+ } catch (e: any) {
+ logError('company.sessionStore: mkdir failed.', { path: p, error: e?.message ?? String(e) });
+ }
+}
+
+/**
+ * Build a stable, filesystem-safe timestamp like `2026-05-13T21-29-04`.
+ * Colons and milliseconds are stripped so the value is portable across
+ * macOS / Linux / Windows.
+ */
+export function newSessionTimestamp(now: Date = new Date()): string {
+ const iso = now.toISOString(); // 2026-05-13T21:29:04.123Z
+ return iso.slice(0, 19).replace(/:/g, '-');
+}
+
+/**
+ * Create a new session directory and return its absolute path. The directory
+ * is empty — callers populate it via `writeBrief`, `writeAgentOutput`, etc.
+ */
+export function createSessionDir(
+ context: vscode.ExtensionContext,
+ timestamp: string,
+): string {
+ const base = resolveCompanyBase(context);
+ const dir = path.join(base, 'sessions', timestamp);
+ _ensureDir(dir);
+ return dir;
+}
+
+/** Write the CEO planner's brief (`_brief.md`). */
+export function writeBrief(
+ sessionDir: string,
+ userPrompt: string,
+ plan: CompanyTaskPlan,
+): void {
+ const lines = [
+ `# Brief — ${path.basename(sessionDir)}`,
+ '',
+ '## User Prompt',
+ userPrompt.trim() || '_(empty)_',
+ '',
+ '## Summary',
+ plan.brief.trim() || '_(no brief)_',
+ '',
+ '## Dispatched Tasks',
+ ];
+ if (plan.tasks.length === 0) {
+ lines.push('_(no tasks — CEO decided no dispatch was necessary)_');
+ } else {
+ for (const [i, t] of plan.tasks.entries()) {
+ lines.push(`${i + 1}. **${t.agent}** — ${t.task}`);
+ }
+ }
+ fs.writeFileSync(path.join(sessionDir, '_brief.md'), lines.join('\n'), 'utf8');
+}
+
+/** Write a single specialist's output to `.md`. */
+export function writeAgentOutput(sessionDir: string, output: AgentTurnOutput): void {
+ const lines = [
+ `# ${output.agentId} — ${path.basename(sessionDir)}`,
+ '',
+ `**Task:** ${output.task}`,
+ `**Duration:** ${(output.durationMs / 1000).toFixed(1)}s`,
+ output.error ? `**Error:** ${output.error}` : '',
+ '',
+ '---',
+ '',
+ output.response,
+ '',
+ ].filter((l) => l !== '');
+ fs.writeFileSync(path.join(sessionDir, `${output.agentId}.md`), lines.join('\n'), 'utf8');
+}
+
+/** Write the CEO's final synthesis to `_report.md`. */
+export function writeReport(sessionDir: string, report: string): void {
+ const header = `# Report — ${path.basename(sessionDir)}\n\n`;
+ fs.writeFileSync(path.join(sessionDir, '_report.md'), header + report.trim() + '\n', 'utf8');
+}
+
+/**
+ * Write a machine-readable copy of the whole turn for tooling (debugging,
+ * replays, future analytics). Keeps the markdown files the source of truth
+ * for the user — the JSON is just a convenience for code that reads it back.
+ */
+export function writeSessionJson(sessionDir: string, result: SessionResult): void {
+ const cloned: SessionResult = {
+ ...result,
+ // Drop the absolute sessionDir from the JSON so the file is portable
+ // across machines — it's already implicit (its own directory).
+ sessionDir: path.basename(sessionDir),
+ };
+ fs.writeFileSync(path.join(sessionDir, '_session.json'), JSON.stringify(cloned, null, 2), 'utf8');
+}
+
+// ── Long-lived per-agent memory + shared decisions ─────────────────────────
+
+function _agentMemoryPath(context: vscode.ExtensionContext, agentId: string): string {
+ return path.join(resolveCompanyBase(context), '_agents', agentId, 'memory.md');
+}
+
+/**
+ * Append a short note to `/memory.md`. Memory accumulates over time;
+ * the dispatcher reads it back as part of the specialist's system prompt so
+ * agents "remember" past work. Best-effort — failures are logged but never
+ * abort the turn.
+ */
+export function appendAgentMemory(
+ context: vscode.ExtensionContext,
+ agentId: string,
+ note: string,
+): void {
+ if (!note.trim()) return;
+ const memPath = _agentMemoryPath(context, agentId);
+ try {
+ _ensureDir(path.dirname(memPath));
+ const stamp = new Date().toISOString();
+ const block = `\n\n## ${stamp}\n${note.trim()}\n`;
+ fs.appendFileSync(memPath, block, 'utf8');
+ } catch (e: any) {
+ logError('company.sessionStore: agent memory append failed.', {
+ agentId, error: e?.message ?? String(e),
+ });
+ }
+}
+
+/** Read `/memory.md` (or empty string if missing). */
+export function readAgentMemory(context: vscode.ExtensionContext, agentId: string): string {
+ const memPath = _agentMemoryPath(context, agentId);
+ if (!fs.existsSync(memPath)) return '';
+ try { return fs.readFileSync(memPath, 'utf8'); } catch { return ''; }
+}
+
+function _sharedPath(context: vscode.ExtensionContext, fileName: string): string {
+ return path.join(resolveCompanyBase(context), '_shared', fileName);
+}
+
+/** Append a decision/learning to `_shared/decisions.md`. */
+export function appendDecision(context: vscode.ExtensionContext, decision: string): void {
+ if (!decision.trim()) return;
+ const p = _sharedPath(context, 'decisions.md');
+ try {
+ _ensureDir(path.dirname(p));
+ const stamp = new Date().toISOString();
+ fs.appendFileSync(p, `- ${stamp} — ${decision.trim()}\n`, 'utf8');
+ } catch (e: any) {
+ logError('company.sessionStore: decisions append failed.', { error: e?.message ?? String(e) });
+ }
+}
+
+/** Read `_shared/decisions.md` (or empty string). Trimmed to the last N chars. */
+export function readDecisions(context: vscode.ExtensionContext, maxChars: number = 2000): string {
+ const p = _sharedPath(context, 'decisions.md');
+ if (!fs.existsSync(p)) return '';
+ try {
+ const raw = fs.readFileSync(p, 'utf8');
+ return raw.length > maxChars ? '…' + raw.slice(-maxChars) : raw;
+ } catch { return ''; }
+}
+
+/** List existing session directories, newest first. */
+export function listSessions(context: vscode.ExtensionContext): string[] {
+ const dir = path.join(resolveCompanyBase(context), 'sessions');
+ if (!fs.existsSync(dir)) return [];
+ try {
+ return fs.readdirSync(dir)
+ .filter((name) => fs.statSync(path.join(dir, name)).isDirectory())
+ .sort()
+ .reverse();
+ } catch (e: any) {
+ logError('company.sessionStore: list failed.', { error: e?.message ?? String(e) });
+ return [];
+ }
+}
+
+/** Convenience used by the chip after a turn finishes. */
+export function logSessionCreated(sessionDir: string, agentCount: number): void {
+ logInfo('company.sessionStore: session created.', {
+ dir: path.basename(sessionDir),
+ agents: agentCount,
+ });
+}
+
+// Re-export path constants for callers that need to namespace under the same dirs.
+export { COMPANY_AGENTS_REL, COMPANY_SESSIONS_REL, COMPANY_SHARED_REL };
diff --git a/src/features/company/types.ts b/src/features/company/types.ts
new file mode 100644
index 0000000..643a035
--- /dev/null
+++ b/src/features/company/types.ts
@@ -0,0 +1,108 @@
+/**
+ * Type definitions for the 1인 기업 (One-Person Company) mode.
+ *
+ * The mode turns the user into a virtual CEO that dispatches work to a roster
+ * of specialist agents. Each turn produces a session directory containing the
+ * CEO's brief, every specialist's output, and the final synthesis report. The
+ * dispatcher runs agents *sequentially* — only one LLM is loaded at any
+ * moment — so the user can run multiple distinct agents on a single
+ * model-constrained machine without RAM thrash.
+ */
+
+/** Static description of a company agent. Loaded from `agents.ts`. */
+export interface CompanyAgentDef {
+ /** Stable identifier used in JSON plans, file names, config keys. */
+ id: string;
+ /** Display name (may be a Korean nickname like "레오" or "코다리"). */
+ name: string;
+ /** Role title shown in the manage panel and used in system prompts. */
+ role: string;
+ /** Single emoji used in chat headers and chip badges. */
+ emoji: string;
+ /** Brand colour for the agent card UI. CSS hex. */
+ color: string;
+ /** Comma-list of areas this agent owns. Drives the CEO's planner. */
+ specialty: string;
+ /** One-line punchy tagline shown under the agent name. */
+ tagline: string;
+ /** Optional voice / personality directive injected into the system prompt. */
+ persona?: string;
+ /**
+ * When true, this agent can't be toggled off in the UI. CEO uses this so
+ * it's always available as the orchestrator.
+ */
+ alwaysOn?: boolean;
+}
+
+/**
+ * Persisted runtime state for the company mode. Stored in VS Code's
+ * `globalState` plus a small JSON file under `.astra/company/_shared/`.
+ */
+export interface CompanyState {
+ /** When false, the chip is shown but prompts route through normal chat. */
+ enabled: boolean;
+ /** User-facing name surfaced in CEO prompts and the chip badge. */
+ companyName: string;
+ /** Agents the user has toggled on. CEO is implicitly included. */
+ activeAgentIds: string[];
+ /**
+ * Optional per-agent model override. Empty string / missing key means
+ * "use the global default model". When the user assigns *different*
+ * models to two agents, the LM Studio lifecycle manager unloads one and
+ * loads the other between dispatches — RAM holds exactly one model at a
+ * time, by design.
+ */
+ modelOverrides: Record;
+}
+
+/** 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. */
+ brief: string;
+ /** Ordered list of agent dispatches. Order is execution order. */
+ tasks: Array<{
+ /** Agent id (must exist in `AGENTS` and be active). */
+ agent: string;
+ /** Concrete, actionable instruction for the specialist. */
+ task: string;
+ }>;
+}
+
+/** One agent's contribution to a turn. */
+export interface AgentTurnOutput {
+ agentId: string;
+ task: string;
+ /** Raw LLM output, before action-tag execution. */
+ response: string;
+ /** Wall-clock milliseconds spent on this dispatch (LLM + tools). */
+ durationMs: number;
+ /** Populated when the dispatch failed; `response` then holds the error. */
+ error?: string;
+}
+
+/** The whole result of a company turn — persisted under sessions//. */
+export interface SessionResult {
+ /** ISO timestamp used as the session directory name. */
+ timestamp: string;
+ /** Absolute filesystem path of the session directory. */
+ sessionDir: string;
+ /** What the user typed. */
+ userPrompt: string;
+ /** The CEO's plan that drove this turn. */
+ plan: CompanyTaskPlan;
+ /** Per-agent outputs, in execution order. */
+ agentOutputs: AgentTurnOutput[];
+ /** CEO's final synthesis. Empty when the synthesis call failed. */
+ report: string;
+ /** Walls-clock milliseconds from prompt arrival to report emission. */
+ totalDurationMs: number;
+}
+
+/** Where on disk the company state lives, relative to the workspace root. */
+export const COMPANY_DIR_REL = '.astra/company';
+export const COMPANY_SHARED_REL = `${COMPANY_DIR_REL}/_shared`;
+export const COMPANY_SESSIONS_REL = `${COMPANY_DIR_REL}/sessions`;
+export const COMPANY_AGENTS_REL = `${COMPANY_DIR_REL}/_agents`;
+
+/** State-key namespaces used in VS Code's globalState. */
+export const COMPANY_STATE_KEY = 'g1nation.company.state';
diff --git a/src/sidebar/chatHandlers.ts b/src/sidebar/chatHandlers.ts
index 33697cf..a99798d 100644
--- a/src/sidebar/chatHandlers.ts
+++ b/src/sidebar/chatHandlers.ts
@@ -18,6 +18,15 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
case 'promptWithFile':
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.
+ if (provider.isCompanyModeEnabled() && typeof data.value === 'string' && data.value.trim()) {
+ await provider._runCompanyTurn(data.value.trim());
+ return true;
+ }
await provider._handlePrompt(data);
await provider._autoWriteChronicleAfterPrompt();
await provider._saveCurrentSession();
@@ -36,6 +45,9 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
// Restore the Project Architecture chip + watcher if the active project
// was already running in architecture mode in a previous VS Code session.
await provider._sendArchitectureStatus();
+ // Restore the Company chip from globalState so the user sees the same
+ // mode they had on at last shutdown.
+ await provider._sendCompanyStatus();
return true;
case 'getReadyStatus':
await provider._sendReadyStatus();
@@ -147,6 +159,45 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
}
return true;
}
+ // ── 1인 기업 모드 메시지 라우팅 ────────────────────────────────────
+ case 'getCompanyStatus':
+ await provider._sendCompanyStatus();
+ return true;
+ case 'getCompanyAgents':
+ await provider._sendCompanyAgents();
+ return true;
+ case 'setCompanyEnabled': {
+ const { setCompanyEnabled } = await import('../features/company');
+ await setCompanyEnabled(provider._context, !!data.value);
+ await provider._sendCompanyStatus();
+ return true;
+ }
+ case 'setCompanyName': {
+ const { setCompanyName } = await import('../features/company');
+ await setCompanyName(provider._context, typeof data.value === 'string' ? data.value : '');
+ await provider._sendCompanyStatus();
+ return true;
+ }
+ case 'setCompanyActiveAgents': {
+ const { setActiveAgents } = await import('../features/company');
+ const ids = Array.isArray(data.value)
+ ? data.value.filter((v: unknown): v is string => typeof v === 'string')
+ : [];
+ await setActiveAgents(provider._context, ids);
+ await provider._sendCompanyStatus();
+ await provider._sendCompanyAgents();
+ return true;
+ }
+ case 'setCompanyAgentModel': {
+ const { setAgentModelOverride } = await import('../features/company');
+ const agentId = typeof data.agentId === 'string' ? data.agentId : '';
+ const model = typeof data.model === 'string' ? data.model : '';
+ if (agentId) {
+ await setAgentModelOverride(provider._context, agentId, model);
+ await provider._sendCompanyAgents();
+ }
+ return true;
+ }
case 'proactiveTrigger':
await provider._handleProactiveSuggestion(data.context);
return true;
diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts
index 6f68330..546a763 100644
--- a/src/sidebarProvider.ts
+++ b/src/sidebarProvider.ts
@@ -34,6 +34,15 @@ import {
scanProject,
} from './features/projectArchitecture';
import { detectProjectIntent, KnownProject } from './features/projectArchitecture/intentDetector';
+import {
+ readCompanyState,
+ runCompanyTurn,
+ summarizeForChip,
+ CompanyTurnEvent,
+ COMPANY_AGENTS,
+ COMPANY_AGENT_ORDER,
+} from './features/company';
+import { AIService } from './core/services';
export interface SidebarLmStudioDeps {
lifecycle: ModelLifecycleManager;
@@ -1177,6 +1186,94 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
});
}
+ // ─── 1인 기업 (Company) Mode ────────────────────────────────────────────
+ //
+ // When `companyState.enabled` is true, prompts coming through the chat
+ // handler are routed to `_runCompanyTurn` instead of the normal
+ // AgentExecutor path. The dispatcher emits `companyTurnUpdate` events as
+ // each phase progresses; the webview shows a step-by-step header for
+ // CEO planning, each specialist's dispatch, and the final synthesis.
+
+ /** True iff company mode is active. Cheap — read from globalState. */
+ isCompanyModeEnabled(): boolean {
+ return readCompanyState(this._context).enabled;
+ }
+
+ /** Send the chip state (active flag + agent count + name) to the webview. */
+ async _sendCompanyStatus(): Promise {
+ if (!this._view) return;
+ const state = readCompanyState(this._context);
+ this._view.webview.postMessage({
+ type: 'companyStatus',
+ value: {
+ enabled: state.enabled,
+ companyName: state.companyName,
+ summary: summarizeForChip(state),
+ activeAgentIds: state.activeAgentIds,
+ modelOverrides: state.modelOverrides,
+ },
+ });
+ }
+
+ /** Push the full agent catalogue when the manage panel opens. */
+ async _sendCompanyAgents(): Promise {
+ if (!this._view) return;
+ const state = readCompanyState(this._context);
+ const agents = COMPANY_AGENT_ORDER.map((id) => {
+ const def = COMPANY_AGENTS[id];
+ return {
+ id,
+ name: def.name,
+ role: def.role,
+ emoji: def.emoji,
+ color: def.color,
+ tagline: def.tagline,
+ specialty: def.specialty,
+ hasPersona: !!def.persona,
+ alwaysOn: !!def.alwaysOn,
+ active: id === 'ceo' || state.activeAgentIds.includes(id),
+ modelOverride: state.modelOverrides[id] || '',
+ };
+ });
+ this._view.webview.postMessage({
+ type: 'companyAgents',
+ value: {
+ companyName: state.companyName,
+ agents,
+ },
+ });
+ }
+
+ /**
+ * Drive one full company turn. Caller is the chat handler; it's already
+ * persisted the user message and started a streaming bubble. We feed
+ * progress events back as `companyTurnUpdate` messages so the same bubble
+ * fills in as each agent finishes.
+ */
+ async _runCompanyTurn(userPrompt: string): Promise {
+ const cfg = getConfig();
+ const ai = new AIService();
+ const emit = (event: CompanyTurnEvent) => {
+ this._view?.webview.postMessage({ type: 'companyTurnUpdate', value: event });
+ };
+ try {
+ await runCompanyTurn(userPrompt, {
+ context: this._context,
+ ai,
+ defaultModel: cfg.defaultModel || 'gemma4:e2b',
+ onEvent: emit,
+ });
+ } catch (e: any) {
+ logError('company.runTurn: unexpected failure.', { error: e?.message ?? String(e) });
+ this._view?.webview.postMessage({
+ type: 'error',
+ value: `1인 기업 모드 실행 실패: ${e?.message ?? e}`,
+ });
+ } finally {
+ void this._sendReadyStatus();
+ }
+ }
+
/** Open the architecture doc in editor group 2. */
async _openArchitectureDoc(): Promise {
const p = this._getActiveChronicleProject();