diff --git a/src/resources/extensions/sf/commands-escalate.ts b/src/resources/extensions/sf/commands-escalate.ts index 19534a3ae..6df25ce89 100644 --- a/src/resources/extensions/sf/commands-escalate.ts +++ b/src/resources/extensions/sf/commands-escalate.ts @@ -13,9 +13,9 @@ import type { ExtensionCommandContext } from "@singularity-forge/pi-coding-agent import { readEscalationArtifact, resolveEscalation } from "./escalation.js"; import { getActiveMilestoneFromDb, - getMilestoneSlices, getSliceTasks, isDbAvailable, + listEscalationArtifacts, } from "./sf-db.js"; function usage(): string { @@ -60,22 +60,39 @@ export async function handleEscalate( ctx.ui.notify("No active milestone — nothing to list.", "info"); return; } - const lines: string[] = [`Pending escalations for milestone ${ms.id}:`]; + // Pass --all to also list resolved escalations (audit trail). + const includeResolved = rest.includes("--all"); + const tasks = listEscalationArtifacts(ms.id, includeResolved); + const header = includeResolved + ? `Escalations for milestone ${ms.id} (active + resolved):` + : `Active escalations for milestone ${ms.id}:`; + const lines: string[] = [header]; 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}`); + for (const task of tasks) { + if (!task.escalation_artifact_path) continue; + const art = readEscalationArtifact(task.escalation_artifact_path); + if (!art) continue; + count++; + const status = task.escalation_pending === 1 + ? "PENDING" + : task.escalation_awaiting_review === 1 + ? "awaiting-review" + : art.respondedAt + ? `resolved (${art.userChoice})` + : "(unknown)"; + lines.push(` ${task.slice_id}/${task.id} [${status}]: ${art.question}`); + if (status === "PENDING") { 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"); + ctx.ui.notify( + includeResolved + ? "No escalations recorded." + : "No active escalations. Use /sf escalate list --all to include resolved.", + "info", + ); return; } ctx.ui.notify(lines.join("\n"), "info"); diff --git a/src/resources/extensions/sf/sf-db.ts b/src/resources/extensions/sf/sf-db.ts index f00a9b24e..d3a3e7301 100644 --- a/src/resources/extensions/sf/sf-db.ts +++ b/src/resources/extensions/sf/sf-db.ts @@ -2216,12 +2216,77 @@ export function insertSlice(s: { * Idempotent — safe to call on already-refined slices. */ export function clearSliceSketch(milestoneId: string, sliceId: string): void { + setSliceSketchFlag(milestoneId, sliceId, false); +} + +/** + * ADR-011 (gsd-2 parity): generalized sketch-flag setter — flip true or false. + * Idempotent. Use this instead of clearSliceSketch when you also need to + * mark a slice as a sketch (e.g., a re-plan flow that wants to revert to + * sketch-then-refine). + */ +export function setSliceSketchFlag( + milestoneId: string, + sliceId: string, + isSketch: boolean, +): void { if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); currentDb .prepare( - `UPDATE slices SET is_sketch = 0 WHERE milestone_id = :mid AND id = :sid`, + `UPDATE slices SET is_sketch = :is_sketch WHERE milestone_id = :mid AND id = :sid`, ) - .run({ ":mid": milestoneId, ":sid": sliceId }); + .run({ + ":is_sketch": isSketch ? 1 : 0, + ":mid": milestoneId, + ":sid": sliceId, + }); +} + +/** + * ADR-011 auto-heal: reconcile stale is_sketch=1 rows whose PLAN file already + * exists on disk. The caller passes a predicate that uses the canonical path + * resolver so path logic stays in one place. Safe to call repeatedly — only + * flips rows that meet the predicate. + */ +export function autoHealSketchFlags( + milestoneId: string, + hasPlanFile: (sliceId: string) => boolean, +): void { + if (!currentDb) return; + const rows = currentDb + .prepare( + `SELECT id FROM slices WHERE milestone_id = :mid AND is_sketch = 1`, + ) + .all({ ":mid": milestoneId }) as Array<{ id: string }>; + for (const row of rows) { + if (hasPlanFile(row.id)) { + setSliceSketchFlag(milestoneId, row.id, false); + } + } +} + +/** + * ADR-011 P2 (gsd-2 parity): list tasks across a milestone that have an + * escalation artifact path. By default returns only ACTIVE escalations + * (pending OR awaiting_review); pass includeResolved=true to also return + * resolved-but-still-recorded entries (audit trail). + * + * Used by `/sf escalate list` to enumerate cross-slice escalations. + */ +export function listEscalationArtifacts( + milestoneId: string, + includeResolved = false, +): TaskRow[] { + if (!currentDb) return []; + const filter = includeResolved + ? "escalation_artifact_path IS NOT NULL" + : "(escalation_pending = 1 OR escalation_awaiting_review = 1) AND escalation_artifact_path IS NOT NULL"; + const rows = currentDb + .prepare( + `SELECT * FROM tasks WHERE milestone_id = :mid AND ${filter} ORDER BY slice_id, sequence, id`, + ) + .all({ ":mid": milestoneId }) as Array>; + return rows.map(rowToTask); } export function upsertSlicePlanning(