#!/usr/bin/env node
/**
 * pipeline-runner — Sequential phase orchestrator.
 *
 * Phases (in order):
 *   1. pickle       → mux-runner.js        (build/implement)
 *   2. citadel      → in-process audit     (pipeline risk gate)
 *   3. anatomy-park → microverse-runner.js  (deep subsystem review)
 *   4. szechuan-sauce → microverse-runner.js (principle-driven deslopping)
 *
 * Each phase runs as a child process. Between phases the runner resets
 * state.json, creates required config files, and spawns the next runner.
 *
 * Usage: node pipeline-runner.js <session-dir>
 * Expects: pipeline.json in session-dir with phase configuration.
 */
import * as fs from 'fs';
import * as path from 'path';
import { execFileSync, spawn, spawnSync } from 'child_process';
import { BACKENDS, MICROVERSE_FATAL_REASONS, PipelineRunnerExitCode, isMicroverseFailureExit } from '../types/index.js';
import { StateManager, safeDeactivate, finalizeTerminalState, recordExitReason, clearExitReason, assertSchemaVersionDeployParity, SchemaVersionDeployDriftError } from '../services/state-manager.js';
import { backendEnvOverrides, isBackend, resolveBackend, buildWorkerInvocation } from '../services/backend-spawn.js';
import { getExtensionRoot, Style, formatTime, printMinimalPanel, safeErrorMessage, ensureMonitorWindow, displayMacNotification, writeStateFile, isoCompactStamp, collectTickets, respawnMonitorWindowForMode, classifyDiffVisualDominance, VISUAL_DOMINANCE_THRESHOLD, } from '../services/pickle-utils.js';
import { isGitIgnoredPath, listWorkingTreeDirtyPaths } from '../services/git-utils.js';
import { logActivity } from '../services/activity-logger.js';
import { emitBundleLinearComments } from '../services/linear-integration.js';
import { readRecoverableJsonObject } from '../services/microverse-state.js';
import { runAcPhaseGate } from '../services/ac-phase-gate.js';
import { resolveScope, refreshScope, filterBySubsystem, ScopeError, } from '../services/scope-resolver.js';
import { runCitadelAudit } from '../services/citadel/audit-runner.js';
import { citadelFindingsToGateResult } from '../services/citadel/citadel-findings-to-gate-result.js';
import { spawnGateRemediatorMain } from './spawn-gate-remediator.js';
import { loadFinalizeGateSettings, resolveFinalizeSettingsRoot } from './finalize-gate.js';
const sm = new StateManager();
const DEFAULT_IGNORE_DIRTY_PATHS = ['prds', 'docs'];
const CODEX_REQUIRED_BACKEND = 'codex-required';
const WATCHER_TERMINATED_BANNER = '◤ FEED TERMINATED ◢';
const DIRTY_ALLOWED_FILE_REL = path.join('extension', '.pipeline-runner-dirty-allowed.json');
// R-PIAP-B2: within this many points of the threshold (0.60), err toward design-safe.
const NEAR_THRESHOLD_BAND = 0.05;
// ---------------------------------------------------------------------------
// Config Parsing
// ---------------------------------------------------------------------------
/** Parse and validate pipeline.json with safe defaults for all integer limit fields. */
function parsePositiveInteger(value, fallback) {
    const parsed = Number(value);
    return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function parseHeartbeatInteger(value, fallback) {
    const parsed = Number(value);
    if (!Number.isFinite(parsed))
        return fallback;
    if (!Number.isInteger(parsed))
        return fallback;
    return parsed > 0 ? parsed : 0;
}
export function parsePipelineConfig(raw) {
    const rawBackend = raw.backend;
    const backend = typeof rawBackend === 'string' && BACKENDS.includes(rawBackend)
        ? rawBackend
        : undefined;
    const rawIgnore = raw.ignore_dirty_paths;
    const ignore_dirty_paths = Array.isArray(rawIgnore) && rawIgnore.every((p) => typeof p === 'string')
        ? rawIgnore
        : [...DEFAULT_IGNORE_DIRTY_PATHS];
    return {
        phases: normalizePipelinePhases(raw.phases),
        target: raw.target || '',
        szechuan_domain: raw.szechuan_domain,
        szechuan_focus: raw.szechuan_focus,
        child_mux_runner_heartbeat_ms: parseHeartbeatInteger(raw.child_mux_runner_heartbeat_ms, 60_000),
        child_mux_runner_stall_seconds: parsePositiveInteger(raw.child_mux_runner_stall_seconds, 1800),
        anatomy_stall_limit: parsePositiveInteger(raw.anatomy_stall_limit, 3),
        szechuan_stall_limit: parsePositiveInteger(raw.szechuan_stall_limit, 5),
        anatomy_max_iterations: parsePositiveInteger(raw.anatomy_max_iterations, 100),
        szechuan_max_iterations: parsePositiveInteger(raw.szechuan_max_iterations, 50),
        citadel_strict: raw.citadel_strict === true || raw.strict === true,
        backend,
        ignore_dirty_paths,
    };
}
function normalizePipelinePhases(rawPhases) {
    if (!Array.isArray(rawPhases))
        return [];
    const phases = [...rawPhases];
    if (phases.includes('citadel'))
        return phases;
    const pickleIndex = phases.indexOf('pickle');
    const anatomyIndex = phases.indexOf('anatomy-park');
    if (pickleIndex !== -1 && anatomyIndex !== -1 && pickleIndex < anatomyIndex) {
        phases.splice(pickleIndex + 1, 0, 'citadel');
    }
    return phases;
}
/**
 * Resolve the effective backend and the source of that value.
 *
 * Precedence (resume must honor user's new --backend):
 *   state.backend (setup.js --backend, authoritative on resume)
 *     → pipeline.json.backend (original launch flag)
 *       → PICKLE_BACKEND env
 *         → 'claude'
 *
 * setup.js writes state.backend whenever --backend is passed, including on
 * resume. pipeline.json is frozen at first launch, so letting it win would
 * pin the old backend forever even after the user explicitly switched.
 */
export function resolveBackendWithSource(state, pipelineBackend, envBackend) {
    const stateBackend = state ? state.backend : undefined;
    if (isBackend(stateBackend))
        return { backend: stateBackend, source: 'state.json' };
    if (pipelineBackend)
        return { backend: pipelineBackend, source: 'pipeline.json' };
    if (isBackend(envBackend))
        return { backend: envBackend, source: 'env' };
    return { backend: 'claude', source: 'default' };
}
function readYamlLikeField(body, field) {
    const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const match = body.match(new RegExp(`^${escaped}:\\s*(.+?)\\s*$`, 'm'));
    return match ? match[1].trim().replace(/^["']|["']$/g, '') : undefined;
}
function extractLeadingYamlFrontmatter(content) {
    const openLen = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : 0;
    if (openLen === 0)
        return undefined;
    const closeIdx = content.indexOf('\n---', openLen);
    return closeIdx === -1 ? undefined : content.slice(openLen, closeIdx);
}
function extractBundleFrontmatterBlock(content) {
    const marker = content.match(/^frontmatter:\s*[\r\n]+```[^\r\n]*[\r\n]/m);
    if (!marker || marker.index == null)
        return undefined;
    const bodyStart = marker.index + marker[0].length;
    const closeIdx = content.indexOf('\n```', bodyStart);
    return closeIdx === -1 ? undefined : content.slice(bodyStart, closeIdx);
}
export function readBundlePrdBackend(content) {
    const bundleBlock = extractBundleFrontmatterBlock(content);
    const bundleBackend = bundleBlock ? readYamlLikeField(bundleBlock, 'backend') : undefined;
    if (bundleBackend)
        return bundleBackend;
    const yamlBlock = extractLeadingYamlFrontmatter(content);
    return yamlBlock ? readYamlLikeField(yamlBlock, 'backend') : undefined;
}
export function assertCodexRequiredBackend(sessionDir, backend, source) {
    const prdPath = path.join(sessionDir, 'prd.md');
    if (!fs.existsSync(prdPath))
        return;
    const requiredBackend = readBundlePrdBackend(fs.readFileSync(prdPath, 'utf-8'));
    if (requiredBackend !== CODEX_REQUIRED_BACKEND || backend === 'codex')
        return;
    throw new Error(`Bundle PRD declares backend: ${CODEX_REQUIRED_BACKEND}, but pipeline-runner resolved backend ` +
        `${backend} from ${source}. Restart with /pickle-pipeline --backend codex.`);
}
// ---------------------------------------------------------------------------
// Subsystem Discovery (mirrors anatomy-park.md Step 3)
// ---------------------------------------------------------------------------
const SOURCE_EXTS = new Set(['.ts', '.js', '.py', '.go', '.rs', '.java', '.tsx', '.jsx']);
const EXCLUDED_DIRS = new Set([
    'node_modules', 'dist', 'build', '.next', 'coverage',
    '__pycache__', '.git', '.turbo', '.vercel',
]);
const TEST_PATTERNS = ['.test.', '.spec.', '__test__', '__spec__'];
export function isTestFile(name) {
    const lower = name.toLowerCase();
    return TEST_PATTERNS.some(p => lower.includes(p));
}
export function discoverSubsystems(target) {
    let entries;
    try {
        entries = fs.readdirSync(target, { withFileTypes: true });
    }
    catch {
        return [];
    }
    const subsystems = [];
    for (const entry of entries) {
        if (!entry.isDirectory() || EXCLUDED_DIRS.has(entry.name) || entry.name.startsWith('.'))
            continue;
        const fullPath = path.join(target, entry.name);
        let sourceCount = 0;
        let testCount = 0;
        const visited = new Set();
        const walk = (p) => {
            // Resolve real path to detect symlink loops
            let realP;
            try {
                realP = fs.realpathSync(p);
            }
            catch {
                return;
            }
            if (visited.has(realP))
                return;
            visited.add(realP);
            let children;
            try {
                children = fs.readdirSync(p, { withFileTypes: true });
            }
            catch {
                return;
            }
            for (const child of children) {
                if (child.isDirectory() && !EXCLUDED_DIRS.has(child.name)) {
                    walk(path.join(p, child.name));
                }
                else if (child.isFile() && SOURCE_EXTS.has(path.extname(child.name))) {
                    sourceCount++;
                    if (isTestFile(child.name))
                        testCount++;
                }
            }
        };
        walk(fullPath);
        // Exclude test-only directories (>80% test files) per anatomy-park spec
        if (sourceCount >= 3 && testCount / sourceCount <= 0.8) {
            subsystems.push({ name: entry.name, fileCount: sourceCount });
        }
    }
    return subsystems.sort((a, b) => a.name.localeCompare(b.name));
}
// ---------------------------------------------------------------------------
// Pre-flight: Clean Working Tree
// ---------------------------------------------------------------------------
/**
 * Pipelines run long and span multiple phases. Starting with a dirty tree
 * masks which phase introduced which change — downstream microverse phases
 * would otherwise auto-commit the user's pre-existing work under a generic
 * message. Fail fast so the user makes that call deliberately.
 *
 * `ignoreDirtyPaths` excludes those path prefixes (typically docs/prds) from
 * the dirty check — frequent doc edits during a long-running epic shouldn't
 * block resume. Defaults to ['prds', 'docs'] when omitted.
 */
function loadAllowedDirtyPaths(workingDir) {
    const filePath = path.join(workingDir, DIRTY_ALLOWED_FILE_REL);
    if (!fs.existsSync(filePath))
        return new Set();
    try {
        const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
        const rawPaths = Array.isArray(parsed)
            ? parsed
            : (typeof parsed === 'object' && parsed !== null && Array.isArray(parsed.paths)
                ? parsed.paths
                : []);
        return new Set(rawPaths
            .filter((value) => typeof value === 'string')
            .map((value) => value.replace(/^\.?\/+/, '').replace(/\/+$/, ''))
            .filter((value) => value.length > 0));
    }
    catch (error) {
        throw new Error(`Invalid dirty allowlist at ${filePath}: ${safeErrorMessage(error)}`);
    }
}
// Match the path SEGMENT (e.g. 'docs', 'prds') at ANY depth, not just as a root prefix.
// git pathspec :!docs/** only excludes root-level docs/; this catches packages/api/docs/prd/foo.md.
function isDirtyPathIgnoredBySegment(filePath, ignoreSegments) {
    const parts = filePath.replace(/\\/g, '/').split('/');
    return ignoreSegments.some((seg) => parts.includes(seg));
}
function allowedDirtyPathsForLaunch(workingDir, ignoreDirtyPaths) {
    const ignore = ignoreDirtyPaths ?? [...DEFAULT_IGNORE_DIRTY_PATHS];
    const allowlist = loadAllowedDirtyPaths(workingDir);
    const dirtyPaths = listWorkingTreeDirtyPaths(workingDir, ignore);
    return dirtyPaths.filter((filePath) => !isDirtyPathIgnoredBySegment(filePath, ignore) &&
        !allowlist.has(filePath) &&
        !isGitIgnoredPath(workingDir, filePath));
}
export function assertCleanWorkingTree(workingDir, ignoreDirtyPaths) {
    const ignore = ignoreDirtyPaths ?? [...DEFAULT_IGNORE_DIRTY_PATHS];
    const blockingPaths = allowedDirtyPathsForLaunch(workingDir, ignore);
    if (blockingPaths.length === 0)
        return;
    const suffix = ignore.length > 0 ? ` (ignored prefixes: ${ignore.join(', ')})` : '';
    throw new Error(`Working tree at ${workingDir} is dirty${suffix}. Dirty files:\n${blockingPaths.join('\n')}\nCommit, stash, or discard changes before starting the pipeline.`);
}
/**
 * At a manager-boundary relaunch (state.manager_relaunch_count > 0), the
 * in-flight ticket's interrupted worker may have left uncommitted partial
 * changes. This resets ONLY the blocking dirty paths (those assertCleanWorkingTree
 * would reject) — exempted paths (prds/, docs/, allowlist) are never touched.
 *
 * Since the pipeline requires a clean tree at first launch, all blocking dirty
 * files at a relaunch boundary MUST be from the interrupted worker, so resetting
 * them is safe and path-scoped to the in-flight ticket's work.
 */
export function resetInterruptedTicketWorkForRelaunch(workingDir, ignoreDirtyPaths, log) {
    const blockingPaths = allowedDirtyPathsForLaunch(workingDir, ignoreDirtyPaths);
    if (blockingPaths.length === 0)
        return;
    log(`[relaunch-reset] Resetting ${blockingPaths.length} dirty blocking file(s) from interrupted in-flight ticket`);
    // Unstage any staged changes so the post-reset status parse is accurate.
    spawnSync('git', ['reset', 'HEAD', '--', ...blockingPaths], {
        cwd: workingDir,
        encoding: 'utf-8',
        timeout: 30_000,
        stdio: ['ignore', 'pipe', 'pipe'],
    });
    // Re-enumerate post-reset status to split tracked vs untracked.
    const statusResult = spawnSync('git', ['status', '--porcelain', '-z', '--', ...blockingPaths], {
        cwd: workingDir,
        encoding: 'utf-8',
        timeout: 30_000,
        stdio: ['ignore', 'pipe', 'pipe'],
    });
    const trackedPaths = [];
    const untrackedPaths = [];
    const tokens = (statusResult.stdout || '').split('\0').filter((t) => t.length > 0);
    for (let i = 0; i < tokens.length; i++) {
        const token = tokens[i];
        if (token.length < 4)
            continue;
        const xy = token.slice(0, 2);
        const filePath = token.slice(3);
        if (xy[0] === 'R' || xy[0] === 'C' || xy[1] === 'R' || xy[1] === 'C')
            i++;
        if (xy === '??') {
            untrackedPaths.push(filePath);
        }
        else {
            trackedPaths.push(filePath);
        }
    }
    if (trackedPaths.length > 0) {
        spawnSync('git', ['checkout', '--', ...trackedPaths], {
            cwd: workingDir,
            encoding: 'utf-8',
            timeout: 30_000,
            stdio: ['ignore', 'pipe', 'pipe'],
        });
    }
    for (const relPath of untrackedPaths) {
        try {
            fs.unlinkSync(path.join(workingDir, relPath));
        }
        catch { /* best effort */ }
    }
    log(`[relaunch-reset] Done: ${trackedPaths.length} tracked restored, ${untrackedPaths.length} untracked removed`);
}
// ---------------------------------------------------------------------------
// R-PIAP-B2: Design-safe detection helpers
// ---------------------------------------------------------------------------
/**
 * Parse unified `git diff` output into a `DiffVisualStat` for
 * `classifyDiffVisualDominance`. Each file's added lines (those prefixed with
 * `+` but not `+++`) are collected under the file path from the `+++ b/…` header.
 */
export function parseDiffForVisualStat(diffOutput) {
    const stat = [];
    let currentPath = null;
    let currentLines = [];
    for (const line of diffOutput.split('\n')) {
        if (line.startsWith('+++ b/')) {
            if (currentPath !== null)
                stat.push({ path: currentPath, changedLines: currentLines });
            currentPath = line.slice('+++ b/'.length);
            currentLines = [];
        }
        else if (currentPath !== null && line.startsWith('+') && !line.startsWith('+++')) {
            currentLines.push(line.slice(1));
        }
    }
    if (currentPath !== null)
        stat.push({ path: currentPath, changedLines: currentLines });
    return stat;
}
const GIT_DIFF_DESIGN_SAFE_TIMEOUT_MS = 30_000;
/**
 * R-PIAP-B2: resolve whether the current branch is design-safe.
 *
 * Precedence:
 *   1. `override` (from `--design-safe` / `--no-design-safe` CLI flags) — wins unconditionally.
 *   2. Auto-detect: run `git diff <startCommit>..HEAD`, parse, classify.
 *      Near-threshold policy: effective threshold = `VISUAL_DOMINANCE_THRESHOLD - NEAR_THRESHOLD_BAND`
 *      so any visual ratio within the band of 0.60 errs toward design-safe.
 *   3. If diff cannot be obtained → false (logic-primary assumed).
 */
export function resolveDesignSafe(startCommit, repoRoot, override) {
    if (override !== undefined)
        return override;
    if (!startCommit || typeof startCommit !== 'string')
        return false;
    let diffOutput;
    try {
        diffOutput = execFileSync('git', ['diff', `${startCommit}..HEAD`], {
            cwd: repoRoot,
            encoding: 'utf-8',
            timeout: GIT_DIFF_DESIGN_SAFE_TIMEOUT_MS,
        });
    }
    catch {
        return false;
    }
    const diffStat = parseDiffForVisualStat(diffOutput);
    // Lower the threshold by the near-band so ratios within [threshold-band, threshold]
    // also classify as UI-primary (err toward design-safe).
    return classifyDiffVisualDominance(diffStat, VISUAL_DOMINANCE_THRESHOLD - NEAR_THRESHOLD_BAND);
}
/**
 * R-PIAP-B2: After `init-microverse.js` creates `microverse.json`, read it back
 * and inject `design_safe: boolean` so cleanup-phase workers can read the flag.
 * Best-effort: a read/write failure is logged but does not block the phase.
 */
function injectDesignSafeIntoMicroverse(sessionDir, designSafe, log) {
    const microversePath = path.join(sessionDir, 'microverse.json');
    try {
        const existing = readRecoverableJsonObject(microversePath);
        if (!existing) {
            log('design_safe inject: microverse.json not found or empty');
            return;
        }
        existing.design_safe = designSafe ?? false;
        writeStateFile(microversePath, existing);
        log(`design_safe=${String(existing.design_safe)} written to microverse.json`);
    }
    catch (err) {
        log(`design_safe inject failed (non-fatal): ${safeErrorMessage(err)}`);
    }
}
// ---------------------------------------------------------------------------
// Bundle Pre-flight
// ---------------------------------------------------------------------------
export class BundlePreflightError extends Error {
    failedAssertion;
    constructor(failedAssertion, message) {
        super(message);
        this.failedAssertion = failedAssertion;
        this.name = 'BundlePreflightError';
    }
}
const R_CODE_RE = /R-[A-Z]+-\d+/;
function resolveComposePath(composePath, workingDir) {
    return path.isAbsolute(composePath) ? composePath : path.join(workingDir, composePath);
}
function emitPreflightFailed(sessionRoot, failedAssertion, reason) {
    try {
        logActivity({
            event: 'bundle_preflight_failed',
            source: 'pickle',
            session: path.basename(sessionRoot),
            gate_payload: { failed_assertion: failedAssertion, reason },
        });
    }
    catch { /* best-effort telemetry */ }
}
function assertComposesPathsResolve(composes, workingDir, sessionRoot) {
    for (const composePath of composes) {
        if (!fs.existsSync(resolveComposePath(composePath, workingDir))) {
            const reason = `composes path not found: ${composePath}`;
            emitPreflightFailed(sessionRoot, 'composes_paths_resolve', reason);
            throw new BundlePreflightError('composes_paths_resolve', reason);
        }
    }
}
function assertComposedPrdsHaveRCodes(composes, workingDir, sessionRoot) {
    for (const composePath of composes) {
        let content = '';
        try {
            content = fs.readFileSync(resolveComposePath(composePath, workingDir), 'utf-8');
        }
        catch { /* empty */ }
        if (!R_CODE_RE.test(content)) {
            const reason = `composed PRD has no R-<KEY>-<N> codes: ${composePath}`;
            emitPreflightFailed(sessionRoot, 'composed_prds_have_R_codes', reason);
            throw new BundlePreflightError('composed_prds_have_R_codes', reason);
        }
    }
}
/**
 * Validate the bundle's composes: chain and refinement manifest before any
 * downstream phase runs. Checks three preconditions in order; the first
 * failure emits bundle_preflight_failed and throws BundlePreflightError.
 *
 * Preconditions (in order):
 *   1. composes_paths_resolve  — all 3 composes: paths resolve to readable files
 *   2. composed_prds_have_R_codes — each composed PRD declares at least one R-<KEY>-<N>
 *   3. manifest_R_code_count_ge_26 — refinement_manifest.json.tickets.length >= 26
 */
export function runBundlePreflight(sessionRoot) {
    const pipelinePath = path.join(sessionRoot, 'pipeline.json');
    const statePath = path.join(sessionRoot, 'state.json');
    const manifestPath = path.join(sessionRoot, 'refinement_manifest.json');
    let workingDir = sessionRoot;
    try {
        const state = sm.read(statePath);
        if (typeof state.working_dir === 'string' && state.working_dir.length > 0) {
            workingDir = state.working_dir;
        }
    }
    catch { /* fall back to sessionRoot */ }
    let composes = [];
    try {
        const pipeline = readRecoverableJsonObject(pipelinePath);
        if (pipeline && Array.isArray(pipeline.composes)) {
            composes = pipeline.composes.filter((p) => typeof p === 'string');
        }
    }
    catch { /* composes stays empty */ }
    assertComposesPathsResolve(composes, workingDir, sessionRoot);
    assertComposedPrdsHaveRCodes(composes, workingDir, sessionRoot);
    let ticketCount = 0;
    try {
        const manifest = readRecoverableJsonObject(manifestPath);
        if (manifest && Array.isArray(manifest.tickets))
            ticketCount = manifest.tickets.length;
    }
    catch { /* ticketCount stays 0 */ }
    if (ticketCount < 26) {
        const reason = `refinement manifest has ${ticketCount} tickets, expected >= 26`;
        emitPreflightFailed(sessionRoot, 'manifest_R_code_count_ge_26', reason);
        throw new BundlePreflightError('manifest_R_code_count_ge_26', reason);
    }
}
// ---------------------------------------------------------------------------
// Child Process Management
// ---------------------------------------------------------------------------
let activeChild = null;
let spawnRunnerOverride = null;
let _closerReleaseActionsForTests = null;
let phaseRunnerContext = null;
function isMuxRunnerInvocation(args) {
    return path.basename(args[0] ?? '') === 'mux-runner.js';
}
function isProcessAlive(pid) {
    try {
        process.kill(pid, 0);
        return true;
    }
    catch {
        return false;
    }
}
export function armChildMuxRunnerHeartbeat(opts, deps = {}) {
    if (opts.heartbeatMs <= 0) {
        return { stop: () => { } };
    }
    const statePath = path.join(opts.sessionDir, 'state.json');
    const statSyncFn = deps.statSync ?? fs.statSync;
    const setIntervalFn = deps.setInterval ?? global.setInterval;
    const clearIntervalFn = deps.clearInterval ?? global.clearInterval;
    const nowFn = deps.now ?? Date.now;
    const isAliveFn = deps.isProcessAlive ?? isProcessAlive;
    const emitActivity = deps.emitActivity ?? logActivity;
    const childPid = opts.child.pid ?? null;
    if (typeof childPid !== 'number' || childPid <= 0) {
        return { stop: () => { } };
    }
    let stopped = false;
    const stop = () => {
        if (stopped)
            return;
        stopped = true;
        clearIntervalFn(timer);
    };
    const tick = () => {
        if (stopped || opts.child.killed)
            return;
        let stat;
        try {
            stat = statSyncFn(statePath);
        }
        catch {
            return;
        }
        const elapsedSeconds = Math.floor((nowFn() - stat.mtimeMs) / 1000);
        if (elapsedSeconds <= opts.stallSeconds)
            return;
        if (!isAliveFn(childPid)) {
            stop();
            return;
        }
        try {
            emitActivity({
                event: 'child_mux_runner_wedge_detected',
                source: 'pickle',
                session: path.basename(opts.sessionDir),
                gate_payload: {
                    child_pid: childPid,
                    last_state_mtime_iso: stat.mtime.toISOString(),
                    elapsed_seconds: elapsedSeconds,
                },
            });
        }
        catch {
            // best-effort telemetry only
        }
        try {
            opts.child.kill('SIGTERM');
        }
        catch {
            // best-effort termination
        }
        stop();
    };
    const timer = setIntervalFn(tick, opts.heartbeatMs);
    return { stop };
}
function spawnRunner(cmd, args, env) {
    return new Promise((resolve, reject) => {
        let settled = false;
        let stdout = '';
        let stderr = '';
        const child = spawn(cmd, args, {
            stdio: ['ignore', 'pipe', 'pipe'],
            env: env ?? process.env,
        });
        activeChild = child;
        const heartbeat = (phaseRunnerContext &&
            isMuxRunnerInvocation(args)) ? armChildMuxRunnerHeartbeat({
            child,
            sessionDir: phaseRunnerContext.sessionDir,
            heartbeatMs: phaseRunnerContext.childMuxRunnerHeartbeatMs,
            stallSeconds: phaseRunnerContext.childMuxRunnerStallSeconds,
        }) : null;
        child.stdout?.on('data', (chunk) => {
            const text = chunk.toString();
            stdout += text;
            process.stdout.write(text);
        });
        child.stderr?.on('data', (chunk) => {
            const text = chunk.toString();
            stderr += text;
            process.stderr.write(text);
        });
        child.on('exit', (code) => {
            if (!settled) {
                settled = true;
                heartbeat?.stop();
                activeChild = null;
                resolve({ exitCode: code ?? 1, stdout, stderr });
            }
        });
        child.on('error', (err) => {
            if (!settled) {
                settled = true;
                heartbeat?.stop();
                activeChild = null;
                reject(err);
            }
        });
    });
}
async function runSpawnRunner(cmd, args, env) {
    const result = await (spawnRunnerOverride ?? spawnRunner)(cmd, args, env);
    if (typeof result === 'number') {
        return { exitCode: result, stdout: '', stderr: '' };
    }
    return result;
}
export function __setSpawnRunnerForTests(fn) {
    spawnRunnerOverride = fn;
}
export function __setCloserReleaseActionsForTests(actions) {
    _closerReleaseActionsForTests = actions;
}
export function writePipelineStatus(sessionDir, status, details = {}) {
    const payload = {
        status,
        current_phase: details.current_phase ?? null,
        completed_phases: details.completed_phases ?? 0,
        skipped_phases: details.skipped_phases ?? 0,
        total_phases: details.total_phases ?? 0,
        updated_at: new Date().toISOString(),
    };
    // R-PSSS-3: carry per-phase skip dispositions only when non-empty so older
    // status consumers and clean runs see no spurious key.
    if (details.phase_skips && Object.keys(details.phase_skips).length > 0) {
        payload.phase_skips = details.phase_skips;
    }
    const statusPath = path.join(sessionDir, 'pipeline-status.json');
    const tmpPath = `${statusPath}.tmp.${process.pid}`;
    fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2));
    fs.renameSync(tmpPath, statusPath);
}
export function writeWatcherLivenessArtifact(sessionDir, phase) {
    const bundleDir = path.join(sessionDir, 'bundle');
    fs.mkdirSync(bundleDir, { recursive: true });
    const checkedFiles = ['tmux-runner.log', 'pipeline-runner.log']
        .map((file) => path.join(sessionDir, file))
        .filter((file) => fs.existsSync(file));
    const matches = checkedFiles.filter((file) => {
        try {
            return fs.readFileSync(file, 'utf-8').includes(WATCHER_TERMINATED_BANNER);
        }
        catch {
            return false;
        }
    });
    writeStateFile(path.join(bundleDir, 'ac-dr-05.json'), {
        ac_id: 'AC-DR-05',
        phase,
        pass: matches.length === 0,
        checked_at: new Date().toISOString(),
        checker: 'pipeline-runner',
        checker_version: '1',
        evidence: {
            checked_files: checkedFiles.map((file) => path.relative(sessionDir, file)),
            forbidden_literal_present: matches.length > 0,
            forbidden_literal: WATCHER_TERMINATED_BANNER,
        },
        failure_reason: matches.length > 0 ? 'watcher-terminated-banner-present' : null,
        remediation_hint: matches.length > 0
            ? 'Inspect tmux-runner.log and pipeline-runner.log for premature watcher shutdown before bundling.'
            : null,
        checked_files: checkedFiles.map((file) => path.relative(sessionDir, file)),
        forbidden_literal_present: matches.length > 0,
        forbidden_literal: WATCHER_TERMINATED_BANNER,
    });
}
// ---------------------------------------------------------------------------
// State Transitions
// ---------------------------------------------------------------------------
export function resetStateForPhase(statePath, template, maxIterations) {
    clearExitReason(statePath, { resetStep: true });
    sm.update(statePath, (s) => {
        // Set inactive — the runner takes ownership and activates on start.
        s.active = false;
        s.iteration = 0;
        s.current_ticket = null;
        // R-CNAR-8: nulling current_ticket REQUIRES clearing the 5 cache fields.
        delete s.current_ticket_tier;
        delete s.current_ticket_budget;
        delete s.current_ticket_max_iterations;
        delete s.current_ticket_worker_timeout_seconds;
        delete s.current_ticket_budget_start_iteration;
        s.start_time_epoch = Math.floor(Date.now() / 1000);
        s.max_iterations = maxIterations;
        s.command_template = template;
        s.chain_meeseeks = false;
        s.tmux_mode = true;
    });
}
export function claimPipelineRunnerActive(statePath) {
    return sm.update(statePath, (s) => {
        s.active = true;
        s.pid = process.pid;
        if (s.exit_reason === 'failed' || s.exit_reason === 'completed'
            || s.exit_reason === 'manager_handoff_pending' || s.exit_reason === 'closer_handoff_terminal') {
            s.exit_reason = null;
        }
    });
}
/**
 * AC-LPB-05: when pipeline-runner re-attaches to a session that already has
 * prior progress (iteration > 0 OR phases_entered non-empty), this is a
 * reconstruction — reset start_time_epoch so wall-clock cap-checks measure
 * from the resume time, not the original launch. Emits a telemetry event
 * (`session_reconstructed_epoch_reset`) for monitor/standup consumers. Fresh
 * launches (iteration === 0 and no phases entered) keep the setup-supplied
 * epoch and this helper is a no-op.
 *
 * Mutates the passed `state` in place AND writes through StateManager so
 * subsequent reads see the new epoch. Returns the {originalEpoch, newEpoch}
 * pair when a reset happened, otherwise null.
 */
export function applyEpochResetOnReconstruction(state, statePath, sessionDir) {
    const isReconstruction = (typeof state.iteration === 'number' && state.iteration > 0) ||
        (Array.isArray(state.phases_entered) && state.phases_entered.length > 0);
    if (!isReconstruction)
        return null;
    const originalEpoch = typeof state.start_time_epoch === 'number' ? state.start_time_epoch : null;
    const newEpoch = Math.floor(Date.now() / 1000);
    sm.update(statePath, (s) => { s.start_time_epoch = newEpoch; });
    state.start_time_epoch = newEpoch;
    try {
        logActivity({
            event: 'session_reconstructed_epoch_reset',
            source: 'pickle',
            session: path.basename(sessionDir),
            original_epoch: originalEpoch ?? undefined,
            new_epoch: newEpoch,
        });
    }
    catch { /* telemetry best-effort */ }
    return { originalEpoch, newEpoch };
}
function archiveFile(sessionDir, filename, phase) {
    const src = path.join(sessionDir, filename);
    if (!fs.existsSync(src))
        return;
    try {
        fs.copyFileSync(src, path.join(sessionDir, `${path.basename(filename, path.extname(filename))}-${phase}${path.extname(filename)}`));
    }
    catch { /* best effort */ }
}
/** Archive and remove inter-phase artifacts that could confuse the next phase. */
// TASK_NOTES.md lifecycle: intra-phase only by design. Pipeline-mode timeout stubs from one phase
// are archived (to TASK_NOTES-<phase>.md) and removed from canonical path before the next phase's
// setup. This prevents stale notes from contaminating downstream phases. See PRD FR-B16.
// Do NOT add cross-phase propagation without updating the PRD.
export function cleanPhaseArtifacts(sessionDir, phase) {
    // TASK_NOTES.md — stale notes from previous phase
    const notesPath = path.join(sessionDir, 'TASK_NOTES.md');
    if (fs.existsSync(notesPath)) {
        archiveFile(sessionDir, 'TASK_NOTES.md', phase);
        try {
            fs.unlinkSync(notesPath);
        }
        catch { /* best effort */ }
    }
    // gap_analysis.md — stale findings could cause szechuan-sauce to skip Phase 0
    const gapPath = path.join(sessionDir, 'gap_analysis.md');
    if (fs.existsSync(gapPath)) {
        archiveFile(sessionDir, 'gap_analysis.md', phase);
        try {
            fs.unlinkSync(gapPath);
        }
        catch { /* best effort */ }
    }
    // handoff.txt — stale handoff from previous runner
    const handoffPath = path.join(sessionDir, 'handoff.txt');
    if (fs.existsSync(handoffPath)) {
        try {
            fs.unlinkSync(handoffPath);
        }
        catch { /* best effort */ }
    }
}
export function readCitadelReport(sessionDir) {
    const reportPath = path.join(sessionDir, 'citadel_report.json');
    if (!fs.existsSync(reportPath))
        return null;
    try {
        const parsed = JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
        if (!isCitadelReport(parsed))
            return null;
        return parsed;
    }
    catch {
        return null;
    }
}
function isCitadelReport(value) {
    if (!value || typeof value !== 'object')
        return false;
    const record = value;
    return Array.isArray(record.findings)
        && typeof record.summary === 'object'
        && record.summary !== null
        && (typeof record.exitCode === 'number' || typeof record.exit_code === 'number');
}
function findingText(finding) {
    const citation = typeof finding.file === 'string'
        ? `${finding.file}:${typeof finding.line === 'number' ? finding.line : 0}`
        : undefined;
    const message = typeof finding.message === 'string' ? finding.message : finding.id;
    return citation
        ? `- [${finding.severity}] ${finding.id} ${citation} - ${message}`
        : `- [${finding.severity}] ${finding.id} - ${message}`;
}
function isUnguardedTrapDoorFinding(finding) {
    const id = finding.id.toLowerCase();
    const message = typeof finding.message === 'string' ? finding.message.toLowerCase() : '';
    return id.includes('trap-door') || message.includes('unguarded trap door');
}
function isDivergenceFinding(finding) {
    const id = finding.id.toLowerCase();
    const source = typeof finding.source === 'string' ? finding.source.toLowerCase() : '';
    return id.includes('divergence') || source.includes('divergence');
}
function buildCitadelAnatomyContext(report) {
    if (!report)
        return [];
    const trapDoors = report.findings.filter(isUnguardedTrapDoorFinding);
    return [
        '',
        '## Citadel Report',
        `Read: ${path.basename('citadel_report.json')}`,
        trapDoors.length > 0
            ? 'Prioritize these unguarded trap-door findings during catalog review:'
            : 'No unguarded trap-door findings were reported by Citadel.',
        ...trapDoors.map(findingText),
    ];
}
function buildCitadelSzechuanContext(report) {
    if (!report)
        return [];
    const divergences = report.findings.filter(isDivergenceFinding);
    return [
        '',
        '## Citadel Report',
        `Read: ${path.basename('citadel_report.json')}`,
        divergences.length > 0
            ? 'Treat these divergence findings as known Citadel inputs; do not double-count intentional divergence:'
            : 'No divergence findings were reported by Citadel.',
        ...divergences.map(findingText),
    ];
}
/**
 * Pickle phase entry: pin command_template and scrub stale phase configs.
 *
 * Two failure modes this guards against on resume:
 *   1. command_template drift: a prior run advanced into anatomy-park or
 *      szechuan-sauce and persisted its template. Without re-pinning, mux-runner
 *      would spawn the pickle worker with the wrong prompt — worker runs the
 *      wrong phase, commits with the wrong prefix, emits EPIC_COMPLETED for the
 *      wrong reason. Always overwrite to '_pickle-manager-prompt.md' on entry.
 *   2. Stale phase config files (anatomy-park.json, szechuan-sauce.json) left
 *      in the session dir from a previous run. A worker that scans the session
 *      dir might infer wrong context even with the right template. Remove them.
 *
 * Intentionally does NOT touch current_ticket / iteration / start_time_epoch —
 * pickle is the only phase that resumes mid-flight, and those pointers must
 * survive an interrupted run. The outer phase transition helper stamps
 * state.step to the active pipeline phase after this entry prep.
 */
export function enterPicklePhase(sessionDir, statePath, backend) {
    // Fix A — pin command_template. Stale value from a previous anatomy-park or
    // szechuan-sauce run would otherwise misroute the pickle worker.
    sm.update(statePath, (s) => {
        s.chain_meeseeks = false;
        s.command_template = '_pickle-manager-prompt.md';
        if (s.backend !== backend)
            s.backend = backend;
    });
    // Fix B — scrub stale foreign-phase residue left behind by a previous
    // pipeline run. cleanPhaseArtifacts archives TASK_NOTES.md / gap_analysis.md
    // and removes handoff.txt for the named phase; the explicit unlinkSync of
    // <phase>.json catches the microverse-runner convergence state files
    // (anatomy-park.json, szechuan-sauce.json) which cleanPhaseArtifacts does
    // not handle. Either residue can misroute a resumed pickle worker even
    // after command_template is pinned.
    cleanPhaseArtifacts(sessionDir, 'anatomy-park');
    cleanPhaseArtifacts(sessionDir, 'szechuan-sauce');
    for (const stalePhase of ['anatomy-park', 'szechuan-sauce']) {
        const stalePath = path.join(sessionDir, `${stalePhase}.json`);
        if (fs.existsSync(stalePath)) {
            try {
                fs.unlinkSync(stalePath);
            }
            catch { /* best effort */ }
        }
    }
}
/**
 * Setup-time scope resolution. Writes `scope.json` and initializes
 * `state.phases_entered = []`. SCOPE_EMPTY_DIFF is demoted to a WARN (CUJ-6a):
 * a scope-configured session with no diff at setup should not kill the
 * pipeline — the build phase may still produce one. Returns the resolved
 * scope, or `null` when the scope is empty at setup (warning path).
 */
export function setupScope(args) {
    const { sessionDir, workingDir, target, scopeFlag, scopeBase, log } = args;
    const statePath = path.join(sessionDir, 'state.json');
    try {
        const scope = resolveScope({
            scopeFlag,
            scopeBase,
            target,
            sessionRoot: sessionDir,
            repoRoot: workingDir,
        });
        sm.update(statePath, (s) => { s.phases_entered = []; });
        log(`scope-setup: mode=${scope.mode} strategy=${scope.strategy} base=${scope.base_ref ?? '-'} allowed=${scope.allowed_paths.length}`);
        return scope;
    }
    catch (err) {
        if (err instanceof Error && err instanceof ScopeError && err.code === 'SCOPE_EMPTY_DIFF') {
            log(`scope-setup WARN: SCOPE_EMPTY_DIFF — ${err.message} (continuing; build phase may produce diff)`);
            sm.update(statePath, (s) => { s.phases_entered = []; });
            return null;
        }
        throw err;
    }
}
/**
 * Write `archive/skipped_by_scope.<phase>.json` — an observability record of
 * what scope filtered out for `phase`. Pure audit file; worker-side filters
 * (A6/A7) are out of scope for this ticket.
 */
export function writeSkippedByScope(sessionDir, scopePhase, scope, target, workingDir) {
    const archiveDir = path.join(sessionDir, 'archive');
    fs.mkdirSync(archiveDir, { recursive: true });
    const outPath = path.join(archiveDir, `skipped_by_scope.${scopePhase}.json`);
    let payload;
    if (scopePhase === 'anatomy-park') {
        const discovered = discoverSubsystems(target).map((s) => s.name);
        const kept = filterBySubsystem(discovered, scope.allowed_paths, target, workingDir);
        const keptSet = new Set(kept);
        const skipped = discovered.filter((n) => !keptSet.has(n));
        payload = {
            phase: scopePhase,
            head_sha: scope.head_sha,
            allowed_paths: scope.allowed_paths,
            subsystems_discovered: discovered,
            subsystems_kept: kept,
            subsystems_skipped: skipped,
        };
    }
    else {
        payload = {
            phase: scopePhase,
            head_sha: scope.head_sha,
            allowed_paths: scope.allowed_paths,
        };
    }
    const tmp = `${outPath}.tmp.${process.pid}`;
    try {
        fs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
        fs.renameSync(tmp, outPath);
    }
    catch (err) {
        try {
            fs.unlinkSync(tmp);
        }
        catch { /* ignore */ }
        throw err;
    }
}
function readPersistedAllowedPaths(sessionDir) {
    const scopePath = path.join(sessionDir, 'scope.json');
    if (!fs.existsSync(scopePath))
        return undefined;
    try {
        const raw = readRecoverableJsonObject(scopePath);
        if (!raw)
            return undefined;
        const field = raw.allowed_paths;
        if (!Array.isArray(field) || field.length === 0 || !field.every((value) => typeof value === 'string')) {
            return undefined;
        }
        return field;
    }
    catch {
        return undefined;
    }
}
function readWorkingDirFromState(sessionDir, fallback) {
    const statePath = path.join(sessionDir, 'state.json');
    if (!fs.existsSync(statePath))
        return fallback;
    try {
        const workingDir = sm.read(statePath).working_dir;
        return typeof workingDir === 'string' && workingDir.length > 0 ? workingDir : fallback;
    }
    catch {
        return fallback;
    }
}
// ---------------------------------------------------------------------------
// Phase Setup: Anatomy Park
// ---------------------------------------------------------------------------
function buildAnatomyPrd(target, subsystems, stallLimit, runnerStallLimit, citadelReport) {
    return [
        '# Anatomy Park: Deep Subsystem Review',
        '',
        '## Objective',
        `Systematically review and fix all subsystems in ${target} through phased review-fix-verify cycles. Catalog structural weaknesses as trap doors in subsystem CLAUDE.md files.`,
        '',
        '## Target',
        target,
        '',
        '## Subsystems',
        ...subsystems.map((s, i) => `${i + 1}. ${s.name} (${s.fileCount} files)`),
        '',
        '## Key Metric',
        '- **Type**: none (worker-managed convergence)',
        `- **Stall Limit**: ${stallLimit} per subsystem | ${runnerStallLimit} total (runner ceiling)`,
        '- **Target**: All subsystems pass clean for 2 consecutive passes',
        '',
        '## Process (each iteration)',
        '1. Select next subsystem from rotation',
        '2. Phase 1: Read-only review — trace data flows, rate all findings',
        '3. Phase 2: Fix the single highest-severity finding + write regression test',
        '4. Phase 3: Read-only self-review of the diff, revert if broken',
        '5. Catalog trap doors in subsystem CLAUDE.md',
        '6. Rotate to next subsystem',
        '',
        '## Rules',
        '- One subsystem per iteration, one fix per iteration',
        '- Three phases per iteration — never combine',
        '- Phase 1 and Phase 3 are READ-ONLY',
        '- Revert on regression, defer to next iteration',
        `- Skip subsystem after ${stallLimit} consecutive failed fixes`,
        ...buildCitadelAnatomyContext(citadelReport),
    ].join('\n');
}
// R-PSSS-1/2: file extensions that count as a reviewable code surface. A
// scope (branch diff) containing none of these is doc-only / test-fixture-only
// and makes anatomy-park / szechuan-sauce a no-op.
const CODE_EXTENSIONS = new Set([
    'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'py', 'go', 'rs', 'java', 'rb', 'php',
    'c', 'cc', 'cpp', 'h', 'hpp', 'cs', 'kt', 'swift', 'scala', 'sh',
]);
function isCodePath(p) {
    const dot = p.lastIndexOf('.');
    return dot >= 0 && CODE_EXTENSIONS.has(p.slice(dot + 1).toLowerCase());
}
/**
 * R-PSSS-2: a resolved-but-code-free scope (a doc-only / fixture-only branch
 * diff). An UNSCOPED run — empty `paths` — is the whole-repo case and is NOT
 * code-free for this purpose, so szechuan-sauce still runs.
 */
function isCodeFreeScope(paths) {
    return Array.isArray(paths) && paths.length > 0 && !paths.some(isCodePath);
}
/**
 * R-PSSS-2: when szechuan-sauce's effective scope contains zero code files,
 * emit the operator WARN + `szechuan_sauce_empty_scope_skip` event and return
 * true so `setupSzechuanSauce` skips the phase. Returns false (no emission)
 * for an unscoped run or a scope with at least one code file.
 */
function shouldSkipSzechuanForEmptyScope(sessionDir, effectiveAllowedPaths, log) {
    const paths = isCodeFreeScope(effectiveAllowedPaths) ? effectiveAllowedPaths : null;
    if (!paths)
        return false;
    log(formatEmptyScopeWarn('szechuan-sauce', 'scope contains no code files', paths));
    logActivity({
        event: 'szechuan_sauce_empty_scope_skip',
        source: 'pickle',
        session: path.basename(sessionDir),
        gate_payload: { in_scope_paths: paths },
    });
    return true;
}
/**
 * R-PSSS-1/2: operator-visible WARN for an empty/code-free-scope phase skip.
 * The original silent `Phase X skipped (setup returned false)` log forced
 * operators to read raw logs to discover why a phase did nothing.
 */
function formatEmptyScopeWarn(phase, cause, inScopePaths) {
    const shown = inScopePaths.slice(0, 20);
    const more = inScopePaths.length > shown.length
        ? `, …(+${inScopePaths.length - shown.length} more)`
        : '';
    return [
        `⚠ ${phase} did not run: ${cause}.`,
        `  In-scope diff (${inScopePaths.length} path(s)): ${shown.join(', ') || '(none)'}${more}`,
        `  Hint: ${phase} reviews code subsystems; a doc-only or test-fixture-only`,
        `  diff has no review surface. Widen with --scope paths:<glob>.`,
    ].join('\n');
}
function resolveAnatomySubsystems(sessionDir, target, scope, log) {
    const discovered = discoverSubsystems(target);
    if (discovered.length === 0) {
        log('No subsystems discovered — skipping anatomy-park phase');
        return { skipReason: 'no_subsystems' };
    }
    if (!scope || scope.allowedPaths.length === 0) {
        log(`Discovered ${discovered.length} subsystems: ${discovered.map(s => s.name).join(', ')}`);
        return discovered;
    }
    const kept = new Set(filterBySubsystem(discovered.map(s => s.name), scope.allowedPaths, target, scope.repoRoot));
    if (kept.size === 0) {
        // R-PSSS-1: the scope filter excluding every subsystem is a real skip the
        // operator must see — not a silent `setup returned false`. Emit the
        // structured WARN plus an `anatomy_park_empty_scope_skip` activity event.
        log(formatEmptyScopeWarn('anatomy-park', 'scope filter excluded all subsystems', scope.allowedPaths));
        logActivity({
            event: 'anatomy_park_empty_scope_skip',
            source: 'pickle',
            session: path.basename(sessionDir),
            gate_payload: {
                in_scope_paths: scope.allowedPaths,
                discovered_subsystems: discovered.map((s) => s.name),
            },
        });
        return { skipReason: 'empty_scope' };
    }
    const filtered = discovered.filter(s => kept.has(s.name));
    log(`anatomy-park: scope filtered ${discovered.length} → ${filtered.length} subsystems: ${filtered.map(s => s.name).join(', ')}`);
    return filtered;
}
function writeAnatomyConfig(sessionDir, subsystems, stallLimit) {
    const subsystemNames = subsystems.map(s => s.name);
    const apState = {
        subsystems: subsystemNames,
        current_index: 0,
        pass_counts: Object.fromEntries(subsystemNames.map(name => [name, 0])),
        consecutive_clean: Object.fromEntries(subsystemNames.map(name => [name, 0])),
        stall_counts: Object.fromEntries(subsystemNames.map(name => [name, 0])),
        stall_limit: stallLimit,
        findings_history: Object.fromEntries(subsystemNames.map(name => [name, []])),
        trap_doors_added: [],
        trap_doors_committed: [],
    };
    writeStateFile(path.join(sessionDir, 'anatomy-park.json'), apState);
}
export function setupAnatomyPark(sessionDir, target, stallLimit, extensionRoot, log, scope, designSafe) {
    const persistedAllowedPaths = !scope || scope.allowedPaths.length === 0
        ? readPersistedAllowedPaths(sessionDir)
        : undefined;
    const effectiveScope = scope && scope.allowedPaths.length > 0
        ? scope
        : persistedAllowedPaths && persistedAllowedPaths.length > 0
            ? {
                allowedPaths: persistedAllowedPaths,
                repoRoot: readWorkingDirFromState(sessionDir, target),
            }
            : undefined;
    if (!scope && effectiveScope) {
        log(`anatomy-park: reusing persisted scope.json with ${effectiveScope.allowedPaths.length} allowed path(s)`);
    }
    const subsystems = resolveAnatomySubsystems(sessionDir, target, effectiveScope, log);
    if (!Array.isArray(subsystems))
        return subsystems;
    const citadelReport = readCitadelReport(sessionDir);
    if (citadelReport)
        log(`anatomy-park: read citadel_report.json with ${citadelReport.findings.length} finding(s)`);
    writeAnatomyConfig(sessionDir, subsystems, stallLimit);
    const runnerStallLimit = subsystems.length * 10;
    const metricJson = JSON.stringify({
        description: 'none', validation: 'none', type: 'none',
        timeout_seconds: 0, tolerance: 0, direction: 'lower',
    });
    const initArgs = [
        path.join(extensionRoot, 'extension', 'bin', 'init-microverse.js'),
        sessionDir, target,
        '--stall-limit', String(runnerStallLimit),
        '--convergence-mode', 'worker',
        '--convergence-file', 'anatomy-park.json',
        '--metric-json', metricJson,
    ];
    const scopePath = path.join(sessionDir, 'scope.json');
    if (effectiveScope && effectiveScope.allowedPaths.length > 0 && fs.existsSync(scopePath)) {
        initArgs.push('--allowed-paths-file', scopePath);
    }
    try {
        execFileSync('node', initArgs, { timeout: 30_000, encoding: 'utf-8' });
    }
    catch (err) {
        log(`init-microverse.js failed: ${safeErrorMessage(err)}`);
        return { skipReason: 'setup_error' };
    }
    injectDesignSafeIntoMicroverse(sessionDir, designSafe, log);
    archiveFile(sessionDir, 'prd.md', 'pickle');
    fs.writeFileSync(path.join(sessionDir, 'prd.md'), buildAnatomyPrd(target, subsystems, stallLimit, runnerStallLimit, citadelReport));
    log('Anatomy Park setup complete');
    return true;
}
// ---------------------------------------------------------------------------
// Phase Setup: Szechuan Sauce
// ---------------------------------------------------------------------------
function buildSzechuanJudgeContext(sessionDir, principlesPath, extensionRoot, domain, focus, log) {
    if (!domain && !focus) {
        return fs.existsSync(principlesPath) ? principlesPath : undefined;
    }
    const parts = [];
    try {
        parts.push(fs.readFileSync(principlesPath, 'utf-8'));
    }
    catch { /* base missing */ }
    if (domain) {
        const domainPath = path.join(extensionRoot, `szechuan-sauce-${domain}-principles.md`);
        try {
            parts.push(fs.readFileSync(domainPath, 'utf-8'));
        }
        catch {
            log(`Domain principles not found: ${domainPath}`);
        }
    }
    if (focus) {
        parts.push(`\n## Focus Directive\n\n${focus}\n\nViolations matching this focus are elevated by one priority level.`);
    }
    const contextPath = path.join(sessionDir, 'judge-context.md');
    fs.writeFileSync(contextPath, parts.join('\n\n'));
    return contextPath;
}
function appendSzechuanPrinciples(prdParts, principlesPath, extensionRoot, domain) {
    prdParts.push('## Principles Reference', `Read: ${principlesPath}`);
    if (domain)
        prdParts.push(`Read: ${path.join(extensionRoot, `szechuan-sauce-${domain}-principles.md`)}`);
}
function appendSzechuanProcess(prdParts, stallLimit) {
    prdParts.push('', '## Key Metric', '- **Type**: llm (LLM judge scoring)', '- **Direction**: lower', '- **Convergence Target**: 0', `- **Stall Limit**: ${stallLimit}`, '', '## Process', '### Iteration 1: Contract Discovery + Gap Analysis', '1. Extract all exports from target files', '2. Grep the entire codebase for importers — build contract map', '3. Flag cross-module mismatches as P1', '4. Catalog all violations into gap_analysis.md', '', '### Each subsequent iteration', '1. Read the principles reference and target code', '2. Identify the highest-priority violation (P0 > P1 > P2 > P3 > P4)', '3. Fix it — one logical change per iteration', '4. Run tests — ensure green', '5. Commit');
}
function buildSzechuanPrd(target, stallLimit, principlesPath, extensionRoot, domain, focus, citadelReport) {
    const prdParts = [
        '# Szechuan Sauce: Iterative Deslopping',
        '',
        '## Objective',
        `Eliminate all coding principle violations in ${target} through iterative review and fix cycles.`,
        '',
        '## Target',
        target,
        '',
    ];
    appendSzechuanPrinciples(prdParts, principlesPath, extensionRoot, domain);
    if (focus)
        prdParts.push('', '## Focus', focus);
    appendSzechuanProcess(prdParts, stallLimit);
    prdParts.push('', '## Rules');
    prdParts.push('- One fix per iteration (atomic, revertible)');
    prdParts.push('- Never repeat a failed approach');
    prdParts.push('- P0 before P1 before P2 before P3 before P4');
    prdParts.push(...buildCitadelSzechuanContext(citadelReport));
    return prdParts.join('\n');
}
export function setupSzechuanSauce(sessionDir, target, stallLimit, extensionRoot, domain, focus, log, scope, designSafe) {
    const principlesPath = path.join(extensionRoot, 'szechuan-sauce-principles.md');
    const judgeContextArg = buildSzechuanJudgeContext(sessionDir, principlesPath, extensionRoot, domain, focus, log);
    const citadelReport = readCitadelReport(sessionDir);
    if (citadelReport)
        log(`szechuan-sauce: read citadel_report.json with ${citadelReport.findings.length} finding(s)`);
    const effectiveAllowedPaths = scope?.allowedPaths?.length
        ? scope.allowedPaths
        : readPersistedAllowedPaths(sessionDir);
    if ((!scope || scope.allowedPaths.length === 0) && effectiveAllowedPaths && effectiveAllowedPaths.length > 0) {
        log(`szechuan-sauce: reusing persisted scope.json with ${effectiveAllowedPaths.length} allowed path(s)`);
    }
    // R-PSSS-2: a scoped pipeline whose effective scope contains zero code files
    // (a doc-only / fixture-only branch diff) makes szechuan-sauce a no-op —
    // unlike anatomy-park it does not skip on its own. Skip with an
    // operator-visible WARN + `szechuan_sauce_empty_scope_skip` event instead of
    // silently grinding the worker over docs. An UNSCOPED run (empty
    // effectiveAllowedPaths) is the whole-repo case and is left to run.
    if (shouldSkipSzechuanForEmptyScope(sessionDir, effectiveAllowedPaths, log)) {
        return { skipReason: 'empty_scope' };
    }
    archiveFile(sessionDir, 'microverse.json', 'pre-szechuan');
    const initArgs = [
        path.join(extensionRoot, 'extension', 'bin', 'init-microverse.js'),
        sessionDir, target,
        '--stall-limit', String(stallLimit),
        '--convergence-target', '0',
    ];
    if (judgeContextArg)
        initArgs.push('--judge-context', judgeContextArg);
    const scopePath = path.join(sessionDir, 'scope.json');
    if (effectiveAllowedPaths && effectiveAllowedPaths.length > 0 && fs.existsSync(scopePath)) {
        initArgs.push('--allowed-paths-file', scopePath);
    }
    try {
        execFileSync('node', initArgs, { timeout: 30_000, encoding: 'utf-8' });
    }
    catch (err) {
        log(`init-microverse.js failed: ${safeErrorMessage(err)}`);
        return { skipReason: 'setup_error' };
    }
    injectDesignSafeIntoMicroverse(sessionDir, designSafe, log);
    archiveFile(sessionDir, 'prd.md', 'anatomy-park');
    fs.writeFileSync(path.join(sessionDir, 'prd.md'), buildSzechuanPrd(target, stallLimit, principlesPath, extensionRoot, domain, focus, citadelReport));
    log('Szechuan Sauce setup complete');
    return true;
}
const PHASE_NAMES = ['pickle', 'citadel', 'anatomy-park', 'szechuan-sauce'];
function isPhaseName(phase) {
    return typeof phase === 'string' && PHASE_NAMES.includes(phase);
}
export function setupPhase(phase, config) {
    if (phase === 'pickle')
        return picklePhaseConfig();
    if (phase === 'citadel')
        return citadelPhaseConfig();
    if (phase === 'anatomy-park')
        return anatomyPhaseConfig(config);
    return szechuanPhaseConfig(config);
}
function picklePhaseConfig() {
    return {
        name: 'pickle',
        prevPhase: null,
        runnerScript: 'mux-runner.js',
        setup: null,
        refreshScope: false,
        throwOnEmptyScope: false,
        preSpawnStateMutation: (s) => { s.chain_meeseeks = false; },
    };
}
function citadelPhaseConfig() {
    return {
        name: 'citadel',
        prevPhase: 'pickle',
        runnerScript: null,
        setup: null,
        refreshScope: false,
        throwOnEmptyScope: false,
        preSpawnStateMutation: null,
    };
}
function anatomyPhaseConfig(config) {
    return {
        name: 'anatomy-park',
        prevPhase: 'citadel',
        runnerScript: 'microverse-runner.js',
        setup: (args) => setupAnatomyPark(args.sessionDir, args.target, config.anatomy_stall_limit, args.extensionRoot, args.log, args.scope ? { allowedPaths: args.scope.allowed_paths, repoRoot: args.workingDir } : undefined, args.designSafe),
        refreshScope: true,
        throwOnEmptyScope: true,
        preSpawnStateMutation: null,
    };
}
function szechuanPhaseConfig(config) {
    return {
        name: 'szechuan-sauce',
        prevPhase: 'anatomy-park',
        runnerScript: 'microverse-runner.js',
        setup: (args) => setupSzechuanSauce(args.sessionDir, args.target, config.szechuan_stall_limit, args.extensionRoot, config.szechuan_domain, config.szechuan_focus, args.log, args.scope ? { allowedPaths: args.scope.allowed_paths } : undefined, args.designSafe),
        setupExtraArgs: { domain: config.szechuan_domain, focus: config.szechuan_focus },
        refreshScope: true,
        throwOnEmptyScope: false,
        preSpawnStateMutation: null,
    };
}
export async function executePhaseRunner(phaseConfig, env) {
    if (!phaseRunnerContext)
        throw new Error('phase runner context not initialized');
    if (!phaseConfig.runnerScript)
        throw new Error(`phase ${phaseConfig.name} does not use a child runner`);
    return await runSpawnRunner('node', [
        path.join(phaseRunnerContext.extensionRoot, 'extension', 'bin', phaseConfig.runnerScript),
        phaseRunnerContext.sessionDir,
    ], env);
}
const defaultCitadelRemediationDeps = {
    runCitadelAudit,
    spawnGateRemediatorMain,
    spawnRemediator: (cmd, args, opts) => {
        execFileSync(cmd, args, { cwd: opts.cwd, timeout: opts.timeout, stdio: 'pipe', env: opts.env });
    },
    loadSettings: () => {
        const s = loadFinalizeGateSettings(resolveFinalizeSettingsRoot());
        return { cap: s.citadel_max_remediation_cycles, remediatorTimeoutMs: s.remediator_timeout_s * 1000 };
    },
};
let citadelRemediationDeps = defaultCitadelRemediationDeps;
export function __setCitadelRemediationDepsForTests(partial) {
    citadelRemediationDeps = partial ? { ...defaultCitadelRemediationDeps, ...partial } : defaultCitadelRemediationDeps;
}
// R-HRP-1: the citadel-strict flag no longer halts — it now WIDENS which findings are remediated.
// Strict remediates High+ (Critical + High); non-strict remediates Critical only. The parameter is
// named `strict` (not the config field name) so the removed halt-threshold ternary is not
// re-introduced under that name anywhere in this file.
function remediationSeverityThreshold(strict) {
    return strict ? 'High' : 'Critical';
}
function logCitadelFindingsUnremediated(runtime, findings, cap) {
    runtime.log(`citadel: remediation cap (${cap}) exhausted with ${findings.length} finding(s) still open — continuing pipeline (no halt)`);
    try {
        sm.update(runtime.statePath, state => {
            const activity = Array.isArray(state.activity) ? state.activity : [];
            state.activity = [
                ...activity,
                {
                    event: 'citadel_findings_unremediated',
                    ts: new Date().toISOString(),
                    cycles: cap,
                    findings_remaining: findings.length,
                    finding_ids: findings.slice(0, 50).map(f => f.id),
                },
            ];
        });
    }
    catch (err) {
        runtime.log(`citadel_findings_unremediated activity write failed: ${safeErrorMessage(err)}`);
    }
}
// Sync FS isolated in a non-async helper (mirrors finalize-gate) so the async remediation flow
// stays free of blocking-fs lint warnings.
function writeCitadelGateResultFile(sessionDir, findings) {
    const gateResult = citadelFindingsToGateResult(findings);
    const gateDir = path.join(sessionDir, 'gate');
    fs.mkdirSync(gateDir, { recursive: true });
    const gateResultPath = path.join(gateDir, `citadel_gate_result_${isoCompactStamp()}.json`);
    writeStateFile(gateResultPath, gateResult);
    return gateResultPath;
}
function readCitadelBriefFile(briefPath, runtime) {
    try {
        return fs.readFileSync(briefPath, 'utf-8');
    }
    catch (err) {
        runtime.log(`citadel: cannot read brief at ${briefPath}: ${safeErrorMessage(err)}`);
        return null;
    }
}
async function remediateCitadelFindings(runtime, findings, remediatorTimeoutMs, cycle) {
    const gateResultPath = writeCitadelGateResultFile(runtime.sessionDir, findings);
    // Brief-prep — invoked exactly as finalize-gate does (argv interface, --reason 'strict').
    const briefLines = [];
    let briefCode;
    try {
        briefCode = await citadelRemediationDeps.spawnGateRemediatorMain({
            argv: ['--gate-result', gateResultPath, '--session-root', runtime.sessionDir, '--reason', 'strict'],
            stdout: (msg) => briefLines.push(msg),
            stderr: (msg) => runtime.log(`[citadel-remediator] ${msg}`),
        });
    }
    catch (err) {
        runtime.log(`citadel: brief-prep threw on cycle ${cycle + 1}: ${safeErrorMessage(err)}`);
        return;
    }
    if (briefCode !== 0) {
        runtime.log(`citadel: brief-prep exited ${briefCode} on cycle ${cycle + 1} — skipping remediator`);
        return;
    }
    const briefPathLine = briefLines.find(l => l.startsWith('BRIEF_PATH='));
    if (!briefPathLine) {
        runtime.log(`citadel: no BRIEF_PATH from brief-prep on cycle ${cycle + 1}`);
        return;
    }
    const briefPath = briefPathLine.slice('BRIEF_PATH='.length);
    const briefContent = readCitadelBriefFile(briefPath, runtime);
    if (briefContent === null)
        return;
    const backend = resolveBackend(sm.read(runtime.statePath));
    const invocation = buildWorkerInvocation(backend, { prompt: briefContent, addDirs: [runtime.workingDir] });
    runtime.log(`citadel: spawning remediator (cycle ${cycle + 1})`);
    try {
        citadelRemediationDeps.spawnRemediator(invocation.cmd, invocation.args, {
            cwd: runtime.workingDir,
            timeout: remediatorTimeoutMs,
            env: { ...process.env, ...backendEnvOverrides(invocation.backend) },
        });
    }
    catch (err) {
        runtime.log(`citadel: remediator exited non-zero or timed out: ${safeErrorMessage(err)}`);
    }
}
export async function executeCitadelPhase(runtime) {
    const state = sm.read(runtime.statePath);
    if (!state.prd_path || !state.start_commit) {
        runtime.log('citadel: missing state.prd_path or state.start_commit — failing phase');
        return { exitCode: 1 };
    }
    const reportPath = path.join(runtime.sessionDir, 'citadel_report.json');
    const { cap, remediatorTimeoutMs } = citadelRemediationDeps.loadSettings();
    const threshold = remediationSeverityThreshold(runtime.config.citadel_strict);
    let remediable = [];
    // Bounded detect→remediate loop (mirrors finalize-gate's cycle structure). The phase ALWAYS
    // returns success; the pipeline continues to anatomy-park regardless of remediation outcome.
    for (let cycle = 0; cycle < cap; cycle++) {
        const result = await citadelRemediationDeps.runCitadelAudit({
            prdPath: state.prd_path,
            diffRange: `${state.start_commit}..HEAD`,
            repoRoot: runtime.repoRoot,
            sessionDir: runtime.sessionDir,
            reportPath,
            strict: runtime.config.citadel_strict,
        });
        remediable = result.findings.filter(f => findingMeetsThreshold(f, threshold));
        runtime.log(`citadel: cycle ${cycle + 1}/${cap} — wrote ${reportPath} with ${result.findings.length} finding(s), ${remediable.length} remediable (>= ${threshold})`);
        if (remediable.length === 0) {
            runtime.log('citadel: no remediable findings — phase complete, continuing pipeline');
            return { exitCode: 0 };
        }
        await remediateCitadelFindings(runtime, remediable, remediatorTimeoutMs, cycle);
    }
    // Cap exhausted with findings still open: surface async + continue (never halt).
    logCitadelFindingsUnremediated(runtime, remediable, cap);
    return { exitCode: 0 };
}
function shouldSkipAnatomyPhaseWithWarning(phase, result, runtime) {
    if (phase !== 'anatomy-park' || result.exitCode === 0)
        return null;
    const runnerState = sm.read(runtime.statePath);
    if (runnerState.command_template !== 'anatomy-park.md' || runnerState.exit_reason !== 'fatal') {
        return null;
    }
    if (!/Cannot read properties of undefined \(reading 'description'\)/.test(result.stderr)) {
        return null;
    }
    return {
        warningClass: 'anatomy_park_missing_key_metric',
        detail: 'microverse-runner crashed on missing key_metric.description; continuing to the next pipeline phase',
    };
}
const SEVERITY_RANK = {
    Critical: 0,
    High: 1,
    Medium: 2,
    Low: 3,
};
function findingMeetsThreshold(finding, threshold) {
    return SEVERITY_RANK[finding.severity] <= SEVERITY_RANK[threshold];
}
const MICROVERSE_FATAL_REASON_SET = new Set(MICROVERSE_FATAL_REASONS);
const GIT_PHASE_COMMIT_COUNT_TIMEOUT_MS = 10_000;
const GIT_REPO_ROOT_TIMEOUT_MS = 5_000;
function countCommitsSince(startCommit, workingDir) {
    const output = execFileSync('git', ['rev-list', '--count', `${startCommit}..HEAD`], {
        cwd: workingDir,
        encoding: 'utf-8',
        timeout: GIT_PHASE_COMMIT_COUNT_TIMEOUT_MS,
    }).trim();
    const count = Number.parseInt(output, 10);
    if (!Number.isInteger(count) || count < 0) {
        throw new Error(`Invalid git rev-list --count output: ${output}`);
    }
    return count;
}
function isMicroverseFatalReason(reason) {
    return typeof reason === 'string' && MICROVERSE_FATAL_REASON_SET.has(reason);
}
export function isFatalPhaseFailure(phase, runtime) {
    try {
        const runnerState = sm.read(runtime.statePath);
        if (phase === 'pickle') {
            // R-PRNF-9: readiness halt is always a hard failure regardless of prior-session commits.
            // Checking exit_reason here covers resumed sessions where countCommitsSince > 0 (prior runs)
            // but this run produced zero build progress.
            if (runnerState.exit_reason === 'readiness_halt')
                return true;
            const startCommit = runnerState.start_commit?.trim();
            if (!startCommit)
                return true;
            return countCommitsSince(startCommit, runtime.repoRoot) === 0;
        }
        if (phase === 'anatomy-park' || phase === 'szechuan-sauce') {
            const reason = runnerState.exit_reason;
            if (isMicroverseFatalReason(reason))
                return true;
            // judge_timeout / all_judge_backends_exhausted are intentionally NOT in MICROVERSE_FAILURE_REASONS
            // so logPhaseHaltReason can route them through finalize-gate (R-PRJT-2). Still treat as
            // halt-eligible here so the halt path runs instead of recordRecoverablePhaseFailure.
            if (reason === 'judge_timeout' || reason === 'all_judge_backends_exhausted')
                return true;
            // Microverse failure exits (judge_unreachable, error, rate_limit_exhausted, ...) halt
            // the pipeline. R-SCJM-3 expects judge_unreachable to halt without finalize-gate.
            if (typeof reason === 'string' && isMicroverseFailureExit(reason)) {
                return true;
            }
            return false;
        }
        return true;
    }
    catch {
        return true;
    }
}
export function shouldHaltAfterPhase(phase, exitCode, runtime) {
    if (exitCode === 0)
        return false;
    // R-HRP-1: no phase is special-cased here any more. The conformance audit became fix-forward —
    // it feeds findings to the remediator and always returns exitCode 0, so it only reaches this
    // function on a genuine misconfiguration (missing PRD/start_commit), which isFatalPhaseFailure
    // still treats as halting. Every phase now follows the same fatal-failure / strict-policy path.
    if (isFatalPhaseFailure(phase, runtime))
        return true;
    // Strict phase policy: persisted pipeline_continue_on_phase_fail=false (via --strict-phases or
    // upstream config) halts on any non-zero exit even when downstream remediation phases exist.
    try {
        const runnerState = sm.read(runtime.statePath);
        if (runnerState.pipeline_continue_on_phase_fail === false)
            return true;
    }
    catch {
        // best-effort; fall through to non-halt
    }
    return false;
}
function getRecoverablePhaseFailureReason(phase, runtime) {
    try {
        const runnerState = sm.read(runtime.statePath);
        if (phase === 'pickle') {
            const startCommit = runnerState.start_commit?.trim();
            if (startCommit) {
                const commits = countCommitsSince(startCommit, runtime.repoRoot);
                if (commits > 0) {
                    return 'non-fatal pickle exit, commits present';
                }
            }
            return 'non-fatal pickle exit';
        }
        // R-HRP-1: citadel no longer halts, so it never produces a "recoverable phase failure" reason;
        // its branch (and the deleted High/Critical halt-threshold logic) is gone.
        if (phase === 'anatomy-park' || phase === 'szechuan-sauce') {
            const exitReason = typeof runnerState.exit_reason === 'string'
                ? runnerState.exit_reason
                : 'unknown';
            return `non-fatal ${phase} exit, exit_reason=${exitReason}`;
        }
    }
    catch {
        // Best-effort telemetry; fall back to a generic reason below.
    }
    return `non-fatal ${phase} exit`;
}
export function recordRecoverablePhaseFailure(runtime, phase, exitCode, phaseIndex, decision) {
    const downstreamPhasesRemaining = runtime.config.phases.slice(phaseIndex + 1);
    try {
        sm.update(runtime.statePath, state => {
            const activity = Array.isArray(state.activity) ? state.activity : [];
            state.activity = [
                ...activity,
                {
                    event: 'recoverable_phase_failure',
                    ts: new Date().toISOString(),
                    phase,
                    exit_code: exitCode,
                    fatal: false,
                    reason: getRecoverablePhaseFailureReason(phase, runtime),
                    downstream_phases_remaining: downstreamPhasesRemaining,
                    decision,
                },
            ];
        });
    }
    catch (err) {
        runtime.log(`recoverable_phase_failure activity write failed: ${safeErrorMessage(err)}`);
    }
}
export function logPhaseContinueReason(runtime, phase, exitCode) {
    const phaseIndex = runtime.config.phases.indexOf(phase);
    const nextPhase = phaseIndex >= 0 ? runtime.config.phases[phaseIndex + 1] : undefined;
    if (nextPhase) {
        runtime.log(`Phase ${phase} exited with code ${exitCode} (non-fatal) — continuing to ${nextPhase} for automated remediation`);
        return;
    }
    runtime.log(`Phase ${phase} exited with code ${exitCode} (non-fatal) — no remaining phases; pipeline complete with non-zero phase exits`);
}
function hasPriorNonZeroRecoverableFailure(activity) {
    if (!Array.isArray(activity))
        return false;
    return activity.some((entry) => (entry?.event === 'recoverable_phase_failure'
        && typeof entry.exit_code === 'number'
        && entry.exit_code !== 0));
}
export function buildCloserReleasePlan(state) {
    if (!hasPriorNonZeroRecoverableFailure(state.activity)) {
        return {
            release: true,
            install: true,
            tag: true,
            skipReason: null,
        };
    }
    return {
        release: false,
        install: false,
        tag: false,
        skipReason: 'prior phase non-zero exit detected',
    };
}
export function executeCloserReleasePlan(plan, actions, log) {
    if (!plan.release) {
        log('Closer: prior phase non-zero exit detected — skipping install and tag');
        return;
    }
    actions.install();
    actions.tag();
}
export async function postPhaseCleanup(phase, sessionDir) {
    const prevPhaseByPhase = {
        pickle: null,
        citadel: 'pickle',
        'anatomy-park': 'citadel',
        'szechuan-sauce': 'anatomy-park',
    };
    const prevPhase = prevPhaseByPhase[phase];
    if (prevPhase)
        cleanPhaseArtifacts(sessionDir, prevPhase);
}
function persistPhaseTransition(runtime, phaseConfig, previousState) {
    sm.update(runtime.statePath, s => {
        const history = Array.isArray(s.history) ? s.history : [];
        const last = history[history.length - 1];
        s.step = phaseConfig.name;
        if (previousState.step !== phaseConfig.name && last?.step !== phaseConfig.name) {
            s.history = [...history, {
                    step: phaseConfig.name,
                    timestamp: samplePhaseHistoryTimestamp(history),
                }];
        }
    });
    try {
        logActivity({
            event: 'phase_transition',
            source: 'pickle',
            session: path.basename(runtime.sessionDir),
            previous_phase: previousState.step,
            next_phase: phaseConfig.name,
            previous_exit_reason: previousState.exit_reason ?? null,
        });
    }
    catch { /* telemetry best-effort */ }
}
export function samplePhaseHistoryTimestamp(history, nowMs = Date.now()) {
    const fallbackNowMs = Number.isFinite(nowMs) ? nowMs : Date.now();
    const lastTimestampMs = Array.isArray(history)
        ? [...history].reverse().reduce((found, entry) => {
            if (found !== null)
                return found;
            if (typeof entry?.timestamp !== 'string')
                return null;
            const parsed = Date.parse(entry.timestamp);
            return Number.isFinite(parsed) ? parsed : null;
        }, null)
        : null;
    const sampledMs = lastTimestampMs === null
        ? fallbackNowMs
        : Math.max(fallbackNowMs, lastTimestampMs + 1);
    return new Date(sampledMs).toISOString();
}
function restampBackendIfNeeded(statePath, backend) {
    const cur = sm.read(statePath);
    if (cur.backend !== backend)
        sm.update(statePath, s => { s.backend = backend; });
}
function preparePhaseState(phaseConfig, runtime) {
    const previousState = sm.read(runtime.statePath);
    const resetByPhase = {
        'anatomy-park': {
            template: 'anatomy-park.md',
            maxIterations: runtime.config.anatomy_max_iterations,
        },
        'szechuan-sauce': {
            template: 'szechuan-sauce.md',
            maxIterations: runtime.config.szechuan_max_iterations,
        },
    };
    const reset = resetByPhase[phaseConfig.name];
    if (phaseConfig.name === 'pickle') {
        enterPicklePhase(runtime.sessionDir, runtime.statePath, runtime.backend);
    }
    else if (reset) {
        resetStateForPhase(runtime.statePath, reset.template, reset.maxIterations);
        restampBackendIfNeeded(runtime.statePath, runtime.backend);
    }
    if (phaseConfig.preSpawnStateMutation) {
        sm.update(runtime.statePath, phaseConfig.preSpawnStateMutation);
    }
    claimPipelineRunnerActive(runtime.statePath);
    persistPhaseTransition(runtime, phaseConfig, previousState);
}
function refreshPhaseScope(phaseConfig, runtime, counters) {
    if (!phaseConfig.refreshScope)
        return undefined;
    try {
        const refreshed = refreshScope(runtime.sessionDir, phaseConfig.name, {
            repoRoot: runtime.repoRoot,
            target: runtime.target,
            log: runtime.log,
        });
        if (refreshed) {
            writeSkippedByScope(runtime.sessionDir, phaseConfig.name, refreshed, runtime.target, runtime.repoRoot);
        }
        return refreshed ?? undefined;
    }
    catch (err) {
        if (phaseConfig.throwOnEmptyScope && err instanceof Error && err instanceof ScopeError && err.code === 'SCOPE_EMPTY_POST_BUILD') {
            runtime.log(`SCOPE_EMPTY_POST_BUILD at ${phaseConfig.name} — ${err.message}`);
            writePipelineStatus(runtime.sessionDir, 'failed', {
                current_phase: phaseConfig.name,
                completed_phases: counters.completed,
                skipped_phases: counters.skipped,
                total_phases: runtime.config.phases.length,
            });
            throw err;
        }
        throw err;
    }
}
async function runConfiguredPhase(runtime, phaseConfig, counters) {
    await postPhaseCleanup(phaseConfig.name, runtime.sessionDir);
    preparePhaseState(phaseConfig, runtime);
    const scope = refreshPhaseScope(phaseConfig, runtime, counters);
    const setupResult = phaseConfig.setup ? phaseConfig.setup({
        sessionDir: runtime.sessionDir,
        target: runtime.target,
        workingDir: runtime.repoRoot,
        extensionRoot: runtime.extensionRoot,
        log: runtime.log,
        scope,
        designSafe: runtime.designSafe,
    }) : true;
    // R-PSSS-3: a non-`true` setup result carries the skip reason.
    if (setupResult !== true)
        return { skipped: true, skipReason: setupResult.skipReason, exitCode: null };
    if (phaseConfig.name === 'citadel')
        return { skipped: false, exitCode: (await executeCitadelPhase(runtime)).exitCode };
    const result = await executePhaseRunner(phaseConfig, runtime.phaseEnv);
    return { skipped: false, exitCode: result.exitCode, stderr: result.stderr };
}
export function applyStrictPhasesOverride(statePath, strictPhases, log) {
    if (!strictPhases)
        return false;
    const state = sm.read(statePath);
    if (state.pipeline_continue_on_phase_fail === false)
        return false;
    sm.update(statePath, s => { s.pipeline_continue_on_phase_fail = false; });
    log?.('strict phase policy enabled via --strict-phases; state.pipeline_continue_on_phase_fail=false');
    return true;
}
function createPipelineLog(sessionDir) {
    const runnerLog = path.join(sessionDir, 'pipeline-runner.log');
    return (msg) => {
        const line = `[${new Date().toISOString()}] ${msg}\n`;
        fs.appendFileSync(runnerLog, line);
        process.stderr.write(line);
    };
}
function ensurePipelineMonitor(sessionDir, extensionRoot, log) {
    try {
        const result = ensureMonitorWindow({ sessionDir, extensionRoot, log });
        log(`ensureMonitorWindow: ${result.status}${result.reason ? ` (${result.reason})` : ''}`);
        if (result.status === 'created' || result.status === 'recreated' || result.status === 'exists') {
            try {
                sm.update(path.join(sessionDir, 'state.json'), s => {
                    const ext = s;
                    if (ext.monitor_mode === undefined || ext.monitor_mode === null) {
                        ext.monitor_mode = 'pickle';
                    }
                });
            }
            catch { /* best-effort — non-fatal */ }
        }
    }
    catch (err) {
        log(`ensureMonitorWindow: threw (ignored): ${safeErrorMessage(err)}`);
    }
}
function readPipelineConfig(pipelinePath) {
    try {
        const recoveredPipeline = readRecoverableJsonObject(pipelinePath);
        if (!recoveredPipeline)
            throw new Error('pipeline.json did not contain an object');
        const raw = recoveredPipeline;
        return { raw, config: parsePipelineConfig(raw) };
    }
    catch (err) {
        throw new Error(`Cannot read pipeline.json: ${safeErrorMessage(err)}`);
    }
}
function readClaimedPipelineState(statePath) {
    if (!fs.existsSync(statePath)) {
        throw new Error('state.json not found — run setup.js first');
    }
    try {
        sm.read(statePath);
    }
    catch (err) {
        throw new Error(`Cannot read state.json: ${safeErrorMessage(err)}`);
    }
    return claimPipelineRunnerActive(statePath);
}
function resolvePipelineBackend(statePath, state, config, sessionDir, log) {
    const { backend, source } = resolveBackendWithSource(state, config.backend, process.env.PICKLE_BACKEND);
    assertCodexRequiredBackend(sessionDir, backend, source);
    if (state.backend !== backend) {
        sm.update(statePath, s => { s.backend = backend; });
    }
    log(`backend resolved: ${backend} (source: ${source})`);
    return { backend, phaseEnv: { ...process.env, ...backendEnvOverrides(backend) } };
}
function setupRuntimeScope(sessionDir, workingDir, target, opts, pipelineRaw, log) {
    const scopeFlag = opts.scopeFlag ?? (typeof pipelineRaw.scope === 'string' ? pipelineRaw.scope : undefined);
    const scopeBase = opts.scopeBase ?? (typeof pipelineRaw.scope_base === 'string' ? pipelineRaw.scope_base : undefined);
    if (!scopeFlag)
        return;
    setupScope({ sessionDir, workingDir, target, scopeFlag, scopeBase, log });
}
function loadPipelineRuntime(sessionDir, opts, log) {
    const extensionRoot = getExtensionRoot();
    const statePath = path.join(sessionDir, 'state.json');
    const pipelinePath = path.join(sessionDir, 'pipeline.json');
    ensurePipelineMonitor(sessionDir, extensionRoot, log);
    const { config, raw: pipelineRaw } = readPipelineConfig(pipelinePath);
    let state = readClaimedPipelineState(statePath);
    const workingDir = state.working_dir || process.cwd();
    const reset = applyEpochResetOnReconstruction(state, statePath, sessionDir);
    if (reset) {
        log(`reconstruction detected (iteration=${state.iteration ?? 0}) — start_time_epoch reset ${reset.originalEpoch ?? '?'} → ${reset.newEpoch}`);
    }
    state = reset ? sm.read(statePath) : state;
    if (applyStrictPhasesOverride(statePath, opts.strictPhases === true, log)) {
        state = sm.read(statePath);
    }
    const { backend, phaseEnv } = resolvePipelineBackend(statePath, state, config, sessionDir, log);
    // At manager-boundary relaunch, reset dirty files left by the interrupted
    // in-flight ticket so the subsequent assertCleanWorkingTree does not throw.
    const relaunchCount = typeof state.manager_relaunch_count === 'number' ? state.manager_relaunch_count : 0;
    if (relaunchCount > 0) {
        resetInterruptedTicketWorkForRelaunch(workingDir, config.ignore_dirty_paths, log);
    }
    assertCleanWorkingTree(workingDir, config.ignore_dirty_paths);
    setupRuntimeScope(sessionDir, workingDir, config.target || workingDir, opts, pipelineRaw, log);
    let repoRoot = workingDir;
    try {
        const out = execFileSync('git', ['-C', workingDir, 'rev-parse', '--show-toplevel'], {
            encoding: 'utf-8',
            timeout: GIT_REPO_ROOT_TIMEOUT_MS,
        }).trim();
        if (out)
            repoRoot = out;
    }
    catch { /* non-git dir — fall back to workingDir */ }
    const designSafe = resolveDesignSafe(state.start_commit, repoRoot, opts.designSafeFlag);
    log(`design_safe resolved: ${String(designSafe)}${opts.designSafeFlag !== undefined ? ' (CLI override)' : ' (auto-detected)'}`);
    return {
        sessionDir,
        extensionRoot,
        statePath,
        config,
        target: config.target || workingDir,
        workingDir,
        repoRoot,
        backend,
        phaseEnv,
        log,
        designSafe,
    };
}
export function installShutdownHandlers(runtime, counters, cancelMarker) {
    const handleShutdown = (signal) => {
        const signalPayload = buildSignalReceivedEvent(runtime, signal);
        try {
            logActivity({ event: 'signal_received', ...signalPayload });
        }
        catch { /* telemetry best effort */ }
        runtime.log(`Received ${signal} — shutting down pipeline`);
        runtime.log(`signal_received ${JSON.stringify(signalPayload)}`);
        try {
            fs.writeFileSync(cancelMarker, signal);
        }
        catch { /* best effort */ }
        try {
            writePipelineStatus(runtime.sessionDir, 'cancelled', {
                current_phase: null,
                completed_phases: counters.completed,
                skipped_phases: counters.skipped,
                total_phases: runtime.config.phases.length,
            });
        }
        catch { /* best effort */ }
        if (activeChild && !activeChild.killed)
            activeChild.kill('SIGTERM');
        recordExitReason(runtime.statePath, `signal:${signal}`);
        safeDeactivate(runtime.statePath);
        logActivity({ event: 'session_end', source: 'pickle', session: path.basename(runtime.sessionDir), mode: 'tmux', backend: runtime.backend });
        process.exit(1);
    };
    const handlers = {
        SIGTERM: () => handleShutdown('SIGTERM'),
        SIGINT: () => handleShutdown('SIGINT'),
        SIGHUP: () => handleShutdown('SIGHUP'),
    };
    process.on('SIGTERM', handlers.SIGTERM);
    process.on('SIGINT', handlers.SIGINT);
    process.on('SIGHUP', handlers.SIGHUP);
    return () => {
        process.off('SIGTERM', handlers.SIGTERM);
        process.off('SIGINT', handlers.SIGINT);
        process.off('SIGHUP', handlers.SIGHUP);
    };
}
function getProcessGroupId(pid) {
    const pgidFn = process.getpgid;
    if (typeof pgidFn !== 'function')
        return null;
    try {
        return pgidFn(pid);
    }
    catch {
        return null;
    }
}
function getHandlerStackFrames() {
    return new Error('signal received').stack
        ?.split('\n')
        .slice(1, 6)
        .map((line) => line.trim()) ?? [];
}
function buildSignalReceivedEvent(runtime, signal) {
    const state = sm.read(runtime.statePath);
    return {
        source: 'pickle',
        session: path.basename(runtime.sessionDir),
        signal,
        pid: process.pid,
        ppid: process.ppid,
        is_tty: Boolean(process.stdin.isTTY || process.stdout.isTTY),
        pgid: getProcessGroupId(process.pid),
        active_child_pid: activeChild?.pid ?? null,
        active_child_cmd: activeChild?.spawnargs?.[0] ?? null,
        current_phase: typeof state.step === 'string' ? state.step : null,
        received_at_iso: new Date().toISOString(),
        handler_stack: getHandlerStackFrames(),
    };
}
function writeRunningStatus(runtime, counters, currentPhase) {
    writePipelineStatus(runtime.sessionDir, 'running', {
        current_phase: currentPhase,
        completed_phases: counters.completed,
        skipped_phases: counters.skipped,
        total_phases: runtime.config.phases.length,
        phase_skips: counters.phaseSkips,
    });
}
function logPhaseStart(runtime, phase, index) {
    const phaseLabel = `${index + 1}/${runtime.config.phases.length}`;
    runtime.log(`\n${'═'.repeat(60)}`);
    runtime.log(`PHASE ${phaseLabel}: ${phase.toUpperCase()} (backend=${runtime.backend})`);
    runtime.log(`${'═'.repeat(60)}`);
    printMinimalPanel(`Pipeline Phase: ${phase}`, {
        Phase: phaseLabel,
        Target: runtime.target,
    }, 'CYAN', '🧪');
}
function writeFinalPipelineActivity(runtime, totalElapsed, phasesSummary, pipelineFailed) {
    runtime.log(`Pipeline finished: ${phasesSummary} phases, ${formatTime(totalElapsed)}`);
    emitBundleLinearComments(runtime.sessionDir, path.join(runtime.sessionDir, 'pipeline-runner.log'));
    logActivity({
        event: 'session_end', source: 'pickle',
        session: path.basename(runtime.sessionDir),
        duration_min: Math.round(totalElapsed / 60),
        mode: 'tmux',
        backend: runtime.backend,
    });
    displayMacNotification(pipelineFailed ? '🧪 Pipeline Stopped' : '🧪 Pipeline Complete', `${phasesSummary} phases, ${formatTime(totalElapsed)}`);
}
const UNFINISHED_TICKETS_PRINT_CAP = 50;
/**
 * Report unfinished tickets when a phase exits with PhaseIncomplete (3).
 * Walks `<session>/<hash>/linear_ticket_<hash>.md`, prints non-Done entries
 * sorted by `order` ascending, capped at UNFINISHED_TICKETS_PRINT_CAP.
 * Stamps `state.exit_reason = 'pipeline_phase_incomplete'` so the
 * pipeline-level outcome is preserved alongside any per-phase
 * `iteration_cap_exhausted` already recorded by mux-runner.
 */
function reportPhaseIncomplete(runtime, phase) {
    const tickets = collectTickets(runtime.sessionDir);
    const unfinished = tickets
        .filter(t => (t.status || '').toLowerCase() !== 'done')
        .sort((a, b) => (a.order || 0) - (b.order || 0));
    const total = tickets.length;
    runtime.log(`Phase ${phase} hit iteration cap; ${unfinished.length}/${total} tickets remain unfinished.`);
    if (unfinished.length > 0) {
        runtime.log('Unfinished tickets:');
        const printable = unfinished.slice(0, UNFINISHED_TICKETS_PRINT_CAP);
        for (const t of printable) {
            const order = String(t.order ?? 0);
            const id = t.id || '<unknown>';
            const title = t.title || '';
            const status = t.status || 'Todo';
            runtime.log(`  ${order}  ${id}  ${title}  [status: ${status}]`);
        }
        const overflow = unfinished.length - printable.length;
        if (overflow > 0) {
            runtime.log(`  ... and ${overflow} more`);
        }
    }
    recordExitReason(runtime.statePath, 'pipeline_phase_incomplete');
}
/**
 * R-PIPE-2: collect Done-ticket count + commits-since-start_commit for the
 * pickle-phase progress gate. Used by `runPhaseIteration` to detect the
 * hallucinated-completion class where mux-runner exits clean (code 0) but
 * no ticket reached markTicketDone AND no commit landed since session start.
 *
 * Both reads are best-effort: a missing/unreadable state file or a `git log`
 * failure collapses to `commitCount: 0` so the gate still fires when
 * tickets are also empty. This matches the B-PIPE-FIX R-PIPE-2 contract:
 * the gate is a safety net for the manager-ran-out-of-turns case observed
 * across the 2026-05-18 PM B-SJET-2 attempts (4 consecutive 31m+ runs at
 * exit_reason='completed' with 0 Done, 0 commits).
 */
/**
 * R-PIPE-2: gate helper extracted from `runPhaseIteration` to keep that
 * function under the cyclomatic-complexity ceiling. Returns a break outcome
 * iff the pickle phase exited clean with 0 Done + 0 commits; otherwise null
 * (caller continues to the existing success path).
 */
function maybeStampPhaseNoProgress(runtime, rawPhase, exitCode, log) {
    if (rawPhase !== 'pickle' || exitCode !== 0)
        return null;
    const progress = collectPicklePhaseProgress(runtime);
    // Sessions without tickets (dispatch-only smoke tests, codex-required PRD
    // bundles that never decompose) have no progress contract to enforce —
    // preserve the existing clean-exit success path. The gate targets the
    // "tickets exist but none completed AND no commits landed" class.
    if (progress.ticketCount === 0)
        return null;
    if (progress.doneCount !== 0 || progress.commitCount !== 0)
        return null;
    const shortStart = progress.startCommit
        ? progress.startCommit.slice(0, 8)
        : 'session start';
    log(`Phase ${rawPhase} exited with no progress (0 Done of ${progress.ticketCount} tickets, 0 commits since ${shortStart})`);
    recordExitReason(runtime.statePath, 'phase_no_progress');
    return { action: 'break', phaseIncomplete: true };
}
function collectPicklePhaseProgress(runtime) {
    const tickets = collectTickets(runtime.sessionDir);
    const doneCount = tickets.filter(t => (t.status || '').toLowerCase() === 'done').length;
    // Pending = still runnable: not Done, not Skipped. Skipped tickets are
    // intentionally terminal, so they must NOT count as incomplete (R-PPPA).
    const pendingCount = tickets.filter(t => {
        const s = (t.status || '').toLowerCase();
        return s !== 'done' && s !== 'skipped';
    }).length;
    let commitCount = 0;
    let startCommit = null;
    try {
        const state = sm.read(runtime.statePath);
        if (typeof state.start_commit === 'string' && state.start_commit.length > 0) {
            startCommit = state.start_commit;
        }
    }
    catch { /* best-effort */ }
    if (startCommit) {
        try {
            // why workingDir, not repoRoot: subprocess cwd — git log resolves HEAD from any dir in the repo
            const out = execFileSync('git', ['log', '--oneline', `${startCommit}..HEAD`], {
                cwd: runtime.workingDir,
                encoding: 'utf8',
                timeout: 10_000,
            });
            if (typeof out === 'string') {
                commitCount = out.split('\n').filter(line => line.trim().length > 0).length;
            }
        }
        catch { /* git failure → treat as 0 commits */ }
    }
    return { doneCount, commitCount, ticketCount: tickets.length, pendingCount, startCommit };
}
/**
 * R-PPPA (Finding #59): catch the false phase-advance where pickle's mux-runner
 * exits clean (code 0) with SOME but not all tickets resolved — the codex
 * manager hallucinating `EPIC_COMPLETED` triggers an early clean exit.
 * `maybeStampPhaseNoProgress` only catches the 0-Done/0-commit case; the
 * N-of-M-Done case (2/13, 3/13) sails through. When runnable tickets remain,
 * stamp the transient `phase_incomplete_tickets` exit_reason and break so the
 * pipeline does NOT advance pickle→citadel→anatomy-park on an incomplete bundle.
 */
function maybeStampPhaseIncompleteTickets(runtime, rawPhase, exitCode, log) {
    if (rawPhase !== 'pickle' || exitCode !== 0)
        return null;
    const progress = collectPicklePhaseProgress(runtime);
    if (progress.ticketCount === 0)
        return null;
    if (progress.pendingCount === 0)
        return null;
    // R-CMWL-2: a pass that shipped ≥1 Done ticket or ≥1 new commit made forward
    // progress — return null so the R-CMWL-1 relaunch path continues the phase
    // rather than treating a progressing-but-incomplete bundle as fatal.
    // Only stamp the terminal reason when the pass made ZERO progress.
    if (progress.doneCount > 0 || progress.commitCount > 0)
        return null;
    log(`Phase ${rawPhase} exited clean but ${progress.pendingCount}/${progress.ticketCount} tickets remain unresolved (${progress.doneCount} Done) — incomplete bundle, no progress this pass`);
    recordExitReason(runtime.statePath, 'phase_incomplete_tickets');
    return { action: 'break', phaseIncomplete: true };
}
/**
 * R-PRH: exit reasons that a phase runner stamps for a documented clean stop
 * where the worker shipped and a human/manager must finish the handoff (closer
 * release work, manager-handoff section). pipeline-runner must preserve these
 * verbatim — folding them into the generic `failed` mislabels a clean handoff
 * as a fatal failure to anyone reading `state.exit_reason`.
 */
// 'recovery_exhausted' is intentionally absent here — it is a fatal non-recoverable failure
// (isFailureExit=true), NOT an operator handoff; auto-resume.sh R-CNAR-4(c) stops on it.
const PIPELINE_HANDOFF_EXIT_REASONS = new Set(['manager_handoff_pending', 'closer_handoff_terminal']);
function readHandoffExitReason(statePath) {
    try {
        const reason = sm.read(statePath).exit_reason;
        return typeof reason === 'string' && PIPELINE_HANDOFF_EXIT_REASONS.has(reason) ? reason : null;
    }
    catch {
        return null;
    }
}
// R-PRNF-9: detect the distinct readiness-halt marker stamped by dispatchHaltAction.
// Preserving it in finalizePipeline prevents the generic 'failed' from overwriting it
// while still allowing effectiveFailed=true (pipeline-status.json reports 'failed').
function readPickleReadinessHalt(statePath) {
    try {
        return sm.read(statePath).exit_reason === 'pickle_readiness_halt';
    }
    catch {
        return false;
    }
}
function finalizePipeline(runtime, counters, cancelMarker, startTime, phaseIncomplete) {
    const totalElapsed = Math.floor((Date.now() - startTime) / 1000);
    const pipelineFailed = (counters.completed + counters.skipped) < runtime.config.phases.length;
    const handoffStop = !!readHandoffExitReason(runtime.statePath);
    // R-PRNF-9: preserve 'pickle_readiness_halt' stamped by dispatchHaltAction;
    // effectiveFailed stays true (pipelineFailed=true, handoffStop=false) so
    // writePipelineStatus still reports 'failed' and process.exit(1) (AC-PRNF-9-3).
    const readinessHalt = readPickleReadinessHalt(runtime.statePath);
    // A handoff stop is a deliberate pause, not a failure — fold it out once.
    const effectiveFailed = pipelineFailed && !handoffStop;
    if (phaseIncomplete || handoffStop || readinessHalt) {
        // Preserve the exit_reason already stamped by reportPhaseIncomplete, by a
        // phase runner's manager/closer handoff (R-PRH), or by dispatchHaltAction
        // for a readiness halt (R-PRNF-9); do not overwrite with the generic 'failed'.
        finalizeTerminalState(runtime.statePath, { step: 'completed' });
    }
    else {
        finalizeTerminalState(runtime.statePath, {
            step: 'completed',
            exitReason: pipelineFailed ? 'failed' : 'completed',
        });
    }
    // R-PSSS-3: name each skip disposition (`anatomy-park: empty_scope`) so the
    // final summary distinguishes an empty-scope skip from a setup error.
    const skipDetail = Object.entries(counters.phaseSkips)
        .map(([phase, reason]) => `${phase}: ${reason}`)
        .join('; ');
    const phasesSummary = counters.skipped > 0
        ? `${counters.completed}/${runtime.config.phases.length} (${counters.skipped} skipped${skipDetail ? ` — ${skipDetail}` : ''})`
        : `${counters.completed}/${runtime.config.phases.length}`;
    printMinimalPanel('Pipeline Complete', {
        Phases: phasesSummary,
        Elapsed: formatTime(totalElapsed),
    }, 'GREEN', '🧪');
    writeFinalPipelineActivity(runtime, totalElapsed, phasesSummary, effectiveFailed);
    // handoff stops skip closer-release
    if (!pipelineFailed && !handoffStop) {
        const closerPlan = buildCloserReleasePlan(sm.read(runtime.statePath));
        executeCloserReleasePlan(closerPlan, _closerReleaseActionsForTests ?? {
            install: () => { },
            tag: () => { },
        }, runtime.log);
    }
    try {
        fs.unlinkSync(cancelMarker);
    }
    catch { /* may not exist */ }
    writePipelineStatus(runtime.sessionDir, effectiveFailed ? 'failed' : 'completed', {
        current_phase: null,
        completed_phases: counters.completed,
        skipped_phases: counters.skipped,
        total_phases: runtime.config.phases.length,
    });
    if (phaseIncomplete) {
        process.exit(PipelineRunnerExitCode.PhaseIncomplete);
    }
    process.exit(effectiveFailed ? PipelineRunnerExitCode.Failure : PipelineRunnerExitCode.Success);
}
function emitHeadMismatchStderr(statePath) {
    try {
        const s = sm.read(statePath);
        if (s.exit_reason !== 'working_tree_modified_externally')
            return false;
        const detail = s.head_pin_mismatch_detail;
        const pinned = detail ? String(detail.pinned_branch ?? 'null') : String(s.pinned_branch ?? 'null');
        const observed = detail ? String(detail.observed_branch ?? 'unknown') : 'unknown';
        process.stderr.write(`[pipeline-runner] HEAD mismatch: pinned_branch=${pinned} observed_branch=${observed}\n`);
        return true;
    }
    catch {
        return false;
    }
}
export function logPhaseHaltReason(runtime, rawPhase, exitCode, log) {
    const haltMsg = `Phase ${rawPhase} failed (exit ${exitCode}) — stopping pipeline`;
    // R-PIWG-1: surface HEAD mismatch before phase-type gating so it fires for all phases.
    if (exitCode !== 0 && emitHeadMismatchStderr(runtime.statePath)) {
        log(`Phase ${rawPhase} aborted: working_tree_modified_externally`);
        return 'abort';
    }
    if (exitCode === 0 || (rawPhase !== 'anatomy-park' && rawPhase !== 'szechuan-sauce')) {
        log(haltMsg);
        return 'abort';
    }
    try {
        const runnerState = sm.read(runtime.statePath);
        const decision = classifyMicroverseHaltDecision(runnerState.exit_reason);
        if (decision.action === 'run-finalize-gate') {
            log(`Phase ${rawPhase}: microverse exited with ${decision.recognizedExitReason} — running finalize-gate anyway (transient measurement timeout, recoverable per R-PRJT-2)`);
            return decision.action;
        }
        if (decision.action === 'run-finalize-gate-incomplete') {
            log(`Phase ${rawPhase}: microverse exited with ${decision.recognizedExitReason} — running finalize-gate (phase will be marked incomplete on pass)`);
            return decision.action;
        }
        if (decision.recognizedExitReason !== null) {
            log(`Phase ${rawPhase}: microverse exited with ${decision.recognizedExitReason} — pipeline aborting (no finalize-gate)`);
            return decision.action;
        }
        log(haltMsg);
        return 'abort';
    }
    catch {
        log(haltMsg);
        return 'abort';
    }
}
/**
 * R-PRJT-2 recovery: a microverse phase that exited on a transient
 * `judge_timeout` re-runs finalize-gate; a clean gate completes the phase,
 * a red gate breaks the pipeline. Extracted from `runPhaseIteration` to keep
 * that function under the eslint complexity ceiling.
 */
async function runJudgeTimeoutFinalizeGate(runtime, counters, rawPhase, log) {
    try {
        logActivity({
            event: 'pipeline_judge_timeout_recovery_attempted',
            source: 'pickle',
            phase: rawPhase,
            attempts: 4,
            fall_through_to_finalize_gate: true,
        });
    }
    catch { /* telemetry best-effort */ }
    const skill = rawPhase === 'anatomy-park' ? 'anatomy-park' : 'szechuan';
    const gateResult = await runSpawnRunner('node', [
        path.join(runtime.extensionRoot, 'extension', 'bin', 'finalize-gate.js'),
        runtime.sessionDir,
        skill,
    ], runtime.phaseEnv);
    if (gateResult.exitCode === 0) {
        counters.completed++;
        writeRunningStatus(runtime, counters, null);
        log(`Phase ${rawPhase} finalize-gate passed after judge_timeout recovery`);
        return { action: 'continue' };
    }
    log(`Phase ${rawPhase} finalize-gate failed after judge_timeout recovery (exit ${gateResult.exitCode})`);
    return { action: 'break' };
}
/**
 * all_judge_backends_exhausted recovery: spawn finalize-gate; on pass mark the
 * phase incomplete so auto-resume can retry; on fail break the pipeline.
 */
async function runAllBackendsExhaustedFinalizeGate(runtime, counters, rawPhase, log) {
    try {
        logActivity({
            event: 'pipeline_all_backends_exhausted_recovery_attempted',
            source: 'pickle',
            phase: rawPhase,
            fall_through_to_finalize_gate: true,
        });
    }
    catch { /* telemetry best-effort */ }
    const skill = rawPhase === 'anatomy-park' ? 'anatomy-park' : 'szechuan';
    const gateResult = await runSpawnRunner('node', [
        path.join(runtime.extensionRoot, 'extension', 'bin', 'finalize-gate.js'),
        runtime.sessionDir,
        skill,
    ], runtime.phaseEnv);
    if (gateResult.exitCode === 0) {
        reportPhaseIncomplete(runtime, rawPhase);
        log(`Phase ${rawPhase} finalize-gate passed after all_judge_backends_exhausted — marking phase incomplete for auto-resume`);
        return { action: 'break', phaseIncomplete: true };
    }
    log(`Phase ${rawPhase} finalize-gate failed after all_judge_backends_exhausted (exit ${gateResult.exitCode})`);
    return { action: 'break' };
}
async function dispatchHaltAction(runtime, counters, rawPhase, exitCode, log) {
    const haltAction = logPhaseHaltReason(runtime, rawPhase, exitCode, log);
    // R-PRNF-9: promote mux-runner's generic 'readiness_halt' to the distinct
    // pipeline-runner exit_reason so finalizePipeline can preserve it (AC-PRNF-9-2).
    if (rawPhase === 'pickle') {
        try {
            if (sm.read(runtime.statePath).exit_reason === 'readiness_halt') {
                recordExitReason(runtime.statePath, 'pickle_readiness_halt');
            }
        }
        catch { /* best-effort; original readiness_halt still signals failure */ }
    }
    if (haltAction === 'run-finalize-gate') {
        return runJudgeTimeoutFinalizeGate(runtime, counters, rawPhase, log);
    }
    if (haltAction === 'run-finalize-gate-incomplete') {
        return runAllBackendsExhaustedFinalizeGate(runtime, counters, rawPhase, log);
    }
    return { action: 'break' };
}
async function runPhaseIteration(runtime, counters, cancelMarker, rawPhase, index, log) {
    logPhaseStart(runtime, rawPhase, index);
    writeRunningStatus(runtime, counters, rawPhase);
    const result = await runConfiguredPhase(runtime, setupPhase(rawPhase, runtime.config), counters);
    if (result.skipped) {
        counters.skipped++;
        // R-PSSS-3: record the specific skip disposition for pipeline-status.json
        // and the final summary instead of the generic "setup returned false".
        if (result.skipReason)
            counters.phaseSkips[rawPhase] = result.skipReason;
        writeRunningStatus(runtime, counters, null);
        log(`Phase ${rawPhase} skipped (${result.skipReason ?? 'setup returned false'})`);
        return { action: 'continue' };
    }
    const exitCode = result.exitCode ?? 1;
    log(`Phase ${rawPhase} exited with code ${exitCode}`);
    // R-PRH: a manager/closer handoff is a documented clean stop — the worker
    // shipped and a human must finish. Stop the pipeline here, preserving the
    // handoff exit_reason, instead of advancing or mislabeling it as 'failed'.
    // R-CCR-3: gate the handoff break on exitCode === 0. A non-zero exit carrying
    // a stale handoff reason must be treated as a failure, not a clean stop.
    const handoffReason = readHandoffExitReason(runtime.statePath);
    if (handoffReason) {
        if (exitCode === 0) {
            log(`Phase ${rawPhase} stopped for manager handoff (exit_reason=${handoffReason}) — pipeline paused for operator/closer work`);
            return { action: 'break' };
        }
        // Non-zero exit — stale handoff reason must be cleared so finalizePipeline
        // does not preserve it as a clean handoff (R-CCR-3 twin-read leak).
        clearExitReason(runtime.statePath);
    }
    const skipWarning = shouldSkipAnatomyPhaseWithWarning(rawPhase, {
        exitCode,
        stdout: '',
        stderr: result.stderr ?? '',
    }, runtime);
    if (skipWarning) {
        counters.skipped++;
        writeRunningStatus(runtime, counters, null);
        log(`phase_skipped_with_warning ${JSON.stringify({
            phase: rawPhase,
            exit_code: exitCode,
            warning_class: skipWarning.warningClass,
            detail: skipWarning.detail,
        })}`);
        return { action: 'continue' };
    }
    if (exitCode === PipelineRunnerExitCode.PhaseIncomplete) {
        reportPhaseIncomplete(runtime, rawPhase);
        return { action: 'break', phaseIncomplete: true };
    }
    const shouldHalt = shouldHaltAfterPhase(rawPhase, exitCode, runtime);
    if (exitCode !== 0 && !shouldHalt) {
        recordRecoverablePhaseFailure(runtime, rawPhase, exitCode, index, 'continue');
        logPhaseContinueReason(runtime, rawPhase, exitCode);
    }
    if (shouldHalt) {
        return dispatchHaltAction(runtime, counters, rawPhase, exitCode, log);
    }
    // why workingDir, not repoRoot: AC gate commands run in the package dir, not the git toplevel
    const acGate = runAcPhaseGate({
        sessionDir: runtime.sessionDir,
        evaluationPhase: 'per-phase',
        pipelinePhase: rawPhase,
        cwd: runtime.workingDir,
        stdout: (msg) => log(msg),
        stderr: (msg) => log(msg),
    });
    writeWatcherLivenessArtifact(runtime.sessionDir, rawPhase);
    if (acGate.status !== 'pass') {
        log(`Phase ${rawPhase} AC gate failed — stopping pipeline`);
        return { action: 'break' };
    }
    return finalizePhaseSuccess(runtime, counters, cancelMarker, rawPhase, exitCode, log);
}
export function classifyMicroverseHaltDecision(exitReason) {
    if (exitReason === 'judge_timeout') {
        return { action: 'run-finalize-gate', recognizedExitReason: exitReason };
    }
    if (exitReason === 'all_judge_backends_exhausted') {
        return { action: 'run-finalize-gate-incomplete', recognizedExitReason: exitReason };
    }
    if (typeof exitReason === 'string'
        && (isMicroverseFatalReason(exitReason)
            || isMicroverseFailureExit(exitReason))) {
        return { action: 'abort', recognizedExitReason: exitReason };
    }
    return { action: 'abort', recognizedExitReason: null };
}
/**
 * R-PIPE-2: post-AC-gate success path extracted from `runPhaseIteration` so
 * the no-progress gate, counter increment, cancel-marker check, and success
 * log do not push `runPhaseIteration` past the cyclomatic-complexity ceiling.
 */
function finalizePhaseSuccess(runtime, counters, cancelMarker, rawPhase, exitCode, log) {
    const noProgressBreak = maybeStampPhaseNoProgress(runtime, rawPhase, exitCode, log);
    if (noProgressBreak)
        return noProgressBreak;
    const incompleteBreak = maybeStampPhaseIncompleteTickets(runtime, rawPhase, exitCode, log);
    if (incompleteBreak)
        return incompleteBreak;
    counters.completed++;
    writeRunningStatus(runtime, counters, null);
    if (fs.existsSync(cancelMarker)) {
        log('Pipeline cancelled (cancel marker found) — stopping');
        return { action: 'break' };
    }
    log(`Phase ${rawPhase} completed successfully`);
    return { action: 'continue' };
}
function setProducerDone(runtime, value) {
    try {
        sm.update(runtime.statePath, s => {
            if (Array.isArray(s.monitor_panes) && s.monitor_panes[2]) {
                s.monitor_panes[2].producer_done = value;
            }
        });
    }
    catch { /* best-effort */ }
}
async function handlePhaseBoundaryRespawn(runtime, rawPhase, nextRawPhase) {
    if (rawPhase === 'pickle' && nextRawPhase === 'citadel')
        return;
    if (nextRawPhase !== 'anatomy-park' && nextRawPhase !== 'szechuan-sauce' && nextRawPhase !== undefined)
        return;
    // R-MDS-6: signal pane 2 producer is done BEFORE respawn
    setProducerDone(runtime, true);
    const phase = nextRawPhase ?? 'exit';
    await respawnMonitorWindowForMode(runtime.sessionDir, phase, { log: runtime.log });
    // R-MDS-6: reset flag so replacement watcher shows normal no-data message
    setProducerDone(runtime, false);
}
export async function main(sessionDir, opts = {}) {
    try {
        assertSchemaVersionDeployParity();
    }
    catch (err) {
        if (err instanceof SchemaVersionDeployDriftError) {
            process.stderr.write(`${safeErrorMessage(err)}\n`);
            process.exit(1);
        }
        throw err;
    }
    const log = createPipelineLog(sessionDir);
    log('pipeline-runner started');
    const runtime = loadPipelineRuntime(sessionDir, opts, log);
    const counters = { completed: 0, skipped: 0, phaseSkips: {} };
    const cancelMarker = path.join(sessionDir, 'pipeline-cancel');
    const cleanupShutdownHandlers = installShutdownHandlers(runtime, counters, cancelMarker);
    const startTime = Date.now();
    let phaseIncomplete = false;
    phaseRunnerContext = {
        sessionDir,
        extensionRoot: runtime.extensionRoot,
        childMuxRunnerHeartbeatMs: runtime.config.child_mux_runner_heartbeat_ms,
        childMuxRunnerStallSeconds: runtime.config.child_mux_runner_stall_seconds,
    };
    writeRunningStatus(runtime, counters, null);
    try {
        for (let i = 0; i < runtime.config.phases.length; i++) {
            const rawPhase = runtime.config.phases[i];
            if (!isPhaseName(rawPhase)) {
                log(`Unknown phase: ${String(rawPhase)} — skipping`);
                continue;
            }
            const outcome = await runPhaseIteration(runtime, counters, cancelMarker, rawPhase, i, log);
            if (outcome.action === 'break') {
                if (outcome.phaseIncomplete)
                    phaseIncomplete = true;
                break;
            }
            // R-MDS-1: Rebind monitor dashboard pane at non-citadel phase boundaries.
            const nextRawPhase = runtime.config.phases[i + 1];
            await handlePhaseBoundaryRespawn(runtime, rawPhase, nextRawPhase);
        }
    }
    finally {
        phaseRunnerContext = null;
        cleanupShutdownHandlers();
    }
    finalizePipeline(runtime, counters, cancelMarker, startTime, phaseIncomplete);
}
/** Extract the value following `flag` in argv, or `undefined` if absent. */
function parseArgvFlag(argv, flag) {
    const idx = argv.indexOf(flag);
    if (idx === -1 || idx + 1 >= argv.length)
        return undefined;
    return argv[idx + 1];
}
/** First argv token that's not a flag and not the value of a preceding flag. */
function findPositional(argv, valuedFlags) {
    for (let i = 0; i < argv.length; i++) {
        const prev = i > 0 ? argv[i - 1] : '';
        if (argv[i].startsWith('--'))
            continue;
        if (valuedFlags.has(prev))
            continue;
        return argv[i];
    }
    return undefined;
}
if (process.argv[1] && path.basename(process.argv[1]) === 'pipeline-runner.js') {
    const argv = process.argv.slice(2);
    const valuedFlags = new Set(['--scope', '--scope-base']);
    const sessionDir = findPositional(argv, valuedFlags);
    const statePath = sessionDir ? path.join(sessionDir, 'state.json') : '';
    if (!sessionDir || readRecoverableJsonObject(statePath) === null) {
        console.error('Usage: node pipeline-runner.js <session-dir> [--scope <flag>] [--scope-base <ref>] [--strict-phases] [--design-safe | --no-design-safe]');
        process.exit(1);
    }
    const scopeFlag = parseArgvFlag(argv, '--scope');
    const scopeBase = parseArgvFlag(argv, '--scope-base');
    const strictPhases = argv.includes('--strict-phases');
    // R-PIAP-B2: explicit override wins; if both present, --design-safe wins.
    const designSafeFlag = argv.includes('--design-safe') ? true :
        argv.includes('--no-design-safe') ? false :
            undefined;
    main(sessionDir, { scopeFlag, scopeBase, strictPhases, designSafeFlag }).catch((err) => {
        // AC-CWRR-5: carry forward completed/skipped/total from the last writeRunningStatus
        // call so the fatal-exit status does not zero out phases that already completed.
        let fatalCompletedPhases = 0;
        let fatalSkippedPhases = 0;
        let fatalTotalPhases = 0;
        try {
            const prior = readRecoverableJsonObject(path.join(sessionDir, 'pipeline-status.json'));
            if (prior) {
                if (typeof prior.completed_phases === 'number')
                    fatalCompletedPhases = prior.completed_phases;
                if (typeof prior.skipped_phases === 'number')
                    fatalSkippedPhases = prior.skipped_phases;
                if (typeof prior.total_phases === 'number')
                    fatalTotalPhases = prior.total_phases;
            }
        }
        catch { /* best effort — fall back to zero counts */ }
        try {
            writePipelineStatus(sessionDir, 'failed', {
                completed_phases: fatalCompletedPhases,
                skipped_phases: fatalSkippedPhases,
                total_phases: fatalTotalPhases,
            });
        }
        catch { /* best effort */ }
        const fatalStatePath = path.join(sessionDir, 'state.json');
        try {
            recordExitReason(fatalStatePath, 'fatal');
            safeDeactivate(fatalStatePath);
        }
        catch { /* best effort — never block fatal exit on state write */ }
        const msg = safeErrorMessage(err);
        console.error(`${Style.RED}[FATAL] ${msg}${Style.RESET}`);
        process.exit(1);
    });
}
