feat(sf): dispatch pause-for-escalation rule (PDD)

Closes the basic escalation loop. With this commit, end-to-end:
- Task agent writes escalation_pending=1 + escalation_artifact_path
  to the tasks DB row (DB schema from 62dacb627).
- State derivation detects the pause and emits phase='escalating-task'
  with /sf escalate hint in nextAction (ea8819906).
- Auto-dispatch sees phase='escalating-task' FIRST in the rule order
  and returns 'stop' with the nextAction message — no other rule runs.

PDD spec:

Purpose: never let the loop continue past a pending escalation.
Consumer: auto-mode dispatcher (DISPATCH_RULES first entry).
Contract:
  1. state.phase !== 'escalating-task' → return null (fall through).
  2. state.phase === 'escalating-task' → return action='stop' with
     the state's nextAction (the /sf escalate hint state.ts produced).
  3. Rule sits at index 0 of DISPATCH_RULES so phase-agnostic rules
     below (rewrite-docs, UAT, reassess) cannot bypass it.
Failure boundary: pure phase check, no fs/db access — nothing to fail.
Evidence: typecheck clean. State derivation already smoke-tested in
  ea8819906 — once that returns phase='escalating-task', this rule
  emits the stop. End-to-end happy path is just two function calls.
Non-goals:
  - Tools to write escalation_pending (the producer side — task
    agents need a tool for this; later fire)
  - /sf escalate user command (later fire)
  - Resolution flow (escalation.ts has the schema; resolveEscalation
    helper from gsd-2 is not yet ported — later fire)
Invariants:
  - Safety: phase !== 'escalating-task' → 1 condition check, return
    null. Zero overhead in the common case.
  - Liveness: when paused, dispatch returns immediately — never
    runs another rule that could mutate slice state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 20:07:56 +02:00
parent ea8819906d
commit a558ff6c64

View file

@ -522,6 +522,25 @@ When done, say: "Validation attention remediated; ready for revalidation."`;
// ─── Rules ────────────────────────────────────────────────────────────────
export const DISPATCH_RULES: DispatchRule[] = [
{
// ADR-011 Phase 2 (gsd-2 ADR): mid-execution escalation pause.
// Must evaluate FIRST — phase-agnostic rules below (rewrite-docs gate,
// UAT checks, reassess) cannot bypass a pending user decision.
// state.ts emits phase='escalating-task' only when there's an actionable
// escalation; this rule turns that into a clean 'stop' with the message
// state.nextAction already populated.
name: "escalating-task → pause-for-escalation",
match: async ({ state, mid }) => {
if (state.phase !== "escalating-task") return null;
return {
action: "stop",
reason:
state.nextAction ||
`${mid}: task escalation awaits user resolution. Run /sf escalate list to see pending items.`,
level: "info",
};
},
},
{
name: "rewrite-docs (override gate)",
match: async ({ mid, midTitle, state, basePath, session: _session }) => {