/**
 * headless-triage.ts — `sf headless triage`
 *
 * Purpose: operator-driven entry point for self-feedback triage. Lists open
 * forge-local self-feedback entries (sorted by impact ↓ effort ↑ ts ↑) and
 * either:
 *   - emits the canonical triage prompt (default): the same one the
 *     bootstrap session_start drain queues as a followUp, but rendered
 *     synchronously to stdout so operators can pipe it into any model
 *     (`sf headless triage | sf headless -p -`, or any external assistant)
 *     without depending on the autonomous-loop turn semantics that
 *     swallow the followUp when no other unit is dispatchable.
 *   - --list: human-readable candidate digest, no prompt — for scanning
 *     the queue at a glance.
 *   - --json: structured candidate list for tooling.
 *
 * Why this command exists (sf-mp4rxkwb-l4baga): the inline-fix worker
 * currently delivers via `triggerTurn:true, deliverAs:"followUp"`. When
 * autonomous mode bails at milestone validation before any turn runs,
 * the followUp never lands and the queue stays unprocessed. This command
 * gives operators a deterministic path to drain the queue today, ahead of
 * the larger refactor that promotes triage to a real SF unit type.
 *
 * Consumer: headless.ts when command === "triage".
 */

import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createJiti } from "@mariozechner/jiti";
import {
	createAgentSession,
	runSubagent,
	SessionManager,
} from "@singularity-forge/coding-agent";
import { parse as parseYaml } from "yaml";
import { resolveBundledSourceResource } from "./bundled-resource-path.js";
import { getSfEnv } from "./env.js";

const jiti = createJiti(import.meta.filename, {
	interopDefault: true,
	debug: false,
});

function sfExtensionPath(moduleName: string): string {
	const agentExtensionsDir = join(getSfEnv().agentDir, "extensions", "sf");
	const useAgentDir = existsSync(join(agentExtensionsDir, "state.js"));
	if (useAgentDir) return join(agentExtensionsDir, `${moduleName}.js`);
	const tsPath = resolveBundledSourceResource(
		import.meta.url,
		"extensions",
		"sf",
		`${moduleName}.ts`,
	);
	if (existsSync(tsPath)) return tsPath;
	return resolveBundledSourceResource(
		import.meta.url,
		"extensions",
		"sf",
		`${moduleName}.js`,
	);
}

export interface HandleTriageOptions {
	json?: boolean;
	list?: boolean;
	max?: number;
	run?: boolean;
	apply?: boolean;
	model?: string;
	agentRunner?: AgentRunner;
}

export interface HandleTriageResult {
	exitCode: number;
}

interface TriageCandidate {
	id: string;
	kind: string;
	severity: string;
	summary: string;
	ts: string;
	impactScore?: number;
	effortEstimate?: number;
}

interface AgentConfig {
	name: string;
	model?: string;
	tools?: string[];
	systemPrompt: string;
	promptParts?: string[];
	source?: string;
	filePath?: string;
}

interface AgentRunResult {
	ok: boolean;
	output: string;
	stderr?: string;
	exitCode?: number;
}

type AgentRunner = (
	agent: AgentConfig,
	task: string,
	options?: { tools?: string[]; model?: string; cwd?: string },
) => Promise<AgentRunResult>;

type RunnableModelRegistry = {
	find(provider: string, modelId: string): unknown;
	getAll(): Array<{ provider?: string; id?: string }>;
	isProviderRequestReady(provider: string): boolean;
};

/**
 * Triage-decider's output contract is a YAML fenced block with key
 * `decisions:`. Parse it. Returns null when no plan is present or YAML
 * fails to load — runTriageApply treats null as "do not apply" (safe
 * default: when in doubt, never mutate).
 *
 * Why a structured plan instead of letting the decider call resolve_issue
 * directly: codex review 2026-05-14 flagged that the original sequential
 * design (decider → review-code) let the decider mutate state during its
 * own turn, before review-code ever saw the decisions. This parser pulls
 * the proposed actions out of the decider's text so they can be reviewed
 * BEFORE any resolve_issue call.
 */
export interface TriageDecision {
	id: string;
	outcome: "fix" | "promote" | "close";
	evidenceKind?: string;
	reason?: string;
	proposedApproach?: string;
	requirementId?: string;
}

export interface ParseTriagePlanResult {
	plan: TriageDecision[] | null;
	error: string | null;
}

const COMPLETION_MARKER = "Self-feedback triage complete";

/**
 * Parse a decider output into a strict decision plan, or return a
 * structured error explaining what's wrong. Codex review 2026-05-14
 * follow-up: refuse the whole plan if ANY item is malformed instead of
 * silently dropping items — partial-trust on a triage plan is worse
 * than no apply at all.
 *
 * Validates:
 *   1. The completion marker is present (signals the decider finished).
 *   2. A single fenced ```yaml block with key `decisions:` exists.
 *   3. Every item has `id` (non-empty string) and `outcome` ∈
 *      {fix, promote, close}.
 *   4. Outcome-specific required fields:
 *        - close   → reason (non-empty) + evidence_kind (defaults to
 *                    human-clear if omitted, but if provided must be
 *                    a non-empty string).
 *        - promote → reason + requirement_id (non-empty strings).
 *        - fix     → proposed_approach (non-empty).
 *   5. If `expectedIds` is supplied (the candidate set the decider was
 *      shown), every decision id must be in that set and every expected
 *      id must have exactly one decision — no extras, no missing.
 */
export function parseTriagePlanStrict(
	text: string,
	expectedIds?: string[],
): ParseTriagePlanResult {
	if (typeof text !== "string" || text.length === 0) {
		return { plan: null, error: "decider output was empty" };
	}
	if (!text.includes(COMPLETION_MARKER)) {
		return {
			plan: null,
			error: `decider output is missing the completion marker "${COMPLETION_MARKER}" — treating as incomplete`,
		};
	}
	const fenceMatches = Array.from(
		text.matchAll(/```ya?ml\s*\n([\s\S]*?)\n```/gi),
	);
	if (fenceMatches.length === 0) {
		return {
			plan: null,
			error: "decider output has no fenced yaml block with the decision plan",
		};
	}
	if (fenceMatches.length > 1) {
		return {
			plan: null,
			error: `decider output has ${fenceMatches.length} fenced yaml blocks — the contract is exactly one`,
		};
	}
	const yamlBody = fenceMatches[0][1];
	let parsed: unknown;
	try {
		parsed = parseYaml(yamlBody);
	} catch (err) {
		return {
			plan: null,
			error: `decision plan failed to parse as yaml: ${
				err instanceof Error ? err.message : String(err)
			}`,
		};
	}
	const root = parsed as Record<string, unknown> | null;
	const decisions = root?.decisions;
	if (!Array.isArray(decisions)) {
		return {
			plan: null,
			error: "decision plan must have a top-level `decisions:` array",
		};
	}
	if (decisions.length === 0) {
		return {
			plan: null,
			error: "decision plan has zero decisions — nothing to apply",
		};
	}
	const out: TriageDecision[] = [];
	const seenIds = new Set<string>();
	for (let i = 0; i < decisions.length; i++) {
		const raw = decisions[i];
		if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
			return {
				plan: null,
				error: `decisions[${i}] is not an object`,
			};
		}
		const item = raw as Record<string, unknown>;
		const id = typeof item.id === "string" ? item.id.trim() : "";
		if (!id) {
			return {
				plan: null,
				error: `decisions[${i}] is missing a non-empty id`,
			};
		}
		if (seenIds.has(id)) {
			return {
				plan: null,
				error: `decisions[${i}] duplicates id "${id}" — each ledger entry must appear exactly once`,
			};
		}
		seenIds.add(id);
		const outcome = item.outcome;
		if (outcome !== "fix" && outcome !== "promote" && outcome !== "close") {
			return {
				plan: null,
				error: `decisions[${i}] (id=${id}) has invalid outcome "${outcome}" — expected fix|promote|close`,
			};
		}
		const decision: TriageDecision = { id, outcome };
		const reason = typeof item.reason === "string" ? item.reason.trim() : "";
		const evidenceKindRaw =
			typeof item.evidence_kind === "string"
				? item.evidence_kind
				: typeof item.evidenceKind === "string"
					? (item.evidenceKind as string)
					: "";
		if (evidenceKindRaw.trim()) decision.evidenceKind = evidenceKindRaw.trim();
		if (reason) decision.reason = reason;
		const proposedApproachRaw =
			typeof item.proposed_approach === "string"
				? item.proposed_approach
				: typeof item.proposedApproach === "string"
					? (item.proposedApproach as string)
					: "";
		if (proposedApproachRaw.trim()) {
			decision.proposedApproach = proposedApproachRaw.trim();
		}
		const requirementIdRaw =
			typeof item.requirement_id === "string"
				? item.requirement_id
				: typeof item.requirementId === "string"
					? (item.requirementId as string)
					: "";
		if (requirementIdRaw.trim()) {
			decision.requirementId = requirementIdRaw.trim();
		}

		// Outcome-specific required fields.
		if (outcome === "close" && !decision.reason) {
			return {
				plan: null,
				error: `decisions[${i}] (id=${id}, outcome=close) is missing required field reason`,
			};
		}
		if (outcome === "promote") {
			if (!decision.reason) {
				return {
					plan: null,
					error: `decisions[${i}] (id=${id}, outcome=promote) is missing required field reason`,
				};
			}
			if (!decision.requirementId) {
				return {
					plan: null,
					error: `decisions[${i}] (id=${id}, outcome=promote) is missing required field requirement_id`,
				};
			}
		}
		if (outcome === "fix" && !decision.proposedApproach) {
			return {
				plan: null,
				error: `decisions[${i}] (id=${id}, outcome=fix) is missing required field proposed_approach`,
			};
		}
		out.push(decision);
	}

	// Expected-id coverage check: if the caller knows which entries the
	// decider was shown, ensure the plan covers them exactly.
	if (Array.isArray(expectedIds) && expectedIds.length > 0) {
		const expected = new Set(expectedIds);
		for (const decision of out) {
			if (!expected.has(decision.id)) {
				return {
					plan: null,
					error: `decision id "${decision.id}" is not in the candidate set the decider was shown — possible hallucination`,
				};
			}
		}
		for (const id of expected) {
			if (!seenIds.has(id)) {
				return {
					plan: null,
					error: `candidate id "${id}" has no decision in the plan — incomplete coverage`,
				};
			}
		}
	}

	return { plan: out, error: null };
}

/**
 * Backwards-compat wrapper for the lenient parser used by existing tests
 * and the test fixtures. Returns just the plan, no error explanation.
 * Production callers should use parseTriagePlanStrict.
 */
export function parseTriagePlan(text: string): TriageDecision[] | null {
	return parseTriagePlanStrict(text).plan;
}

/**
 * Default per-agent timeout: 8 minutes. Long enough for a real LLM reasoning
 * pass + tool calls; short enough that a hung gemini OAuth or stalled
 * provider doesn't lock the whole triage flow indefinitely. Operators can
 * override via SF_TRIAGE_AGENT_TIMEOUT_MS env var.
 *
 * The earlier version had no timeout at all — `defaultAgentRunner` waited
 * forever on `proc.on("close")`, so a single hung subagent dispatch
 * blocked the orchestrator until manual kill (observed 2026-05-14:
 * 33-minute-stuck triage --apply caused by an unresponsive provider).
 * The watchdog is now enforced inside `runSubagent` (SDK helper).
 */
const DEFAULT_AGENT_TIMEOUT_MS = (() => {
	const fromEnv = Number.parseInt(
		process.env.SF_TRIAGE_AGENT_TIMEOUT_MS ?? "",
		10,
	);
	if (Number.isFinite(fromEnv) && fromEnv > 0) return fromEnv;
	return 8 * 60 * 1000;
})();

function parseProviderModel(input: string): [string, string] | null {
	const slash = input.indexOf("/");
	if (slash <= 0 || slash === input.length - 1) return null;
	return [input.slice(0, slash), input.slice(slash + 1)];
}

/**
 * Select the first router-ranked model that the subagent runtime can actually
 * request.
 *
 * Purpose: prevent `sf headless triage --apply` from advertising a router pick
 * like `openrouter/openai/gpt-5.1` when the in-process subagent session registry
 * cannot use that route and would immediately fall back silently.
 *
 * Consumer: handleTriage before passing a model override into runTriageApply.
 */
export function chooseRunnableSubagentModel(
	candidates: string[],
	registry: RunnableModelRegistry,
): string | undefined {
	for (const candidate of candidates) {
		if (typeof candidate !== "string" || candidate.trim() === "") continue;
		const trimmed = candidate.trim();
		const parsed = parseProviderModel(trimmed);
		let match: { provider?: string; id?: string } | undefined;
		if (parsed) {
			const [provider, modelId] = parsed;
			match = registry.find(provider, modelId) as
				| { provider?: string; id?: string }
				| undefined;
		} else {
			match = registry
				.getAll()
				.find((m) => m.id === trimmed || `${m.provider}/${m.id}` === trimmed);
		}
		if (!match?.provider || !match.id) continue;
		if (!registry.isProviderRequestReady(match.provider)) continue;
		return `${match.provider}/${match.id}`;
	}
	return undefined;
}

async function resolveRunnableTriageModel(
	cwd: string,
	candidates: string[],
): Promise<string | undefined> {
	const { session } = await createAgentSession({
		cwd,
		sessionManager: SessionManager.inMemory(),
		persistModelChanges: false,
	});
	return chooseRunnableSubagentModel(candidates, session.modelRegistry);
}

async function defaultAgentRunner(
	agent: AgentConfig,
	task: string,
	options: { tools?: string[]; model?: string; cwd?: string } = {},
): Promise<AgentRunResult> {
	// Compose the system prompt via the prompt-parts registry. Dynamic
	// import because src/resources/ is excluded from the root tsconfig
	// (extensions get their own build). If the module isn't available
	// fall back to the agent's raw systemPrompt — degrades gracefully.
	const promptPartsModule = (await jiti.import(
		sfExtensionPath("subagent/prompt-parts"),
	)) as {
		composeAgentPrompt?: (
			agent: AgentConfig,
			context: { cwd: string; surface: string; tools?: string[] },
		) => string;
	};
	const composed =
		promptPartsModule.composeAgentPrompt?.(agent, {
			cwd: options.cwd ?? process.cwd(),
			surface: "headless",
			tools: options.tools ?? agent.tools,
		}) ?? agent.systemPrompt;
	const appendedPrompt = `${composed}\n\n## Task Input\n\n${task}`;
	// noOutputTimeoutMs = 60000 (60s): without this, a hung session.prompt
	// (sf-mp8e02m1-zpk903 — Promise deadlock pre-LLM-dispatch with 0 syscall
	// activity) blocks for the full timeoutMs (8min) before the parent
	// abort fires. 60s no-output captures the hang fast enough to keep the
	// autonomous loop moving while the root-cause investigation continues.
	const result = await runSubagent(
		{
			systemPrompt: appendedPrompt,
			model: options.model ?? agent.model,
			tools: options.tools ?? agent.tools,
			cwd: options.cwd ?? process.cwd(),
			name: agent.name,
		},
		task,
		{ timeoutMs: DEFAULT_AGENT_TIMEOUT_MS, noOutputTimeoutMs: 60_000 },
	);
	return {
		ok: result.ok,
		output: result.output,
		stderr:
			result.exitCode === 124
				? `${agent.name} timed out after ${DEFAULT_AGENT_TIMEOUT_MS}ms (configure SF_TRIAGE_AGENT_TIMEOUT_MS to extend)`
				: (result.stderr ?? ""),
		exitCode: result.exitCode,
	};
}

async function emitTriageApplyJournal(
	cwd: string,
	flowId: string,
	seq: number,
	eventType: string,
	data: Record<string, unknown> = {},
): Promise<void> {
	try {
		const journalModule = (await jiti.import(sfExtensionPath("journal"))) as {
			emitJournalEvent?: (
				basePath: string,
				entry: Record<string, unknown>,
			) => void;
		};
		journalModule.emitJournalEvent?.(cwd, {
			ts: new Date().toISOString(),
			flowId,
			seq,
			eventType,
			data,
		});
	} catch {
		// Journal is best-effort; the apply result remains authoritative.
	}
}

export interface RunTriageApplyResult {
	ok: boolean;
	agreed: boolean;
	error?: string;
	deciderOutput?: string;
	reviewOutput?: string;
	resolvedIds: string[];
	rejectedIds?: string[];
	pendingFixIds?: string[];
	flowId: string;
}

export async function runTriageApply(
	cwd: string,
	prompt: string,
	options: {
		model?: string;
		agentRunner?: AgentRunner;
		candidateCount?: number;
		// Expected ledger ids the decider was shown. When supplied, the
		// strict plan parser refuses any plan that adds new ids or omits
		// expected ones.
		expectedIds?: string[];
		// Test escape hatch. Production callers MUST NOT set this. Required
		// to pass a custom agentRunner because an arbitrary runner could
		// side-channel-mutate the ledger despite the read-only tool override
		// the orchestrator enforces (codex review 2026-05-14 follow-up).
		allowUntrustedRunner?: boolean;
		// When true, run Phase 1 (triage-decider) and Phase 2 (review-code)
		// but skip Phase 3 (applyTriagePlan). Designed for --run mode: the
		// decision plan is surfaced for operator review without any ledger
		// mutations. The trusted-source guard is also relaxed — read-only
		// inspection does not require built-in agents.
		dryRun?: boolean;
	} = {},
): Promise<RunTriageApplyResult> {
	const flowId = `triage-${options.dryRun ? "run" : "apply"}-${randomUUID()}`;
	let seq = 0;
	const emit = (eventType: string, data: Record<string, unknown> = {}) =>
		emitTriageApplyJournal(cwd, flowId, seq++, eventType, data);

	// Slice 3a of "Make UOK the SF Control Plane": every triage --apply run
	// emits three gate_run trace events (trusted-agent-source-gate,
	// triage-plan-validation-gate, triage-apply-review-gate) with canonical
	// UOK run context. status uok reads surface/runControl/... from these
	// events and classifies coverage as "ok"/"stale"/"incomplete".
	//
	// Dynamic imports because src/resources is excluded from the root
	// tsconfig (extensions have their own build). buildUokRunContext and
	// appendTraceEventRequired lives there; loaded once per run.
	const runContextModule = (await jiti.import(
		sfExtensionPath("uok/run-context"),
	)) as {
		buildUokRunContext: (opts: Record<string, unknown>) => {
			surface: string;
			runControl: string;
			permissionProfile: string;
			traceId: string;
			parentTrace?: string;
		} | null;
	};
	const traceWriterModule = (await jiti.import(
		sfExtensionPath("uok/trace-writer"),
	)) as {
		appendTraceEvent?: (
			basePath: string,
			traceId: string,
			event: Record<string, unknown>,
		) => void;
		appendTraceEventRequired?: (
			basePath: string,
			traceId: string,
			event: Record<string, unknown>,
		) => void;
		readTraceEvents?: (
			basePath: string,
			type: string,
			windowHours?: number,
		) => Array<Record<string, unknown>>;
	};

	// surface: "headless"        - runTriageApply is always operator-invoked
	//                              via sf headless triage --apply or --run.
	// runControl: "supervised"   - the operator launched this command; it's
	//                              not an autonomous-loop self-initiation.
	// permissionProfile: "high"  - --apply mutates the ledger, so the run
	//                              must have write permission.
	//                              dryRun=true uses "read" — no mutations.
	// traceId: flowId             - already a UUID-stamped per-run id.
	const uokContext = runContextModule.buildUokRunContext({
		surface: "headless",
		runControl: "supervised",
		permissionProfile: options.dryRun ? "low" : "high",
		traceId: flowId,
	});

	const emitTriageGate = (
		gateId: string,
		outcome: "pass" | "fail" | "manual-attention",
		rationale: string,
		extra: Record<string, unknown> = {},
	): Error | null => {
		if (!uokContext) {
			return new Error(
				`buildUokRunContext returned null for triage --${options.dryRun ? "run" : "apply"}`,
			);
		}
		const event = {
			type: "gate_run",
			traceId: uokContext.traceId,
			turnId: `triage-apply:${gateId}`,
			gateId,
			gateType: "quality-gate",
			outcome,
			failureClass:
				outcome === "fail"
					? "policy"
					: outcome === "manual-attention"
						? "manual-attention"
						: "none",
			rationale,
			attempt: 1,
			maxAttempts: 1,
			retryable: false,
			evaluatedAt: new Date().toISOString(),
			durationMs: 0,
			// Canonical UOK run context. status uok reads these from
			// trace events (slice 3a addition) so the gate
			// classifies as "ok" without needing a quality_gates parent
			// FK row to exist.
			surface: uokContext.surface,
			runControl: uokContext.runControl,
			permissionProfile: uokContext.permissionProfile,
			...extra,
		};
		try {
			if (typeof traceWriterModule.appendTraceEventRequired === "function") {
				traceWriterModule.appendTraceEventRequired(cwd, flowId, event);
			} else if (typeof traceWriterModule.appendTraceEvent === "function") {
				traceWriterModule.appendTraceEvent(cwd, flowId, event);
				const persisted = traceWriterModule
					.readTraceEvents?.(cwd, "gate_run", 24 * 30)
					.some(
						(ev) =>
							ev.traceId === event.traceId &&
							ev.turnId === event.turnId &&
							ev.gateId === event.gateId,
					);
				if (!persisted) {
					return new Error(
						`legacy trace writer did not persist ${gateId} gate event`,
					);
				}
			} else {
				return new Error("trace writer does not expose appendTraceEvent");
			}
			return null;
		} catch (err) {
			return err instanceof Error ? err : new Error(String(err));
		}
	};

	const emitRequiredTriageGate = async (
		gateId: string,
		outcome: "pass" | "fail" | "manual-attention",
		rationale: string,
		extra: Record<string, unknown> = {},
	): Promise<RunTriageApplyResult | null> => {
		const err = emitTriageGate(gateId, outcome, rationale, extra);
		if (!err) return null;
		await emit("triage-apply-failed", {
			reason: "uok-gate-emission-failed",
			gateId,
			error: err.message,
		});
		return {
			ok: false,
			agreed: false,
			error: `UOK gate emission failed for ${gateId}: ${err.message}`,
			resolvedIds: [],
			flowId,
		};
	};

	await emit("triage-apply-start", {
		candidateCount: options.candidateCount ?? null,
	});
	const agentsModule = (await jiti.import(
		sfExtensionPath("subagent/agents"),
	)) as {
		discoverAgents?: (cwd: string, scope: string) => { agents: AgentConfig[] };
	};
	const agents = agentsModule.discoverAgents?.(cwd, "both").agents ?? [];
	const triageDecider = agents.find((agent) => agent.name === "triage-decider");
	const reviewCode = agents.find((agent) => agent.name === "review-code");
	if (!triageDecider || !reviewCode) {
		const missing = [
			triageDecider ? null : "triage-decider",
			reviewCode ? null : "review-code",
		]
			.filter(Boolean)
			.join(", ");
		// Missing agents is a trusted-source-gate failure cause too — the
		// gate's contract is "both built-ins exist AND have source=builtin".
		// Emit it so operators see the failure in status uok, not just in
		// the triage-apply journal.
		const gateFailure = await emitRequiredTriageGate(
			"trusted-agent-source-gate",
			"fail",
			`required built-in agent(s) not discovered: ${missing}`,
			{ missing },
		);
		if (gateFailure) return gateFailure;
		await emit("triage-apply-failed", { reason: "missing-agent", missing });
		return {
			ok: false,
			agreed: false,
			error: `Missing built-in agent(s): ${missing}`,
			resolvedIds: [],
			flowId,
		};
	}
	// Trusted-source guard (codex review 2026-05-14): when --apply will
	// mutate the ledger, BOTH agents must be SF-shipped built-ins. A
	// project-level override could silently disable review-code's
	// independence. Operators can still customize behavior for inspect
	// workflows, but --apply uses only the shipped review contract.
	// dryRun=true (--run mode) skips this guard: read-only inspection does
	// not require built-in agents because no ledger mutations will occur.
	if (
		!options.dryRun &&
		(triageDecider.source !== "builtin" || reviewCode.source !== "builtin")
	) {
		const rationale = `non-builtin agents (triage-decider=${triageDecider.source}, review-code=${reviewCode.source})`;
		const gateFailure = await emitRequiredTriageGate(
			"trusted-agent-source-gate",
			"fail",
			rationale,
			{
				triageDeciderSource: triageDecider.source,
				reviewCodeSource: reviewCode.source,
			},
		);
		if (gateFailure) return gateFailure;
		await emit("triage-apply-failed", {
			reason: "untrusted-agent-source",
			triageDeciderSource: triageDecider.source,
			reviewCodeSource: reviewCode.source,
		});
		return {
			ok: false,
			agreed: false,
			error: `Refusing to --apply with non-builtin agents (triage-decider=${triageDecider.source}, review-code=${reviewCode.source}). Use \`sf headless triage --run\` for a reviewable decision artifact, or remove the project/user override.`,
			resolvedIds: [],
			flowId,
		};
	}
	if (!options.dryRun) {
		const trustGateFailure = await emitRequiredTriageGate(
			"trusted-agent-source-gate",
			"pass",
			"both triage-decider and review-code are SF-shipped built-ins",
		);
		if (trustGateFailure) return trustGateFailure;
	}

	// Custom-runner guard (codex review follow-up): an injected agentRunner
	// can side-channel-mutate the ledger despite the read-only tool override.
	// Only allow it when allowUntrustedRunner is explicitly set (test path).
	// dryRun=true relaxes this because no mutations are performed.
	if (options.agentRunner && !options.allowUntrustedRunner && !options.dryRun) {
		// Same trust contract as missing-agent / non-builtin source: the
		// run cannot guarantee built-in behavior, so it's a failure of the
		// trusted-agent-source-gate, surfaced through status uok.
		const gateFailure = await emitRequiredTriageGate(
			"trusted-agent-source-gate",
			"fail",
			"runTriageApply: custom agentRunner injected without allowUntrustedRunner; production callers cannot bypass the built-in agent contract",
		);
		if (gateFailure) return gateFailure;
		await emit("triage-apply-failed", { reason: "untrusted-runner" });
		return {
			ok: false,
			agreed: false,
			error:
				"runTriageApply: a custom agentRunner was supplied without allowUntrustedRunner. Production callers cannot inject a runner — only tests can, via the explicit allowUntrustedRunner option.",
			resolvedIds: [],
			flowId,
		};
	}
	const runner = options.agentRunner ?? defaultAgentRunner;

	// Phase 1: triage-decider runs in PLAN-ONLY mode. Drop resolve_issue
	// from its tool list (the YAML already drops it, but this is defense-
	// in-depth in case a project override resurrects it). The decider
	// emits a YAML decision plan; we parse it post-hoc.
	const decider = await runner(triageDecider, prompt, {
		model: options.model,
		cwd,
		tools: ["view", "grep", "glob", "git_log"],
	});
	await emit("triage-apply-decider-finished", {
		ok: decider.ok,
		exitCode: decider.exitCode ?? null,
	});
	if (!decider.ok) {
		await emit("triage-apply-failed", { reason: "decider-failed" });
		return {
			ok: false,
			agreed: false,
			error: decider.stderr || "triage-decider failed",
			deciderOutput: decider.output,
			resolvedIds: [],
			flowId,
		};
	}

	// Parse the structured plan in strict mode. Refuses on any malformed
	// item, missing completion marker, multiple yaml blocks, or mismatch
	// against the expected candidate set (when supplied). Partial trust on
	// a triage plan is worse than no apply at all.
	const parseResult = parseTriagePlanStrict(
		decider.output,
		options.expectedIds,
	);
	if (!parseResult.plan) {
		const gateFailure = await emitRequiredTriageGate(
			"triage-plan-validation-gate",
			"fail",
			parseResult.error ?? "decider produced an unparseable plan",
			{ parseError: parseResult.error ?? null },
		);
		if (gateFailure) return gateFailure;
		await emit("triage-apply-failed", {
			reason: "no-plan",
			parseError: parseResult.error,
		});
		return {
			ok: false,
			agreed: false,
			error: `triage-decider plan rejected: ${
				parseResult.error ?? "unknown parse error"
			}`,
			deciderOutput: decider.output,
			resolvedIds: [],
			flowId,
		};
	}
	const plan = parseResult.plan;
	const validationGateFailure = await emitRequiredTriageGate(
		"triage-plan-validation-gate",
		"pass",
		`decider plan parsed cleanly: ${plan.length} decisions`,
		{ decisionCount: plan.length },
	);
	if (validationGateFailure) return validationGateFailure;
	await emit("triage-apply-plan-parsed", {
		decisionCount: plan.length,
		outcomes: plan.reduce<Record<string, number>>((acc, d) => {
			acc[d.outcome] = (acc[d.outcome] ?? 0) + 1;
			return acc;
		}, {}),
	});

	// Phase 2: review-code reviews the plan with read-only tools. The
	// review task explicitly hands the plan as the artifact under
	// scrutiny — the reviewer's job is to spot bad calls before they land.
	const reviewTask = [
		"Review this self-feedback triage decision PLAN. The plan has NOT yet been applied — your verdict gates whether any resolve_issue mutation runs.",
		'Return "review-code: agree" only if every decision in the plan is safe and coherent against the current code/ledger state.',
		"On disagreement, name each concerning decision explicitly so the operator (or a follow-up apply pass) can pull just those entries out and proceed with the rest.",
		"",
		"## Original triage prompt (the ledger entries the decider saw)",
		prompt,
		"",
		"## triage-decider output (includes the plan as a fenced yaml block)",
		decider.output,
	].join("\n");
	const review = await runner(reviewCode, reviewTask, {
		model: options.model,
		cwd,
		tools: ["view", "grep", "glob", "git_log", "query_journal"],
	});
	const agreed = /^review-code:\s*agree\b/im.test(review.output.trim());
	await emit(
		agreed
			? "triage-apply-review-code-agree"
			: "triage-apply-review-code-disagree",
		{
			ok: review.ok,
			exitCode: review.exitCode ?? null,
		},
	);
	if (!review.ok) {
		const gateFailure = await emitRequiredTriageGate(
			"triage-apply-review-gate",
			"manual-attention",
			"review-code subagent failed to complete; review pending operator",
			{ exitCode: review.exitCode ?? null },
		);
		if (gateFailure) return gateFailure;
		await emit("triage-apply-failed", { reason: "review-code-failed" });
		return {
			ok: false,
			agreed: false,
			error: review.stderr || "review-code failed",
			deciderOutput: decider.output,
			reviewOutput: review.output,
			resolvedIds: [],
			flowId,
		};
	}
	if (!agreed) {
		// Disagreement is a clean pause, not a failure. The plan and the
		// review are both persisted in the decision report; the operator
		// can read both and act.
		const gateFailure = await emitRequiredTriageGate(
			"triage-apply-review-gate",
			"fail",
			"review-code disagreed with the proposed plan; no mutations applied",
		);
		if (gateFailure) return gateFailure;
		return {
			ok: false,
			agreed: false,
			error: "review-code disagreed — pausing for operator review",
			deciderOutput: decider.output,
			reviewOutput: review.output,
			resolvedIds: [],
			flowId,
		};
	}
	const reviewGateFailure = await emitRequiredTriageGate(
		"triage-apply-review-gate",
		"pass",
		"review-code agreed with the proposed plan; apply phase proceeds",
	);
	if (reviewGateFailure) return reviewGateFailure;

	// dryRun (--run mode): surface the plan + review for operator inspection
	// without calling applyTriagePlan. No ledger mutations occur.
	if (options.dryRun) {
		await emit("triage-run-complete", {
			decisionCount: plan.length,
			dryRun: true,
		});
		return {
			ok: true,
			agreed: true,
			deciderOutput: decider.output,
			reviewOutput: review.output,
			resolvedIds: [],
			pendingFixIds: plan.map((d) => d.id),
			flowId,
		};
	}

	// Phase 3: apply the plan. We (this runner) call markResolved for
	// each close/promote decision; fix decisions get surfaced for the
	// operator but never auto-mutate. Mutations happen ONCE, post-review,
	// and the resolvedIds we return reflect actual ledger state.
	const applyResult = await applyTriagePlan(cwd, plan, emit);
	// Per-decision failure surfacing (codex review follow-up): if any
	// approved close/promote failed to apply, runTriageApply reports
	// ok=false. Operator sees both partial success (resolvedIds) and
	// partial failure (rejectedIds) so they can investigate.
	const approvedMutationCount = plan.filter((d) => d.outcome !== "fix").length;
	const hasFailures = applyResult.rejectedIds.length > 0;

	// triage-apply-mutation-gate (codex review 2026-05-14 follow-up on
	// slice 3a): observability for the post-review apply phase. pass when
	// every approved mutation landed; fail when any rejected; manual-
	// attention when there were no approved mutations to apply (all
	// decisions were "fix" handoffs, which require operator action).
	const mutationOutcome: "pass" | "fail" | "manual-attention" =
		approvedMutationCount === 0
			? "manual-attention"
			: hasFailures
				? "fail"
				: "pass";
	const mutationRationale =
		approvedMutationCount === 0
			? `no approved mutations: ${applyResult.pendingFixIds.length} fix decisions await operator`
			: hasFailures
				? `${applyResult.rejectedIds.length} of ${approvedMutationCount} approved mutations failed: ${applyResult.rejectedIds.join(", ")}`
				: `${applyResult.resolvedIds.length} of ${approvedMutationCount} approved mutations applied cleanly`;
	const mutationGateFailure = await emitRequiredTriageGate(
		"triage-apply-mutation-gate",
		mutationOutcome,
		mutationRationale,
		{
			resolvedCount: applyResult.resolvedIds.length,
			rejectedCount: applyResult.rejectedIds.length,
			pendingFixCount: applyResult.pendingFixIds.length,
		},
	);
	if (mutationGateFailure) return mutationGateFailure;

	return {
		ok: !hasFailures,
		agreed: true,
		error: hasFailures
			? `${applyResult.rejectedIds.length} of ${approvedMutationCount} approved mutations failed: ${applyResult.rejectedIds.join(", ")}`
			: undefined,
		deciderOutput: decider.output,
		reviewOutput: review.output,
		resolvedIds: applyResult.resolvedIds,
		rejectedIds: applyResult.rejectedIds,
		pendingFixIds: applyResult.pendingFixIds,
		flowId,
	};
}

interface ApplyTriagePlanResult {
	resolvedIds: string[];
	rejectedIds: string[];
	pendingFixIds: string[];
}

/**
 * Apply an approved decision plan. Calls markResolved (via the SF
 * extension's self-feedback writer, which runs the existing writer-
 * layer constraints — accepted evidence kinds, commit-exists check for
 * agent-fix, etc.) for each close/promote decision. Fix decisions are
 * not auto-applied; they require operator implementation work.
 *
 * Returns three lists: resolvedIds (actually mutated), rejectedIds
 * (writer-layer refused OR markResolved threw), pendingFixIds (fix
 * decisions surfaced for operator handoff). runTriageApply uses the
 * rejectedIds count to decide ok=true vs ok=false.
 */
async function applyTriagePlan(
	cwd: string,
	plan: TriageDecision[],
	emit: (eventType: string, data?: Record<string, unknown>) => Promise<void>,
): Promise<ApplyTriagePlanResult> {
	const resolvedIds: string[] = [];
	const rejectedIds: string[] = [];
	const pendingFixIds: string[] = [];
	const sfModule = (await jiti.import(sfExtensionPath("self-feedback"))) as {
		markResolved?: (
			entryId: string,
			resolution: Record<string, unknown>,
			basePath?: string,
		) => boolean;
	};
	if (typeof sfModule.markResolved !== "function") {
		await emit("triage-apply-mutation-failed", {
			reason: "markResolved-unavailable",
		});
		// Every approved close/promote becomes a rejection — can't mutate
		// anything if the writer module isn't loadable.
		for (const decision of plan) {
			if (decision.outcome === "fix") pendingFixIds.push(decision.id);
			else rejectedIds.push(decision.id);
		}
		return { resolvedIds, rejectedIds, pendingFixIds };
	}
	for (const decision of plan) {
		if (decision.outcome === "fix") {
			// Fix decisions are operator handoffs — surface in the report
			// (via the caller's deciderOutput / decision plan), don't mutate.
			pendingFixIds.push(decision.id);
			await emit("triage-apply-fix-pending-operator", { id: decision.id });
			continue;
		}
		const evidenceKind =
			decision.evidenceKind ??
			(decision.outcome === "promote"
				? "promoted-to-requirement"
				: "human-clear");
		const evidence: Record<string, unknown> = { kind: evidenceKind };
		if (decision.outcome === "promote" && decision.requirementId) {
			evidence.requirementId = decision.requirementId;
		}
		const reason = decision.reason ?? "";
		try {
			const ok = sfModule.markResolved(decision.id, { reason, evidence }, cwd);
			if (ok) {
				resolvedIds.push(decision.id);
				await emit("triage-apply-resolved", {
					id: decision.id,
					outcome: decision.outcome,
					evidenceKind,
				});
			} else {
				rejectedIds.push(decision.id);
				await emit("triage-apply-mutation-rejected", {
					id: decision.id,
					outcome: decision.outcome,
					evidenceKind,
					note: "writer layer refused the resolution",
				});
			}
		} catch (err) {
			rejectedIds.push(decision.id);
			await emit("triage-apply-mutation-failed", {
				id: decision.id,
				error: err instanceof Error ? err.message : String(err),
			});
		}
	}
	return { resolvedIds, rejectedIds, pendingFixIds };
}

/**
 * Render the triage queue or canonical triage prompt to stdout.
 *
 * Never throws — best-effort, returns non-zero exit on assembly failure.
 */
export async function handleTriage(
	cwd: string,
	options: HandleTriageOptions = {},
): Promise<HandleTriageResult> {
	// Open the project DB before reading. The one-shot bypass path doesn't
	// run the full SF agent bootstrap, so DB-open isn't done for us.
	try {
		const autoStartModule = (await jiti.import(
			sfExtensionPath("auto-start"),
			{},
		)) as { openProjectDbIfPresent?: (cwd: string) => Promise<unknown> };
		if (typeof autoStartModule.openProjectDbIfPresent === "function") {
			await autoStartModule.openProjectDbIfPresent(cwd);
		}
	} catch (err) {
		const msg = err instanceof Error ? err.message : String(err);
		process.stderr.write(`[triage] DB pre-open warning: ${msg}\n`);
	}

	let drainModule: {
		selectInlineFixCandidates: (basePath: string) => TriageCandidate[];
		buildInlineFixPrompt: (entries: TriageCandidate[]) => string;
		runTriage: (
			prompt: string,
			options?: { model?: string; timeoutMs?: number },
		) => Promise<{
			ok: boolean;
			content?: string;
			error?: string;
			cleanFinish?: boolean;
			provider?: string;
			modelId?: string;
		}>;
		writeTriageDecisionReport: (
			basePath: string,
			content: string,
		) => string | null;
		rankTriageModelsViaRouter: (candidates?: string[]) => Promise<string[]>;
	};
	try {
		drainModule = (await jiti.import(
			sfExtensionPath("self-feedback-drain"),
		)) as typeof drainModule;
	} catch (err) {
		const msg = err instanceof Error ? err.message : String(err);
		process.stderr.write(
			`[triage] failed to load self-feedback-drain module: ${msg}\n`,
		);
		return { exitCode: 1 };
	}

	let candidates: TriageCandidate[];
	try {
		candidates = drainModule.selectInlineFixCandidates(cwd);
	} catch (err) {
		const msg = err instanceof Error ? err.message : String(err);
		process.stderr.write(`[triage] candidate selection failed: ${msg}\n`);
		return { exitCode: 1 };
	}

	if (typeof options.max === "number" && options.max > 0) {
		candidates = candidates.slice(0, options.max);
	}

	if (candidates.length === 0) {
		if (options.json) {
			process.stdout.write(`${JSON.stringify({ ok: true, candidates: [] })}\n`);
		} else {
			process.stdout.write("No open self-feedback candidates to triage.\n");
		}
		return { exitCode: 0 };
	}

	// --run/--apply take precedence over --json/--list because they describe the
	// ACTION, not the output format. With --run/--apply, --json controls whether
	// the result is JSON vs. human text. Without an action, --json emits
	// the candidate digest as JSON (the inspect path).
	if (!options.run && !options.apply) {
		if (options.json) {
			process.stdout.write(
				`${JSON.stringify({
					ok: true,
					count: candidates.length,
					candidates: candidates.map((c) => ({
						id: c.id,
						kind: c.kind,
						severity: c.severity,
						summary: c.summary,
						ts: c.ts,
						impact: c.impactScore ?? null,
						effort: c.effortEstimate ?? null,
					})),
				})}\n`,
			);
			return { exitCode: 0 };
		}

		if (options.list) {
			process.stdout.write(
				`${candidates.length} candidate${candidates.length === 1 ? "" : "s"} (priority: impact↓ effort↑ ts↑)\n\n`,
			);
			for (const c of candidates) {
				const impact = c.impactScore != null ? `i${c.impactScore}` : "i?";
				const effort = c.effortEstimate != null ? `e${c.effortEstimate}` : "e?";
				process.stdout.write(
					`  [${c.severity}] ${impact} ${effort}  ${c.id}  ${c.kind}\n`,
				);
				process.stdout.write(`    ${c.summary}\n`);
			}
			return { exitCode: 0 };
		}
	}

	// Render the canonical triage prompt (used by both the default
	// pipe-to-model output and the --run dispatch path below).
	let prompt: string;
	try {
		prompt = drainModule.buildInlineFixPrompt(candidates);
	} catch (err) {
		const msg = err instanceof Error ? err.message : String(err);
		process.stderr.write(`[triage] prompt render failed: ${msg}\n`);
		return { exitCode: 1 };
	}

	if (!options.run && !options.apply) {
		process.stdout.write(`${prompt}\n`);
		return { exitCode: 0 };
	}

	if (options.apply) {
		// Pre-resolve a model via the router when no --model was supplied and
		// no custom runner is injected. The selected route must also exist in
		// the subagent session registry; @singularity-forge/ai can know about
		// routes that the coding-agent runtime cannot request.
		let resolvedModel = options.model;
		if (!resolvedModel && !options.agentRunner) {
			try {
				const ranked = await drainModule.rankTriageModelsViaRouter();
				resolvedModel = await resolveRunnableTriageModel(cwd, ranked);
				if (!resolvedModel) {
					process.stderr.write(
						`[triage] router candidates were not runnable by subagent registry; using session default model\n`,
					);
				}
			} catch (err) {
				const msg = err instanceof Error ? err.message : String(err);
				process.stderr.write(
					`[triage] router pre-resolution failed; falling back to session default: ${msg}\n`,
				);
			}
		}
		process.stderr.write(
			`[triage] applying via triage-decider -> review-code${
				resolvedModel ? ` (model: ${resolvedModel})` : ""
			} (this can take a few minutes)…\n`,
		);
		const result = await runTriageApply(cwd, prompt, {
			model: resolvedModel,
			agentRunner: options.agentRunner,
			candidateCount: candidates.length,
			expectedIds: candidates.map((candidate) => candidate.id),
		});
		const payload = {
			ok: result.ok,
			agreed: result.agreed,
			error: result.error,
			flowId: result.flowId,
			resolvedIds: result.resolvedIds,
			deciderOutput: result.deciderOutput,
			reviewOutput: result.reviewOutput,
		};
		if (options.json) {
			process.stdout.write(`${JSON.stringify(payload)}\n`);
		} else if (result.ok) {
			process.stdout.write(
				`Triage apply complete: review-code agreed (${result.resolvedIds.length} resolved)\n`,
			);
			if (result.resolvedIds.length > 0) {
				process.stdout.write(`Resolved: ${result.resolvedIds.join(", ")}\n`);
			}
		} else {
			process.stdout.write(`[triage] apply blocked: ${result.error}\n`);
			if (result.reviewOutput) process.stdout.write(`${result.reviewOutput}\n`);
		}
		return { exitCode: result.ok ? 0 : 1 };
	}

	// --run: dispatch via triage-decider + review-code (same agent path as
	// --apply) but with dryRun=true so Phase 3 (applyTriagePlan) is skipped.
	// The triage-decider's decision plan is captured and persisted as a
	// decision report for operator review. Model routing uses SF's router
	// (rankTriageModelsViaRouter) — no hardcoded model string.
	let resolvedModel = options.model;
	if (!resolvedModel && !options.agentRunner) {
		try {
			const ranked = await drainModule.rankTriageModelsViaRouter();
			resolvedModel = await resolveRunnableTriageModel(cwd, ranked);
			if (!resolvedModel) {
				process.stderr.write(
					`[triage] router candidates were not runnable by subagent registry; using session default model\n`,
				);
			}
		} catch (err) {
			const msg = err instanceof Error ? err.message : String(err);
			process.stderr.write(
				`[triage] router pre-resolution failed; falling back to session default: ${msg}\n`,
			);
		}
	}
	process.stderr.write(
		`[triage] running triage-decider -> review-code (dry-run, no mutations)${
			resolvedModel ? ` (model: ${resolvedModel})` : ""
		} (this can take a few minutes)…\n`,
	);
	const result = await runTriageApply(cwd, prompt, {
		model: resolvedModel,
		agentRunner: options.agentRunner,
		candidateCount: candidates.length,
		expectedIds: candidates.map((candidate) => candidate.id),
		dryRun: true,
	});
	if (!result.ok) {
		const payload = {
			ok: false,
			error: result.error ?? "unknown triage error",
			flowId: result.flowId,
		};
		process.stdout.write(
			options.json
				? `${JSON.stringify(payload)}\n`
				: `[triage] failed: ${payload.error}\n`,
		);
		return { exitCode: 1 };
	}
	const reportPath = drainModule.writeTriageDecisionReport(
		cwd,
		result.deciderOutput ?? "",
	);
	const payload = {
		ok: true,
		reportPath,
		flowId: result.flowId,
		deciderOutput: result.deciderOutput,
		reviewOutput: result.reviewOutput,
	};
	if (options.json) {
		process.stdout.write(`${JSON.stringify(payload)}\n`);
	} else {
		process.stdout.write(`Triage decisions written to: ${reportPath}\n`);
	}
	return { exitCode: 0 };
}
