feat(headless): sf headless triage --run — dispatch via @singularity-forge/ai

Adds runTriage to self-feedback-drain.js, mirroring runReflection in
reflection.js: provider-agnostic dispatch via @singularity-forge/ai's
completeSimple, dependency-injectable for tests, 8-minute timeout race,
clean-finish detection on the canonical "Self-feedback triage complete"
terminator.

`sf headless triage --run [--model provider/modelId]` now dispatches the
canonical triage prompt and writes the model's decision text to
.sf/triage/decisions/<ts>.md. Operators apply the decisions (resolve_issue
calls, code edits) — a tool-enabled variant that lets the model close
entries directly is follow-up work.

Default model: google-gemini-cli/gemini-3-pro-preview (matches
DEFAULT_REFLECTION_MODEL).

Continues the bounded chip away at sf-mp4rxkwb-l4baga: triage now has
both an operator-pipe path (default) and a one-shot dispatch path (--run).
The full unit-type registration that wires this into the autonomous
dispatcher's idle path is the remaining slice of that entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-14 07:29:29 +02:00
parent 8fde12301f
commit 34521814cc
4 changed files with 291 additions and 2 deletions

View file

@ -59,6 +59,8 @@ export interface HandleTriageOptions {
json?: boolean;
list?: boolean;
max?: number;
run?: boolean;
model?: string;
}
export interface HandleTriageResult {
@ -102,6 +104,21 @@ export async function handleTriage(
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;
};
try {
drainModule = (await jiti.import(
@ -183,6 +200,54 @@ export async function handleTriage(
return { exitCode: 1 };
}
process.stdout.write(`${prompt}\n`);
if (!options.run) {
process.stdout.write(`${prompt}\n`);
return { exitCode: 0 };
}
// --run: dispatch the prompt via @singularity-forge/ai completeSimple,
// capture the decision text, persist to .sf/triage/decisions/<ts>.md.
// Same shape as `sf headless reflect --run`. The model's output is a
// decision matrix — applying the decisions (resolve_issue calls, code
// edits) is operator-driven; a tool-enabled variant is follow-up work.
process.stderr.write(
"[triage] dispatching to model (this can take a few minutes)…\n",
);
const result = await drainModule.runTriage(prompt, { model: options.model });
if (!result.ok) {
const payload = {
ok: false,
error: result.error ?? "unknown triage error",
provider: result.provider,
modelId: result.modelId,
};
process.stdout.write(
options.json
? `${JSON.stringify(payload)}\n`
: `[triage] failed: ${payload.error}\n`,
);
return { exitCode: 1 };
}
const reportPath = drainModule.writeTriageDecisionReport(
cwd,
result.content ?? "",
);
const payload = {
ok: true,
reportPath,
cleanFinish: result.cleanFinish === true,
provider: result.provider,
modelId: result.modelId,
};
if (options.json) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
} else {
process.stdout.write(`Triage decisions written to: ${reportPath}\n`);
if (!result.cleanFinish) {
process.stderr.write(
'[triage] WARNING: report did not include "Self-feedback triage complete" terminator — output may be truncated\n',
);
}
}
return { exitCode: 0 };
}

View file

@ -837,17 +837,25 @@ async function runHeadlessOnce(
if (options.command === "triage") {
const wantsJson = options.json || options.commandArgs.includes("--json");
const wantsList = options.commandArgs.includes("--list");
const wantsRun = options.commandArgs.includes("--run");
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 modelIdx = options.commandArgs.indexOf("--model");
const model =
modelIdx >= 0 && modelIdx + 1 < options.commandArgs.length
? options.commandArgs[modelIdx + 1]
: undefined;
const { handleTriage } = await import("./headless-triage.js");
const result = await handleTriage(process.cwd(), {
json: wantsJson,
list: wantsList,
max,
run: wantsRun,
model,
});
return { exitCode: result.exitCode, interrupted: false, timedOut: false };
}

View file

@ -17,7 +17,7 @@ import {
} from "node:fs";
import { dirname, join } from "node:path";
import { getErrorMessage } from "./error-utils.js";
import { sfRuntimeRoot } from "./paths.js";
import { sfRoot, sfRuntimeRoot } from "./paths.js";
import { readAllSelfFeedback } from "./self-feedback.js";
/**
@ -338,6 +338,163 @@ export function dispatchSelfFeedbackInlineFixIfNeeded(basePath, ctx, pi) {
*
* Consumer: register-hooks.ts turn_end handler.
*/
/**
* Default provider/model used by runTriage when --model is not supplied.
* Matches DEFAULT_REFLECTION_MODEL both reasoning passes benefit from the
* same pro tier and route through the operator's persistent gemini-cli
* session by default.
*/
const DEFAULT_TRIAGE_MODEL = "google-gemini-cli/gemini-3-pro-preview";
const TRIAGE_TERMINATOR = "Self-feedback triage complete";
function parseTriageModelString(input) {
if (typeof input !== "string") return null;
const slash = input.indexOf("/");
if (slash <= 0 || slash === input.length - 1) return null;
return [input.slice(0, slash), input.slice(slash + 1)];
}
async function resolveTriageModel(providerModelString) {
const parsed = parseTriageModelString(providerModelString);
if (!parsed) return null;
const [provider, modelId] = parsed;
const ai = await import("@singularity-forge/ai");
if (typeof ai.getModel !== "function") return null;
try {
return ai.getModel(provider, modelId) ?? null;
} catch {
return null;
}
}
function extractAssistantText(message) {
const content = message?.content;
if (!Array.isArray(content)) return "";
const out = [];
for (const part of content) {
if (part && typeof part === "object" && part.type === "text") {
if (typeof part.text === "string") out.push(part.text);
}
}
return out.join("");
}
/**
* Persist a triage decision report to .sf/triage/decisions/<timestamp>.md.
* Returns the absolute path on success, null on failure (best-effort).
*
* Why a separate subdirectory under .sf/triage/: the captures-triage system
* (commands-todo.js) already owns .sf/triage/{backlog,...}; using
* .sf/triage/decisions/ keeps both subsystems clearly namespaced without
* either subsystem accidentally consuming the other's files.
*
* Consumer: headless-triage operator surface (--run flag).
*/
export function writeTriageDecisionReport(basePath, content) {
try {
const dir = join(sfRoot(basePath), "triage", "decisions");
mkdirSync(dir, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const path = join(dir, `${ts}.md`);
writeFileSync(path, content, "utf-8");
return path;
} catch {
return null;
}
}
/**
* Run a triage pass against the canonical inline-fix prompt.
*
* Provider-agnostic: routes through @singularity-forge/ai's completeSimple,
* mirroring runReflection. Captures the model's decision text the actual
* fix/promote/close actions are operator-applied (or, in a follow-up, a
* tool-enabled variant will let the model call resolve_issue directly).
*
* options.model "provider/modelId" string. Defaults to DEFAULT_TRIAGE_MODEL.
* options.complete dependency injection for tests; same shape as the
* reflection runner.
* options.timeoutMs defaults to 8 minutes.
*
* Returns { ok, content?, cleanFinish?, error?, provider?, modelId? }.
* Best-effort; never throws.
*
* Consumer: headless-triage operator surface (--run flag).
*/
export async function runTriage(prompt, options = {}) {
const modelString = options.model ?? DEFAULT_TRIAGE_MODEL;
const timeoutMs = options.timeoutMs ?? 8 * 60 * 1000;
let model;
try {
model = await resolveTriageModel(modelString);
} catch (err) {
return {
ok: false,
error: `failed to load model catalog: ${getErrorMessage(err)}`,
};
}
if (!model) {
return {
ok: false,
error: `unknown model "${modelString}" — expected "provider/modelId" with a model registered in @singularity-forge/ai MODELS`,
};
}
const context = {
systemPrompt: undefined,
messages: [
{
role: "user",
content: [{ type: "text", text: prompt }],
timestamp: Date.now(),
},
],
};
const completeFn =
options.complete ??
(await (async () => {
const ai = await import("@singularity-forge/ai");
return ai.completeSimple;
})());
const callPromise = (async () => {
try {
const message = await completeFn(model, context, {});
const content = extractAssistantText(message);
return {
ok: true,
content,
cleanFinish: content.includes(TRIAGE_TERMINATOR),
provider: model.provider,
modelId: model.id,
};
} catch (err) {
return {
ok: false,
error: `provider call failed: ${getErrorMessage(err)}`,
provider: model.provider,
modelId: model.id,
};
}
})();
const timeoutPromise = new Promise((resolve) => {
setTimeout(() => {
resolve({
ok: false,
error: `triage call timed out after ${timeoutMs}ms`,
provider: model.provider,
modelId: model.id,
});
}, timeoutMs);
});
return Promise.race([callPromise, timeoutPromise]);
}
export function consumeCompletedInlineFixClaim(basePath) {
const claim = readClaim(basePath);
if (!claim || claim.ids.length === 0) return [];

View file

@ -9,7 +9,9 @@ import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import {
buildInlineFixPrompt,
runTriage,
selectInlineFixCandidates,
writeTriageDecisionReport,
} from "../self-feedback-drain.js";
let tempDirs = [];
@ -277,3 +279,60 @@ describe("buildInlineFixPrompt", () => {
expect(prompt).toContain("Self-feedback triage complete");
});
});
describe("runTriage (dependency-injected)", () => {
test("returns ok+content on success and flags clean-finish from terminator", async () => {
const fakeMessage = {
content: [
{ type: "text", text: "decision: close.\nSelf-feedback triage complete" },
],
};
const result = await runTriage("triage prompt", {
model: "anthropic/claude-sonnet-4-6",
complete: async () => fakeMessage,
});
expect(result.ok).toBe(true);
expect(result.content).toContain("decision: close");
expect(result.cleanFinish).toBe(true);
});
test("flags cleanFinish=false when terminator missing", async () => {
const result = await runTriage("triage prompt", {
model: "anthropic/claude-sonnet-4-6",
complete: async () => ({
content: [{ type: "text", text: "partial output, no terminator" }],
}),
});
expect(result.ok).toBe(true);
expect(result.cleanFinish).toBe(false);
});
test("returns ok=false on unknown model string", async () => {
const result = await runTriage("triage prompt", {
model: "no-such-provider/no-such-model",
complete: async () => ({ content: [] }),
});
expect(result.ok).toBe(false);
expect(result.error).toContain("unknown model");
});
test("returns ok=false when complete throws", async () => {
const result = await runTriage("triage prompt", {
model: "anthropic/claude-sonnet-4-6",
complete: async () => {
throw new Error("provider boom");
},
});
expect(result.ok).toBe(false);
expect(result.error).toContain("provider boom");
});
});
describe("writeTriageDecisionReport", () => {
test("writes to .sf/triage/decisions/<ts>.md and returns the path", () => {
const dir = makeForgeProject();
const path = writeTriageDecisionReport(dir, "## decisions\nfoo");
expect(path).toBeTruthy();
expect(path).toMatch(/\.sf\/triage\/decisions\/.*\.md$/);
});
});