feat(sf): /sf escalate user command + resolveEscalation (PDD)

Closes the user-facing loop for ADR-011 P2. The full escalation
end-to-end now works: agent files → loop pauses → user resolves
via /sf escalate → loop continues.

PDD spec for this change:

Purpose: let the user resolve a paused task escalation. Without this,
  escalation_pending=1 has no exit ramp other than manual SQL.
Consumer: users at the prompt — '/sf escalate list', '/sf escalate
  show <slice>/<task>', '/sf escalate resolve <slice>/<task> <choice>
  [-- <rationale>]'.
Contract:
  1. /sf escalate list → enumerate pending escalations in the active
     milestone, showing slice/task, question, options, recommendation.
  2. /sf escalate show <slice>/<task> → print the artifact's question
     + options with tradeoffs + recommendation + resolution status
     (resolved or unresolved).
  3. /sf escalate resolve <slice>/<task> <option-id> [-- <rationale>]
     → resolveEscalation in escalation.ts:
       - 'accept' selects the recommended option
       - any option id from the artifact is also valid
       - invalid choice → returns 'invalid-choice' with valid list
       - already resolved → 'already-resolved' with prior timestamp
       - not found → 'not-found' with the task path
     On success: artifact gains respondedAt/userChoice/userRationale,
     DB flags cleared, UOK audit event 'escalation-user-responded'
     emitted.
Failure boundary:
  - DB unavailable → 'SF database is not available. Run /sf doctor.'
  - Active milestone missing → 'No active milestone — nothing to list.'
  - Malformed artifact path → readEscalationArtifact returns null →
    handler returns 'not-found'.
  - clearTaskEscalationFlags called inside the resolver — never
    leaves the row in a half-resolved state.
Evidence: smoke test exercises 4 contract conditions end-to-end:
  invalid-choice, accept→resolved (chosen option = recommendation),
  already-resolved on re-run, not-found for unknown task. Typecheck
  clean.
Non-goals:
  - reject-blocker choice (gsd-2 has it; needs a blocker_source DB
    column SF doesn't have)
  - Carry-forward injection (claimEscalationOverride —
    findUnappliedEscalationOverride flow). The override is logged in
    the artifact for the user; agent context injection lands when
    the executor's prompt builder is wired to read it.
  - Cross-milestone listing (current implementation: active milestone
    only — matches /sf escalate list's most useful default behavior).
Invariants:
  - Safety: invalid-choice and not-found return without writing —
    no half-state.
  - Safety: clearTaskEscalationFlags zeros pending+awaiting in one
    UPDATE — reader can never see half-cleared state.
  - Liveness: after resolve, next state derivation cycle sees
    escalation_pending=0 → phase != 'escalating-task' → dispatch
    routes normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 20:31:45 +02:00
parent 2bf6c51fde
commit f757a18417
4 changed files with 306 additions and 3 deletions

View file

@ -0,0 +1,189 @@
// SF Command — `/sf escalate` (ADR-011 P2)
//
// Subcommands:
// list — show pending escalations across all active milestones
// show <slice>/<task> — print the escalation question + options for one task
// resolve <slice>/<task> <option> [-- <rationale>]
// — apply user choice, clear flag, allow loop to continue
//
// All operations run against the active project's DB (process.cwd()-rooted).
import type { ExtensionCommandContext } from "@singularity-forge/pi-coding-agent";
import { readEscalationArtifact, resolveEscalation } from "./escalation.js";
import {
getActiveMilestoneFromDb,
getMilestoneSlices,
getSliceTasks,
isDbAvailable,
} from "./sf-db.js";
function usage(): string {
return [
"Usage: /sf escalate <subcommand>",
"",
"Subcommands:",
" list List pending escalations",
" show <sliceId>/<taskId> Show escalation details",
" resolve <sliceId>/<taskId> <option> [-- <rationale>]",
" Apply user choice (option id or 'accept')",
].join("\n");
}
function parseSliceTask(spec: string): { sliceId: string; taskId: string } | null {
const m = spec.match(/^(S\d+[A-Za-z0-9-]*)\/(T\d+[A-Za-z0-9-]*)$/);
if (!m) return null;
return { sliceId: m[1], taskId: m[2] };
}
export async function handleEscalate(
args: string,
ctx: ExtensionCommandContext,
): Promise<void> {
if (!isDbAvailable()) {
ctx.ui.notify(
"SF database is not available. Run /sf doctor.",
"error",
);
return;
}
const trimmed = args.trim();
if (!trimmed) {
ctx.ui.notify(usage(), "info");
return;
}
const [sub, ...rest] = trimmed.split(/\s+/);
if (sub === "list") {
const ms = getActiveMilestoneFromDb();
if (!ms) {
ctx.ui.notify("No active milestone — nothing to list.", "info");
return;
}
const lines: string[] = [`Pending escalations for milestone ${ms.id}:`];
let count = 0;
for (const slice of getMilestoneSlices(ms.id)) {
for (const task of getSliceTasks(ms.id, slice.id)) {
if (task.escalation_pending !== 1) continue;
if (!task.escalation_artifact_path) continue;
const art = readEscalationArtifact(task.escalation_artifact_path);
if (!art || art.respondedAt) continue;
count++;
lines.push(` ${slice.id}/${task.id}: ${art.question}`);
lines.push(` options: ${art.options.map((o) => o.id).join(", ")}`);
lines.push(` recommend: ${art.recommendation}`);
}
}
if (count === 0) {
ctx.ui.notify("No pending escalations.", "info");
return;
}
ctx.ui.notify(lines.join("\n"), "info");
return;
}
if (sub === "show") {
const spec = rest[0];
const parsed = spec ? parseSliceTask(spec) : null;
if (!parsed) {
ctx.ui.notify(
"Usage: /sf escalate show <sliceId>/<taskId> (e.g. S01/T01)",
"warning",
);
return;
}
const ms = getActiveMilestoneFromDb();
if (!ms) {
ctx.ui.notify("No active milestone.", "warning");
return;
}
const tasks = getSliceTasks(ms.id, parsed.sliceId);
const task = tasks.find((t) => t.id === parsed.taskId);
if (!task || !task.escalation_artifact_path) {
ctx.ui.notify(
`No escalation found for ${parsed.sliceId}/${parsed.taskId}.`,
"warning",
);
return;
}
const art = readEscalationArtifact(task.escalation_artifact_path);
if (!art) {
ctx.ui.notify(
`Escalation artifact at ${task.escalation_artifact_path} is missing or malformed.`,
"error",
);
return;
}
const out: string[] = [
`Escalation: ${ms.id}/${parsed.sliceId}/${parsed.taskId}`,
`Question: ${art.question}`,
"",
"Options:",
];
for (const o of art.options) {
const isRec = o.id === art.recommendation ? " (recommended)" : "";
out.push(` ${o.id}: ${o.label}${isRec}`);
if (o.tradeoffs) out.push(` tradeoffs: ${o.tradeoffs}`);
}
out.push(`\nRationale for recommendation: ${art.recommendationRationale}`);
if (art.respondedAt) {
out.push(
`\nResolved ${art.respondedAt} → choice="${art.userChoice}"${art.userRationale ? ` (rationale: ${art.userRationale})` : ""}`,
);
} else {
out.push(
`\nUnresolved. Run /sf escalate resolve ${parsed.sliceId}/${parsed.taskId} <option-id|accept>`,
);
}
ctx.ui.notify(out.join("\n"), "info");
return;
}
if (sub === "resolve") {
const spec = rest[0];
const parsed = spec ? parseSliceTask(spec) : null;
if (!parsed) {
ctx.ui.notify(
"Usage: /sf escalate resolve <sliceId>/<taskId> <option> [-- <rationale>]",
"warning",
);
return;
}
const choice = rest[1];
if (!choice) {
ctx.ui.notify(
"Missing choice. Pass 'accept' or one of the artifact's option ids.",
"warning",
);
return;
}
// Optional `-- <rationale>` separator
const dashIdx = rest.indexOf("--");
const rationale =
dashIdx >= 0 ? rest.slice(dashIdx + 1).join(" ") : "";
const ms = getActiveMilestoneFromDb();
if (!ms) {
ctx.ui.notify("No active milestone.", "warning");
return;
}
const result = resolveEscalation(
process.cwd(),
ms.id,
parsed.sliceId,
parsed.taskId,
choice,
rationale,
);
const level: "info" | "warning" | "error" =
result.status === "resolved"
? "info"
: result.status === "invalid-choice" ||
result.status === "already-resolved"
? "warning"
: "error";
ctx.ui.notify(result.message, level);
return;
}
ctx.ui.notify(`Unknown subcommand "${sub}".\n${usage()}`, "warning");
}

View file

@ -55,6 +55,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly SfCommandDefinition[] = [
{ cmd: "discuss", desc: "Discuss architecture and decisions" },
{ cmd: "capture", desc: "Fire-and-forget thought capture" },
{ cmd: "debug", desc: "Create and inspect persistent /sf debug sessions" },
{ cmd: "escalate", desc: "List, show, or resolve task escalations (ADR-011 P2)" },
{ cmd: "changelog", desc: "Show categorized release notes" },
{ cmd: "triage", desc: "Manually trigger triage of pending captures" },
{ cmd: "todo", desc: "Triage root TODO.md dump into eval/backlog artifacts" },

View file

@ -16,6 +16,7 @@ import {
handleUpdate,
} from "../../commands-handlers.js";
import { handleDebug } from "../../commands-debug.js";
import { handleEscalate } from "../../commands-escalate.js";
import { handleInspect } from "../../commands-inspect.js";
import { handleLogs } from "../../commands-logs.js";
import {
@ -71,6 +72,10 @@ export async function handleOpsCommand(
await handleDebug(trimmed.replace(/^debug\s*/, "").trim(), ctx, pi);
return true;
}
if (trimmed === "escalate" || trimmed.startsWith("escalate ")) {
await handleEscalate(trimmed.replace(/^escalate\s*/, "").trim(), ctx);
return true;
}
if (trimmed === "forensics" || trimmed.startsWith("forensics ")) {
const { handleForensics } = await import("../../forensics.js");
await handleForensics(trimmed.replace(/^forensics\s*/, "").trim(), ctx, pi);

View file

@ -1,8 +1,8 @@
// SF Extension — ADR-011 Phase 2 Mid-Execution Escalation (gsd-2 ADR)
//
// Owns: artifact I/O (read/build/write), detection, and the producer-side
// flag flips. Resolution + listing land when the user-facing /sf escalate
// command lands.
// Owns: artifact I/O (read/build/write), detection, producer-side flag flips,
// and user-facing resolution. The reject-blocker choice from gsd-2 is
// deferred — needs a blocker_source column SF doesn't yet have.
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
@ -11,6 +11,8 @@ import { atomicWriteSync } from "./atomic-write.js";
import { resolveSlicePath } from "./paths.js";
import type { TaskRow } from "./sf-db.js";
import {
clearTaskEscalationFlags,
getTask,
setTaskEscalationAwaitingReview,
setTaskEscalationPending,
} from "./sf-db.js";
@ -213,3 +215,109 @@ export function claimOverrideForInjection(
): { injectionBlock: string; sourceTaskId: string } | null {
return null;
}
// ─── Resolution ────────────────────────────────────────────────────────────
export interface ResolveEscalationResult {
status:
| "resolved"
| "not-found"
| "already-resolved"
| "invalid-choice";
message: string;
artifactPath?: string;
chosenOption?: EscalationOption;
}
/** Apply a user response to a pending escalation:
* 1) Update the artifact with respondedAt + userChoice + userRationale.
* 2) Clear the DB escalation flags (artifact_path is preserved as audit trail).
* 3) Emit a UOK audit event.
*
* `choice` accepts either "accept" (selects the recommended option) or a
* concrete option id from the artifact's options array. Invalid choices are
* rejected with a list of valid ones.
*
* Note: this does NOT set up carry-forward injection the next dispatch
* cycle picks up phase != 'escalating-task' (because flags are cleared) and
* routes to execute-task normally. Override-as-context-injection is a future
* fire (claimOverrideForInjection currently returns null). */
export function resolveEscalation(
basePath: string,
milestoneId: string,
sliceId: string,
taskId: string,
choice: string,
rationale: string,
): ResolveEscalationResult {
const task = getTask(milestoneId, sliceId, taskId);
if (!task || !task.escalation_artifact_path) {
return {
status: "not-found",
message: `No escalation artifact found for ${milestoneId}/${sliceId}/${taskId}.`,
};
}
const art = readEscalationArtifact(task.escalation_artifact_path);
if (!art) {
return {
status: "not-found",
message: `Escalation artifact at ${task.escalation_artifact_path} is missing or malformed.`,
};
}
if (art.respondedAt) {
return {
status: "already-resolved",
message: `Escalation for ${taskId} was already resolved at ${art.respondedAt}.`,
};
}
let chosenOption: EscalationOption | undefined;
if (choice === "accept") {
chosenOption = art.options.find((o) => o.id === art.recommendation);
} else {
chosenOption = art.options.find((o) => o.id === choice);
if (!chosenOption) {
const valid = ["accept", ...art.options.map((o) => o.id)].join(", ");
return {
status: "invalid-choice",
message: `Unknown choice "${choice}". Valid choices: ${valid}.`,
};
}
}
const respondedAt = new Date().toISOString();
const updated: EscalationArtifact = {
...art,
respondedAt,
userChoice: choice,
userRationale: rationale,
};
atomicWriteSync(
task.escalation_artifact_path,
JSON.stringify(updated, null, 2),
);
clearTaskEscalationFlags(milestoneId, sliceId, taskId);
emitUokAuditEvent(
basePath,
buildAuditEnvelope({
traceId: `escalation:${milestoneId}:${sliceId}:${taskId}`,
category: "gate",
type: "escalation-user-responded",
payload: {
milestoneId,
sliceId,
taskId,
chosenOptionId: chosenOption?.id,
rationale,
},
}),
);
return {
status: "resolved",
message: `Escalation resolved. Next ${sliceId} dispatch will run normally.`,
artifactPath: task.escalation_artifact_path,
chosenOption,
};
}