feat(headless): sf headless triage — operator-driven self-feedback drain

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) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-14 07:04:01 +02:00
parent a342868068
commit 8fde12301f
4 changed files with 247 additions and 2 deletions

188
src/headless-triage.ts Normal file
View file

@ -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<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;
};
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 };
}

View file

@ -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");

View file

@ -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) =>
[

View file

@ -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");
});
});