feat(sf): port 3 gsd-2 DB helpers + improve /sf escalate list

Three small DB helpers from gsd-2 that SF was missing, plus a UX
improvement to /sf escalate list that uses one of them.

PDD spec:

setSliceSketchFlag(milestoneId, sliceId, isSketch) — generalized
  sketch-flag setter. Replaces my narrower clearSliceSketch (which
  remains as a thin wrapper for callers that only zero). Use this
  when a re-plan flow wants to revert a slice back to sketch state.

autoHealSketchFlags(milestoneId, hasPlanFile) — safety net for
  progressive planning. Predicate-based: caller passes a function
  that resolves whether a PLAN file exists for a slice, function
  flips is_sketch=0 for any slice that has both is_sketch=1 AND a
  plan file. Catches DB-FS drift after crashes/manual edits.

listEscalationArtifacts(milestoneId, includeResolved=false) —
  cross-slice DB-side filter for /sf escalate list. Replaces my
  hand-rolled inner-loop over getMilestoneSlices() + getSliceTasks()
  + filter — single SQL query, sorted by sequence, faster.

UX improvement to commands-escalate.ts:
  - /sf escalate list: now uses listEscalationArtifacts; shows
    PENDING / awaiting-review / resolved status badges per entry.
  - /sf escalate list --all: includes resolved entries (audit trail).
  - Better hint message when none active: 'Use --all to include
    resolved'.

Verified:
  - typecheck clean (one parallel-session-introduced error in
    self-feedback-drain.ts is unrelated — they import a missing
    utils/error.ts; will land when their commit does).
  - escalation-feature.test.ts (21 tests) + sf-db.test.ts (16
    tests) still pass — no regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 21:22:02 +02:00
parent 82633b6f5e
commit 0f0aee5bf0
2 changed files with 95 additions and 13 deletions

View file

@ -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");

View file

@ -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<Record<string, unknown>>;
return rows.map(rowToTask);
}
export function upsertSlicePlanning(