/**
 * 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
 * (`<create_file>`, `<run_command>`); 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 { getActiveBrainProfile, logError, logInfo } from '../../utils';
import { retrieveScoped, buildContextBlock } from '../../skills/scopedBrainRetriever';
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
import {
    mapWeightToBrainFileLimit,
    buildKnowledgeMixPolicy,
} from '../../retrieval/knowledgeMix';
import {
    listActiveAgentsByCategory,
    modelForAgent, readCompanyState, resolveActivePipeline, resolveAgent, resolveCompanyKnowledgeMix,
} 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 {
    markResumeStatus,
    readResumeState,
    resolveSessionDir,
    writeResumeState,
} from './resumeStore';
import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport';
// ── Self-reflector + intent alignment 모듈 정적 import (옛 dynamic require 8회 통합) ──
// 옛 코드는 매 stage 마다 `await import(...)` 로 모듈을 로드했음. 이유는 cyclic import
// 회피로 짐작됐지만 실제로 selfReflector / intentAlignment 모듈 어느 것도 dispatcher 를
// import 하지 않아 안전하게 정적 promote 가능. 코드 흐름 명확해지고, 매 dispatch 마다
// require 호출 8회 → 0회 (모듈 캐시 자동).
import { verifyResponse, formatIssuesForRetry } from '../selfReflector/selfReflectorVerifier';
import { verifyCreatedFiles } from '../selfReflector/selfReflectorExecution';
import { verifyHollow } from '../selfReflector/selfReflectorHollow';
import { formatContractForPrompt } from './intentAlignment';
import { getConfig as getDispatcherConfig } from '../../config';
import {
    AgentRoleCategory, AgentTurnOutput, CompanyResumeState, CompanyState, CompanyTaskPlan,
    PipelineDef, PipelineStage, RequirementContract, ROLE_CATEGORY_LABELS, 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.
 */
/**
 * User's decision after a stage with `requiresApproval=true` finishes.
 *   - 'approve'   → proceed to next stage as-is
 *   - 'revise'    → re-run the same stage; comment is prepended to its instruction
 *   - 'abort'     → end the turn (same as hitting Stop)
 */
export type ApprovalDecision =
    | { kind: 'approve' }
    | { kind: 'revise'; comment: string }
    | { kind: 'abort' };

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 }
    /**
     * Pipeline-mode only: emitted when a stage's output matches the
     * configured `loopBackPattern` and the dispatcher jumps back to a
     * previous stage. The webview uses this to render "🔁 stage X → Y
     * (재시도 N차)" in the chat.
     */
    | { phase: 'stage-loop'; from: string; to: string; iteration: number }
    /**
     * Manual approval gate. Emitted right before the dispatcher awaits the
     * user's decision. Webview surfaces 승인/수정요청/중단 buttons.
     */
    | { 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 }
    /**
     * Emitted after the secretary attempts to mirror the CEO report to
     * Telegram. `ok=true` ⇒ delivered. `ok=false` ⇒ delivery failed (network
     * / API error). `ok=null` ⇒ no mirror was attempted because the user
     * hasn't opted in (no token, no chat id, or `telegram.enabled=false`).
     */
    | { phase: 'telegram-mirror'; ok: boolean | null; reason?: string }
    | { phase: 'session-saved'; sessionDir: string }
    | { phase: 'aborted'; reason: string }
    // 일반 정보·경고·에러 메시지 — 진행 UI 와 별개로 사용자에게 전달할 텍스트.
    // 예: resume state 저장 실패, optional feature 미설치 안내 등.
    | { phase: 'log'; level: 'info' | 'warn' | 'error'; message: 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;
    /**
     * Global Knowledge Mix weight (0–100) — fallback when an agent has no
     * per-agent override. Mirrors `g1nation.knowledgeMix.secondBrainWeight`.
     */
    globalKnowledgeMixWeight: number;
    /**
     * Baseline number of brain files to retrieve at weight=50 (balanced).
     * The actual count is `mapWeightToBrainFileLimit(weight, baseline)`.
     * Pass the same value the chat path uses (`config.memoryLongTermFiles`)
     * so company-mode behaviour stays in sync.
     */
    brainFileBaseline?: number;
    /**
     * Apply ConnectAI's action-tag executor to the specialist's raw response.
     * Without this hook, agent outputs containing `<create_file>` etc. would
     * be shown to the user as a *claim* of file creation but nothing would
     * actually land on disk. We thread it through deps (rather than
     * importing AgentExecutor directly) so the dispatcher stays free of
     * sidebar / agent.ts dependencies for unit testability.
     */
    executeActionTags?: (text: string) => Promise<string[]>;
    /** Per-call cancellation. The sidebar's Stop button flips this. */
    signal?: AbortSignal;
    /** Optional event sink for the webview. Receives events synchronously. */
    onEvent?: CompanyTurnEmitter;
    /**
     * Manual-approval bridge. When a pipeline stage has `requiresApproval`,
     * the dispatcher emits `phase: 'awaiting-approval'` and then *awaits*
     * this promise. The host (SidebarChatProvider) is responsible for:
     *   1. Storing a resolver tied to the emitted stageId
     *   2. Surfacing approval buttons in the webview chat
     *   3. Resolving the promise when the user clicks one of them
     *   4. Resolving with `{ kind: 'abort' }` if the turn is cancelled
     *      (so the dispatcher doesn't hang forever)
     */
    awaitApproval?: (ctx: { stageId: string; stageLabel: string }) => Promise<ApprovalDecision>;
    /**
     * 이번 turn 한정으로 활성 파이프라인을 *override*. 비어 있으면 평소대로
     * `state.activePipelineId` 따른다. 의도 분류기의 `suggestedPipelineId` 또는
     * 사용자 키워드(`[파이프라인:id]`) 검출 시 chatHandlers가 채워서 넘긴다.
     * 알 수 없는 id면 dispatcher가 silent fallback해서 legacy 동작
     * (state.activePipelineId 또는 CEO planner)로 진행.
     */
    pipelineIdOverride?: string;
    /**
     * Intent Alignment 단계에서 사용자와 합의된 Requirement Contract. 있으면
     * CEO planner / specialist prompt / 검수자(inspector + CEO) prompt 전부에
     * 같은 ground truth로 주입되어 에이전트들이 추측 대신 contract를 따른다.
     * 없으면 legacy 동작 — alignment 단계를 거치지 않았거나 사용자 모드가
     * 'off'였던 경우.
     */
    requirementContract?: RequirementContract;
    /**
     * 현재 워크스페이스의 아키텍처 컨텍스트 (architecture.md 요약, 호출자가
     * 절단해 전달). 일반 챗은 이 컨텍스트를 자동 주입받지만 기업 모드는 빠져
     * 있던 공백 수정 — specialist 가 "이 프로젝트가 뭐냐"를 추측하지 않게.
     * contract 와 같은 4개 지점(planner/specialist/verifier/inspector)에 prepend.
     */
    architectureContextBlock?: string;
    /**
     * 사용자 프롬프트에 포함된 URL 의 pre-fetch 결과 ([URL CONTENT] 블록).
     * 기업 모드에는 continuation loop 가 없어 LLM 주도 fetch 결과를 재분석할
     * 기회가 없으므로, dispatch 전에 호출자가 가져와 모든 에이전트에게 배포.
     */
    webContextBlock?: string;
}

/**
 * deps 의 자동 수집 컨텍스트(architecture + web)를 한 prefix 문자열로 합성.
 * 둘 다 없으면 빈 문자열 — 기존 동작과 100% 동일.
 */
function buildExtraContextPrefix(deps: DispatcherDeps): string {
    const blocks = [deps.architectureContextBlock, deps.webContextBlock]
        .map((b) => (b || '').trim())
        .filter(Boolean);
    return blocks.length > 0 ? blocks.join('\n\n') : '';
}

/**
 * Run a single company turn. Returns a fully-populated `SessionResult` even
 * on partial failure (so callers can always render *something* in chat).
 *
 * When `seed` is supplied, this is a *resume* of a previously-aborted turn:
 * the saved sessionDir/plan/outputs are reused, and dispatch picks up at the
 * saved `nextIndex` instead of starting from scratch. The CEO planner is
 * skipped on resume — we trust the original plan to keep behaviour
 * deterministic. Pipeline mode also restores `latestByStage` / `iterations`
 * / `revisionNotes` so loop-backs and template tokens resolve as if the
 * turn never paused.
 */
export async function runCompanyTurn(
    userPrompt: string,
    deps: DispatcherDeps,
    seed?: CompanyResumeState | null,
): Promise<SessionResult> {
    const startedAt = Date.now();
    const state = readCompanyState(deps.context);
    const timestamp = seed?.timestamp ?? newSessionTimestamp();
    const sessionDir = seed
        ? resolveSessionDir(deps.context, seed.timestamp)
        : createSessionDir(deps.context, timestamp);
    const startedAtIso = seed?.startedAt ?? new Date().toISOString();

    const emit: CompanyTurnEmitter = deps.onEvent ?? (() => { /* noop */ });
    const isAborted = () => deps.signal?.aborted === true;

    // ── Resume state writer ──
    // 모든 의미 있는 시점(plan 확정 / 각 stage 직후 / abort)에 같은 파일을 덮어쓴다.
    // dispatch 중에 캡처해야 할 cursor·캐시들은 클로저로 넘기는 게 매번
    // 인자를 6개씩 줄지어 보내는 것보다 깔끔하다.
    const persistResume = (
        status: CompanyResumeState['status'],
        partial: {
            plan: CompanyTaskPlan;
            pipelineId: string | null;
            outputs: AgentTurnOutput[];
            nextIndex: number;
            pipelineContext?: CompanyResumeState['pipelineContext'];
            abortReason?: string;
        },
    ): void => {
        const result = writeResumeState(sessionDir, {
            version: 1,
            timestamp,
            userPrompt,
            pipelineId: partial.pipelineId,
            plan: partial.plan,
            agentOutputs: partial.outputs,
            nextIndex: partial.nextIndex,
            pipelineContext: partial.pipelineContext,
            status,
            abortReason: partial.abortReason,
            lastUpdatedAt: new Date().toISOString(),
            startedAt: startedAtIso,
        });
        // 옛 코드는 write 실패해도 silent 로 logError 만 → 사용자는 *resume turn 손실*
        // 사실을 모름. 실패 시 emit 으로 webview 에 통보해 사용자가 즉시 인지.
        if (!result.ok) {
            emit({
                phase: 'log',
                level: 'warn',
                message: `Resume 상태 저장 실패 (${status}): ${result.reason}. 이 turn 은 이어서 진행 못 할 수 있습니다.`,
            });
        }
    };

    const fail = (reason: string, ctx?: {
        plan: CompanyTaskPlan;
        pipelineId: string | null;
        outputs: AgentTurnOutput[];
        nextIndex: number;
        pipelineContext?: CompanyResumeState['pipelineContext'];
    }): SessionResult => {
        emit({ phase: 'aborted', reason });
        // abort 시점의 상태를 _resume.json에 영구화 — 이후 사용자가 "이어서 진행"
        // 누르면 이 파일에서 plan + 진행도를 복원해 nextIndex부터 재개 가능.
        if (ctx) {
            persistResume('aborted', { ...ctx, abortReason: reason });
        } else if (seed) {
            // Resume 도중에 즉시 abort — 들어왔던 seed를 그대로 abort로 다시 마킹.
            markResumeStatus(sessionDir, 'aborted', reason);
        }
        return {
            timestamp, sessionDir,
            userPrompt,
            plan: ctx?.plan ?? seed?.plan ?? { brief: '', tasks: [] },
            agentOutputs: ctx?.outputs ?? seed?.agentOutputs ?? [],
            report: '',
            totalDurationMs: Date.now() - startedAt,
        };
    };
    if (isAborted()) return fail('signal-aborted');

    // ── Phase 1: plan (pipeline or legacy planner — skipped on resume) ──
    let pipeline: PipelineDef | null;
    let plan: CompanyTaskPlan;
    let plannerRaw = '';
    let plannerParsed = true;
    if (seed) {
        // Resume path: reuse the original plan + pipeline binding verbatim.
        // If the user edited the pipeline definition in the meantime we still
        // re-resolve by id from the current state — that way deleted stages
        // wouldn't crash the dispatch (resolveActivePipeline returns null
        // gracefully). Worst case the dispatch finishes the leftover plan.
        pipeline = seed.pipelineId
            ? (state.pipelines?.[seed.pipelineId] ?? null)
            : null;
        plan = seed.plan;
        emit({ phase: 'plan-start' });
        emit({ phase: 'plan-ready', plan, parsed: true, raw: '' });
    } else {
        emit({ phase: 'plan-start' });
        // 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)}`,
                // 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 plannerExtraPrefix = buildExtraContextPrefix(deps);
            const plannerContract = deps.requirementContract
                ? formatContractForPrompt(deps.requirementContract)
                : undefined;
            const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, {
                model: ceoModel,
                contractBlock: [plannerExtraPrefix, plannerContract].filter(Boolean).join('\n\n') || undefined,
                signal: deps.signal,
            });
            plan = plannerResult.plan;
            plannerRaw = plannerResult.raw;
            plannerParsed = plannerResult.parsed;
        }
        if (isAborted()) {
            return fail('aborted-after-plan', {
                plan, pipelineId: pipeline?.id ?? null, outputs: [], nextIndex: 0,
            });
        }
        emit({ phase: 'plan-ready', plan, parsed: plannerParsed, raw: plannerRaw });
        writeBrief(sessionDir, userPrompt, plan);
    }
    const pipelineId = pipeline?.id ?? null;

    // 초기 resume 상태(또는 resume 진행 시작 상태) 영속화.
    persistResume('in-progress', {
        plan, pipelineId,
        outputs: seed?.agentOutputs ?? [],
        nextIndex: seed?.nextIndex ?? 0,
        pipelineContext: seed?.pipelineContext,
    });

    // ── Phase 2: sequential dispatch ──
    const outputs: AgentTurnOutput[] = seed?.agentOutputs ? [...seed.agentOutputs] : [];
    if (pipeline) {
        const runResult = await _runPipeline(
            pipeline, userPrompt, plan.brief, sessionDir, timestamp, state, deps, isAborted, emit,
            seed && seed.pipelineId === pipeline.id
                ? {
                    outputs,
                    latestByStage: seed.pipelineContext?.latestByStage ?? {},
                    iterations: seed.pipelineContext?.iterations ?? {},
                    revisionNotes: seed.pipelineContext?.revisionNotes ?? {},
                    startIndex: seed.nextIndex,
                }
                : undefined,
            (commit) => {
                // 각 stage 직후 호출됨 — _runPipeline의 내부 cursor·캐시를 통째로 받아
                // resume 파일을 갱신. 다음 stage가 시작되기 전에 디스크에 한 번 떨어짐.
                persistResume('in-progress', {
                    plan, pipelineId,
                    outputs: commit.outputs,
                    nextIndex: commit.nextIndex,
                    pipelineContext: {
                        latestByStage: commit.latestByStage,
                        iterations: commit.iterations,
                        revisionNotes: commit.revisionNotes,
                    },
                });
            },
        );
        if (runResult.aborted) {
            return fail(runResult.aborted, {
                plan, pipelineId,
                outputs: runResult.outputs,
                nextIndex: runResult.nextIndex ?? outputs.length,
                pipelineContext: runResult.pipelineContext,
            });
        }
        // outputs는 이미 _runPipeline 내부에서 누적 — 새 객체 push 불필요.
        outputs.length = 0; outputs.push(...runResult.outputs);
    } else {
        const total = plan.tasks.length;
        const startIdx = seed?.nextIndex ?? 0;
        for (let i = startIdx; i < total; i++) {
            if (isAborted()) {
                return fail('aborted-mid-dispatch', {
                    plan, pipelineId: null, outputs, nextIndex: i,
                });
            }
            const task = 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);
            appendAgentMemory(
                deps.context,
                task.agent,
                `[${timestamp}] ${task.task} — ${turn.error ? `❌ ${turn.error}` : '✅'}`,
            );
            emit({ phase: 'agent-done', agentId: task.agent, output: turn, index: i, total });
            // 각 task 직후 resume cursor 영속화 — 다음 task 전에 abort/crash 나도
            // 같은 task 중복 실행 없이 그 다음부터 이어진다.
            persistResume('in-progress', {
                plan, pipelineId: null, outputs, nextIndex: i + 1,
            });
        }
    }

    // ── Phase 3: synthesis ──
    // 여기까지 왔다는 건 모든 stage/task가 끝난 상태. abort 시에도 outputs는
    // 이미 disk에 떨어져 있으므로 resume cursor를 plan.tasks.length로 옮겨
    // 다음 재개 시점에는 report 단계부터 시작하도록 한다 (= 사실상 완료에 가깝다).
    if (isAborted()) {
        return fail('aborted-before-report', {
            plan, pipelineId,
            outputs,
            nextIndex: plan.tasks.length,
        });
    }
    emit({ phase: 'report-start' });
    const reportModel = modelForAgent(state, 'ceo', deps.defaultModel);
    const reportResult = await runCeoReporter(
        deps.ai,
        plan,
        outputs,
        state,
        { model: reportModel, signal: deps.signal },
    );
    writeReport(sessionDir, reportResult.report);
    emit({ phase: 'report-done', report: reportResult.report, ok: reportResult.ok });

    // ── Phase 3.5: Secretary mirror to Telegram ──
    // Origin parity (Connect_origin extension.ts ~line 20620): after the CEO
    // synthesizes the round, the Secretary (영숙) ships a digest to the user's
    // Telegram chat. Strictly best-effort — failures never break the turn.
    let telegramOk: boolean | null = null;
    let telegramReason: string | undefined;
    try {
        const reporter = await buildTelegramReporter(deps.context);
        if (!reporter) {
            telegramReason = 'no-config'; // opt-in off, no token, or no chat id
        } else {
            const tgText = formatCompanyTelegramReport({
                state,
                userPrompt,
                plan,
                outputs,
                report: reportResult.report,
                sessionTimestamp: timestamp,
                sessionDir,
            });
            telegramOk = await reporter(tgText);
            if (!telegramOk) telegramReason = 'delivery-failed';
        }
    } catch (e: any) {
        telegramOk = false;
        telegramReason = e?.message ?? String(e);
        logError('company.dispatcher: telegram mirror threw.', { error: telegramReason });
    }
    emit({ phase: 'telegram-mirror', ok: telegramOk, reason: telegramReason });

    // ── Phase 4: persist + side effects ──
    const result: SessionResult = {
        timestamp, sessionDir,
        userPrompt,
        plan,
        agentOutputs: outputs,
        report: reportResult.report,
        totalDurationMs: Date.now() - startedAt,
    };
    writeSessionJson(sessionDir, result);
    // 자연 종료 — resume 파일은 'completed' 마킹으로 listResumable에서 자동 제외.
    // 파일은 삭제하지 않고 남겨 추후 감사/디버깅 용도로 활용 (수 KB 수준).
    markResumeStatus(sessionDir, 'completed');
    // 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;
}

/**
 * Resume a previously-aborted company turn from disk.
 *
 * sessionDir의 `_resume.json`을 읽어 plan + 진행 cursor + 캐시를 복원한 다음
 * `runCompanyTurn`을 seed 옵션으로 호출. 같은 sessionDir을 재사용하므로
 * markdown 산출물(`_brief.md`, `<agentId>.md` 등)이 누적됨.
 *
 * @returns 복구 가능한 상태가 있으면 SessionResult, 아니면 null
 *   (`_resume.json`이 없거나, 이미 'completed' 상태이거나, plan이 비어 있는 경우).
 */
export async function resumeCompanyTurn(
    timestamp: string,
    deps: DispatcherDeps,
): Promise<SessionResult | null> {
    const sessionDir = resolveSessionDir(deps.context, timestamp);
    const saved = readResumeState(sessionDir);
    if (!saved) {
        logInfo('company.dispatcher: resume requested but no state found.', { timestamp });
        return null;
    }
    if (saved.status === 'completed') {
        logInfo('company.dispatcher: resume requested for completed session — ignoring.', { timestamp });
        return null;
    }
    if (!saved.plan || !Array.isArray(saved.plan.tasks) || saved.plan.tasks.length === 0) {
        logInfo('company.dispatcher: resume requested but plan is empty.', { timestamp });
        return null;
    }
    logInfo('company.dispatcher: resuming turn.', {
        timestamp,
        pipelineId: saved.pipelineId,
        nextIndex: saved.nextIndex,
        totalTasks: saved.plan.tasks.length,
    });
    return runCompanyTurn(saved.userPrompt, deps, saved);
}

/**
 * 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<typeof readCompanyState>,
    deps: DispatcherDeps,
    /** Pipeline stage override — wins over the agent's own model override. */
    stageModelOverride?: string,
): Promise<AgentTurnOutput> {
    const startedAt = Date.now();
    const def = resolveAgent(state, 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);
    // Google Calendar iCal 캐시 (선택 사항). 셋업 안 된 사용자는 빈 문자열 → 무시.
    // 매 dispatch 마다 디스크 read 1회 발생하지만 캐시는 KB 단위라 비용 무시 가능.
    let calendarContext = '';
    try {
        const { readCalendarCache } = require('../calendar') as typeof import('../calendar');
        calendarContext = readCalendarCache(deps.context) ?? '';
    } catch { /* feature 미설치 / 캐시 없음 — silent skip */ }

    // Task tracker — _shared/tasks.md 의 active 항목 요약. 모든 agent 가 진척 상황을
    // 한 눈에 볼 수 있도록. 비어있으면 빈 문자열 → 프롬프트에서 섹션 자체 생략.
    let tasksContext = '';
    try {
        const { readTaskStore, summarizeActiveTasks } = require('../tasks') as typeof import('../tasks');
        tasksContext = summarizeActiveTasks(readTaskStore(deps.context));
    } catch { /* silent */ }
    const peerOutputs = earlierOutputs
        .filter((o) => !o.error) // skip failed peers — they'd just confuse the next agent
        .map((o) => {
            const peerDef = resolveAgent(state, 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,
            };
        });

    // ── Second Brain RAG for this specialist ────────────────────────────────
    // The non-company chat path uses `AgentExecutor.buildMemoryContext` to
    // pull RAG chunks before every LLM call. The dispatcher used to skip
    // that entirely, leaving company agents *blind* to the user's stored
    // knowledge — which made the Knowledge Mix slider effectively a no-op
    // for company turns. We now run a lightweight scoped retrieval here so
    // every dispatch sees the same brain the user expects, weighted by the
    // agent's own Knowledge Mix.
    const { weight: knowledgeWeight, source: knowledgeMixSource } =
        resolveCompanyKnowledgeMix(state, agentId, deps.globalKnowledgeMixWeight);
    const brainFileLimit = mapWeightToBrainFileLimit(knowledgeWeight, deps.brainFileBaseline ?? 6);
    let brainContext = '';
    if (brainFileLimit > 0) {
        try {
            const brain = getActiveBrainProfile();
            const brainRoot = brain?.localBrainPath || '';
            if (brainRoot) {
                // Reuse the agent ↔ knowledge map: if the same agent name
                // appears there (free-form .md path or canonical id), we
                // honour its `knowledgeFolders` scope. Otherwise we search
                // the whole brain so a missing mapping doesn't starve the
                // dispatcher.
                const scope = resolveScopeForAgent(agentId, brainRoot);
                const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
                    maxResults: brainFileLimit,
                });
                brainContext = buildContextBlock(retrieval);
            }
        } catch (e: any) {
            logError('company.dispatcher: RAG retrieval failed; continuing without brain context.', {
                agentId, error: e?.message ?? String(e),
            });
        }
    }
    const policyBlock = buildKnowledgeMixPolicy({
        weight: knowledgeWeight,
        source: knowledgeMixSource,
        agent: def.name,
    });

    const system = buildSpecialistPrompt({
        agentId, state,
        agentMemory: memory, sharedDecisions: decisions,
        calendarContext,
        tasksContext,
        peerOutputs,
        brainContext,                          // injected as `[SECOND BRAIN CONTEXT]` block
        knowledgeMixPolicy: policyBlock,       // injected as `[KNOWLEDGE MIX POLICY]` block
        // alignment 단계에서 도출된 contract + 자동 수집 컨텍스트(architecture/web)가
        // deps에 있으면 모든 specialist의 system 프롬프트에 같은 ground truth로
        // prepend된다. 추측 방지.
        contractBlock: [
            buildExtraContextPrefix(deps),
            deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
        ].filter(Boolean).join('\n\n') || undefined,
    });
    // 우선순위: stage > agent > global default.
    const model = (stageModelOverride && stageModelOverride.trim())
        ? stageModelOverride.trim()
        : modelForAgent(state, agentId, deps.defaultModel);

    try {
        const result = await deps.ai.chat({
            system,
            user: task,
            model,
            signal: deps.signal,
        });
        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 8회 → 정적 import 로 promote (파일 상단). 모듈 자체 cyclic 없음.
            const cfgRuntime = getDispatcherConfig();
            if (cfgRuntime.selfReflectorExternalEnabled && rawResponse) {
                const contractBlock = [
                    buildExtraContextPrefix(deps),
                    deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
                ].filter(Boolean).join('\n\n') || 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,
                            signal: deps.signal,
                        });
                        const retried = (retryRes.content || '').trim();
                        if (retried) {
                            rawResponse = retried;
                            verifierSummary = `검증 fail → 1회 retry 적용 (${verdict.issues.length}개 지적 반영)`;
                        }
                    } catch (e: any) {
                        logError('selfReflector.B: retry call failed; keeping original.', {
                            agentId, error: e?.message ?? String(e),
                        });
                    }
                }
            }
        } catch (e: any) {
            // Phase B 전체가 실패해도 dispatch 자체는 계속.
            logError('selfReflector.B: hook failed; continuing without verification.', {
                agentId, error: e?.message ?? String(e),
            });
        }

        // Apply ConnectAI's action-tag executor so `<create_file>`,
        // `<run_command>`, `<edit_file>`, etc. emitted by the agent actually
        // hit disk / shell. The report (e.g. "✅ Created: foo.py") is
        // appended to the response so the user sees what really happened.
        let finalResponse = rawResponse || '_(empty response)_';
        let actionReport: string[] | undefined;
        const hasTag = !!rawResponse && hasActionTag(rawResponse);
        if (rawResponse && deps.executeActionTags && hasTag) {
            try {
                const report = await deps.executeActionTags(rawResponse);
                actionReport = report;

                // ── Self-Reflector Phase C — 생성/편집된 파일 syntax 체크 ──
                // 사용자가 selfReflector.executionVerification 켰을 때만. 추가
                // report 항목들을 actionReport에 append + finalResponse 첨부 본문에도 반영.
                try {
                    const cfgRuntime = getDispatcherConfig();
                    if (cfgRuntime.selfReflectorExecutionEnabled && actionReport.length > 0) {
                        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),
                    });
                }

                // ── Hollow Code Check (휴리스틱, LLM 콜 0) ──
                // Phase C(syntax)가 잡지 못하는 *빈 깡통* 패턴을 정규식으로 잡는다.
                // v2.2.203: Phase A(selfReflectorEnabled) 와 분리 — 독립 toggle
                // (hollowCheck.enabled, 기본 ON). action-tag 있는 모든 응답에 무조건 실행.
                // Retry 도 Phase B 와 분리 (hollowCheck.autoRetry, 기본 ON) — 다수 사용자가
                // 자동 안전망 받도록.
                try {
                    const cfgRuntime = getDispatcherConfig();
                    if (cfgRuntime.hollowCheckEnabled && actionReport.length > 0) {
                        const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
                        if (projectRoot) {
                            const hollowRes = verifyHollow(actionReport, projectRoot);
                            if (hollowRes.hasHollow) {
                                actionReport = [...actionReport, ...hollowRes.extraLines];
                                // hollowCheck.autoRetry 가 켜져 있고 아직 retry 안 했다면 hollow를
                                // 자동 재작업 트리거. (Phase B 와 무관 — 빈 깡통은 늘 잡아야)
                                if (cfgRuntime.hollowCheckAutoRetry && verifierIssues.length === 0) {
                                    verifierIssues = hollowRes.hollowReasons.map((r) => `빈 깡통: ${r}`);
                                    verifierSummary = `Hollow code 감지 — 자동 재시도 트리거`;
                                    // 같은 specialist 1회 retry: 빈 깡통 지적을 task 앞에 prepend.
                                    try {
                                        const retryTask = `${formatIssuesForRetry(verifierIssues)}\n\n[원래 지시]\n${task}`;
                                        const retryRes = await deps.ai.chat({ system, user: retryTask, model, signal: deps.signal });
                                        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.hollowCheckAutoRetry) {
                                    // auto-retry OFF — 사용자에게 경고만.
                                    verifierSummary = `⚠️ Hollow code 감지 — hollowCheck.autoRetry 켜면 자동 재시도`;
                                }
                            }
                        }
                    }
                } 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
                // success is more useful than dropping the whole response.
                const err = e?.message ?? String(e);
                logError('company.dispatcher: action-tag execution failed.', { agentId, err });
                finalResponse = `${rawResponse}\n\n---\n⚠️ Action 실행 실패: ${err}`;
            }
        } else if (rawResponse && !hasTag && claimsFileCreation(rawResponse)) {
            // Hallucination guard: small models love to *narrate* file
            // creation ("foo.py를 생성했습니다 …") without emitting the
            // <create_file> tag — so the user sees ✅ in chat but nothing
            // on disk. Catch the mismatch here and flag it loudly so the
            // CEO synthesis (which reads this response) and the user both
            // know nothing was actually written.
            const warning = '⚠️ **실제 파일이 생성되지 않았습니다.** Agent가 파일 생성을 텍스트로 설명했지만 ConnectAI 액션 태그(`<create_file>` 등)를 사용하지 않아 디스크에 아무것도 만들어지지 않았어요. 같은 요청을 다시 시도하거나, 사용자가 직접 만드세요.';
            finalResponse = `${rawResponse}\n\n---\n${warning}`;
            logInfo('company.dispatcher: agent claimed creation without action tag.', { agentId });
        }
        // `error: 'no-action-tag-but-claimed'` is *advisory* — we still let
        // the turn complete because some agents (Writer, Researcher) are
        // legitimately answer-only. But by flagging the agent output we
        // 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,
            durationMs: Date.now() - startedAt,
            error: rawResponse
                ? (claimedButDidnt ? 'claimed-creation-no-tag' : undefined)
                : 'empty-response',
            actionReport,
        };
    } 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,
        };
    }
}

/**
 * Run an authored pipeline: each stage dispatches its agent with a templated
 * instruction. Stages can declare a `loopBackPattern` regex — when it
 * matches the stage's output, the dispatcher jumps back to `loopBackTo` (a
 * stage that must precede the current one). Iteration count is bounded by
 * `maxIterations` (default 3) to keep run-away loops from hanging the user.
 *
 * Returns `{ outputs, aborted }`: `aborted` is set only when the abort
 * signal flipped mid-run; the outer dispatcher then short-circuits.
 */
interface PipelineSeed {
    /** 이미 끝낸 stage들의 출력. 새로운 outputs 배열의 시드로 들어감. */
    outputs: AgentTurnOutput[];
    /** stage id → 최신 출력. `{{stage.<id>}}` 치환에 사용. */
    latestByStage: Record<string, AgentTurnOutput>;
    /** stage id → loop-back 누적 횟수. */
    iterations: Record<string, number>;
    /** stage id → 사용자 수정요청 코멘트. */
    revisionNotes: Record<string, string>;
    /** 재개를 시작할 0-based stage index. */
    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),
            signal: deps.signal,
        });
        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' };
}
// resolveInspector / parseInspectorVerdict / parseCeoVerdict / renderStageInstruction
// / hasActionTag / claimsFileCreation
//   → `src/features/company/dispatcherHelpers.ts`
import {
    resolveInspector,
    parseInspectorVerdict,
    parseCeoVerdict,
    renderStageInstruction,
    hasActionTag,
    claimsFileCreation,
} from './dispatcherHelpers';





/**
 * 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 contractPrefixParts = [
            buildExtraContextPrefix(deps),
            deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
        ].filter(Boolean).join('\n\n');
        const contractPrefix = contractPrefixParts ? contractPrefixParts + '\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),
                signal: deps.signal,
            });
            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),
                signal: deps.signal,
            });
            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[];
    latestByStage: Record<string, AgentTurnOutput>;
    iterations: Record<string, number>;
    revisionNotes: Record<string, string>;
    nextIndex: number;
}

async function _runPipeline(
    pipeline: PipelineDef,
    userPrompt: string,
    brief: string,
    sessionDir: string,
    timestamp: string,
    state: CompanyState,
    deps: DispatcherDeps,
    isAborted: () => boolean,
    emit: CompanyTurnEmitter,
    seed?: PipelineSeed,
    onStageCommit?: (commit: PipelineCommit) => void,
): Promise<{
    outputs: AgentTurnOutput[];
    aborted?: string;
    /** abort 시점의 stage cursor — runCompanyTurn이 resume 파일 영속화에 사용. */
    nextIndex?: number;
    pipelineContext?: {
        latestByStage: Record<string, AgentTurnOutput>;
        iterations: Record<string, number>;
        revisionNotes: Record<string, string>;
    };
}> {
    const outputs: AgentTurnOutput[] = seed?.outputs ? [...seed.outputs] : [];
    // Keep the latest output per stage id so `{{stage.<id>}}` template
    // tokens always resolve to the most recent value across loop-backs.
    const latestByStage: Record<string, AgentTurnOutput> = seed?.latestByStage
        ? { ...seed.latestByStage }
        : {};
    const iterations: Record<string, number> = seed?.iterations
        ? { ...seed.iterations }
        : {};
    const total = pipeline.stages.length;
    // Per-stage extra instruction injected by user revision requests. Cleared
    // after the stage re-runs successfully so it doesn't pollute the rest of
    // the pipeline.
    const revisionNotes: Record<string, string> = seed?.revisionNotes
        ? { ...seed.revisionNotes }
        : {};
    let i = seed?.startIndex ?? 0;
    // stepIndex는 emit의 index 인자(UI 진행률) — 재개 시 이미 완료된 stage 수만큼
    // 미리 진행시켜둬야 "참여 중인 stage 4/7" 같은 표시가 정확해진다.
    let stepIndex = seed?.outputs?.length ?? 0;
    /** Snapshot helper — 현재 cursor·캐시 묶음을 PipelineCommit으로 캡슐. */
    const snapshot = (nextIdx: number): PipelineCommit => ({
        outputs: [...outputs],
        latestByStage: { ...latestByStage },
        iterations: { ...iterations },
        revisionNotes: { ...revisionNotes },
        nextIndex: nextIdx,
    });
    const abortReturn = (reason: string) => ({
        outputs, aborted: reason,
        nextIndex: i,
        pipelineContext: {
            latestByStage: { ...latestByStage },
            iterations: { ...iterations },
            revisionNotes: { ...revisionNotes },
        },
    });
    while (i < pipeline.stages.length) {
        if (isAborted()) return abortReturn('aborted-mid-pipeline');
        const stage = pipeline.stages[i];
        const baseTask = renderStageInstruction(stage, userPrompt, brief, latestByStage);
        const note = revisionNotes[stage.id];
        const task = note
            ? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}`
            : baseTask;
        // 동적 담당자 해결. 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, resolvedAgentId,
            `[${timestamp}][${pipeline.id}/${stage.id}] ${task.slice(0, 120)} — ${turn.error ? `❌ ${turn.error}` : '✅'}`,
        );
        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
        // gate when (a) the stage opted in, (b) we have an awaitApproval
        // bridge from the host, and (c) the stage didn't error out
        // (errored stages would loop forever waiting for "approval" of a
        // failure — the user should just hit Stop).
        if (stage.requiresApproval && deps.awaitApproval && !turn.error) {
            emit({
                phase: 'awaiting-approval',
                stageId: stage.id,
                stageLabel: stage.label || stage.id,
                index: stepIndex - 1,
                total,
            });
            let decision: ApprovalDecision;
            try {
                decision = await deps.awaitApproval({ stageId: stage.id, stageLabel: stage.label || stage.id });
            } catch {
                // 호스트가 에러를 던지면 안전하게 중단 — 무한 대기 방지.
                decision = { kind: 'abort' };
            }
            if (isAborted()) return abortReturn('aborted-mid-approval');
            emit({ phase: 'approval-resolved', stageId: stage.id, decision: decision.kind });
            if (decision.kind === 'abort') {
                return abortReturn('aborted-by-user-at-approval');
            }
            if (decision.kind === 'revise') {
                revisionNotes[stage.id] = decision.comment || '(추가 코멘트 없음)';
                // 같은 stage 재실행 — i를 그대로 두고 continue.
                // 수정요청 코멘트도 resume에 반영해야 재개 시 사용자 의도 보존.
                onStageCommit?.(snapshot(i));
                continue;
            }
            // 'approve' → 아래 loop-back/다음 stage 진행 로직으로 자연히 fall-through.
        }

        // Loop-back evaluation. We only loop on *successful* responses with
        // non-empty body — an error or empty response would loop forever.
        if (stage.loopBackTo && stage.loopBackPattern && !turn.error && turn.response.trim()) {
            const limit = stage.maxIterations ?? 3;
            const count = (iterations[stage.id] ?? 0) + 1;
            iterations[stage.id] = count;
            let re: RegExp | null = null;
            try { re = new RegExp(stage.loopBackPattern, 'i'); } catch { re = null; }
            if (re && re.test(turn.response) && count <= limit) {
                const targetIdx = pipeline.stages.findIndex((s) => s.id === stage.loopBackTo);
                if (targetIdx !== -1 && targetIdx < i) {
                    emit({ phase: 'stage-loop', from: stage.id, to: stage.loopBackTo, iteration: count });
                    i = targetIdx;
                    onStageCommit?.(snapshot(i));
                    continue;
                }
            }
        }
        i++;
        // 매 stage 자연 완료 직후 resume 파일을 갱신 — 다음 stage 시작 전에 디스크에
        // 떨어지므로 그 사이에 abort/crash가 나도 정확히 그 다음 stage부터 재개된다.
        onStageCommit?.(snapshot(i));
    }
    return { outputs };
}



