"use strict";
// src/orchestrator/story/StoryExecutor.ts
// v1.17.0 — Autonomous Story Loop main driver.
//
// Composes C1 (GitOps + ResponseStructureExtractor) + C2 (PersonaRouter +
// HostInvoker adapters) + C3 (StoryStateTracker + HandoffArtifact) +
// existing v1.14 ContextComposer + ModelTierRouter.
Object.defineProperty(exports, "__esModule", { value: true });
exports.StoryExecutor = void 0;
const GitOps_1 = require("../util/GitOps");
const ResponseStructureExtractor_1 = require("../util/ResponseStructureExtractor");
const PersonaRouter_1 = require("./PersonaRouter");
const HostInvoker_1 = require("./HostInvoker");
const StoryStateTracker_1 = require("./StoryStateTracker");
const HandoffArtifact_1 = require("./HandoffArtifact");
// Hard-coded safety ceiling — env override allows lower but never higher.
const MAX_ITERS_CEILING = 50;
const NO_PROGRESS_LIMIT = 3;
function resolveMaxIters(opt) {
    const envRaw = process.env.OPENLIFE_STORY_MAX_ITERS;
    const env = envRaw ? Math.max(1, parseInt(envRaw, 10)) : null;
    const candidate = opt ?? env ?? 10;
    return Math.min(candidate, MAX_ITERS_CEILING);
}
class StoryExecutor {
    /**
     * Run the autonomous loop on the named story id.
     */
    async run(storyId, opts = {}) {
        const startedAt = Date.now();
        const errors = [];
        const commits = [];
        let iter = 0;
        let acsCheckedThisRun = 0;
        // Load story via the existing registry.
        // eslint-disable-next-line @typescript-eslint/no-var-requires
        const { StoryRegistry } = require('./StoryRegistry');
        const registry = new StoryRegistry();
        await registry.load();
        const storyRaw = registry.get(storyId);
        if (!storyRaw) {
            return {
                ok: false,
                storyId,
                status: 'failed',
                reason: `story_not_found: ${storyId}`,
                iterations: 0,
                acsCheckedThisRun: 0,
                commits: [],
                durationMs: Date.now() - startedAt,
                errors: [`story_not_found: ${storyId}`],
            };
        }
        const story = {
            id: storyRaw.id,
            path: storyRaw.sourcePath,
            title: storyRaw.title,
            status: storyRaw.status,
        };
        // Plan-only short-circuit.
        if (opts.planOnly) {
            const personas = opts.persona ? [opts.persona] : (0, PersonaRouter_1.pickPersona)(storyRaw);
            const totalACs = (0, StoryStateTracker_1.countTotalACs)(story.path);
            const openACs = (0, StoryStateTracker_1.countOpenACs)(story.path);
            process.stdout.write(`# Plan for story ${story.id} — ${story.title}\n\n`);
            process.stdout.write(`Status: ${story.status}\n`);
            process.stdout.write(`ACs: ${totalACs - openACs}/${totalACs} done\n`);
            process.stdout.write(`Personas (sequence): ${personas.join(' → ')}\n`);
            process.stdout.write(`Adapter: ${opts.adapter || process.env.OPENLIFE_HOST_ADAPTER || 'claude (default)'}\n`);
            process.stdout.write(`Max-iters: ${resolveMaxIters(opts.maxIters)}\n`);
            return {
                ok: true,
                storyId,
                status: 'plan-only',
                iterations: 0,
                acsCheckedThisRun: 0,
                commits: [],
                durationMs: Date.now() - startedAt,
            };
        }
        // Branch / working-tree guard.
        const gitOps = new GitOps_1.GitOps(opts.cwd);
        let branch;
        try {
            branch = await gitOps.getCurrentBranch();
            if (!opts.force) {
                const dirty = await gitOps.getDirtyFiles();
                if (dirty.length > 0) {
                    return {
                        ok: false,
                        storyId,
                        status: 'failed',
                        reason: `dirty_working_tree: ${dirty.length} file(s) — pass --force to override`,
                        iterations: 0, acsCheckedThisRun: 0, commits: [], durationMs: Date.now() - startedAt,
                        errors: [`dirty_working_tree: ${dirty.slice(0, 5).join(', ')}`],
                    };
                }
            }
            if (await gitOps.isOnProtectedBranch()) {
                const branchName = GitOps_1.GitOps.branchNameFromStory(story.id, story.title);
                const createR = await gitOps.createBranch(branchName);
                if (!createR.ok) {
                    // If branch already exists, attempt checkout instead — but we don't
                    // expose a checkout helper in GitOps; refuse here to be safe.
                    return {
                        ok: false,
                        storyId,
                        status: 'failed',
                        reason: `branch_create_failed: ${createR.error}`,
                        iterations: 0, acsCheckedThisRun: 0, commits: [], durationMs: Date.now() - startedAt,
                        errors: [createR.error || 'unknown'],
                    };
                }
                branch = branchName;
            }
        }
        catch (err) {
            // Non-git directory → loop can still run, but no commits will happen.
            errors.push(`git_unavailable: ${err instanceof Error ? err.message : String(err)}`);
        }
        // Persona sequence. --stop-after truncates the sequence at that persona
        // (inclusive), so the round-robin never picks anyone past the cutoff.
        let personaSequence = opts.persona
            ? [opts.persona]
            : (0, PersonaRouter_1.pickPersona)(storyRaw);
        if (opts.stopAfter) {
            const cutIdx = personaSequence.indexOf(opts.stopAfter);
            if (cutIdx >= 0)
                personaSequence = personaSequence.slice(0, cutIdx + 1);
        }
        const maxIters = resolveMaxIters(opts.maxIters);
        let handoff = null;
        let lastDoneCount = (0, StoryStateTracker_1.countTotalACs)(story.path) - (0, StoryStateTracker_1.countOpenACs)(story.path);
        let noProgressCount = 0;
        const filesModifiedSoFar = new Set();
        const decisionsSoFar = [];
        while ((0, StoryStateTracker_1.countOpenACs)(story.path) > 0 && iter < maxIters) {
            iter++;
            const persona = personaSequence[(iter - 1) % personaSequence.length];
            const iterStart = Date.now();
            const prompt = this.buildPrompt(story, persona, handoff);
            // Pick the adapter — explicit > env > inferred from ModelTierRouter.
            const adapterName = opts.adapter || process.env.OPENLIFE_HOST_ADAPTER || this.routerPickAdapter(persona);
            const result = await HostInvoker_1.HostInvoker.invoke(prompt, {
                provider: adapterName,
                timeoutMs: opts.iterTimeoutMs ?? 300_000,
                cwd: opts.cwd,
            });
            if (!result.ok) {
                errors.push(`iter${iter}_invoke_failed: ${result.error || 'unknown'}`);
                // Failure costs a no-progress strike.
                noProgressCount++;
                if (noProgressCount >= NO_PROGRESS_LIMIT) {
                    return this.haltWithReason(storyId, branch, iter, acsCheckedThisRun, commits, startedAt, 'invoke_repeatedly_failed', errors);
                }
                continue;
            }
            const parsed = (0, ResponseStructureExtractor_1.parseAll)(result.text);
            // Apply mutations: in adapter A path the host already wrote files;
            // in this v1.17 minimum we trust adapter response + recorded file
            // paths. Future enhancement (v1.17.1): write code blocks directly
            // when adapter didn't do so.
            // Check off ACs.
            const acsBefore = (0, StoryStateTracker_1.countTotalACs)(story.path) - (0, StoryStateTracker_1.countOpenACs)(story.path);
            let acsCheckedThisIter = 0;
            for (const idx of parsed.acsChecked) {
                if ((0, StoryStateTracker_1.checkAC)(story.path, idx)) {
                    acsCheckedThisIter++;
                }
            }
            // Record file list + dev-agent entry.
            if (parsed.filePaths.length) {
                (0, StoryStateTracker_1.appendFileList)(story.path, parsed.filePaths);
                for (const p of parsed.filePaths)
                    filesModifiedSoFar.add(p);
            }
            // Run test command if proposed (skip in dryRunTests when no source changed).
            let testPassed;
            let testCmd;
            if (parsed.testCmd) {
                testCmd = parsed.testCmd;
                if (!opts.dryRunTests) {
                    testPassed = await this.runTestCmd(parsed.testCmd, opts.cwd);
                }
            }
            const summaryShort = result.text.split('\n').slice(0, 5).join('\n').slice(0, 400);
            (0, StoryStateTracker_1.appendDevAgentRecord)(story.path, {
                iter,
                persona,
                summary: summaryShort || `(no narrative output)`,
                duration_ms: Date.now() - iterStart,
                test_cmd: testCmd,
                test_passed: testPassed,
            });
            decisionsSoFar.push(`${persona} iter ${iter}: ${parsed.acsChecked.length} AC(s) marked, ${parsed.filePaths.length} file(s) recorded`);
            // Commit ONLY when AC progress was made.
            const acsAfter = (0, StoryStateTracker_1.countTotalACs)(story.path) - (0, StoryStateTracker_1.countOpenACs)(story.path);
            if (acsAfter > lastDoneCount) {
                noProgressCount = 0;
                const newACs = acsAfter - lastDoneCount;
                acsCheckedThisRun += newACs;
                lastDoneCount = acsAfter;
                if (parsed.filePaths.length > 0) {
                    const stage = await gitOps.stageFiles([...parsed.filePaths, story.path]);
                    if (stage.ok) {
                        const commitMsg = `feat(${story.id}): iter ${iter} — ${persona} (${newACs} AC)`;
                        const commitResult = await gitOps.commit(commitMsg);
                        if (commitResult.ok && commitResult.sha)
                            commits.push(commitResult.sha.slice(0, 7));
                        else
                            errors.push(`iter${iter}_commit_failed: ${commitResult.error}`);
                    }
                }
            }
            else {
                noProgressCount++;
                if (noProgressCount >= NO_PROGRESS_LIMIT) {
                    return this.haltWithReason(storyId, branch, iter, acsCheckedThisRun, commits, startedAt, 'no_progress_after_3_iters', errors);
                }
            }
            // Build handoff for next iteration.
            handoff = (0, HandoffArtifact_1.buildHandoffArtifact)({
                fromAgent: persona,
                toAgent: personaSequence[(iter) % personaSequence.length] || persona,
                story: {
                    id: story.id,
                    path: story.path,
                    status: story.status,
                    title: story.title,
                    openACCount: (0, StoryStateTracker_1.countOpenACs)(story.path),
                    totalACCount: (0, StoryStateTracker_1.countTotalACs)(story.path),
                    branch,
                },
                decisions: decisionsSoFar,
                filesModified: Array.from(filesModifiedSoFar),
                blockers: errors.slice(-3),
                nextAction: (0, StoryStateTracker_1.countOpenACs)(story.path) > 0
                    ? `Continue with ${personaSequence[iter % personaSequence.length] || persona}`
                    : `All ACs done — transition InReview`,
            });
        }
        // All ACs done? → transition InReview then run sentinel QA gate.
        const allDone = (0, StoryStateTracker_1.countOpenACs)(story.path) === 0;
        if (allDone) {
            const inReview = (0, StoryStateTracker_1.transitionStatus)(story.path, story.status, 'InReview', 'dev');
            if (!inReview.ok) {
                errors.push(`transition_to_inreview_failed: ${inReview.error}`);
            }
            else if (!opts.stopAfter || opts.stopAfter === 'sentinel') {
                // QA gate via the canonical test suite. Sentinel = npm run test:all.
                const qaResult = await this.runTestCmd('npm run test:all', opts.cwd);
                if (qaResult) {
                    const done = (0, StoryStateTracker_1.transitionStatus)(story.path, 'InReview', 'Done', 'qa');
                    if (!done.ok)
                        errors.push(`transition_to_done_failed: ${done.error}`);
                }
                else {
                    errors.push('qa_gate_failed: npm run test:all exited non-zero');
                }
            }
        }
        return {
            ok: errors.length === 0 && allDone,
            storyId,
            status: allDone ? 'completed' : (iter >= maxIters ? 'halted' : 'failed'),
            reason: !allDone && iter >= maxIters ? 'max_iters_reached' : undefined,
            iterations: iter,
            acsCheckedThisRun,
            commits,
            durationMs: Date.now() - startedAt,
            branch,
            errors: errors.length > 0 ? errors : undefined,
        };
    }
    /** Construct the per-iteration prompt fed to the host CLI. */
    buildPrompt(story, persona, handoff) {
        const handoffYaml = handoff ? (0, HandoffArtifact_1.serializeHandoff)(handoff) : '(no prior handoff — first iteration)';
        return [
            `You are acting as the ${persona} persona of OpenLife's autonomous story loop.`,
            ``,
            `# Story ${story.id}`,
            `Title: ${story.title}`,
            `Status: ${story.status}`,
            `Path: ${story.path}`,
            ``,
            `Read the story file, identify the next OPEN acceptance criterion, and propose code changes.`,
            ``,
            `# Output contract`,
            `- Reference each modified file path in backticks (e.g. \`src/foo.ts\`).`,
            `- Mark acceptance criteria you completed with \`- [x]\` checkboxes.`,
            `- If a test should run, mention it as \`npm test ...\` in backticks.`,
            ``,
            `# Last handoff`,
            '```yaml',
            handoffYaml,
            '```',
        ].join('\n');
    }
    /** Map persona → ModelTierRouter category, return adapter name. */
    routerPickAdapter(persona) {
        try {
            // eslint-disable-next-line @typescript-eslint/no-var-requires
            const { ModelTierRouter } = require('../router/ModelTierRouter');
            const decision = ModelTierRouter.pick(persona === 'sentinel' ? 'chat.fast' : 'chat.smart');
            const provider = decision?.provider;
            if (provider === 'anthropic')
                return 'claude';
            if (provider === 'gemini-cli' || provider === 'gemini')
                return 'gemini';
            if (provider === 'openai-cli' || provider === 'codex')
                return 'codex';
        }
        catch { /* router unavailable — fall through */ }
        return 'claude';
    }
    /** Spawn a test command via the user's shell. */
    async runTestCmd(cmd, cwd) {
        // Whitelist: only allow npm/yarn/pnpm/bun/pytest/jest/vitest/mocha/cargo/go/deno.
        const allowed = /^(npm|yarn|pnpm|bun|pytest|jest|vitest|mocha|cargo|go|deno)\s+/.test(cmd);
        if (!allowed)
            return false;
        return new Promise((resolve) => {
            // eslint-disable-next-line @typescript-eslint/no-var-requires
            const cp = require('child_process');
            const child = cp.spawn(cmd, { shell: true, cwd: cwd || process.cwd(), stdio: 'ignore' });
            child.on('exit', (code) => resolve(code === 0));
            child.on('error', () => resolve(false));
        });
    }
    haltWithReason(storyId, branch, iter, acsCheckedThisRun, commits, startedAt, reason, errors) {
        return {
            ok: false,
            storyId,
            status: 'halted',
            reason,
            iterations: iter,
            acsCheckedThisRun,
            commits,
            durationMs: Date.now() - startedAt,
            branch,
            errors,
        };
    }
}
exports.StoryExecutor = StoryExecutor;
