From ea8819906d5cf380e0fc37855e046c40a792d9be Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 20:06:29 +0200 Subject: [PATCH] feat(sf): wire escalation detection into state derivation (PDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State derivation now emits phase='escalating-task' when a task in the active slice is paused waiting for a user decision. Builds on the type+DDL foundation in 62dacb627. Together they get the loop to STOP when there's a pending escalation rather than carrying past an undocumented decision. PDD spec for this change: Purpose: pause auto-mode at the state-derivation layer when any task in the active slice has escalation_pending=1 with an unresolved escalation artifact. The dispatcher (next fire) sees phase= 'escalating-task' and returns 'stop' rather than dispatching new work over a pending decision. Consumer: state.ts deriveStateFromDb() callers — the auto-loop, the /sf status dashboard, the future /sf escalate command. Contract: 1. Empty tasks list → null (no pause). Verified. 2. Task without escalation_pending → null. Verified. 3. escalation_pending=1 but no artifact path → null (treats as not actionable). Verified. 4. escalation_pending=1 + valid artifact + no respondedAt → returns task id; state.phase = 'escalating-task' with task id in blockers and a /sf escalate hint in nextAction. Verified. 5. respondedAt set → null (already resolved, fall through). Verified. Failure boundary: any read/parse failure on the artifact returns null from detectPendingEscalation — state derivation falls through to existing behavior. Strict schema validation in readEscalationArtifact treats malformed artifacts as 'no actionable escalation here.' Evidence: smoke test exercises all 5 contract conditions end-to-end with real filesystem artifacts. Typecheck clean. Existing state derivation paths unchanged when no task is paused (early continue on escalation_pending !== 1 in detectPendingEscalation's loop). Non-goals: - Dispatch rule that returns 'stop' on phase='escalating-task' (next fire — needs no DB changes, just an auto-dispatch.ts edit) - Escalation artifact creation tools (gsd-2 has writeEscalation- Artifact + buildEscalationArtifact + setTaskEscalationPending — those land when a task agent needs to file an escalation) - /sf escalate user command (later fire) Invariants: - Safety: no escalation pending → 0 file system reads (loop early- continues), zero behavior change vs current. - Liveness: if a task IS paused, state.phase becomes 'escalating- task' immediately — no race with dispatch ordering. Assumptions verified: - SF's EscalationArtifact + EscalationOption types match gsd-2's schema (verified earlier this session). - TaskRow has escalation_pending and escalation_artifact_path fields (added in 62dacb627). - getSliceTasks() returns DB rows that include those fields after the v23 migration ran. - state.ts has the slice-level scope I need (activeMilestone + activeSlice + registry + requirements + progress all visible at the insertion point). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resources/extensions/sf/escalation.ts | 80 ++++++++++++++++++++++- src/resources/extensions/sf/state.ts | 31 +++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/sf/escalation.ts b/src/resources/extensions/sf/escalation.ts index 46f77be03..bbf21ca67 100644 --- a/src/resources/extensions/sf/escalation.ts +++ b/src/resources/extensions/sf/escalation.ts @@ -1,4 +1,82 @@ -// ADR-011 Phase 2 Mid-Execution Escalation — stub pending full DB schema port. +// SF Extension — ADR-011 Phase 2 Mid-Execution Escalation (gsd-2 ADR) +// +// Currently scoped to detection only. The wider gsd-2 module (build/write/ +// resolve/list) requires several DB helpers SF doesn't yet have; those land +// in subsequent fires. This file covers what state derivation + dispatch +// need today: reading an artifact and detecting whether any task in a slice +// is paused waiting for a user response. + +import { existsSync, readFileSync } from "node:fs"; + +import type { TaskRow } from "./sf-db.js"; +import type { EscalationArtifact, EscalationOption } from "./types.js"; + +/** Read an escalation artifact by path. Returns null when missing or malformed. + * + * Schema validation is strict (matches the eventual buildEscalationArtifact) + * so a hand-edited artifact cannot be weaker than what the writer would emit. + * Downstream callers can treat null as "no actionable escalation here." */ +export function readEscalationArtifact(path: string): EscalationArtifact | null { + if (!existsSync(path)) return null; + try { + const raw = readFileSync(path, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return null; + const art = parsed as Partial; + if (art.version !== 1) return null; + if (typeof art.taskId !== "string" || art.taskId.length === 0) return null; + if (typeof art.sliceId !== "string" || art.sliceId.length === 0) return null; + if (typeof art.milestoneId !== "string" || art.milestoneId.length === 0) { + return null; + } + if (typeof art.question !== "string" || art.question.length === 0) { + return null; + } + if ( + !Array.isArray(art.options) || + art.options.length < 2 || + art.options.length > 4 + ) { + return null; + } + const optionIds = new Set(); + for (const opt of art.options) { + if (!opt || typeof opt !== "object") return null; + const o = opt as Partial; + if (typeof o.id !== "string" || o.id.length === 0) return null; + if (typeof o.label !== "string") return null; + if (typeof o.tradeoffs !== "string") return null; + if (optionIds.has(o.id)) return null; + optionIds.add(o.id); + } + if (typeof art.recommendation !== "string") return null; + if (!art.options.some((o) => o.id === art.recommendation)) return null; + if (typeof art.continueWithDefault !== "boolean") return null; + if (typeof art.createdAt !== "string") return null; + return art as EscalationArtifact; + } catch { + return null; + } +} + +/** Returns the task id of the first task with an un-resolved pause-escalation + * (escalation_pending=1, artifact present, no respondedAt). Returns null when + * nothing in the slice is paused — caller should treat that as "carry on." + * + * O(n) over the slice's tasks, with an early continue when escalation_pending + * isn't set, so the common no-escalation path costs almost nothing. */ +export function detectPendingEscalation( + tasks: TaskRow[], + _basePath: string, +): string | null { + for (const t of tasks) { + if (t.escalation_pending !== 1) continue; + if (!t.escalation_artifact_path) continue; + const art = readEscalationArtifact(t.escalation_artifact_path); + if (art && !art.respondedAt) return t.id; + } + return null; +} export function claimOverrideForInjection( _basePath: string, diff --git a/src/resources/extensions/sf/state.ts b/src/resources/extensions/sf/state.ts index 62816d19e..d689c09cc 100644 --- a/src/resources/extensions/sf/state.ts +++ b/src/resources/extensions/sf/state.ts @@ -5,6 +5,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { debugCount, debugTime } from "./debug-logger.js"; +import { detectPendingEscalation } from "./escalation.js"; import { isValidTaskSummary, loadFile, @@ -2167,6 +2168,36 @@ export async function _deriveStateImpl(basePath: string): Promise { } } + // ── Mid-execution escalation (ADR-011 P2 — gsd-2 ADR) ──────────────── + // Pause the loop if any task in the active slice has escalation_pending=1 + // and an unresolved escalation artifact. The user must run /sf escalate + // resolve before auto-mode will continue. Falls through (returns null + // from detectPendingEscalation) when nothing is paused — no perf cost + // in the common path. + { + const dbTasks = getSliceTasks(activeMilestone.id, activeSlice.id); + const escalatingTaskId = detectPendingEscalation(dbTasks, basePath); + if (escalatingTaskId) { + return { + activeMilestone, + activeSlice, + activeTask: { id: escalatingTaskId, title: "" }, + phase: "escalating-task", + recentDecisions: [], + blockers: [ + `Task ${escalatingTaskId} requires a user decision before the loop can proceed`, + ], + nextAction: `Run \`/sf escalate show ${escalatingTaskId}\` to review the options, then \`/sf escalate resolve ${escalatingTaskId} \` to proceed.`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + }, + }; + } + } + // ── Blocker detection: scan completed task summaries ────────────────── // If any completed task has blocker_discovered: true and no REPLAN.md // exists yet, transition to replanning-slice instead of executing.