diff --git a/src/headless-triage.ts b/src/headless-triage.ts index 7ad2e2dfb..0564120ac 100644 --- a/src/headless-triage.ts +++ b/src/headless-triage.ts @@ -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/.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 }; } diff --git a/src/headless.ts b/src/headless.ts index 9cede8cbc..5db72120e 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -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 }; } diff --git a/src/resources/extensions/sf/self-feedback-drain.js b/src/resources/extensions/sf/self-feedback-drain.js index 8b332e5ce..8e589e531 100644 --- a/src/resources/extensions/sf/self-feedback-drain.js +++ b/src/resources/extensions/sf/self-feedback-drain.js @@ -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/.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 []; 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 39fc40389..35d24c7a5 100644 --- a/src/resources/extensions/sf/tests/self-feedback-drain.test.mjs +++ b/src/resources/extensions/sf/tests/self-feedback-drain.test.mjs @@ -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/.md and returns the path", () => { + const dir = makeForgeProject(); + const path = writeTriageDecisionReport(dir, "## decisions\nfoo"); + expect(path).toBeTruthy(); + expect(path).toMatch(/\.sf\/triage\/decisions\/.*\.md$/); + }); +});