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:
parent
2bf6c51fde
commit
f757a18417
4 changed files with 306 additions and 3 deletions
189
src/resources/extensions/sf/commands-escalate.ts
Normal file
189
src/resources/extensions/sf/commands-escalate.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue