From 8fde12301fd1b5e6b90ecda69cc411ba0c9aeb37 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 14 May 2026 07:04:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(headless):=20sf=20headless=20triage=20?= =?UTF-8?q?=E2=80=94=20operator-driven=20self-feedback=20drain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a deterministic, turn-independent path to drain the self-feedback queue. Modes: - default: emits the canonical buildInlineFixPrompt() output for piping into any model (sf headless triage | sf headless -p -) - --list: human-readable digest sorted by impact↓ effort↑ ts↑ - --json: structured candidate list for tooling - --max N: cap candidates Why this matters (partial step toward sf-mp4rxkwb-l4baga): the existing session_start drain queues triage as `triggerTurn:true, deliverAs:"followUp"`. When autonomous mode bails at milestone validation before any turn runs, the followUp gets dropped and the queue stays unprocessed. This command sidesteps that by rendering the prompt synchronously to stdout — operators can pipe it into any model without depending on autonomous-loop turn semantics. The full unit-type registration that fixes the underlying dispatcher gap is larger work tracked in the parent entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/headless-triage.ts | 188 ++++++++++++++++++ src/headless.ts | 23 +++ .../extensions/sf/self-feedback-drain.js | 2 +- .../sf/tests/self-feedback-drain.test.mjs | 36 +++- 4 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/headless-triage.ts 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"); + }); +});