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.