From 2bf6c51fdebe0a3c8dcfce617d0c4f0fddd5c740 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 20:24:04 +0200 Subject: [PATCH] feat(sf): expose escalation via sf_task_complete (PDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../extensions/sf/bootstrap/db-tools.ts | 43 +++++++++++++ .../extensions/sf/preferences-validation.ts | 8 +++ .../sf/tools/workflow-tool-executors.ts | 63 ++++++++++++++++++- 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/sf/bootstrap/db-tools.ts b/src/resources/extensions/sf/bootstrap/db-tools.ts index ad7f15455..48df9c4ec 100644 --- a/src/resources/extensions/sf/bootstrap/db-tools.ts +++ b/src/resources/extensions/sf/bootstrap/db-tools.ts @@ -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: "2–4 options the user can choose between.", + }, + ), + recommendation: Type.String({ + description: "Option id the executor recommends.", + }), + recommendationRationale: Type.String({ + description: "Why the recommendation — 1–2 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([ diff --git a/src/resources/extensions/sf/preferences-validation.ts b/src/resources/extensions/sf/preferences-validation.ts index f88bf496e..9da653b70 100644 --- a/src/resources/extensions/sf/preferences-validation.ts +++ b/src/resources/extensions/sf/preferences-validation.ts @@ -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)) { diff --git a/src/resources/extensions/sf/tools/workflow-tool-executors.ts b/src/resources/extensions/sf/tools/workflow-tool-executors.ts index d1da72d47..e172b4082 100644 --- a/src/resources/extensions/sf/tools/workflow-tool-executors.ts +++ b/src/resources/extensions/sf/tools/workflow-tool-executors.ts @@ -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 & @@ -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) {