diff --git a/src/headless-triage.ts b/src/headless-triage.ts new file mode 100644 index 000000000..7ad2e2dfb --- /dev/null +++ b/src/headless-triage.ts @@ -0,0 +1,188 @@ +/** + * 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 { existsSync } from "node:fs"; +import { join } from "node:path"; +import { createJiti } from "@mariozechner/jiti"; +import { resolveBundledSourceResource } from "./bundled-resource-path.js"; +import { getSfEnv } from "./env.js"; + +const jiti = createJiti(import.meta.filename, { + interopDefault: true, + debug: false, +}); + +const agentExtensionsDir = join(getSfEnv().agentDir, "extensions", "sf"); +const useAgentDir = existsSync(join(agentExtensionsDir, "state.js")); + +function sfExtensionPath(moduleName: string): string { + 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; +} + +export interface HandleTriageResult { + exitCode: number; +} + +interface TriageCandidate { + id: string; + kind: string; + severity: string; + summary: string; + ts: string; + impactScore?: number; + effortEstimate?: number; +} + +/** + * 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 { + // 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 }; + 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; + }; + 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 }; + } + + 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 }; + } + + // Default: emit the canonical triage prompt for piping into a model. + 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 }; + } + + process.stdout.write(`${prompt}\n`); + return { exitCode: 0 }; +} diff --git a/src/headless.ts b/src/headless.ts index 2277e294f..9cede8cbc 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -829,6 +829,29 @@ async function runHeadlessOnce( // prior report) and emit either the rendered prompt brief (default) or // the raw corpus JSON (--json). Operator-driven — the autonomous-loop // reflection unit is a separate follow-up. + // Triage: deterministic operator path to drain the self-feedback queue + // without relying on the autonomous-loop followUp delivery (which gets + // swallowed when the loop bails at validation before any turn runs — + // sf-mp4rxkwb-l4baga). Outputs the canonical triage prompt that can be + // piped into any model, or a structured candidate list with --json/--list. + if (options.command === "triage") { + const wantsJson = options.json || options.commandArgs.includes("--json"); + const wantsList = options.commandArgs.includes("--list"); + const maxIdx = options.commandArgs.indexOf("--max"); + let max: number | undefined; + if (maxIdx >= 0 && maxIdx + 1 < options.commandArgs.length) { + const n = Number.parseInt(options.commandArgs[maxIdx + 1] ?? "", 10); + if (Number.isFinite(n) && n > 0) max = n; + } + const { handleTriage } = await import("./headless-triage.js"); + const result = await handleTriage(process.cwd(), { + json: wantsJson, + list: wantsList, + max, + }); + return { exitCode: result.exitCode, interrupted: false, timedOut: false }; + } + if (options.command === "reflect") { const wantsJson = options.json || options.commandArgs.includes("--json"); const wantsRun = options.commandArgs.includes("--run"); diff --git a/src/resources/extensions/sf/self-feedback-drain.js b/src/resources/extensions/sf/self-feedback-drain.js index 13c9a2557..8b332e5ce 100644 --- a/src/resources/extensions/sf/self-feedback-drain.js +++ b/src/resources/extensions/sf/self-feedback-drain.js @@ -225,7 +225,7 @@ export function selectInlineFixCandidates(basePath) { return a.ts.localeCompare(b.ts); }); } -function buildInlineFixPrompt(entries) { +export function buildInlineFixPrompt(entries) { const rendered = entries .map((entry) => [ diff --git a/src/resources/extensions/sf/tests/self-feedback-drain.test.mjs b/src/resources/extensions/sf/tests/self-feedback-drain.test.mjs index da0f55600..39fc40389 100644 --- a/src/resources/extensions/sf/tests/self-feedback-drain.test.mjs +++ b/src/resources/extensions/sf/tests/self-feedback-drain.test.mjs @@ -7,7 +7,10 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import { selectInlineFixCandidates } from "../self-feedback-drain.js"; +import { + buildInlineFixPrompt, + selectInlineFixCandidates, +} from "../self-feedback-drain.js"; let tempDirs = []; @@ -243,3 +246,34 @@ describe("selectInlineFixCandidates", () => { expect(ids).toEqual(["low-but-bumped", "high-default"]); }); }); + +describe("buildInlineFixPrompt", () => { + // Exported (sf-mp4rxkwb-l4baga partial) so headless-triage.ts can render + // the canonical triage prompt without going through the followUp dispatch + // path that gets dropped when the autonomous loop bails before any turn + // runs. + test("renders one section per entry with the triage decision protocol", () => { + const prompt = buildInlineFixPrompt([ + entry({ id: "sf-a", kind: "gap:foo", summary: "first thing" }), + entry({ id: "sf-b", kind: "gap:bar", summary: "second thing" }), + ]); + expect(prompt).toContain("## sf-a — gap:foo"); + expect(prompt).toContain("## sf-b — gap:bar"); + expect(prompt).toContain("first thing"); + expect(prompt).toContain("second thing"); + // The decision protocol must be present so external models can + // follow it without additional priming. + expect(prompt).toContain("A. Fix it."); + expect(prompt).toContain("B. Promote it."); + expect(prompt).toContain("C. Close it."); + expect(prompt).toContain("Self-feedback triage complete"); + }); + + test("returns a usable prompt for an empty list", () => { + // Operators occasionally pass no entries (e.g. piping a filtered + // candidate set). The prompt should still render the protocol so + // the model knows what to do — and so the function never throws. + const prompt = buildInlineFixPrompt([]); + expect(prompt).toContain("Self-feedback triage complete"); + }); +});