feat(headless,auto): surface self-feedback queue at autonomous-loop idle

Two thin slices toward sf-mp4rxkwb-l4baga:

1. Help text. The triage and reflect commands have shipped over the
   last few commits but neither was discoverable via `sf headless help`.
   Add both to the command list + add five usage examples covering the
   piping and --run patterns.

2. Bail-time queue notifier. When the autonomous loop is about to break
   for "no-active-milestone" or "milestone-complete" while open
   self-feedback entries still exist, surface the queue with a clear
   pointer to `sf headless triage --list` / `--run`. Best-effort wrapper
   that never throws — the proper fix (triage as a real unit type with
   begin/dispatch/checkpoint/complete lifecycle) is the larger remaining
   slice of the parent entry; this just makes the queue VISIBLE at the
   exact moment operators historically lost track of it.

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

View file

@ -225,6 +225,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
" query Machine snapshot: JSON state + next dispatch + costs (no LLM)", " query Machine snapshot: JSON state + next dispatch + costs (no LLM)",
" usage Live LLM-provider usage snapshot (today: gemini-cli tier + per-model quota)", " usage Live LLM-provider usage snapshot (today: gemini-cli tier + per-model quota)",
" reflect Assemble reflection corpus + render prompt for cross-corpus pattern analysis (--json for raw, --run to dispatch to gemini-cli, --model <id> to override)", " reflect Assemble reflection corpus + render prompt for cross-corpus pattern analysis (--json for raw, --run to dispatch to gemini-cli, --model <id> to override)",
" triage Render canonical self-feedback triage prompt for piping into a model (--list for digest, --json for structured, --max N to cap, --run to dispatch + write decisions to .sf/triage/decisions/, --model <id> to override)",
"", "",
"new-milestone flags:", "new-milestone flags:",
" --context <path> Path to spec/PRD file (use '-' for stdin)", " --context <path> Path to spec/PRD file (use '-' for stdin)",
@ -255,6 +256,11 @@ const SUBCOMMAND_HELP: Record<string, string> = {
" sf headless query Instant machine JSON state snapshot", " sf headless query Instant machine JSON state snapshot",
" sf headless status uok UOK gate health table (last 24h)", " sf headless status uok UOK gate health table (last 24h)",
" sf headless status uok --json UOK gate health as JSON", " sf headless status uok --json UOK gate health as JSON",
" sf headless triage --list Self-feedback queue digest (impact↓ effort↑ ts↑)",
" sf headless triage | sf-some-model Pipe triage prompt to any model",
" sf headless triage --run Dispatch triage to default model + write decisions",
" sf headless reflect Render reflection prompt for piping",
" sf headless reflect --run Dispatch reflection + write report",
"", "",
"Exit codes: 0 = success, 1 = error/timeout, 10 = blocked, 11 = cancelled", "Exit codes: 0 = success, 1 = error/timeout, 10 = blocked, 11 = cancelled",
].join("\n"), ].join("\n"),

View file

@ -63,6 +63,7 @@ import {
rollbackToCheckpoint, rollbackToCheckpoint,
} from "../safety/git-checkpoint.js"; } from "../safety/git-checkpoint.js";
import { resolveSafetyHarnessConfig } from "../safety/safety-harness.js"; import { resolveSafetyHarnessConfig } from "../safety/safety-harness.js";
import { selectInlineFixCandidates } from "../self-feedback-drain.js";
import { recordSelfFeedback } from "../self-feedback.js"; import { recordSelfFeedback } from "../self-feedback.js";
import { import {
checkpointWal, checkpointWal,
@ -123,6 +124,35 @@ import {
} from "./types.js"; } from "./types.js";
import { closeoutAndStop, generateMilestoneReport, maybeFireProductAudit, shouldRunPlanningFlowGate } from "./phases-helpers.js"; import { closeoutAndStop, generateMilestoneReport, maybeFireProductAudit, shouldRunPlanningFlowGate } from "./phases-helpers.js";
/**
* Surface the open self-feedback queue to the operator at idle-bail time.
*
* Why here (sf-mp4rxkwb-l4baga partial): the autonomous loop has historically
* exited silently on "no-active-milestone" / "milestone-complete" while the
* self-feedback queue still had unresolved entries. Operators didn't know
* the queue was waiting, so triage never ran. This helper makes the queue
* visible at the exact moment the loop is about to bail the proper fix
* (triage as a real unit type) is the larger remaining slice of the parent
* entry.
*
* Best-effort: never throws, never blocks. If candidate selection fails or
* the basePath isn't a forge repo, this is a no-op.
*/
function surfaceSelfFeedbackQueueOnIdle(ctx, basePath, exitReason) {
try {
const candidates = selectInlineFixCandidates(basePath);
if (!Array.isArray(candidates) || candidates.length === 0) return;
const n = candidates.length;
const noun = n === 1 ? "entry" : "entries";
ctx.ui.notify(
`Idle (${exitReason}) but ${n} self-feedback ${noun} still open. Run \`sf headless triage --list\` to scan, or \`sf headless triage --run\` to dispatch the canonical triage prompt.`,
"warning",
);
} catch {
// Best-effort — never block the loop's bail path on a queue probe.
}
}
// ─── runPreDispatch ─────────────────────────────────────────────────────────── // ─── runPreDispatch ───────────────────────────────────────────────────────────
/** /**
* Phase 1: Pre-dispatch resource guard, health gate, state derivation, * Phase 1: Pre-dispatch resource guard, health gate, state derivation,
@ -616,6 +646,7 @@ export async function runPreDispatch(ic, loopState) {
`No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`,
); );
} }
surfaceSelfFeedbackQueueOnIdle(ctx, s.basePath, "no-active-milestone");
debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" }); debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" });
deps.emitJournalEvent({ deps.emitJournalEvent({
ts: new Date().toISOString(), ts: new Date().toISOString(),
@ -709,6 +740,7 @@ export async function runPreDispatch(ic, loopState) {
); );
deps.logCmuxEvent(prefs, `Milestone ${mid} complete.`, "success"); deps.logCmuxEvent(prefs, `Milestone ${mid} complete.`, "success");
await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`); await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
surfaceSelfFeedbackQueueOnIdle(ctx, s.basePath, "milestone-complete");
debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" }); debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
deps.emitJournalEvent({ deps.emitJournalEvent({
ts: new Date().toISOString(), ts: new Date().toISOString(),