# Task execution pipeline — one run per execution attempt of a task (design §6).
#
# Orchestration is configuration (ADR-022 / §3.2): this is YAML over the existing
# dual-workflow engine — zero new engine code. The pipeline NEVER touches files
# directly — `record` writes via `spur task update --section`, and status moves go
# through the normal `spur task update <wbs> <status>` verb so the lifecycle guards
# (0055) apply identically. Run linkage is written to `task_run_links` (kind=pipeline).
#
# Shape: precheck → implement[→escalate] → test[→test-fix↔test-recheck] → review → approve(HITL)
#        → verify → record → done
#        (precheck failure short-circuits to `failed`; approve routes to `failed` on
#        operator rejection or `cancelled` on operator cancel — R1, bug-750).
#        `test` is the project quality gate (shell + bounded /sp:dev-fixall), not
#        /sp:dev-unit (coverage gap-fill; router C3/C5).
#
# F96 residual sweep (0950, reordered 0983): precheck captures the resume-safe base
# commit; record scans and folds residuals AFTER `task record` flips the verdict-proven
# boxes (a post-record blocking leftover downgrades PASS → PARTIAL, fails the record →
# done guard closed, and re-records Testing before the failed edge); done settles
# deferrals; failed renders the recovery report. No new state, edge, or model query.
#
# Confidence gate (session finding after 1088, task 1068 follow-up): the verify answer has
# carried a `Confidence: HIGH|MEDIUM|LOW` line since 1068, and nothing read it — a PASS at
# LOW confidence certified exactly like HIGH. The verify stamp now records a `confidence`
# check row, and BOTH completion guards (verify → record, record → done) refuse a LOW (or
# absent) level unless the run was launched with `--vars '{"ackLowConfidence":"true"}'`.
# The acknowledgement is an operator decision, not a model one: it is the only way to
# certify a verdict the verifier itself would not stand behind.
#
# Review-failure routing (session finding after 1088): `review` had no outgoing edge for a
# FAIL verdict, so a failing review could only be remediated by driving the graph by hand
# (re-entering `test` and hand-emitting rows). The review stage now derives
# `.spur/run/<wbs>-review.status` from its own answer file (fail-closed to FAIL), and a FAIL
# routes through `review-fail-triage` — the review-side twin of `test-fail-triage` (0943) —
# into the SAME bounded fix budget, so remediation stays on-graph and bounded.
#
# Vars (passed as a JSON object via `--vars`):
#   wbs                       — task WBS (required)
#   profile                   — "auto" skips HITL approve (R4)
#   agent / spurBin           — executor + spur binary (CLI overrides spurBin)
#   stepTimeoutMs             — agent.run budget for review/verify/test-fix (ms)
#   implementTimeoutMs        — implement agent.run budget (ms)
#   qualityGateCmd            — project gate (default: bun run spur-check)
#   qualityGateMaxFixAttempts — max /sp:dev-fixall hops after a red gate (default: 2)
#   maxEscalations            — max operator-question pauses per implement chain (default: 2)
#
# Seeded by `spur init`. agent.run inputs are pure slash commands (ADR-043).

"$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
kind: state-machine
name: task-pipeline
# 0772 R2: explicit identity tag. Set only after the proof failure-path tests
# passed; the engine treats unversioned external definitions as supported.
# v4 (session finding after 1088): the completion gate reads the verifier's confidence level and
# the graph routes a non-PASS review into bounded remediation — both are contract changes, so the
# identity moves with them.
# v5 (task 1077's inline run, 2026-10-06): the verify verdict action is explicitly `onError:
# continue`. `task verdict` exits non-zero on any non-PASS verdict BY DESIGN (1003 R2) — that is
# data for the verify guards, not a state failure. Under the engine's default 'fail' policy
# (`resolveOnErrorPolicy: action ?? workflowDefault ?? runOption ?? 'fail'`) the state sequence
# halted on that exit code and `lifecycle.fail('verify')` finalized the run before any transition
# guard was evaluated, which made the declared `verify → test-fix` "repairable non-PASS" lane
# UNREACHABLE: a non-PASS verify could only end the run failed. `test-fix` also gained the
# review-lane evidence projection (see its onEnter) so the repair hop is not fed an empty findings
# list when the failing hop was `review-fail-triage` rather than the quality gate.
version: "5"
description: "Standard task pipeline: precheck → implement → quality-gate (test/fix/recheck) → review → approve → verify → record → done"
# Task entity lifecycle (owned by task-lifecycle.yaml):
#   normal: backlog → todo → wip → testing → done
#   blocked is bidirectional with todo/wip/testing; done may reopen to wip.
#   any non-terminal state may transition to cancelled.
# Guards:
#   wip → testing:  spur task check <wbs>
#   testing → done: spur task check <wbs> --as done, then PASS-verdict provenance
# Invalid: todo → testing, todo → done, and wip → done.
# Pipeline states below are orchestration states, not task statuses.
iterationBound: 20
initialState: precheck
terminalStates:
    - done
    - failed
    - cancelled
failureStates:
    - failed
    - cancelled
vars:
    wbs: "0000"
    profile: "standard"
    mode: ""
    __runId: ""
    __definitionDigest: ""
    # PATH-independent spur invocation for shell guards/actions. The CLI overrides this
    # at run start (resolveSpurBin); the literal default is a safe fallback so direct/dry
    # runs and `workflow validate` resolve the template. (See AGENTS.md / ADR-026.)
    spurBin: "spur"
    # Agent the pipeline's agent.run steps invoke. Override per run with
    # `--vars '{"agent":"claude"}'`. `auto` is the reserved config-resolving selector:
    # `agent.default` role -> tier -> cheapest USABLE executor. A named literal here would
    # pin a box-specific binary into tracked SSOT and escape that usability ladder.
    # It does not inherit the host session's interactive model; required capability
    # attestation still gates the chosen executor before dispatch (0706).
    agent: "auto"
    # Implement-only executor override (R1, task 0454). Resolves like `agent` unless
    # overridden. The `--agent` flag (execution-batch.md §3.2) forwards into BOTH
    # `agent` and `implementAgent`, so a pinned `--agent X` reaches implement too. To
    # pin ONLY implement while other hops keep the default, pass
    # `--vars '{"implementAgent":"pi-zai"}'`.
    implementAgent: "auto"
    # Remediation can mutate code only when BOTH this cap and the task's declared
    # mutationPolicy permit code. none/tests require scoped manual repair, not fixall.
    mutationPolicy: "code"
    # Step-level timeout for agentic hops (review / verify / test-fix) in ms.
    # Soft quality-gate shells are unbounded by this var (host shell only).
    # Raised 600s → 1800s (task 0398 R4 / H6 dogfood). Override:
    # `--vars '{"stepTimeoutMs":"120000"}'`. 30 min is headroom, not a licence to
    # run unbounded — if a hop still hits this wall, STOP and record it.
    stepTimeoutMs: "1800000"
    # Timeout for the `implement` step specifically (R2a). Implementation is the
    # heaviest agent.run step (full read/write/test-probe loop) and has timed out
    # at the 600s default in five consecutive dogfood runs (bugs 742/744/746/748),
    # a 100% failure rate at that budget. 30 min gives real headroom; STOP and
    # record honestly rather than raising it further without operator sign-off.
    implementTimeoutMs: "1800000"
    # Answer captured by the approve gate's hitl.confirm (R1): "yes" | "no" | "cancel".
    # Empty by default; only meaningful once the approve state has been entered.
    __hitlAnswer: ""
    # Operator answer captured by the escalate hop's hitl.input (0933). Empty by
    # default; set by `spur workflow continue --answer-text` after the pause and read
    # by the escalate→implement / escalate→failed guards. Non-empty = answered.
    __hitlInput: ""
    # Proof-state bracket (task 0612, ADR-071; restructured by task 0703). `proofDigest` is the
    # canonical capture taken at quality-gate ENTRY — immediately before the evidence-producing
    # final chain (quality → review → verify) — and re-captured at `test-recheck` when bounded
    # remediation mutated the tree, so every evidence stage names one fresh digest (R2/R4).
    # `proofDigestNow` is the live re-capture compared against `proofDigest` at verify entry and
    # immediately before `record`; a mismatch means a proof input changed after evidence was
    # established, so the run routes to `failed` instead of crossing the completion boundary (R5).
    # `taskSpecPath` carries the task file path because `docs/tasks*` is excluded from the digest's
    # git-tree half — spec content is folded in explicitly or a task-file edit would go undetected.
    # The fingerprint scopes task content to the proof-input sections only (Background, Requirements,
    # Acceptance Criteria, Design, Plan), so record-time Solution/Testing/Review evidence writes do
    # not retroactively invalidate the certified input set (R6).
    proofDigest: ""
    proofDigestNow: ""
    taskSpecPath: ""
    # 0785 R2: linked feature spec path, resolved at `test` entry from the task frontmatter.
    # Empty (orphan task) means the feature spec is legitimately omitted from the digest inputs
    # (empty-string compatibility); a declared feature whose path fails to resolve fails closed.
    featureSpecPath: ""
    # 0710 R4: task priority tier (P0..P4) extracted from the task frontmatter at the
    # quality-gate stage. P0/P1 make the review/verify distinct-executor policy apply;
    # unknown/empty priority means fresh-context-only (executor reuse allowed).
    taskPriority: ""
    # Project quality gate for the `test` hop (probe + fixall + recheck). Override per project
    # with the same package-manager surface (this monorepo is Bun-only):
    # `--vars '{"qualityGateCmd":"bun run lint && bun run test"}'`. Soft probe, hard recheck,
    # and the fixall slash input all use this same var so the command stays single-sourced.
    # TRUSTED CONFIG ONLY — this string is executed via `sh -c` (see test/test-recheck). Never
    # interpolate untrusted operator/LLM input into qualityGateCmd (task 0436 SECUA residual).
    # NO `format` PREFIX: `implement` already ran `$formatCmd` on its way out, and `test`
    # captures `proofDigest` (onEnter[3]) BEFORE this command runs (onEnter[4]) — a formatter
    # inside the gate rewrites the very tree the digest just fingerprinted (ADR-071 proof
    # window). It was a no-op only because the implement-stage format got there first; that is
    # an accident, not an invariant. The gate observes, it does not mutate.
    qualityGateCmd: "bun run spur-check"
    # Cheap red-detector run before the full gate on **recheck only**; empty ⇒ no probe
    # (full gate every recheck — the pre-0587 behavior). A project overriding qualityGateCmd
    # should override this too. TRUSTED CONFIG ONLY — executed via `sh -c` (same surface as
    # qualityGateCmd). Invariant: `review` is only ever entered through a full green
    # qualityGateCmd — only the full gate writes PASS to <wbs>-test-gate.status.
    gateProbeCmd: "bun run lint"
    # Max /sp:dev-fixall attempts after a red quality-gate probe/recheck (bounded; no thrash).
    # Attempt counter: .spur/run/<wbs>-test-fix-attempt. Default 2 = two fixall hops before failed.
    qualityGateMaxFixAttempts: "2"
    # Max operator-question pauses (0933) before the implement chain routes to failed.
    # Escalation counter: .spur/run/<wbs>-escalation-count. Default 2 = two answered
    # questions; a third pause routes to failed with a report note.
    maxEscalations: "2"
    # Operator acknowledgement of a LOW-confidence certification (session finding after 1088).
    # The verify answer's `Confidence:` line is captured into the verdict artifact; a LOW (or
    # absent) level fails the completion guards unless this is "true", because a PASS the
    # verifier would not stand behind is an operator risk decision, not a mechanical one.
    # Set per run: `--vars '{"ackLowConfidence":"true"}'` (and export it on the inline path).
    # Deliberately NOT settable from the task file or the verifier's own answer: the point is
    # that a second party, not the producer, accepts the residual uncertainty.
    ackLowConfidence: ""
    # Runtime-written, 0933: the escalate hop's file.read.into-var fills this from
    # .spur/run/<wbs>-question.md; the hitl.input prompt below references it. Declared
    # here (empty) so the static var-reference validator sees the template source.
    escalationQuestion: ""
    # Post-implement auto-format. Overridable like qualityGateCmd so a non-Bun seeded
    # project can point it at its own formatter; invoked best-effort (a missing or
    # failing formatter must never abort a run — the quality gate is the real gate).
    formatCmd: "bun run format"
    # Bounded `file:line` anchors extracted from the failing quality-gate log by the
    # `test` / `test-recheck` hops (deduped, max 20, space-separated on one line).
    # `test-fix` reads `.spur/run/<wbs>-test-gate.findings` into this var and names the
    # anchors directly in the /sp:dev-fixall input, so the fix agent starts AT the failing
    # file:line instead of re-deriving it from a fresh gate run (0482 R3).
    gateFindings: ""
    # Diff-scope guard on the implement hop (R1, task 0487). When the target task
    # body backticks at least one path, non-corpus changes outside those paths
    # fail the step by name. New files beside a declared file are allowed. Empty
    # (default) = on; set to "off" to bypass:
    # `--vars '{"implementScopeGuard":"off"}'`.
    implementScopeGuard: ""
    # 0931 R5: parallel batches defer the per-task feature sync so a task branch never
    # touches feature files or docs/features/INDEX.md. When "true", the record step's
    # post-record shell appends a deferral note and skips the sync; the parallel batch
    # orchestrator runs the sync + `spur feature refresh` once per touched feature on the
    # base ref after integration. Sequential/inline keep the default "false" (unchanged).
    deferFeatureSync: "false"

states:
    - id: precheck
      description: >
          Fast deterministic task readiness and size checks. Failures route to the
          `failed` terminal state (not a raw lifecycle abort mid-enter).
      onEnter:
          # 0960 R2: run-start script-resolution identity — record which tree this run
          # resolved (source-repo vs installed) before any step executes. Never aborts.
          - kind: shell
            options:
                command: >-
                    S=plugins/sp/scripts/script-root.ts; [ -f config/plugin-scripts.json -a -f "$S" ] || S="$(superskill script path sp script-root.mjs 2>/dev/null)"; RUNNER=bun; case "$S" in *.mjs) RUNNER=node ;; esac; [ -f "$S" ] && "$RUNNER" "$S" --run-id "$__runId" --spur-bin "$spurBin" || echo "script-root failed closed — run 'superskill install sp --marketplace gobing-ai/spur'" >&2; exit 0
          # (e) git-status hygiene advisory; never fails the run (0487 R6 / 0511 R1). 1007 R3:
          # the F96 base-sha capture (0950) merged into this shell — original commands in order,
          # then the one-time base commit capture; a resumed run keeps its original base so
          # residual scans stay anchored to the same diff.
          - kind: shell
            options:
                command: >-
                    D=$(git status --porcelain -- . ':(exclude)docs/tasks*' ':(exclude)docs/features' 2>/dev/null); [ -z "$D" ] || printf 'precheck: WARNING - working tree has uncommitted non-corpus changes — commit or stash before starting a new task:\n%s\n' "$D"; C=$(git status --porcelain -- ':(glob)docs/tasks*/**' 2>/dev/null); [ -z "$C" ] || printf 'precheck: NOTE - task corpus has uncommitted changes — review before staging with this task:\n%s\n' "$C"; mkdir -p .spur/run; [ -f ".spur/run/$wbs-base.sha" ] || git rev-parse HEAD > ".spur/run/$wbs-base.sha"; exit 0
          # (e) auto-profile feature reopen; exit 1 when reactivation fails (0723 R3).
          - kind: shell
            options:
                command: >-
                    [ "$profile" = auto ] || exit 0; FID=$($spurBin task show $wbs --json 2>/dev/null | jq -r '.feature_id // .frontmatter.feature_id // empty' 2>/dev/null); [ -z "$FID" ] || $spurBin feature sync "$FID" --force 2>/dev/null || $spurBin feature update "$FID" active 2>/dev/null || { echo "precheck: FAIL - feature reactivation $FID failed — feature sync + feature update both errored" >&2; exit 1; }; exit 0
          # (e) route-reason lookup and routes log; proportional-routing tests locate it
          # (0759 R1/R5 run-scoped artifact; 0804 R8 run-id safety — one line, no in-scalar `#`).
          - kind: shell
            options:
                command: >-
                    mkdir -p .spur/run .spur/memory; RUN_ID="$__runId"; [ -n "$RUN_ID" ] || RUN_ID="pipeline-$wbs"; case "$RUN_ID" in *'$'*|*'{'*|*'}'*|*vars.*|*/*|*'\'*|*..*) echo "route-reason: refusing unsafe run id: $RUN_ID" >&2; exit 1 ;; esac; REASON_FILE=".spur/run/$RUN_ID-route-reason.txt"; jq -rn --arg m "$mode" '{"fast":"fast:evidence complete+consistent","":"safety:standard verification","unknown":"safety:unknown evidence quality","conflict":"safety:conflicting evidence"}[$m] // "safety:unrecognized evidence (mode=\($m))"' > "$REASON_FILE"; printf '%s %s %s\n' "$RUN_ID" "$wbs" "$(cat "$REASON_FILE")" >> .spur/memory/task-pipeline-routes.log; exit 0

          # (e) B7 R1 (0894): resolve every declared role ONCE at precheck — pins land
          # in __executor.<role> run vars so stage dispatch performs no doctor call.
          # Soft probe: failures mark the status file and stages degrade to their own
          # resolution; nothing here aborts the run.
          - kind: doctor.probe
            options:
                resultFile: ".spur/run/${vars.__runId}-precheck-roles.status"
                spurBin: "${vars.spurBin}"
                roles:
                    coder: "${vars.implementAgent}"
                    reviewer: "${vars.agent}"

    - id: implement
      description: >
          Run agent-driven implementation via /sp:dev-run --mode implement, THEN move the
          task to `wip` via the normal verb. The lifecycle transition fires AFTER the
          implement agent.run succeeds — onEnter runs in declaration order and the default
          'fail' policy halts the sequence (routing the run to `failed`) if the agent.run
          fails or times out, so a halted implement never leaves the task at `wip` with an
          empty Solution (the F3 half-state). The `--no-lifecycle` flag is correct here: the
          pipeline owns this transition; the FSM guard would otherwise re-run `spur task
          check`, which is precheck's job.
          NOTE: this step must NOT call /sp:dev-run in full mode (the default) —
          that command DRIVES this pipeline, so calling it here recurses.
          --mode implement is the single-step implement entry.
      onEnter:
          # 0933 R27 (guard-lines compression): the transcript append lives HERE, on
          # (re-)entry, not in the escalate→implement guard. On first entry there is no
          # pending question (no-op); on a resume entry the guard already confirmed a
          # non-empty $__hitlInput and a fresh question file, so this shell records the
          # Q/A pair (## Q<n>/## A<n>, R3) and consumes the question file before the
          # agent.run re-dispatches — which also disarms the implement→escalate edge.
          - kind: shell
            options:
                command: >-
                    test -n "$__hitlInput" -a -s .spur/run/$wbs-question.md || exit 0;
                    qn="$(cat .spur/run/$wbs-escalation-count 2>/dev/null || echo 0)";
                    printf '## Q%s\n%s\n\n## A%s\n%s\n' "$qn" "$(cat .spur/run/$wbs-question.md 2>/dev/null)" "$qn" "$__hitlInput" >> .spur/run/$wbs-escalation.md;
                    rm -f .spur/run/$wbs-question.md;
                    exit 0
          - kind: agent.run
            options:
                agent: ${vars.implementAgent}
                # Pure slash command only (ADR-043). Anti-recursion / implement discipline
                # Declared Layer-1 role (0538 R2/0710 R7): routing reason; no executor pin — role routing feeds the distinctness gate.
                role: coder
                # B7 R3/R6 (0894): coder stages reuse the role session — the test-fix hop
                # resumes the implement session instead of re-reading the task cold.
                session: reuse
                # lives in /sp:dev-run --mode implement → sp:code-implementation, not YAML prose.
                # 0933 R28: --escalation-file names the Q/A transcript the implement
                # agent appends to when pausing and reads back on re-dispatch; absent
                # on a first attempt means "no prior escalations".
                input: /sp:dev-run --mode implement ${vars.wbs} --auto --escalation-file .spur/run/${vars.wbs}-escalation.md
                timeoutMs: ${vars.implementTimeoutMs}
                # R3 (task 0424): empty-implement no-op guard — the agent.run action
                # fails the step when exit 0 produced zero non-corpus file changes, so
                # a silent no-op routes the run to `failed` here instead of drifting
                # into test/review and being caught a full pass later.
                requireDiff: true
                # Verification-only re-run admission (task 0976): when requireDiff
                # sees zero non-corpus changes because the implementation already
                # landed through another path, `spur task check <wbs>` PASS admits
                # the empty implement as a certification pass; a check failure
                # keeps the 0424 R3 empty-implement violation. Static option —
                # deliberately no run-var knob (ADR-115).
                requireDiffAllowCleanCheck: true
                # 0933 R26: escalation contract — the agent.run option names the QUESTION
                # file (the pause signal, deleted pre-dispatch for freshness); a paused
                # attempt (agent wrote its question there and exited 0) succeeds with
                # data.escalated=true and skips requireDiff for THAT attempt only. The
                # Q/A transcript (.spur/run/<wbs>-escalation.md) is separate: it reaches
                # the agent via --escalation-file in the input below.
                escalationFile: .spur/run/${vars.wbs}-question.md
                # 0706 R6: this stage mutates the working tree unattended under the
                # auto profile, so it declares minimum execution-capability
                # requirements. Dispatch fails closed (before spawn) when the
                # resolved executor's attestation cannot satisfy them — attest the
                # executor in agent config.
                requiresCapabilities:
                    fsWrite: available
                    processSpawn: available
          # (c) command.gate owns the wip transition with classified transient retry.
          - kind: command.gate
            options:
                id: task-implement-transition
                executable: "${vars.spurBin}"
                args: ["task", "update", "${vars.wbs}", "wip", "--no-lifecycle"]
                retry:
                    maxAttempts: 2
                    delayMs: 2000
                    on: ["sqlite-busy", "ENOENT", "EBUSY", "ENOTEMPTY"]
                resultFile: .spur/run/${vars.__runId}-implement-transition.status
                softFail: false
                timeoutMs: 120000
          # Post-implement cleanup: auto-format any unformatted output the agent produced.
          # The agent may leave files that don't pass `biome check`, which then fails
          # `bun run lint` downstream. Running format here is cheap (< 1 s) and prevents
          # the test stage from tripping on a purely mechanical formatting defect (dogfood
          # bug-733 — omp left agent-run.test.ts unformatted, causing a spurious lint gate
          # failure that the ## Testing section mis-attributed to "pre-existing gaps").
          # Best-effort: `; exit 0` (not `&&`) so a missing/failing formatter in a seeded
          # non-Bun project cannot abort a run mid-implement. Formatting is a convenience;
          # `${vars.qualityGateCmd}` at `test` is the gate that actually decides.
          - kind: shell
            options:
                command: "$formatCmd ; exit 0"

    # ── escalate hop (operator-question pause, 0933) ──────────────────────────
    # The implement agent paused on an operator question: it wrote
    # .spur/run/<wbs>-question.md and exited 0 (agent.run reported
    # data.escalated=true, skipping requireDiff for that attempt). This hop bounds
    # the asks (counter), surfaces the question verbatim through the HITL responder
    # (the run pauses here), and — after `spur workflow continue --answer-text <a>`
    # delivers __hitlInput — resumes implement with the Q/A transcript
    # (.spur/run/<wbs>-escalation.md) named in the implement input via
    # --escalation-file. The transcript append + question-file consumption happen in
    # the escalate→implement guard so a stale question can never re-trigger the hop
    # after the answered attempt.
    - id: escalate
      description: >-
          Operator-question pause (0933): the implement agent asked a question
          headlessly; surface it to the operator and resume implement with the answer.
      onEnter:
          # Bounded asks: increment the per-task escalation counter before pausing.
          - kind: shell
            options:
                command: >-
                    mkdir -p .spur/run; n="$(cat .spur/run/$wbs-escalation-count 2>/dev/null || echo 0)"; echo $((n + 1)) > .spur/run/$wbs-escalation-count; exit 0
          # The agent's question, verbatim, becomes the pause prompt.
          - kind: file.read.into-var
            options:
                file: .spur/run/${vars.wbs}-question.md
                var: escalationQuestion
          # Pause for the operator (responder pause:true stops the run after this
          # onEnter; `spur workflow continue --answer-text <answer>` writes
          # __hitlInput and re-evaluates the escalate guards).
          - kind: hitl.input
            options:
                prompt: "${vars.escalationQuestion}"

    # ── test hop (quality gate + bounded auto-fix) ─────────────────────────────
    # NOT /sp:dev-unit. That command (sp:code-testing) *extends/generates* tests toward
    # a coverage target; it is not the project quality gate. Coverage gap-fill remains
    # available via router C3/C5 (`/sp:dev-unit`) or inside implement — not this hop.
    #
    # Shape (engine has no per-action "run next only if previous failed" without
    # `onError`, and Spur's action schema does not expose `onError`):
    #   test         — soft probe: qualityGateCmd → PASS|FAIL status; always exit 0
    #                  (green path: one gate run → review; no fixall/recheck tax)
    #   test-fix     — increment attempt + pure slash /sp:dev-fixall (≤ max attempts)
    #   test-recheck — soft recheck → review | test-fix | failed (never silent lifecycle abort)
    - id: test
      description: >
          Proof-chain entry + soft quality-gate probe (single logical gate on the green path; bounded retries only
          for SQLite lock contention). Capture of the canonical proof-input digest happens HERE — before any
          evidence-producing final check (task 0703, ADR-071) — so the gate, review, and verification evidence
          all name one digest. Then runs
          `${vars.qualityGateCmd}`, records PASS|FAIL under
          `.spur/run/<wbs>-test-gate.status`, resets the fix attempt counter, always exit 0.
      onEnter:
          # R6/R2 (0703): resolve the task-spec path BEFORE the digest capture. `docs/tasks*` is excluded
          # from the digest's git-tree half, so the spec is folded in explicitly via `taskSpecPath`.
          # (e) task path + priority resolution; fail-closed on an unresolvable spec (0751 R2, 0710 R4).
          - kind: shell
            options:
                # 0710 R4: resolve the spec path, then extract `priority:` from the TASK FILE itself (not the
                # path listing); normalize to upper so requiresDistinctExecutor's exact 'P0'/'P1' match hits.
                # 0751 R2: the task path is NOT optional - an unresolved lookup fails
                # the step (no `|| true`, no forced `exit 0`, no stderr suppression)
                # instead of degrading the proof to whole-tree-only. The priority read
                # stays tolerant: a missing line is genuinely optional.
                command: 'mkdir -p .spur/run; $spurBin task path $wbs --json | jq -r ".path // .filePath // empty" > ".spur/run/$wbs-taskpath.txt"; task_path="$(cat ".spur/run/$wbs-taskpath.txt")"; if [ -z "$task_path" ]; then echo "fail-closed proof chain (0751 R2): task path for $wbs did not resolve - the task spec cannot be folded into the proof digest" >&2; exit 1; fi; sed -n "s/^priority:[[:space:]]*//p" "$task_path" | head -1 | tr -d "[:space:]" | tr "[:lower:]" "[:upper:]" > ".spur/run/$wbs-priority.txt"'
          - kind: file.read.into-var
            options:
                path: .spur/run/${vars.wbs}-taskpath.txt
                var: taskSpecPath
          # 0710 R4: carry the task's priority tier into the review/verify risk policy.
          - kind: file.read.into-var
            options:
                path: .spur/run/${vars.wbs}-priority.txt
                var: taskPriority
          # 0785 R2: resolve the task's linked feature spec path so the digest covers it too. An
          # orphan task (no feature_id) resolves to empty — legitimately omitted via empty-string
          # compatibility; a declared feature whose spec path does not resolve fails closed instead
          # of silently folding a stale spec into the proof.
          # (e) linked feature-spec path resolution; fail-closed when a declared feature does not resolve (0785 R2).
          - kind: shell
            options:
                command: 'mkdir -p .spur/run; FID=$($spurBin task show $wbs --json 2>/dev/null | jq -r ".feature_id // .frontmatter.feature_id // empty" 2>/dev/null); FPATH_OUT=""; if [ -n "$FID" ]; then FPATH_OUT=$($spurBin feature show "$FID" --json | jq -r ".filePath // .path // empty"); if [ -z "$FPATH_OUT" ]; then echo "fail-closed proof chain (0785 R2): feature $FID linked to task $wbs but its spec path did not resolve" >&2; exit 1; fi; fi; printf "%s" "$FPATH_OUT" > ".spur/run/$wbs-featurepath.txt"'
          - kind: file.read.into-var
            options:
                path: .spur/run/${vars.wbs}-featurepath.txt
                var: featureSpecPath
          # R2 (0703, ADR-071): THE canonical proof capture. Placement is load-bearing: immediately before
          # the final evidence chain, after every implement mutation (including the post-implement format).
          # Capture-only here; `record` compares. A remediation pass re-captures at `test-recheck` (R4).
          - kind: proof.fingerprint
            options:
                var: proofDigest
                taskFile: ${vars.taskSpecPath}
                featureFile: ${vars.featureSpecPath}
          # (d) quality-gate.ts owns the gate; shell only resolves it and fails closed.
          - kind: shell
            options:
                command: >-
                    eval "$(jq -r 'if .mode=="source-repo" then "S="+@sh"\(.dir)/quality-gate.ts"+" RUNNER=bun" elif .mode=="installed" then "S="+@sh"\(.dir)/quality-gate.mjs"+" RUNNER=node" else empty end' ".spur/run/$__runId-script-root.json" 2>/dev/null)"; if [ -f "$S" ]; then "$RUNNER" "$S" run; else echo "quality gate failed closed — quality-gate script not found — run 'superskill install sp --marketplace gobing-ai/spur'" >&2; printf 'FAIL\n' > ".spur/run/$wbs-test-gate.status"; fi; exit 0

    - id: test-fix
      description: >
          Bounded auto-fix hop when the quality gate is red OR final verification returned a
          repairable non-PASS (task 0703 R4: remediation never happens inside verify — it loops
          through here, then re-enters quality → review → verify on a fresh digest). Increments
          `.spur/run/<wbs>-test-fix-attempt` (the shared bound with the quality path), projects the
          verify verdict into the gate log when one exists so the repair hop sees it, then pure
          slash (ADR-043) `/sp:dev-fixall` against `${vars.qualityGateCmd}`.
      onEnter:
          - kind: shell
            options:
                command: >-
                    TASK_POLICY=$($spurBin task show "$wbs" --json | jq -er '.frontmatter.mutationPolicy // ([.content | scan("(?m)^mutationPolicy:[ \t]*([^ \r\n]+)[ \t]*$") | .[0]] | if length == 0 then "code" elif length == 1 then .[0] else "ambiguous" end)') || exit 1; [ "$mutationPolicy:$TASK_POLICY" = "code:code" ] || { echo "test-fix: mutation policy forbids automatic code repair (run=$mutationPolicy, task=$TASK_POLICY) — gate failure preserved, no agent dispatched" >&2; exit 1; }
          # (e) fixall attempt counter, verdict hand-off and residual artifact in one log
          # append shell (1007 R3: the two appends merged — original commands in order, exit 0).
          # (d) F96 R3 (0950): dev-fixall treats residual items as fix targets and may defer
          # P3/markers to residual-deferrals.json (see dev-fixall.md); fold already merged the
          # anchors into <wbs>-test-gate.findings, this carries the structured artifact too.
          - kind: shell
            options:
                command: >-
                    printf '%s\n' "$(( $(cat ".spur/run/$wbs-test-fix-attempt" 2>/dev/null || echo 0) + 1 ))" > ".spur/run/$wbs-test-fix-attempt"; [ -f ".spur/run/$wbs-verdict.json" ] && echo '--- verify verdict (remediation input, task 0703 R4) ---' >> ".spur/run/$wbs-test-gate.log" && cat ".spur/run/$wbs-verdict.json" >> ".spur/run/$wbs-test-gate.log"; [ -f ".spur/run/$wbs-residuals.json" ] && echo '--- residual artifact (F96 fix targets; deferrals go to residual-deferrals.json, P3/markers only) ---' >> ".spur/run/$wbs-test-gate.log" && cat ".spur/run/$wbs-residuals.json" >> ".spur/run/$wbs-test-gate.log"; exit 0
          # Review-lane input (v5, task 1077 run): `review-fail-triage` routes a non-PASS review
          # into this same bounded repair hop, but it stages no review evidence — the fixer then
          # received a GREEN gate log and an empty findings list, so the review finding it exists
          # to repair was invisible to it. Project the reviewer's answer into the same log the
          # other lanes use, and only on the review lane (the decision file exists only after
          # `review-fail-triage`). Own action, not an append to the one above: ADR-115's 800-char
          # shell cap is already 602 chars there.
          - kind: shell
            options:
                command: >-
                    [ -f ".spur/run/$wbs-review-failure-class.decision" ] && [ -f ".spur/run/$__runId-review-answer.txt" ] && echo '--- review verdict (remediation input, review lane) ---' >> ".spur/run/$wbs-test-gate.log" && cat ".spur/run/$__runId-review-answer.txt" >> ".spur/run/$wbs-test-gate.log"; exit 0
          # R3 (0482): project the extracted gate anchors into a var so the dispatch input
          # can NAME the failing file:line, not merely point at a log. A vars template cannot
          # run a shell, so the read is an action, not an inline `$(cat ...)` substitution.
          - kind: file.read.into-var
            options:
                path: .spur/run/${vars.wbs}-test-gate.findings
                var: gateFindings
          - kind: agent.run
            options:
                agent: ${vars.agent}
                # R3 (0482): `--findings` names the failing anchors inline; `--gate-log` remains
                # Declared Layer-1 role (0538 R2/0710 R7): routing reason; no executor pin — role routing feeds the distinctness gate.
                role: coder
                # B7 R3/R6 (0894): coder stages reuse the role session — the fix hop
                # continues the implement session it is repairing.
                session: reuse
                # the full-context escape hatch when the digest is not enough.
                input: /sp:dev-fixall "${vars.qualityGateCmd}" --gate-log .spur/run/${vars.wbs}-test-gate.log --findings "${vars.gateFindings}"
                timeoutMs: ${vars.stepTimeoutMs}
                # The captured remediation answer is the run's dispatch evidence (0823 R1):
                # expectFile fails the step when no answer was captured. Quality stays with
                # the review proof digest and the verify verdict.
                answerFile: .spur/run/${vars.__runId}-test-fix-answer.txt
                expectFile: .spur/run/${vars.__runId}-test-fix-answer.txt
                # 0706 R6: bounded remediation hop — unattended and tree-mutating,
                # so it declares the same minimum requirements as `implement`.
                requiresCapabilities:
                    fsWrite: available
                    processSpawn: available

    - id: test-recheck
      description: >
          Soft recheck after fixall with bounded SQLite-lock retry. Writes PASS|FAIL (always exit 0). Transitions
          branch to `triage` (PASS — the 0943 lane router), `test-fail-triage` (FAIL — the 0943
          failure-class router), or the pipeline `failed` state (corrupt status) — never a
          raw lifecycle abort that skips the terminal `failed` state.
      onEnter:
          # R4 (0703, ADR-071): bounded remediation mutated the tree by design, so the fresh evidence
          # chain (recheck gate → review → verify) must start from a NEWLY captured digest. Capture-only;
          # the guards and `record` compare against this value.
          - kind: proof.fingerprint
            options:
                var: proofDigest
                taskFile: ${vars.taskSpecPath}
                featureFile: ${vars.featureSpecPath}
          # 0587 R3: probe-then-full recheck. A red gateProbeCmd records FAIL and skips the full
          # gate (the measured waste is re-running a 110–140s gate to learn the tree is still red);
          # a green probe (or empty gateProbeCmd) falls through to the full-gate loop unchanged.
          # Only the full gate writes PASS, so the `test-recheck → review` guard (reads PASS)
          # still means a full green qualityGateCmd ran — invariant preserved by construction.
          # (d) quality-gate.ts owns the recheck; shell only resolves it and fails closed
          # (0587 R3 probe-then-full: a red gateProbeCmd is the recheck verdict, the full gate is skipped).
          - kind: shell
            options:
                command: >-
                    eval "$(jq -r 'if .mode=="source-repo" then "S="+@sh"\(.dir)/quality-gate.ts"+" RUNNER=bun" elif .mode=="installed" then "S="+@sh"\(.dir)/quality-gate.mjs"+" RUNNER=node" else empty end' ".spur/run/$__runId-script-root.json" 2>/dev/null)"; if [ -f "$S" ]; then "$RUNNER" "$S" recheck; else echo "quality gate failed closed — quality-gate script not found — run 'superskill install sp --marketplace gobing-ai/spur'" >&2; printf 'FAIL\n' > ".spur/run/$wbs-test-gate.status"; fi; exit 0

    - id: triage
      description: >-
          Deterministic lane router between a green gate and the review/verify fork (0943 R1).
          Writes the diffstat evidence file, honors a caller-set `mode` (never overridden), pins
          the safety lane on sensitive paths or >400 changed lines without a model call (R2c),
          otherwise consults `decide task-triage` (low → fast lane; anything else stays empty →
          standard review path), and appends the triage route reason to
          .spur/memory/task-pipeline-routes.log (R5). The `decide` action itself runs
          unconditionally (the engine has no onEnter conditionals); its result is consulted only
          when neither a caller mode nor the deterministic guard resolved the lane. The resolved
          lane lands in the `mode` var for the triage → verify/review guards.
      onEnter:
          # (a) diffstat producer: task-diffstat.ts owns git reading and sensitive-path
          # classification; shell only resolves it and fails closed (writes sensitive:true,
          # which the pre-guard below maps to the safety lane).
          - kind: shell
            options:
                command: >-
                    eval "$(jq -r 'if .mode=="source-repo" then "S="+@sh"\(.dir)/task-diffstat.ts"+" RUNNER=bun" elif .mode=="installed" then "S="+@sh"\(.dir)/task-diffstat.mjs"+" RUNNER=node" else empty end' ".spur/run/$__runId-script-root.json" 2>/dev/null)"; if [ -f "$S" ]; then "$RUNNER" "$S" --spur-bin "$spurBin"; else echo "task-diffstat failed closed — task-diffstat script not found — run 'superskill install sp --marketplace gobing-ai/spur'" >&2; printf '{"files":0,"insertions":0,"deletions":0,"paths":[],"sensitive":true}\n' > ".spur/run/$wbs-diffstat.json"; fi; exit 0
          # (b)+(c) lane pre-guard BEFORE decide: a caller-set mode is projected verbatim (R2b);
          # a sensitive path or >400 changed lines pins safety (R2c) and the decide result below
          # is never consulted. A missing/malformed diffstat row (producer crashed or never ran,
          # disk error) pins safety HERE: an empty mode file would let the low decide row project
          # the fast lane downstream, so the "never to fast" fail-safe is enforced at the guard,
          # not by the projection (the full-suite gate run exposed this fail-open path).
          - kind: shell
            options:
                command: >-
                    mkdir -p .spur/run; if [ -n "$mode" ]; then printf '%s\n' "$mode" > ".spur/run/$wbs-mode.txt"; exit 0; fi; DF=".spur/run/$wbs-diffstat.json"; if ! jq -e 'type == "object"' "$DF" >/dev/null 2>&1; then printf 'safety\n' > ".spur/run/$wbs-mode.txt"; exit 0; fi; jq -r 'if .sensitive == true or ((.insertions // 0) + (.deletions // 0)) > 400 then "safety" else empty end' "$DF" 2>/dev/null > ".spur/run/$wbs-mode.txt"; exit 0
          # (d) lane decision — degrades to the standard default whenever workflow.decideDecisionMaker
          # is off (R4), so with the flag off this state is pure shell: no model call is made.
          - kind: decide
            options:
                id: task-triage
                method: choice
                question: >-
                    The quality gate is green. Which verification lane does this diff deserve —
                    low (small, contained diff — fast lane), standard, or high caution?
                choices: [low, standard, high]
                default: standard
                evidence:
                    - .spur/run/${vars.wbs}-diffstat.json
                    - ${vars.taskSpecPath}
                resultFile: .spur/run/${vars.wbs}-triage.decision
          # (d) project the decision, then append the triage route reason (1007 R3: the
          # mode-fallback shell merged into the memory-append shell — original commands in
          # order, the non-empty mode file is still never overridden; the append keeps the
          # failure signal of the original pair). low → fast lane; anything else (standard/high,
          # degraded default, missing row) leaves the mode empty → standard review path.
          - kind: shell
            options:
                command: >-
                    MF=".spur/run/$wbs-mode.txt"; [ -s "$MF" ] || jq -r 'if .value == "low" then "fast" else empty end' ".spur/run/$wbs-triage.decision" 2>/dev/null > "$MF"; m="$(cat "$MF")"; RUN_ID="$__runId"; [ -n "$RUN_ID" ] || RUN_ID="pipeline-$wbs"; mkdir -p .spur/memory; printf '%s %s %s\n' "$RUN_ID" "$wbs" "$(jq -rn --arg m "$m" --arg c "$mode" 'if $c != "" then ($c + ":caller-set") else {"fast":"fast:triage low lane","safety":"safety:triage deterministic-high","":"safety:triage standard lane"}[$m] // "safety:triage unrecognized mode" end')" >> .spur/memory/task-pipeline-routes.log
          # (d) the resolved lane becomes the `mode` var for the triage → verify/review guards.
          - kind: file.read.into-var
            options:
                path: .spur/run/${vars.wbs}-mode.txt
                var: mode

    - id: test-fail-triage
      description: >-
          Deterministic failure-class router on a red gate (0943 R3). The engine has no onEnter
          conditionals, so the FAIL branch from `test`/`test-recheck` lands here as its own
          deterministic state (the sanctioned alternative), while the green branch goes to
          `triage`. `decide failure-class` reads the bounded gate findings and routes: fix →
          test-fix (the bounded repair hop; its default, recorded as `source: default` when no
          backend decides — 0976 R2), stop → failed with terminalReason failed-check. A
          missing/corrupt decision fails closed to failed-check. (0976 R2 collapsed the former
          `retryable` lane: the declared default made it unreachable on the inline path.)
      onEnter:
          - kind: decide
            options:
                id: failure-class
                method: choice
                question: >-
                    The quality gate failed. Is the failure fixable by the repair agent, or should
                    the run stop here?
                choices: [fix, stop]
                default: fix
                evidence:
                    - .spur/run/${vars.wbs}-test-gate.findings
                resultFile: .spur/run/${vars.wbs}-failure-class.decision

    - id: review
      description: Three-dimensional code review via /sp:dev-review (functional requirements traceability + SECUA framework (Security, Efficiency, Correctness, Usability, Architecture) + architecture depth), findings written to `## Review`.
      onEnter:
          - kind: agent.run
            options:
                # 0710 R2: review always runs on a fresh session — no implementation-session
                # inheritance, no latch resume; implementation context reaches the reviewer
                # only via the persisted task spec, the recorded diff, and run artifacts.
                # 0710 R4/R5: the agent pin is deliberately gone — role: reviewer routes
                # through the executor registry, and the runner enforces (pre-dispatch,
                # fail-closed) that a P0/P1 task's review resolves a DIFFERENT executor
                # spec than the implement stage recorded in __agentRouting_implement.
                input: /sp:dev-review --tasks ${vars.wbs} --auto
                # The captured review answer is the run's dispatch evidence (0823 R1):
                # expectFile fails the step when no answer was captured. Quality stays with
                # the review proof digest and the verify verdict.
                answerFile: .spur/run/${vars.__runId}-review-answer.txt
                expectFile: .spur/run/${vars.__runId}-review-answer.txt
                # Declared Layer-1 role (0538 R2/0710 R7): routing reason; no executor pin — role routing feeds the distinctness gate.
                role: reviewer
                # B7 R3/R6 (0894): declared fresh policy (reviewer default) — the action
                # result records this as declared, not defaulted. `freshSession: true` is
                # kept as the action-level hard guarantee (0710 R2 structural assertion).
                freshSession: true
                session: fresh
                priority: ${vars.taskPriority}
                compareExecutorWith: implement
                timeoutMs: ${vars.stepTimeoutMs}
          # 0785 R4: the reviewer's completion evidence for THIS run — the current proof digest is
          # written to a run-scoped marker by the review stage itself, not caller-stamped. The
          # verify stamp marks review `completed` only when this marker equals the current digest;
          # otherwise `skipped` (an honest downgrade, never a fake completed), and the bound
          # `record` registration refuses a skipped review.
          # Session finding after 1088: the review VERDICT is read straight out of the reviewer's
          # own answer file by the review → * guards below, so this stage needs no extra artifact
          # and no extra action (ADR-115 legibility cap).
          - kind: shell
            options:
                command: 'mkdir -p .spur/run; printf "%s" "$proofDigest" > ".spur/run/$__runId-review-proof.digest"; exit 0'

    # ── review-fail-triage (session finding after 1088) ────────────────────────
    # A review that reports FAIL used to have no outgoing edge: the graph could not see the
    # verdict and nothing consumed it, so remediating a failing review meant driving the
    # states by hand. This state is the review-side twin of `test-fail-triage` (0943 R3):
    # the deterministic classifier routes the failure into the SAME bounded fix budget
    # (`.spur/run/<wbs>-test-fix-attempt` against `qualityGateMaxFixAttempts`), and the fix
    # re-enters quality → review → verify on a freshly captured digest. The `decide` action
    # itself always runs (the engine has no onEnter conditionals); with
    # `workflow.decideDecisionMaker` off it degrades to its declared default.
    - id: review-fail-triage
      description: >-
          Deterministic router for a review that did NOT certify PASS (session finding after 1088).
          `decide review-failure-class` reads the reviewer's answer and the task spec and routes:
          fix → the shared test-fix hop, stop → failed with terminalReason failed-check. A
          missing/corrupt decision fails closed to failed-check.
      onEnter:
          - kind: decide
            options:
                id: review-failure-class
                method: choice
                question: >-
                    The review did not certify PASS (FAIL or PARTIAL). Is the finding fixable by the
                    repair agent, or should the run stop here for the operator?
                choices: [fix, stop]
                default: fix
                evidence:
                    - .spur/run/${vars.__runId}-review-answer.txt
                    - ${vars.taskSpecPath}
                resultFile: .spur/run/${vars.wbs}-review-failure-class.decision

    - id: approve
      description: >
          Human-in-the-loop approval gate. Under `profile=auto` this state is never entered
          (review routes around it straight to verify). In interactive mode this state pauses
          the run for `spur workflow continue` (E3), making approval an explicit operator action.
          The operator's answer is routed (R1, bug-750): yes -> verify; no -> failed (rejection
          recorded, not silently approved); cancel -> cancelled (a distinct terminal state).
      pause: true
      onEnter:
          - kind: hitl.confirm
            options:
                prompt: "Approve task ${vars.wbs} to proceed to verification?"
                decision:
                    mode: never

    - id: verify
      description: >
          Observe-only functional verification (BDD + traceability) via /sp:dev-verify --fix none
          (task 0703 R1, ADR-071): the verifier certifies the state, it never repairs its own subject.
          A live digest compare BEFORE the agent refuses to certify a state that drifted after the
          quality/review evidence was produced (R2). The agent's
          captured answer is written to `.spur/run/<wbs>-verify-answer.txt` and MUST follow
          the answer-file schema contract (explicit `Verdict: PASS|PARTIAL|FAIL` line plus
          `| Req | Status | Evidence |` and `| AC | Status | Evidence Type | Evidence |` tables);
          a deterministic shell step then derives the verdict and writes the gate artifact
          `.spur/run/<wbs>-verdict.json` (so the completion gate reads a real verdict, not
          agent discretion — R9) with a proof block naming the digest and the per-stage results (R3).
          Repairable non-PASS routes once through the bounded remediation hop (verify → test-fix,
          R4); the chain reruns on a fresh digest.
      onEnter:
          # R2 (0703): midpoint bracket compare — refuse to spend verification on a state that no
          # longer matches the digest the quality/review evidence names. Reuses `proofDigestNow`:
          # set here and re-set by the bound `run.artifact` registration at `record` entry (0785 R3).
          - kind: proof.fingerprint
            options:
                var: proofDigestNow
                taskFile: ${vars.taskSpecPath}
                featureFile: ${vars.featureSpecPath}
                expect: ${vars.proofDigest}
          - kind: agent.run
            options:
                # 0710 R2: verify is a separate fresh-session execution — never the review
                # session, never the implement session (R6).
                # 0710 R4/R5: role-routed like review; P0/P1 demands a distinct executor.
                # R1 (0703, ADR-071): `--fix none` — final verification is observe-only. Remediation
                # belongs to the bounded test-fix hop, never to the certifying pass.
                input: /sp:dev-verify ${vars.wbs} --auto --fix none --focus all
                # Declared Layer-1 role (0538 R2/0710 R7): routing reason; no executor pin — role routing feeds the distinctness gate.
                role: reviewer
                # B7 R3/R6 (0894): declared fresh policy (reviewer default).
                # `freshSession: true` kept as the action-level hard guarantee (0710 R2).
                freshSession: true
                session: fresh
                priority: ${vars.taskPriority}
                compareExecutorWith: implement
                timeoutMs: ${vars.stepTimeoutMs}
                expectFile: .spur/run/${vars.wbs}-verify-answer.txt
          # (d) `spur task verdict` owns the answer lint (task 1003 R2) — the shell below
          # fails closed when the verifier's answer is malformed, before any verdict artifact.
          - kind: shell
            # `task verdict` exits 1 on a non-PASS verdict by design (1003 R2). The `continue`
            # policy keeps that exit code as recorded evidence (the action row is still `failed`)
            # while letting the state finish its remaining actions and reach the guards below;
            # without it the default 'fail' policy halts the run here and `verify → test-fix`
            # (the declared bounded remediation lane) can never fire (v5, task 1077 run).
            onError: continue
            options:
                command: "$spurBin task verdict $wbs --from-answer .spur/run/$wbs-verify-answer.txt"
          # Session finding after 1088: the verifier's confidence level becomes a `confidence` check
          # row (warn unless HIGH) and a gate-log line, so the level the completion guards enforce is
          # visible in the artifact and the log — not only in the answer file. This is its own action
          # because the proof-binding stamp below must stay the state's LAST action AND under the
          # ADR-115 800-char shell cap.
          - kind: shell
            options:
                command: >-
                    V=".spur/run/$wbs-verdict.json"; [ -f "$V" ] || exit 0;
                    jq --arg ack "$ackLowConfidence" '. + {checks: ((.checks // []) | map(select(.name != "confidence"))) + [{name: "confidence", status: (if (.confidence // "LOW") == "HIGH" then "pass" else "warn" end), evidence: ((.confidence // "LOW (absent from artifact)") + (if $ack == "true" then " (operator-acknowledged)" else "" end))}]}' "$V" > "$V.tmp" && mv "$V.tmp" "$V";
                    printf 'confidence: %s (ack=%s)\n' "$(jq -r '.confidence // "LOW (absent)"' "$V")" "$ackLowConfidence" >> ".spur/run/$wbs-test-gate.log";
                    exit 0
          # (e) one jq mutation binds the verdict to the proof digest (0703 R3; runId 0730 §B.2 /
          # 0757 R4; definitionDigest 0759 R5; honest review stamp 0785 R4; soft action + hard guard).
          - kind: shell
            options:
                command: >-
                    V=".spur/run/$wbs-verdict.json"; [ -f "$V" ] && [ -n "$proofDigest" ] || exit 0; jq --arg d "$proofDigest" --arg r "$__runId" --arg dd "$__definitionDigest" --arg rp "$(cat .spur/run/$__runId-review-proof.digest 2>/dev/null)" --arg g "$(cat .spur/run/$wbs-test-gate.status 2>/dev/null || echo UNKNOWN)" '. + {proof: {digest: $d, runId: $r, definitionDigest: $dd, capturePoint: "quality-gate-entry", stages: {qualityGate: {status: $g, digest: $d}, review: {status: (if $rp == $d then "completed" else "skipped" end), digest: $d}, verification: {status: .verdict, digest: $d}}}} | .checks += [{name: "proof-input-digest", status: "pass", evidence: $d}]' "$V" > "$V.tmp" && mv "$V.tmp" "$V"; exit 0

    - id: record
      description: >
          Record pipeline results into the task file via `spur task record` —
          Testing/Review from the verdict, Solution backfilled from git diff as a
          safety net, optional transition to testing. Entry is gated by the bound
          `run.artifact` registration (0785 R3): the verify verdict is ledger-registered
          against a freshly captured proof digest — including review-completion marker
          evidence (0785 R4) — before any task record or status mutation. Post-record step
          conditionally syncs feature status via `spur feature sync`
          (task 0411 retry-suppression) if `feature_id` is present, or appends an orphan
          link proposal to the run report if absent (task 0328 / ADR-0322). The post-record
          residual sweep (0983) re-scans the task file after the verdict-proven box flips and
          folds blocking leftovers into the verdict before `record → done`; a downgrade
          re-records Testing from the final artifact so a failed task never keeps a stale
          PASS narrative.
      onEnter:
          # 0785 R3: BOUND verify-verdict registration — FIRST action, before any task record or
          # status mutation, replacing the redundant proof.fingerprint compare (the action itself
          # freshly re-captures the proof inputs via its processExecutor seam). The action refuses
          # — before the ledger write AND before `task record` — unless: the run's declared digest
          # equals a fresh capture over the canonical task spec (+ linked feature when linked);
          # the verdict artifact is canonical-valid PASS for this wbs; its raw proof block names
          # the fresh digest, the certifying run id, and the run row's resume/definition digest;
          # qualityGate and verification stages are PASS with that digest; review is completed and
          # independently evidenced by the run-scoped marker `<runId>-review-proof.digest`
          # (0785 R4). Task-spec evidence writes (Testing/Review/Solution, R6) happen only in the
          # actions AFTER this registration, and the fingerprint scopes task content to the
          # proof-input sections, so they cannot invalidate the certified input set.
          - kind: run.artifact
            options:
                path: .spur/run/${vars.wbs}-verdict.json
                artifactKind: verify-verdict
                proofBinding: current
                taskFile: ${vars.taskSpecPath}
                featureFile: ${vars.featureSpecPath}
          # (c) command.gate owns the record write with classified transient retry.
          - kind: command.gate
            options:
                id: task-record-transition
                executable: "${vars.spurBin}"
                # 0980: --no-lifecycle mirrors the wip/done hops — the pipeline run is
                # already the lifecycle record, so a record-stage transition must not
                # spawn a nested task-lifecycle run that outlives the pipeline as a
                # `running` orphan. The target-aware check gate still runs (inline).
                args: ["task", "record", "${vars.wbs}", "--solution-from-diff", "--transition", "testing", "--no-lifecycle"]
                retry:
                    maxAttempts: 2
                    delayMs: 2000
                    on: ["sqlite-busy", "ENOENT", "EBUSY", "ENOTEMPTY"]
                resultFile: .spur/run/${vars.__runId}-record-transition.status
                softFail: false
                timeoutMs: 120000
          # (d) Best-effort `exit 0` — feature status sync is a follow-up, not a completion gate;
          # `record → done` runs `spur task check`. 0931 R5: with deferFeatureSync "true" (parallel
          # mode) the deferral note lands in the task report and the sync is skipped entirely, so
          # task branches never touch feature corpus files; the batch orchestrator performs the
          # deferred sync on the base ref. 1004 R4: the sync is a bare `feature sync` — repeated-
          # BLOCKED suppression (0411) now lives in the feature sync service itself, so the shell
          # resolves feature_id via jq and delegates without a helper script.
          - kind: shell
            options:
                command: >-
                    if [ "$deferFeatureSync" = "true" ]; then echo "feature sync deferred to batch integration" >> ".spur/run/$wbs-report.txt"; else FID="$($spurBin task show $wbs --json 2>/dev/null | jq -r '(.feature_id // .frontmatter.feature_id) // empty' 2>/dev/null)"; if [ -n "$FID" ]; then $spurBin feature sync "$FID" --json || echo "feature sync failed — re-run: $spurBin feature sync $FID" >> ".spur/run/$wbs-report.txt"; else echo "Orphan task $wbs — no feature_id linked — proposal: consider linking to a parent feature." >> ".spur/run/$wbs-report.txt"; fi; fi; exit 0
          # (d) F96 (0983): residual-scan.ts owns the sweep; shell only resolves it. The
          # scan moved here from verify: it must read the task file AFTER `task record`
          # flipped the verdict-proven Requirement/AC boxes (0692), or a proven-but-unticked
          # box folds every good run to PARTIAL and the done guard is unreachable (0967).
          # Classification unchanged: any box still unchecked post-record (unproven R/AC,
          # unfinished Plan) stays blocking and fails the record → done guard closed.
          # ONE hard action (scan+fold): a scanner crash fails record closed.
          - kind: shell
            options:
                command: >-
                    eval "$(jq -r 'if .mode=="source-repo" then "S="+@sh"\(.dir)/residual-scan.ts"+" RUNNER=bun" elif .mode=="installed" then "S="+@sh"\(.dir)/residual-scan.mjs"+" RUNNER=node" else empty end' ".spur/run/$__runId-script-root.json" 2>/dev/null)"; if [ ! -f "$S" ]; then echo "residual-scan failed closed — scanner not found in plugins/sp/scripts/ nor staged — run 'superskill install sp --marketplace gobing-ai/spur'" >&2; exit 1; fi; "$RUNNER" "$S" scan "$wbs" --spur-bin "$spurBin" && "$RUNNER" "$S" fold "$wbs" --spur-bin "$spurBin"
          # (e) F96 R5 (0983): a sweep downgrade must reach the task record — re-record from
          # the FINAL (post-fold) artifact so Testing carries the downgraded verdict; a failed
          # task must not retain a stale PASS Testing narrative. Soft (exit 0): the downgrade
          # itself already fails the record → done guard; a re-record failure prints its
          # re-run command and the failed state renders the recovery report.
          # 0980: --no-lifecycle for the same orphan reason as the main record command —
          # inert today (the task is already at testing, so this is a same-status no-op),
          # but it keeps the no-nested-lifecycle invariant if the flow shifts.
          - kind: shell
            options:
                command: >-
                    verdict="$(jq -r .verdict .spur/run/$wbs-verdict.json 2>/dev/null)"; if [ -n "$verdict" ] && [ "$verdict" != PASS ]; then $spurBin task record $wbs --solution-from-diff --transition testing --no-lifecycle || echo "post-downgrade re-record failed — re-run: $spurBin task record $wbs" >&2; fi; exit 0

    - id: done
      description: >
          Terminal — task executed, verified, and recorded. The `record → done`
          guard runs `spur task check` before certifying; a genuinely non-compliant
          task routes to `failed` instead of a silent bad `done`.
      onEnter:
          # (d) F96 R4 (0950): residual-scan.ts owns the settle; shell only resolves it.
          # Soft best-effort (exit 0) — runs after certification and must never change
          # the outcome; failure prints the re-run command.
          - kind: shell
            options:
                command: >-
                    eval "$(jq -r 'if .mode=="source-repo" then "S="+@sh"\(.dir)/residual-scan.ts"+" RUNNER=bun" elif .mode=="installed" then "S="+@sh"\(.dir)/residual-scan.mjs"+" RUNNER=node" else empty end' ".spur/run/$__runId-script-root.json" 2>/dev/null)"; if [ -f "$S" ]; then "$RUNNER" "$S" settle "$wbs" --spur-bin "$spurBin" || echo "residual-settle failed — re-run: residual-scan settle $wbs" >&2; else echo "residual-scan not staged — settle skipped — re-run: residual-scan settle $wbs" >&2; fi; exit 0
          # (c) command.gate owns the done transition with classified transient retry.
          - kind: command.gate
            options:
                id: task-done-transition
                executable: "${vars.spurBin}"
                args: ["task", "update", "${vars.wbs}", "done", "--no-lifecycle"]
                retry:
                    maxAttempts: 2
                    delayMs: 2000
                    on: ["sqlite-busy", "ENOENT", "EBUSY", "ENOTEMPTY"]
                resultFile: .spur/run/${vars.__runId}-done-transition.status
                softFail: false
                timeoutMs: 120000
          # (e) advisory terminal checkpoint, one printf (0711 R1–R3; binding corrected 0784 R4).
          # 1007 R3: the former done note ("Pipeline complete ...") is dropped — the checkpoint
          # below is the terminal record.
          - kind: shell
            options:
                command: >-
                    mkdir -p .spur/memory/sessions; CP_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"; CP_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo unknown)"; printf '%s\n' '---' 'schema_version: 1' "session_id: $(date -u +%Y-%m-%d)-$wbs" 'workflow: task-pipeline' "run_id: $__runId" "task_wbs: $wbs" 'feature_id: ""' 'phase: done' 'status: done' 'last_gate: record' "source_commit: $CP_COMMIT" "digest: $proofDigest" "generated_at: $CP_TS" "updated_at: $CP_TS" "next_action: none - task $wbs complete (terminal; advisory only)" 'artifacts:' "  - .spur/run/$wbs-verdict.json" "  - .spur/run/$wbs-test-gate.log" '---' '' '## Session Notes' '' "Terminal checkpoint for task $wbs (task-pipeline done)." 'Advisory only; the task file is authoritative.' > .spur/memory/sessions/$wbs-checkpoint.md; exit 0

    - id: failed
      description: >
          Terminal — precheck, quality-gate exhaustion, verify non-PASS, record check
          failure, or operator rejection; reported, not advanced. Also receives an
          escalation-bound pause or an operator give-up (0933).
      onEnter:
          # 0933 R30 (guard-lines compression): the escalation-bound report note lives
          # here as a conditional — no-op unless the run reached failed with a pending
          # question at (or above) the ask bound (bound pause or final give-up). The
          # unanswered question itself is appended to the task report (report.txt, R2).
          - kind: shell
            options:
                command: >-
                    n="$(cat .spur/run/$wbs-escalation-count 2>/dev/null || echo 0)";
                    test "$n" -ge "$maxEscalations" -a -s .spur/run/$wbs-question.md || exit 0;
                    printf '\n## Escalation bound reached\n\nThe implement step asked an operator question %s times (bound %s) and ended without a resumable answer. Unanswered question:\n\n' "$n" "$maxEscalations" >> .spur/run/$wbs-report.txt;
                    cat .spur/run/$wbs-question.md >> .spur/run/$wbs-report.txt;
                    exit 0
          # (d) F96 R5 (0950): residual-scan.ts owns the report; shell only resolves it.
          # Soft (exit 0): a run that exhausts its fix budget on residuals leaves the
          # report + recovery line while the task stays `wip`.
          - kind: shell
            options:
                command: >-
                    eval "$(jq -r 'if .mode=="source-repo" then "S="+@sh"\(.dir)/residual-scan.ts"+" RUNNER=bun" elif .mode=="installed" then "S="+@sh"\(.dir)/residual-scan.mjs"+" RUNNER=node" else empty end' ".spur/run/$__runId-script-root.json" 2>/dev/null)"; if [ -f "$S" ]; then "$RUNNER" "$S" report "$wbs" --spur-bin "$spurBin" || echo "residual-report failed — re-run: residual-scan report $wbs" >&2; else echo "residual-scan not staged — report skipped — re-run: residual-scan report $wbs" >&2; fi; exit 0

    - id: cancelled
      description: Terminal — pipeline cancelled by operator at the approval gate (R1).

transitions:
    # ── precheck: task check --precheck (size + evidence + four-layer, 1002 R4) → implement; ──
    # else → failed. The flag folds the two deleted precheck scripts into one fail-closed
    # guard: any size/evidence/structural error exits 1 and the fallback edge terminates.
    - from: precheck
      to: implement
      description: Deterministic size, evidence, and task checks are green — begin implementation.
      guard:
          kind: shell
          options:
              command: '$spurBin task check $wbs --precheck'
    - from: precheck
      to: failed
      terminalReason: failed-check
      description: Size and/or task check failed — stop before implement.
      guard:
          kind: always

    # ── implement routing: escalation first (0933 R26/R30), then the linear body ──
    # Declaration order matters: the escalation bound is checked FIRST — a paused
    # attempt that already exhausted its maxEscalations asks routes to failed (with a
    # report note appended in the same guard) instead of asking forever; then the
    # question-presence hop; then the normal always edge. The bound guard requires a
    # fresh question file so a COMPLETED implement after max asks still routes to test.
    - from: implement
      to: failed
      terminalReason: retry-exhausted
      description: Escalation bound exhausted — the agent paused again after maxEscalations operator answers.
      # 0933 R30 (guard-lines compression): thin predicate; the report note is written
      # by the failed state's onEnter when it observes a bound-exhausted pause.
      guard:
          kind: shell
          options:
              command: >-
                  n="$(cat .spur/run/$wbs-escalation-count 2>/dev/null || echo 0)";
                  test "$n" -ge "$maxEscalations" -a -s .spur/run/$wbs-question.md
    - from: implement
      to: escalate
      description: Implement agent paused on an operator question (non-empty question file) — pause for an answer.
      guard:
          kind: shell
          options:
              command: 'test -s .spur/run/$wbs-question.md'
    # ── linear body ──
    - from: implement
      to: test
      description: Implementation done — quality-gate probe.
      guard:
          kind: always
    # escalate branching (0933 R27): an answered pause resumes implement — the transcript
    # append and question consumption happen in implement's onEnter (guards stay thin
    # under ADR-115 guard-lines caps); an empty answer is an operator give-up → failed
    # (question + transcript preserved as evidence).
    - from: escalate
      to: implement
      description: Operator answered — re-enter implement, which records the Q/A transcript and consumes the question.
      guard:
          kind: shell
          options:
              command: 'test -n "$__hitlInput"'
    - from: escalate
      to: failed
      terminalReason: cancelled
      description: Empty operator answer — give-up routes to failed (question + transcript preserved as evidence).
      guard:
          kind: shell
          options:
              command: 'test -z "$__hitlInput"'
    # Soft probe branching (declaration order: PASS → triage, FAIL → test-fail-triage, defense).
    # 0943 R1/R3: the four former gate-PASS edges (test/test-recheck → verify/review, split on
    # `$mode`) are replaced by the deterministic `triage` lane producer; a red gate lands in
    # `test-fail-triage` where `decide failure-class` routes the failure instead of a static fixall.
    - from: test
      to: triage
      description: Quality gate green — route the lane deterministically (triage, 0943 R1).
      guard:
          kind: shell
          options:
              command: >-
                  gate_status="$(cat .spur/run/$wbs-test-gate.status 2>/dev/null)";
                  test "$gate_status" = PASS
    - from: test
      to: test-fail-triage
      description: Quality gate red — classify the failure before the repair hop (0943 R3).
      guard:
          kind: shell
          options:
              command: 'test "$(cat .spur/run/$wbs-test-gate.status 2>/dev/null)" = FAIL'
    # 0943 review P3#2: the former `test → test-fix` FAIL edge is removed — it was dead (the
    # identical guard on test → test-fail-triage fires first) and a silent spec-bypass trap:
    # restoring it would skip failure-class on every red gate. The always-defense below keeps
    # pre-0943 corrupt-status behavior (fixall then recheck).
    - from: test
      to: test-fix
      description: Probe status missing/corrupt — attempt fixall then recheck.
      guard:
          kind: always
    - from: test-fix
      to: test-recheck
      description: Fixall finished — soft recheck the same quality gate.
      guard:
          kind: always
    # Recheck branching (PASS → triage; red → test-fail-triage; defense). The two former
    # static FAIL edges (under-max → test-fix, exhausted → failed retry-exhausted) moved into
    # test-fail-triage's decision routing (0943 R3).
    - from: test-recheck
      to: triage
      description: Quality gate green after fixall — route the lane deterministically (triage, 0943 R1).
      guard:
          kind: shell
          options:
              command: >-
                  gate_status="$(cat .spur/run/$wbs-test-gate.status 2>/dev/null)";
                  test "$gate_status" = PASS
    - from: test-recheck
      to: test-fail-triage
      description: Still red after fixall — classify the failure (0943 R3).
      guard:
          kind: shell
          options:
              command: >-
                  gate_status="$(cat .spur/run/$wbs-test-gate.status 2>/dev/null)";
                  test "$gate_status" = FAIL
    # Defense: corrupt recheck status — failed, not review.
    - from: test-recheck
      to: failed
      terminalReason: failed-check
      description: Recheck status missing/corrupt — stop at failed.
      guard:
          kind: always
    # ── triage routing (0943 R1/R2): the lane producer replaced the four gate-PASS edges.
    # mode=fast → verify; anything else (standard/high, deterministic-high, degraded default,
    # caller-set non-fast) → review. Together the two guards are exhaustive, so triage never hangs.
    - from: triage
      to: verify
      description: Triage resolved the fast lane (caller-set fast or task-triage low) — skip review.
      guard:
          kind: shell
          options:
              command: 'test "$mode" = fast'
    - from: triage
      to: review
      description: Triage resolved a review lane (standard/high, deterministic-high, or caller-set) — review.
      guard:
          kind: shell
          options:
              command: 'test "$mode" != fast'
    # ── failure-class routing (0943 R3): stop first (never mislabeled by the cap), then the
    # attempt cap, then the fix lane, then the fail-closed defense. Declaration order matters.
    - from: test-fail-triage
      to: failed
      terminalReason: failed-check
      description: Failure class stop — the run terminates at failed, never bypassed.
      guard:
          kind: shell
          options:
              command: >-
                  jq -e '.value == "stop"' ".spur/run/$wbs-failure-class.decision" >/dev/null 2>&1
    - from: test-fail-triage
      to: failed
      terminalReason: retry-exhausted
      description: Attempt cap reached — qualityGateMaxFixAttempts bounds the fix lane.
      # (warn) 5 commands: named fix attempts (legibility, 0874).
      guard:
          kind: shell
          options:
              command: >-
                  fix_attempts="$(cat .spur/run/$wbs-test-fix-attempt 2>/dev/null || echo 0)";
                  test "$fix_attempts" -ge "$qualityGateMaxFixAttempts"
    - from: test-fail-triage
      to: test-fix
      description: Failure class fix and under the cap — the bounded repair hop, as before.
      guard:
          kind: shell
          options:
              command: >-
                  jq -e '.value == "fix"' ".spur/run/$wbs-failure-class.decision" >/dev/null 2>&1
    # Defense: missing/corrupt failure-class decision — fail closed instead of silently repairing.
    - from: test-fail-triage
      to: failed
      terminalReason: failed-check
      description: Failure-class decision missing/corrupt — stop at failed.
      guard:
          kind: always
    # ── review-failure routing (session finding after 1088): the review-side twin of the
    # failure-class router above — stop first, then the shared attempt cap, then the fix lane,
    # then the fail-closed defense. The counter is the SAME one the quality-gate loop uses, so a
    # review FAIL cannot buy extra attempts and the iteration bound stays finite.
    - from: review-fail-triage
      to: failed
      terminalReason: failed-check
      description: Review failure class stop — the run terminates at failed, never bypassed.
      guard:
          kind: shell
          options:
              command: >-
                  jq -e '.value == "stop"' ".spur/run/$wbs-review-failure-class.decision" >/dev/null 2>&1
    - from: review-fail-triage
      to: failed
      terminalReason: retry-exhausted
      description: Attempt cap reached — qualityGateMaxFixAttempts bounds the review repair lane.
      guard:
          kind: shell
          options:
              command: >-
                  fix_attempts="$(cat .spur/run/$wbs-test-fix-attempt 2>/dev/null || echo 0)";
                  test "$fix_attempts" -ge "$qualityGateMaxFixAttempts"
    - from: review-fail-triage
      to: test-fix
      description: Review failure class fix and under the cap — the shared bounded repair hop.
      guard:
          kind: shell
          options:
              command: >-
                  jq -e '.value == "fix"' ".spur/run/$wbs-review-failure-class.decision" >/dev/null 2>&1
    # Defense: missing/corrupt review-failure decision — fail closed instead of silently repairing.
    - from: review-fail-triage
      to: failed
      terminalReason: failed-check
      description: Review-failure decision missing/corrupt — stop at failed.
      guard:
          kind: always
    # ── review routing: FAIL → bounded remediation; PASS → approve or the auto-skip (R4) ──
    # Declaration order matters and is FAIL-CLOSED: the two PASS edges each demand the reviewer's
    # own `Verdict: PASS` line, and the catch-all sends EVERYTHING ELSE (FAIL, a missing answer, an
    # unparseable one) into `review-fail-triage`. Before this, a FAIL review had no outgoing edge at
    # all — the graph could not see the verdict, so remediation meant driving states by hand
    # (session finding after 1088). The reviewer writes `Verdict: PASS|FAIL` in its answer; both
    # plain and bold forms are accepted. Under `profile=auto` the approve gate is skipped, so the
    # PASS+auto edge is tried first and the interactive PASS edge second.
    - from: review
      to: verify
      description: profile=auto and the review certified PASS — skip the human approval gate.
      guard:
          kind: shell
          options:
              command: >-
                  test "$profile" = auto &&
                  grep -qiE '^[[:space:]]*\**Verdict:\** *PASS' ".spur/run/$__runId-review-answer.txt"
    - from: review
      to: approve
      description: Review certified PASS — gate on human approval before verification.
      guard:
          kind: shell
          options:
              command: grep -qiE '^[[:space:]]*\**Verdict:\** *PASS' ".spur/run/$__runId-review-answer.txt"
    - from: review
      to: review-fail-triage
      description: >-
          No PASS in the reviewer's answer (FAIL, PARTIAL, missing, or unparseable — fail closed) —
          classify the failure and route it into the bounded repair hop instead of dead-ending the run.
      guard:
          kind: always

    # ── approve: HITL gate (reached only when profile != auto). Routed by the
    # operator's captured answer (R1, bug-750) — three ordered guards, declaration
    # order matters (yes first, then no, then cancel), mirroring idea-pipeline's
    # design-approval gate (.spur/workflows/idea-pipeline.yaml:352-366). No
    # `always` edge remains out of `approve`. ──
    - from: approve
      to: verify
      description: Approved — proceed to verification.
      guard:
          kind: shell
          options:
              command: 'test "$__hitlAnswer" = yes'
    - from: approve
      to: failed
      terminalReason: cancelled
      description: Operator rejected at the approval gate — report and stop.
      guard:
          kind: shell
          options:
              command: 'test "$__hitlAnswer" = no'
    - from: approve
      to: cancelled
      terminalReason: cancelled
      description: Operator cancelled at the approval gate.
      guard:
          kind: shell
          options:
              command: 'test "$__hitlAnswer" = cancel'

    # ── completion gate (the YAML-native replacement for rd3's default-on --postflight-verify) ──
    # The verify step emits .spur/run/<wbs>-verdict.json with a required proof block (task 0703 R3/R5).
    # Only `verdict: PASS` PLUS a proof block whose digest — top level and every named stage — equals
    # the captured `proofDigest` clears the gate to `record`; any non-PASS, a missing file, malformed
    # JSON, or missing/mismatched proof evidence does not. The verifier's confidence level is part of
    # that certification (session finding after 1088): a PASS the verifier itself marked LOW — or left
    # unstated — clears the gate only when the operator accepted it with
    # `--vars '{"ackLowConfidence":"true"}'`. Declaration order: PASS+proof guard FIRST,
    # then the bounded remediation route (R4), then the always catch-all so a PASS verdict with a
    # missing/malformed proof block still terminates at `failed` instead of hanging the state.
    - from: verify
      to: record
      description: Verification verdict is PASS, its proof block names the captured digest on every stage, and any LOW confidence was operator-acknowledged — record results and proceed to done.
      guard:
          # (one jq -e predicate) PASS verdict; proof, quality-gate, review and verification digests
          # equal to proofDigest; review completed; matching run id and definition digest (0703 R5, 0785 R4);
          # confidence HIGH/MEDIUM, or LOW/absent with the operator's ack (session finding after 1088).
          kind: shell
          options:
              command: >-
                  jq -e --arg d "$proofDigest" --arg r "$__runId" --arg dd "$__definitionDigest" --arg ack "$ackLowConfidence" '
                  .verdict == "PASS"
                  and (.proof.digest // "") == $d
                  and (.proof.stages.qualityGate.digest // "") == $d
                  and (.proof.stages.review.digest // "") == $d
                  and (.proof.stages.review.status // "") == "completed"
                  and (.proof.stages.verification.digest // "") == $d
                  and (.proof.runId // "") == $r
                  and (.proof.definitionDigest // "") == $dd
                  and ((.confidence // "LOW") != "LOW" or $ack == "true")
                  ' ".spur/run/$wbs-verdict.json" >/dev/null 2>&1
    - from: verify
      to: test-fix
      description: >-
          Verification returned a repairable non-PASS and the shared fix budget is not exhausted —
          bounded remediation hop (task 0703 R4); the chain re-enters quality → review → verify on a
          freshly captured digest. Never reached on PASS: remediation cannot follow certification.
      guard:
          # (e) remediation route: verdict not PASS and fix attempts below qualityGateMaxFixAttempts (0703 R4).
          kind: shell
          options:
              command: >-
                  verdict="$(jq -r .verdict .spur/run/$wbs-verdict.json 2>/dev/null)";
                  test -n "$verdict" && test "$verdict" != PASS &&
                  test "$(cat .spur/run/$wbs-test-fix-attempt 2>/dev/null || echo 0)" -lt "$qualityGateMaxFixAttempts"
    - from: verify
      to: failed
      terminalReason: retry-exhausted
      description: >-
          Non-PASS with the fix budget exhausted, or a PASS/missing/malformed verdict whose proof
          block is absent or mismatched (task 0703 R5) — block before done; defense catch-all so the
          state always has a viable outgoing edge.
      guard:
          kind: always
    # ── done gate: record → done/failed gated on `spur task check` (ADR-026 amendment 2026-06-23)
    # PLUS the proof-block re-assertion (task 0703 R5): the verdict artifact must still be PASS and
    # still name the captured digest — a forged or mutated completion artifact fails closed here.
    # PLUS the confidence re-assertion (session finding after 1088): the same ack rule as verify →
    # record, re-read from the artifact so a mutation between the two boundaries cannot slip through.
    # Declaration order: pass guard first.
    - from: record
      to: done
      description: Task check passed, the verdict proof block still names the captured digest, and any LOW confidence was acknowledged — certify done.
      guard:
          kind: shell
          options:
              # 0804 R6: `--as done` projects the check onto the done target so open
              # Plan/AC checkboxes fail HERE — before the done action runs — mirroring
              # the CLI's own target-aware `--no-lifecycle` backstop (F92 R3). No `#`
              # comments inside the folded scalar: `>-` joins same-indent lines into
              # one shell line.
              # Two logical commands, deliberately (ADR-115): the `task check` projection plus
              # ONE jq predicate re-asserting the whole completion contract — PASS verdict, the
              # captured proof digest, and the confidence rule (session finding after 1088).
              command: >-
                  $spurBin task check $wbs --as done &&
                  jq -e --arg p "$proofDigest" --arg ack "$ackLowConfidence" '
                  .verdict == "PASS"
                  and ((.proof.digest // "") == $p)
                  and ((.confidence // "LOW") != "LOW" or $ack == "true")
                  ' ".spur/run/$wbs-verdict.json" >/dev/null 2>&1
    - from: record
      to: failed
      terminalReason: failed-check
      description: Task check failed, a post-record residual downgrade, or proof evidence missing/malformed/mismatched — block before done.
      guard:
          kind: always
