feat(sf): expose escalation via sf_task_complete (PDD)

Closes the agent surface for ADR-011 P2. Task agents can now include
an optional 'escalation' payload on sf_task_complete, gated by
phases.mid_execution_escalation. When the preference is on and the
field is present, the executor builds and writes the artifact, which
flips tasks.escalation_pending or escalation_awaiting_review based
on continueWithDefault. The producer chain from 14efcd773 is now
agent-callable.

PDD spec for this change:

Purpose: give task agents a way to file a mid-execution escalation
  through the same tool they already call to record completion. No
  new tool surface — escalation rides as an optional field on
  sf_task_complete (matches gsd-2's design intent).
Consumer: task agents (execute-task) when they hit ambiguity that
  requires user judgment.
Contract:
  1. phases.mid_execution_escalation !== true → escalation field
     silently ignored, current behavior preserved. Verified.
  2. preference on + escalation field → buildEscalationArtifact
     validates, writeEscalationArtifact persists, DB flag set,
     result text + details report path + status. Verified.
  3. continueWithDefault=false → status='pending' (loop pauses).
     continueWithDefault=true → status='awaiting-review' (no pause).
  4. Escalation write failures are caught — task completion never
     blocks on an escalation error (logged via logError).
Failure boundary:
  - Validation errors from buildEscalationArtifact propagate as
    caught try/catch in the executor → logged → task still completes.
  - Preference loader fails → behaves as if preference is off.
  - DB write failures fall through; the task is already recorded.
Evidence: smoke test exercises both preference states (on writes
  artifact + sets flag; off silently ignores). Typecheck clean.
  Existing sf_task_complete callers without an escalation field
  see zero change in result shape or behavior.
Non-goals:
  - resolveEscalation (apply user's choice → carry forward as
    override) — bigger flow, later fire.
  - listActionableEscalations / listAllEscalations — for /sf
    escalate list, later fire.
  - /sf escalate user command (later fire).
Invariants:
  - Safety: escalation field is Optional in the schema; no caller
    is forced to migrate.
  - Liveness: build+write happen synchronously after handleCompleteTask
    returns; on success, the next state-derivation cycle picks up
    pending=1 and pauses.
Schema additions to preferences-validation.ts:
  - mid_execution_escalation, progressive_planning recognized as
    valid phases keys (previously typed in PhaseSkipPreferences but
    silently stripped by the validator).

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

View file

@ -1358,6 +1358,49 @@ export function registerDbTools(pi: ExtensionAPI): void {
description: "Whether a plan-invalidating blocker was discovered",
}),
),
// ADR-011 Phase 2: mid-execution escalation — agent flags an ambiguity
// for the user. Only honored when phases.mid_execution_escalation=true.
escalation: Type.Optional(
Type.Object(
{
question: Type.String({
description:
"The question the user needs to answer — one clear sentence.",
}),
options: Type.Array(
Type.Object({
id: Type.String({
description:
"Short id (e.g. 'A', 'B') used by /sf escalate resolve.",
}),
label: Type.String({ description: "One-line label." }),
tradeoffs: Type.String({
description: "1-2 sentences on the tradeoffs of this option.",
}),
}),
{
minItems: 2,
maxItems: 4,
description: "24 options the user can choose between.",
},
),
recommendation: Type.String({
description: "Option id the executor recommends.",
}),
recommendationRationale: Type.String({
description: "Why the recommendation — 12 sentences.",
}),
continueWithDefault: Type.Boolean({
description:
"When true, loop continues (artifact logged for later review). When false, auto-mode pauses until the user resolves via /sf escalate resolve.",
}),
},
{
description:
"ADR-011 P2: optional escalation payload. Only honored when phases.mid_execution_escalation is true.",
},
),
),
verificationEvidence: Type.Optional(
Type.Array(
Type.Union([

View file

@ -766,6 +766,12 @@ export function validatePreferences(preferences: SFPreferences): {
if ((p as any).require_slice_discussion !== undefined)
(validatedPhases as any).require_slice_discussion = !!(p as any)
.require_slice_discussion;
if ((p as any).mid_execution_escalation !== undefined)
(validatedPhases as any).mid_execution_escalation = !!(p as any)
.mid_execution_escalation;
if ((p as any).progressive_planning !== undefined)
(validatedPhases as any).progressive_planning = !!(p as any)
.progressive_planning;
// Warn on unknown phase keys
const knownPhaseKeys = new Set([
"skip_research",
@ -774,6 +780,8 @@ export function validatePreferences(preferences: SFPreferences): {
"skip_milestone_validation",
"reassess_after_slice",
"require_slice_discussion",
"mid_execution_escalation",
"progressive_planning",
]);
for (const key of Object.keys(p)) {
if (!knownPhaseKeys.has(key)) {

View file

@ -218,6 +218,18 @@ type VerificationEvidenceInput =
}
| string;
/** ADR-011 Phase 2: optional escalation payload on sf_task_complete.
* When phases.mid_execution_escalation is enabled, the executor calls
* buildEscalationArtifact + writeEscalationArtifact after the task is
* recorded, flipping the appropriate task escalation flag. */
export interface TaskCompleteEscalationInput {
question: string;
options: Array<{ id: string; label: string; tradeoffs: string }>;
recommendation: string;
recommendationRationale: string;
continueWithDefault: boolean;
}
export interface TaskCompleteParams {
taskId: string;
sliceId: string;
@ -231,6 +243,7 @@ export interface TaskCompleteParams {
keyDecisions?: string[];
blockerDiscovered?: boolean;
verificationEvidence?: VerificationEvidenceInput[];
escalation?: TaskCompleteEscalationInput;
}
export type CompleteMilestoneExecutorParams = Partial<CompleteMilestoneParams> &
@ -293,11 +306,57 @@ export async function executeTaskComplete(
isError: true,
};
}
// ADR-011 P2: optional escalation payload. Only honored when the
// phases.mid_execution_escalation preference is true. When false (default),
// any escalation field on the params is silently ignored — keeps the
// payload backwards-compatible for callers that always send it.
let escalationPath: string | undefined;
let escalationStatus: "pending" | "awaiting-review" | undefined;
if (params.escalation) {
try {
const { loadEffectiveSFPreferences } = await import(
"../preferences.js"
);
const prefs = loadEffectiveSFPreferences()?.preferences;
if (prefs?.phases?.mid_execution_escalation === true) {
const { buildEscalationArtifact, writeEscalationArtifact } =
await import("../escalation.js");
const artifact = buildEscalationArtifact({
taskId: result.taskId,
sliceId: result.sliceId,
milestoneId: result.milestoneId,
question: params.escalation.question,
options: params.escalation.options,
recommendation: params.escalation.recommendation,
recommendationRationale:
params.escalation.recommendationRationale,
continueWithDefault: params.escalation.continueWithDefault,
});
escalationPath = writeEscalationArtifact(basePath, artifact);
escalationStatus = params.escalation.continueWithDefault
? "awaiting-review"
: "pending";
}
} catch (err) {
// Escalation is additive — never block task completion if it fails.
logError(
"tool",
`sf_task_complete escalation write failed: ${err instanceof Error ? err.message : String(err)}`,
{ tool: "sf_task_complete", op: "escalation" },
);
}
}
const baseText = `Completed task ${result.taskId} (${result.sliceId}/${result.milestoneId})`;
const escalationSuffix = escalationStatus
? ` — escalation ${escalationStatus} at ${escalationPath}`
: "";
return {
content: [
{
type: "text",
text: `Completed task ${result.taskId} (${result.sliceId}/${result.milestoneId})`,
text: baseText + escalationSuffix,
},
],
details: {
@ -306,6 +365,8 @@ export async function executeTaskComplete(
sliceId: result.sliceId,
milestoneId: result.milestoneId,
summaryPath: result.summaryPath,
...(escalationPath ? { escalationArtifactPath: escalationPath } : {}),
...(escalationStatus ? { escalationStatus } : {}),
},
};
} catch (err) {