# 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 → 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).
#
# 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)
#
# 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.
version: "3"
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> --strict-core, 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: ""
    # 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"
    # 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: ""
    # Max R-items in ## Requirements before size precheck fails (R2, task 0454).
    # Override with `--vars '{"maxImplementReqs":"20"}'`.
    maxImplementReqs: "10"
    # Max checklist items under ## Plan before size precheck fails (R2, task 0454).
    # Override with `--vars '{"maxImplementPlanItems":"32"}'`.
    maxImplementPlanItems: "16"
    # 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: ""

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:
          # (e) git-status hygiene advisory; never fails the run (0487 R6 / 0511 R1).
          - 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"; exit 0
          - kind: note
            options:
                # R4 task_run_links linkage (kind=pipeline) is written by a WorkflowService
                # hook at run start — see the 0062 follow-up note; there is no link-writing
                # CLI verb to call from a shell step, so it cannot live in pure YAML.
                message: "Pipeline start for task ${vars.wbs}."
          # (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
          # (d) task-size-precheck.ts owns the check; shell only resolves it and fails closed (0454 R2 / 0723 R2).
          - kind: shell
            options:
                command: >-
                    mkdir -p .spur/run; S=plugins/sp/scripts/task-size-precheck.ts; [ -f "$S" ] || S="$(superskill script path sp task-size-precheck.ts 2>/dev/null)"; if [ -f "$S" ]; then bun "$S" "$wbs" --spur-bin "$spurBin" --max-reqs "$maxImplementReqs" --max-plan-items "$maxImplementPlanItems"; else echo "task-size-precheck failed closed — checker not found in plugins/sp/scripts/ nor staged — run 'superskill install sp'." >&2; echo "FAIL" > ".spur/run/$wbs-precheck-size.status"; fi; exit 0
          # (d) task-evidence-precheck.ts owns the check; shell only resolves it and fails closed (0726 R2).
          - kind: shell
            options:
                command: >-
                    mkdir -p .spur/run; S=plugins/sp/scripts/task-evidence-precheck.ts; [ -f "$S" ] || S="$(superskill script path sp task-evidence-precheck.ts 2>/dev/null)"; if [ -f "$S" ]; then bun "$S" "$wbs" --spur-bin "$spurBin"; else echo "task-evidence-precheck failed closed — checker not found in plugins/sp/scripts/ nor staged — run 'superskill install sp'." >&2; echo "FAIL" > ".spur/run/$wbs-precheck-evidence.status"; fi; 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:
          - 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.
                input: /sp:dev-run --mode implement ${vars.wbs} --auto
                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
                # 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"

    # ── 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: >-
                    mkdir -p .spur/run; if [ -f plugins/sp/scripts/quality-gate.ts ]; then bun plugins/sp/scripts/quality-gate.ts run; elif Q="$(superskill script path sp quality-gate.mjs 2>/dev/null)" && [ -f "$Q" ]; then node "$Q" run; else echo "quality gate failed closed — quality-gate script not found — run 'superskill install sp'" >&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 and verdict hand-off to the remediation log.
          - kind: shell
            options:
                command: >-
                    mkdir -p .spur/run; A=".spur/run/$wbs-test-fix-attempt"; n=$(cat "$A" 2>/dev/null || echo 0); printf '%s\n' "$((n + 1))" > "$A"; [ ! -f ".spur/run/$wbs-verdict.json" ] || { echo '--- verify verdict (remediation input, task 0703 R4) ---'; cat ".spur/run/$wbs-verdict.json"; } >> ".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 review (PASS), another test-fix (FAIL and under max attempts),
          or the pipeline `failed` state (FAIL and attempts exhausted) — 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: >-
                    mkdir -p .spur/run; if [ -f plugins/sp/scripts/quality-gate.ts ]; then bun plugins/sp/scripts/quality-gate.ts recheck; elif Q="$(superskill script path sp quality-gate.mjs 2>/dev/null)" && [ -f "$Q" ]; then node "$Q" recheck; else echo "quality gate failed closed — quality-gate script not found — run 'superskill install sp'" >&2; printf 'FAIL\n' > ".spur/run/$wbs-test-gate.status"; fi; exit 0

    - 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 ${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.
          - kind: shell
            options:
                command: 'mkdir -p .spur/run; printf "%s" "$proofDigest" > ".spur/run/$__runId-review-proof.digest"; exit 0'

    - 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?"

    - 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
          # 0726 R3: hard lint gate over the verifier-owned answer — shape and
          # evidence-row identity, before the verdict derivation reads it.
          # Hard action: a malformed answer halts the sequence here instead of
          # poisoning the verdict parse downstream.
          # (d) verify-answer-lint.ts owns the answer lint; shell only resolves it.
          - kind: shell
            options:
                command: >-
                    LINT_SCRIPT="plugins/sp/scripts/verify-answer-lint.ts";
                    [ -f "$LINT_SCRIPT" ] ||
                    LINT_SCRIPT="$(superskill script path sp verify-answer-lint.ts 2>/dev/null)";
                    if [ -z "$LINT_SCRIPT" ] || [ ! -f "$LINT_SCRIPT" ]; then
                      echo "verify-answer-lint: checker not found in plugins/sp/scripts/ nor staged — run 'superskill install sp'" >&2;
                      exit 1;
                    fi;
                    bun "$LINT_SCRIPT" "$wbs"
                    --answer ".spur/run/$wbs-verify-answer.txt"
                    --spur-bin "$spurBin"
          - kind: shell
            options:
                command: "$spurBin task verdict $wbs --from-answer .spur/run/$wbs-verify-answer.txt"
          # (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 the bounded `feature-sync-bounded` wrapper
          (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).
      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}"
                args: ["task", "record", "${vars.wbs}", "--solution-from-diff", "--transition", "testing"]
                retry:
                    maxAttempts: 2
                    delayMs: 2000
                    on: ["sqlite-busy", "ENOENT", "EBUSY", "ENOTEMPTY"]
                resultFile: .spur/run/${vars.__runId}-record-transition.status
                softFail: false
                timeoutMs: 120000
          # (d) feature-sync-bounded.ts owns the sync; shell adds the orphan note and fallbacks
          # (0411 retry-suppression; 0328 / ADR-0322). Best-effort `exit 0` — feature status sync is a
          # follow-up, not a completion gate; `record → done` runs `spur task check`.
          - kind: shell
            options:
                command: >-
                    FID=$($spurBin task show $wbs --json 2>/dev/null | jq -r '.feature_id // .frontmatter.feature_id // empty' 2>/dev/null); if [ -z "$FID" ]; then echo "Orphan task $wbs — no feature_id linked — proposal: consider linking to a parent feature." >> ".spur/run/$wbs-report.txt"; elif [ -f plugins/sp/scripts/feature-sync-bounded.ts ]; then bun plugins/sp/scripts/feature-sync-bounded.ts "$FID" --spur-bin "$spurBin" --json; elif M="$(superskill script path sp feature-sync-bounded.mjs 2>/dev/null)" && [ -f "$M" ]; then node "$M" "$FID" --spur-bin "$spurBin" --json; else $spurBin feature sync "$FID" --json; 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:
          # (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
          - kind: note
            options:
                message: "Pipeline complete for task ${vars.wbs} (done gate cleared)."
          # (e) advisory terminal checkpoint, one printf (0711 R1–R3; binding corrected 0784 R4).
          - 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.

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

transitions:
    # ── precheck: size PASS + task check → implement; else → failed ──
    - from: precheck
      to: implement
      description: Deterministic size, evidence, and task checks are green — begin implementation.
      # (warn) 5 commands: named size/evidence status reads + task check (legibility, 0874).
      guard:
          kind: shell
          options:
              command: >-
                  size_status="$(cat .spur/run/$wbs-precheck-size.status 2>/dev/null)";
                  evidence_status="$(cat .spur/run/$wbs-precheck-evidence.status 2>/dev/null)";
                  test "$size_status" = PASS && test "$evidence_status" = PASS && $spurBin task check $wbs
    - from: precheck
      to: failed
      description: Size and/or task check failed — stop before implement.
      guard:
          kind: always

    # ── linear body ──
    - from: implement
      to: test
      description: Implementation done — quality-gate probe.
      guard:
          kind: always
    # Soft probe branching (declaration order: PASS first, then FAIL, then defense).
    - from: test
      to: verify
      description: Quality gate already green and mode is fast — proportional fast path bypasses review.
      guard:
          kind: shell
          options:
              command: >-
                  gate_status="$(cat .spur/run/$wbs-test-gate.status 2>/dev/null)";
                  test "$gate_status" = PASS && test "$mode" = fast
    - from: test
      to: review
      description: Quality gate already green and safety mode — proceed to review.
      guard:
          kind: shell
          options:
              command: >-
                  gate_status="$(cat .spur/run/$wbs-test-gate.status 2>/dev/null)";
                  test "$gate_status" = PASS && test "$mode" != fast
    - from: test
      to: test-fix
      description: Quality gate red — start bounded fixall loop.
      guard:
          kind: shell
          options:
              command: 'test "$(cat .spur/run/$wbs-test-gate.status 2>/dev/null)" = FAIL'
    # Defense: missing/corrupt status — treat as FAIL path, not silent PASS.
    - 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 first; under-max FAIL → fixall again; exhausted → failed).
    - from: test-recheck
      to: verify
      description: Quality gate green after fixall and mode is fast — proportional fast path bypasses review.
      guard:
          kind: shell
          options:
              command: >-
                  gate_status="$(cat .spur/run/$wbs-test-gate.status 2>/dev/null)";
                  test "$gate_status" = PASS && test "$mode" = fast
    - from: test-recheck
      to: review
      description: Quality gate green after fixall and safety mode — proceed to review.
      guard:
          kind: shell
          options:
              command: >-
                  gate_status="$(cat .spur/run/$wbs-test-gate.status 2>/dev/null)";
                  test "$gate_status" = PASS && test "$mode" != fast
    - from: test-recheck
      to: test-fix
      description: Still red and under qualityGateMaxFixAttempts — another fixall hop.
      # (warn) 5 commands: named gate status + fix attempts (legibility, 0874).
      guard:
          kind: shell
          options:
              command: >-
                  gate_status="$(cat .spur/run/$wbs-test-gate.status 2>/dev/null)";
                  fix_attempts="$(cat .spur/run/$wbs-test-fix-attempt 2>/dev/null || echo 0)";
                  test "$gate_status" = FAIL && test "$fix_attempts" -lt "$qualityGateMaxFixAttempts"
    - from: test-recheck
      to: failed
      description: Still red after max fixall attempts — stop at failed (not silent abort).
      # (warn) 5 commands: named gate status + fix attempts (legibility, 0874).
      guard:
          kind: shell
          options:
              command: >-
                  gate_status="$(cat .spur/run/$wbs-test-gate.status 2>/dev/null)";
                  fix_attempts="$(cat .spur/run/$wbs-test-fix-attempt 2>/dev/null || echo 0)";
                  test "$gate_status" = FAIL && test "$fix_attempts" -ge "$qualityGateMaxFixAttempts"
    # Defense: corrupt recheck status — failed, not review.
    - from: test-recheck
      to: failed
      description: Recheck status missing/corrupt — stop at failed.
      guard:
          kind: always
    # ── review → approve, OR skip the HITL gate entirely when profile=auto (R4) ──
    # Declaration order matters: the auto-skip guard is tried FIRST, so under
    # profile=auto the run routes straight to verify and never ENTERS approve
    # (whose onEnter hitl.confirm would otherwise block). Otherwise fall through
    # to approve for the human gate.
    - from: review
      to: verify
      description: profile=auto — skip the human approval gate, go straight to verify.
      guard:
          kind: shell
          options:
              command: 'test "$profile" = auto'
    - from: review
      to: approve
      description: Review done — gate on human approval.
      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
      description: Operator rejected at the approval gate — report and stop.
      guard:
          kind: shell
          options:
              command: 'test "$__hitlAnswer" = no'
    - from: approve
      to: 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. 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 and its proof block names the captured digest on every stage — 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).
          kind: shell
          options:
              command: >-
                  jq -e --arg d "$proofDigest" --arg r "$__runId" --arg dd "$__definitionDigest" '
                  .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
                  ' ".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
      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.
    # Declaration order: pass guard first.
    - from: record
      to: done
      description: Task check passed and the verdict proof block still names the captured digest — certify done.
      # (warn) 5 commands: named verdict + proof digest reads after the task check (legibility, 0874).
      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.
              command: >-
                  $spurBin task check $wbs --as done &&
                  verdict="$(jq -r .verdict .spur/run/$wbs-verdict.json 2>/dev/null)" &&
                  proof_digest="$(jq -r '.proof.digest // ""' .spur/run/$wbs-verdict.json 2>/dev/null)" &&
                  test "$verdict" = PASS && test "$proof_digest" = "$proofDigest"
    - from: record
      to: failed
      description: Task check failed or proof evidence missing/malformed/mismatched — block before done.
      guard:
          kind: always
