Destaris
← Back to the library

Feature delivery

Loop

A project-agnostic engineering Loop. One PASS delivers a whole feature: it verifies the spec has already been broken into tickets, builds every ticket through to a merged trunk PR, then watches that PR. A brain judges whether the feature is actually done and, when it isn’t, names the gaps concretely so the next pass picks up exactly the work those gaps produced.

Pairs with: /feature-spec

Save into .destaris/loops/ as feature-delivery.yaml.

Loops and Agentic Workflows are in beta — format and behaviour may still change.

feature-delivery.yaml

# Feature delivery — a project-agnostic engineering Loop.
#
# One PASS delivers a whole feature: verify the spec has been broken into tickets, build every
# ticket through to merged-in-trunk, then watch the trunk PR. The brain judges whether the feature
# is actually done and, when it isn't, says what the gaps are — the next pass picks up whatever
# work that gap produced.
#
# Pairs with the "feature-spec" skill in this library: that skill authors the spec AND breaks it
# into the numbered ticket files this Loop builds. Run the skill first; point this Loop at the spec
# it produces.
#
# The nesting, outermost first:
#   pass            the whole feature            (this Loop, judged by the brain)
#     ticket_loop     one ticket per round       (until no todo tickets remain, or one is needs_human)
#       delivery_loop   engineering -> PR feedback  (until the ticket's PR is merged into the trunk)
#         review_loop     engineer <-> internal critics (until no unresolved blocking findings)
#     watch_trunk     trunk PR CI + feedback     (feedback becomes NEW ticket files -> next pass)
#
# ── Two working surfaces, and it matters which one a stage writes to ────────────────────────────────
#
#   TRUNK worktree   .claude/worktrees/<key>-trunk, on trunk/<key>
#                    Ticket FILES live here. Created/reconciled here, and the `done` + merge_commit
#                    bookkeeping is committed here — after a ticket PR merges its branch is gone, so
#                    there is no branch left to carry that update.
#   TICKET worktree  .claude/worktrees/<id>, on the ticket branch
#                    Code, tests, and the findings checkboxes. Everything that becomes the PR.
#
# The repo ROOT is permanently on your default branch and is never written to by this Loop. A stage
# that committed there would be committing straight to production.
#
# ── This Loop assumes GitHub + pnpm, as concrete defaults ───────────────────────────────────────────
#
# `gh` (GitHub CLI) drives every PR/CI interaction, and the gate battery below runs `pnpm`. Both are
# placeholders for whatever your git host and package manager actually are — swap them; the SHAPES
# (poll CI until it settles, open/merge a PR, run a gate battery before pushing) carry over
# unchanged. Every gate command below is marked "# replace with your project's gate" for the same
# reason: `pnpm test` is illustrative, not a claim about what your project runs.
#
# ── This Loop references agent files it does not ship ───────────────────────────────────────────────
#
# Six stages below use `agent: <name>` (a saved agent file at `.destaris/agents/<name>.md`) instead
# of an inline prompt: `test-author`, `build-ticket`, `review-code`, `review-security`,
# `review-spec`, `ticket-breakdown`. Write your own for each — the stage's own prompt below states
# what it hands that agent and what it expects back — or swap `agent: <name>` for an inline
# `prompt:` on any stage you'd rather not maintain a separate file for.
#
# ── Four engine rules that shape the graph ──────────────────────────────────────────────────────────
#
#  1. A node runs on its FIRST enqueue and does not wait for other predecessors — there are
#     therefore NO fan-in joins here. Where a stage is optional it is skipped with a task.branch
#     whose labels are mutually exclusive, so exactly one path reconverges.
#  2. A loop's item/index scope is REPLACED by each nested loop, not stacked — inside delivery_loop
#     there is no `item` from ticket_loop above. Every stage therefore addresses the current ticket
#     through `{{pick_ticket.stdout}}`, a plain node output readable at any depth.
#  3. A stage that reads another stage's fields needs those fields to exist: task.command gets
#     stdout/stderr/exitCode only with `captureExit: true`, and agent.run resolves every field but
#     `text` to null unless it declares a `decision` schema.
#  4. The brain has no tools. It judges from stage outputs and `successCondition.references` only —
#     which is what `feature_status` exists to produce.
#
# Loop state lives in the repo, never in the app: ticket frontmatter, findings checkboxes in the
# ticket body, and merge commits. That is what makes every stage safe to re-run on the next pass,
# and what lets a killed run resume without losing a thing.
#
# ── Agent access posture (every agent.run stage below: access: full + scope: workflow-folder) ──────
#
# Full access, confined to this repo. `access: broad` is an allow-list, and in a headless step an
# unmatched command is REFUSED — there is nobody to approve it, and a shell chain (`cd x && git y`)
# can fall outside an allow-list even when both halves are individually listed. So these stages run
# `access: full`: everything is pre-approved and nothing can stall on a refusal.
#
# `scope: workflow-folder`, not `machine`, is what keeps that bounded: the engine pins the step's
# cwd and denies any Edit/Write resolving outside it. For a Loop the host falls back to the project
# root when the Loop's name doesn't match a `.destaris/workflows/` folder — so give this Loop a
# `name:` that never collides with one of your workflow files, or its agent steps lose write access
# to everything.
#
# HONEST LIMIT: this is not a kernel sandbox. The guard watches the file tools; a shell child can
# still write outside the repo, and the path check is lexical (it does not follow symlinks). The
# boundaries that carry the rest: per-ticket worktrees, the PR lifecycle living in deterministic
# `task.command` stages rather than in an agent, branch protection on your default branch, and CI.
#
# ── "Feed the stages" (the house rule this Loop follows) ────────────────────────────────────────────
#
# A task.command that gathers a fact costs cents; an agent.run that discovers the same fact costs a
# whole conversation, and every turn of an agentic stage re-sends it. So every review stage below is
# handed the diff, the gate result, and the test result as DATA from a deterministic stage's
# stdout — none of them is told to go run `git diff`, `pnpm build`, or the test suite itself. See
# `compute_diff` and the gate-fed prompts on the three review stages for where this actually happens.

name: Feature delivery
title: Spec -> tickets -> merged trunk PR, one ticket at a time

# The feature to deliver. `bootstrap` accepts any of the forms someone actually types — a bare
# feature key, a spec directory, or a full spec path — so a per-run input from "Run on an input…"
# works without ceremony. That per-run value OVERRIDES this declared block and keys the run, so two
# features can be in flight at once without colliding.
input:
  kind: text
  text: checkout-redesign

brain:
  # A strong model, deliberately. This judge runs at most `maxIterations` times per run, and its
  # verdict routes every correction — a weaker brain here is the cheapest place to save tokens and
  # the most expensive place to actually save them, because a wrong verdict burns a whole extra pass.
  model: claude-opus-5
  effort: high
  successCondition:
    # Judged after every pass, from feature_status's output. Deliberately a bar, not a recipe.
    text: |
      The feature described by the input spec is delivered and human-reviewable:

      1. Every acceptance criterion in the spec maps to at least one ticket that is `status: done`
         with a `merge_commit` that is an ancestor of the trunk. A ticket claiming `done` with no
         merge behind it is NOT done — say so.
      2. No ticket is `status: needs_human`. If one is, the feature is not done: report which ticket
         and the diagnosis in its `why`.
      3. The trunk PR exists, its CI is green, and it carries no unresolved reviewer feedback.
      4. No unbuilt `todo` tickets remain — including any raised from trunk feedback this pass.

      If any of these fails, answer `continue` and name the gaps concretely: which acceptance
      criteria have no merged ticket, which tickets are blocked and why, and what new tickets are
      needed. Route a correction to the stage that can close each gap — `tickets` when the breakdown
      missed an acceptance criterion, `engineer` when the implementation is short, `test_author` when
      the tests do not actually pin the criterion down.

      Do not judge from optimism in a stage's prose. `feature_status` prints the ticket table, the
      merge evidence and the CI state; that output is the evidence, and where it disagrees with a
      stage's summary it wins.

      Trunk-feedback lines marked `- [~]` (and a `human_owned=` count in the poll summary) are items
      only a human can fix — name them in your verdict so they are not lost, but do not count them as
      unresolved reviewer feedback under condition 3.

      Distinguish a WORK gap from an INFRASTRUCTURE failure before routing anything. A stage output
      showing an error, a refused tool call, a timeout, or work that exists but could not move (a PR
      that could not open, a merge that could not run) is infrastructure: route NO correction for it
      — a correction cannot fix a wedged tool — and name it plainly as needing a human. In
      particular, never tell `tickets` to split or re-break a ticket whose implementation already
      exists; the real blocker there is usually a refused tool call, not a ticket that was cut wrong.

# Feature-level correction rounds. Each pass is a whole feature's worth of work, so this is small on
# purpose — it is the "another round of engineering" budget, not the ticket budget. A real run of
# this shape has been measured spending tens of millions of tokens per pass once tickets and their
# review rounds are counted — raise `maxTokens` above the 10,000,000 default before you run this for
# real, or set it to `unlimited` once you trust the graph.
maxIterations: 4

nodes:
  # ── Setup ────────────────────────────────────────────────────────────────────────────────────────
  # Idempotent by construction: re-runs on every pass and does nothing the second time.
  - id: bootstrap
    type: task.command
    config:
      # NO `captureExit` here, deliberately — unlike every verify stage below.
      #
      # `captureExit` turns a non-zero exit into DATA rather than a stage error. That is right for a
      # verifier (red tests are the ordinary mid-work state and the brain must read them), and wrong
      # for a PRECONDITION. Every `exit 3` in this stage is environmental — an input that names no
      # feature, a missing spec, a trunk worktree that could not be created. There is nothing for the
      # brain to correct, and no later stage can proceed without what this stage publishes.
      #
      # With `captureExit` a failure here is silent and expensive: bootstrap publishes nothing, every
      # downstream `{{bootstrap.stdout}}` reader hits its own `exit 3` in turn, the brain sees every
      # stage fail and answers `continue`, and the run burns the whole `maxIterations` budget before
      # landing on `stuck`. Without it the run stops on pass 1 as `failed`, with the reason on stderr.
      timeoutSeconds: 900
      command: |
        set -u
        # Diagnostics must never reach stdout: a consumer interpolates this stage's stdout into a
        # shell string and parses key=value out of it, so one stray line breaks every parse in that
        # stage. fd 1 becomes stderr for the whole stage and fd 3 carries the contract. Anything
        # written the ordinary way — echo, sed, git, pnpm — lands on stderr where a human still sees
        # it. Only >&3 is output.
        exec 3>&1 1>&2

        # ── root git operations retry ──────────────────────────────────────────────────────────
        # Two loop runs — one per feature — can share ONE repo root, and git's locking is fail-fast
        # rather than queueing: concurrent `fetch`, `branch` and `worktree add` collide on a ref
        # lock and the loser dies immediately instead of waiting the millisecond it would have
        # taken. A bounded retry is the whole fix, and it is deliberately NOT a mutex: the
        # contention is transient by construction, while a mutex can deadlock and leaves a stale
        # lock behind when a run is cancelled mid-hold. Use it ONLY for commands whose stdout is
        # discarded — it captures output in order to inspect it.
        git_root() {
          _n=0
          while :; do
            if _err=$("$@" 2>&1); then
              [ -z "$_err" ] || printf '%s\n' "$_err"
              return 0
            fi
            case "$_err" in
              *"cannot lock ref"*.lock* | *"Unable to create"*.lock* | *"File exists"*.lock* | \
              *"index.lock"* | *"another git process"* | *"is already locked"*)
                _n=$((_n + 1))
                if [ "$_n" -gt 30 ]; then
                  printf '%s\n' "$_err"
                  echo "git_root: still locked after 30 retries — another loop run may be wedged"
                  return 1
                fi
                sleep 2
                ;;
              *)
                [ -z "$_err" ] || printf '%s\n' "$_err"
                return 1
                ;;
            esac
          done
        }

        # Read the feature key out of whatever form the input arrived in:
        #
        #   checkout-redesign                                  a bare key typed into "Run on an input…"
        #   docs/specs/checkout-redesign/spec.md                a repo-relative path
        #   /abs/path/to/repo/docs/specs/checkout-redesign/spec.md  an absolute path
        #   "This loop's input is the saved artifact /abs/…/spec.md. Read it…"
        #
        # That last one is what an `artifact` input actually resolves to — a SENTENCE naming the
        # file, not the file's contents and not a bare path. Read the run input through a QUOTED
        # heredoc, never inside a quoted command argument — substitution is textual, so a placeholder
        # anywhere else (even a comment) gets the whole input pasted into it, and a multi-line input
        # then continues past that line and is executed as code. The marker and terminator sit at
        # THIS block's indentation on purpose: YAML strips it when it parses the literal scalar, so
        # they land at column 0 in the script the shell actually runs, which is where a heredoc
        # terminator has to be.
        DESTARIS_INPUT_FILE=$(mktemp)
        cat > "$DESTARIS_INPUT_FILE" <<'DESTARIS_LOOP_INPUT_EOF'
        {{input}}
        DESTARIS_LOOP_INPUT_EOF
        RAW=$(tr -d '\r' < "$DESTARIS_INPUT_FILE")
        rm -f "$DESTARIS_INPUT_FILE"

        # This Loop runs on a spec ARTIFACT and nothing else. Two accepted forms:
        #   - text NAMING a docs/specs/<key>/spec.md path (the usual "saved artifact …" sentence);
        #   - the spec file's resolved CONTENTS (`key:` in frontmatter — some picker paths hand the
        #     text itself over instead of a path).
        # A bare key, a directory, or any other file is REFUSED: a loose key lets runs build features
        # against unreviewed or absent specs, and the fix is one click — pick the feature's spec.md
        # as the run artifact.
        KEY=$(printf '%s' "$RAW" | grep -o 'docs/specs/[^/[:space:]]*/spec\.md' | head -1 | sed 's|docs/specs/||; s|/spec\.md$||')
        if [ -z "$KEY" ]; then
          # Contents form: `key:` is honoured ONLY inside the leading YAML frontmatter block. No
          # leading `---` ⇒ not spec contents ⇒ refuse.
          KEY=$(printf '%s\n' "$RAW" | awk 'NR==1 { if ($0 != "---") exit; next } /^---/ { exit } { print }' \
            | sed -n 's/^key: *//p' | head -1 | tr -d "\"'" | sed 's/[[:space:]]*$//')
        fi
        if [ -z "$KEY" ]; then
          echo "REFUSING to run: this loop only runs on a spec artifact." >&2
          echo "Pick the feature's docs/specs/<key>/spec.md as the run input; got:" >&2
          printf '  %s\n' "$(printf '%s' "$RAW" | head -2)" >&2
          exit 3
        fi
        # ALLOWLIST, not a blocklist. The key comes from a human-typed run input and then flows into
        # this stage's stdout, which every downstream stage interpolates into a shell command — so a
        # key containing a backtick or $( ) would be EXECUTED there (CWE-78).
        case "$KEY" in
          ""|.|..|*[!A-Za-z0-9._-]*)
            echo "cannot read a usable feature key from the spec artifact." >&2
            echo "the input must name docs/specs/<key>/spec.md (or be that file's contents); got:" >&2
            printf '  %s\n' "$(printf '%s' "$RAW" | head -2)" >&2
            exit 3
            ;;
        esac
        SPEC="docs/specs/$KEY/spec.md"
        # Existence is checked against $TWT/$SPEC further down, AFTER the trunk worktree exists and
        # is synced — never against the repo root here, whose checkout can lag origin and
        # false-refuse a perfectly valid artifact.

        git_root git fetch --quiet origin || true

        # The trunk every ticket PR targets. Both creations stop the run on failure — without that
        # the next statement is `git worktree add … trunk/$KEY`, which fails too, with its own
        # message about the WORKTREE, burying the real cause under a symptom.
        if ! git show-ref --verify --quiet "refs/heads/trunk/$KEY"; then
          if git ls-remote --exit-code --heads origin "trunk/$KEY" >/dev/null 2>&1; then
            git_root git branch --quiet "trunk/$KEY" "origin/trunk/$KEY" \
              || { echo "could not create trunk/$KEY from origin/trunk/$KEY — see the git error above"; exit 3; }
          else
            git_root git branch --quiet "trunk/$KEY" origin/main \
              || { echo "could not create trunk/$KEY from origin/main — see the git error above"; exit 3; }
          fi
        fi

        # ADOPT an existing worktree for the trunk rather than making a second one. Git refuses to
        # check the same branch out twice, so a feature that was already worked on by hand — its
        # trunk sitting in .claude/worktrees/<key>-trunk already — would otherwise fail every run.
        TWT=$(git worktree list --porcelain | awk -v b="refs/heads/trunk/$KEY" '
          /^worktree /{p=substr($0,10)}
          /^branch /{if (substr($0,8)==b) {print p; exit}}')
        if [ -z "$TWT" ]; then
          TWT="$(pwd)/.claude/worktrees/$KEY-trunk"
          git_root git worktree add --quiet "$TWT" "trunk/$KEY" || { echo "could not create trunk worktree $TWT"; exit 3; }
        fi
        # CONTAINMENT, before the path is published. `git worktree list` returns worktrees
        # registered ANYWHERE, and the agents' file tools are folder-guarded to this repo
        # (scope: workflow-folder) — publishing an external path would have every agent Edit/Write
        # denied there, and Bash writes would silently bypass the boundary. pwd -P so a symlinked
        # registration cannot dodge the lexical guard either.
        ROOT_P=$(pwd -P)
        TWT_P=$(cd "$TWT" 2>/dev/null && pwd -P) || { echo "trunk worktree unreadable: $TWT"; exit 3; }
        case "$TWT_P/" in
          "$ROOT_P"/*) TWT="$TWT_P" ;;
          *)
            echo "REFUSING to run: the trunk worktree for trunk/$KEY is OUTSIDE this repo:" >&2
            echo "  $TWT_P" >&2
            echo "  Agent file tools are confined to the repo (scope: workflow-folder), so every" >&2
            echo "  write there would be denied. Move or remove that worktree, then re-run." >&2
            exit 3
            ;;
        esac
        (cd "$TWT" && git pull --ff-only --quiet origin "trunk/$KEY" 2>/dev/null) || true

        # Bring the trunk up to date with your default branch BEFORE any ticket is cut from it —
        # agents read process docs and the spec from the WORKTREE by relative path, so a stale trunk
        # hands every agent in the run an outdated view of both.
        #
        # A conflict is NOT fatal. It is resolved against `main`, which this Loop must never write
        # to, so the merge aborts and leaves the trunk untouched — tickets merge into the TRUNK, not
        # main, so they remain valid work either way. `poll_trunk` raises the conflict for a human at
        # the end. Wrapped so nothing in here can fail bootstrap, which has no captureExit.
        #
        # Two things this must never do:
        #  1. Leave the local trunk AHEAD of origin without publishing — a ticket worktree cut from
        #     an unpublished trunk would target a base ref GitHub does not have. The merge is only
        #     kept if it PUBLISHES; if the push fails the trunk is put back exactly where it was.
        #  2. Abort a merge it did not start. `git merge --abort` in a worktree where a human was
        #     mid-merge destroys their resolution — refuse to touch a dirty tree or one already
        #     holding MERGE_HEAD, and abort only when MERGE_HEAD proves the merge is ours.
        (
          cd "$TWT" || exit 0
          GITDIR=$(git rev-parse --git-dir 2>/dev/null) || exit 0

          if [ -n "$(git status --porcelain 2>/dev/null)" ] || [ -f "$GITDIR/MERGE_HEAD" ]; then
            echo "trunk_sync: skipped — $TWT has uncommitted work or a merge already in progress" >&2
            exit 0
          fi

          git fetch --quiet origin main || { echo "trunk_sync: skipped — could not fetch origin/main"; exit 0; }
          git merge-base --is-ancestor origin/main HEAD 2>/dev/null && exit 0

          BEFORE=$(git rev-parse HEAD 2>/dev/null) || exit 0
          if git merge --no-edit --quiet origin/main 2>/dev/null; then
            if git push --quiet origin "HEAD:trunk/$KEY" 2>/dev/null; then
              echo "trunk_sync: merged origin/main into trunk/$KEY and published it" >&2
            else
              git reset --hard --quiet "$BEFORE" 2>/dev/null \
                || echo "trunk_sync: WARNING could not reset $TWT to $BEFORE — it may be ahead of origin"
              echo "trunk_sync: push failed — trunk restored to $BEFORE and NOT synced this run" >&2
            fi
          else
            if [ -f "$GITDIR/MERGE_HEAD" ]; then
              git merge --abort 2>/dev/null \
                || echo "trunk_sync: WARNING merge --abort failed — $TWT is left mid-merge"
            fi
            if [ -f "$GITDIR/MERGE_HEAD" ] || [ -n "$(git diff --name-only --diff-filter=U 2>/dev/null)" ]; then
              echo "trunk_sync: WARNING $TWT still holds an unresolved merge — a human must clear it" >&2
            else
              echo "trunk_sync: CONFLICT merging origin/main into trunk/$KEY — trunk left untouched" >&2
            fi
          fi
        ) || true

        # Both preconditions are checked in the TRUNK worktree, never against this checkout. The
        # repo root may be behind origin, so a spec that exists on trunk/$KEY — or one merged since
        # the last local pull — would look absent here and fail the run on pass 1.
        if [ ! -d "$TWT/docs/specs/$KEY" ]; then
          echo "no feature directory docs/specs/$KEY on trunk/$KEY — parsed key '$KEY' from the input." >&2
          echo "features available:" >&2
          ls "$TWT/docs/specs" 2>/dev/null | sed 's/^/  /' | head -20
          exit 3
        fi
        [ -f "$TWT/$SPEC" ] || { echo "spec not found in the trunk worktree: $TWT/$SPEC"; exit 3; }
        mkdir -p "$TWT/docs/specs/$KEY/tickets"

        # ── Publishing trunk_worktree= is a CLAIM, so prove it ────────────────────────────────────
        # Everything downstream writes here, and every ticket worktree is branched from this ref, so
        # emitting this line asserts two things:
        #  1. THE WORKTREE IS SAFE TO WRITE TO — the sync above deliberately leaves a human's dirty
        #     tree or in-progress merge alone, so proving it's clean here matters.
        #  2. THE LOCAL TRUNK IS THE PUBLISHED TRUNK — containing origin/main is not the same as
        #     BEING origin/trunk/$KEY. Any earlier best-effort push that failed leaves local ahead of
        #     origin, and tickets would then be cut from a trunk GitHub does not have.
        # Both are fatal here rather than best-effort: bootstrap has no captureExit, so exit 3 stops
        # the run with the reason on stderr — there is no safe way to continue.
        (
          cd "$TWT" || exit 3
          GITDIR=$(git rev-parse --git-dir 2>/dev/null) || exit 3
          if [ -n "$(git status --porcelain 2>/dev/null)" ] \
             || [ -f "$GITDIR/MERGE_HEAD" ] \
             || [ -n "$(git diff --name-only --diff-filter=U 2>/dev/null)" ]; then
            echo "REFUSING to run: the trunk worktree is not safe to write to." >&2
            echo "  $TWT" >&2
            git status --short 2>/dev/null | head -10
            exit 3
          fi

          # Ask the SERVER, not the tracking ref — `git fetch || true` followed by reading a stale
          # tracking ref cannot tell "fetch failed" from "branch was deleted on origin" apart, and a
          # SHA comparison can "pass" against something that is not there any more.
          LOCAL=$(git rev-parse --verify --quiet HEAD 2>/dev/null || true)
          [ -n "$LOCAL" ] || { echo "REFUSING to run: $TWT has no HEAD commit"; exit 3; }

          if ! REMOTE_LS=$(git ls-remote --heads origin "refs/heads/trunk/$KEY" 2>/dev/null); then
            echo "REFUSING to run: could not query origin for trunk/$KEY." >&2
            exit 3
          fi
          REMOTE=$(printf '%s\n' "$REMOTE_LS" | awk 'NR==1{print $1}')
          if [ -z "$REMOTE" ]; then
            git push --quiet origin "HEAD:refs/heads/trunk/$KEY" 2>/dev/null || {
              echo "REFUSING to run: trunk/$KEY is not published and could not be pushed." >&2
              exit 3
            }
          elif [ "$LOCAL" != "$REMOTE" ]; then
            echo "REFUSING to run: the local trunk is not the published trunk." >&2
            echo "  local  $LOCAL" >&2
            echo "  origin $REMOTE" >&2
            echo "  Push or reset trunk/$KEY so the two match, then re-run." >&2
            exit 3
          fi
        ) || exit 3

        printf 'key=%s\nspec=%s\ntrunk=trunk/%s\ntrunk_worktree=%s\ntickets_dir=docs/specs/%s/tickets\n' \
          "$KEY" "$SPEC" "$KEY" "$TWT" "$KEY" >&3

  # ── Recovery ─────────────────────────────────────────────────────────────────────────────────────
  # A run dies for ordinary reasons: the app is quit, the machine sleeps, an account runs out of
  # tokens mid-ticket. None of those corrupt anything — every piece of state this Loop keeps is a
  # file in the repo — but they DO leave claims that no longer match reality. This stage reconciles
  # claims against evidence before any work starts, so the next pass resumes instead of rebuilding.
  #
  # The whole basis: `status` is a claim, a merge commit that is an ancestor of the trunk is
  # evidence. Where they disagree, evidence wins.
  - id: recover
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 600
      command: |
        set -u
        TWT=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^trunk_worktree=//p' | head -1)
        KEY=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^key=//p' | head -1)
        [ -n "$TWT" ] && [ -d "$TWT" ] || { echo "no trunk worktree to recover in"; exit 3; }

        # Worktree registrations whose directories a killed run left behind — without this, adopting
        # a worktree below can hand back a path that no longer exists.
        git worktree prune

        cd "$TWT" || exit 3
        git fetch --quiet origin "trunk/$KEY" || true
        T="docs/specs/$KEY/tickets"
        ls "$T"/*.md >/dev/null 2>&1 || { echo "no tickets yet — nothing to reconcile"; exit 0; }

        CHANGED=0
        for f in "$T"/*.md; do
          [ -f "$f" ] || continue
          # Only files carrying ticket frontmatter are tickets — a companion file in this directory
          # (a review sidecar, a note) must never be read as statusless work.
          grep -q '^id: ' "$f" || continue
          ST=$(sed -n 's/^status: *//p' "$f" | head -1 | tr -d "\"'")
          MC=$(sed -n 's/^merge_commit: *//p' "$f" | head -1 | tr -d "\"'")
          BR=$(sed -n 's/^branch: *//p' "$f" | head -1 | tr -d "\"'")
          MERGED=no
          if [ -n "$MC" ] && git merge-base --is-ancestor "$MC" "origin/trunk/$KEY" 2>/dev/null; then
            MERGED=yes
          fi

          # a) `done` with nothing behind it is not done. Reset it and let it be built.
          if [ "$ST" = "done" ] && [ "$MERGED" = "no" ]; then
            perl -0pi -e 's/^status: *done/status: todo/m' "$f"
            perl -0pi -e 's/^merge_commit:.*\n//m' "$f"
            echo "reset to todo (claimed done, no merge behind it): $f"
            CHANGED=1
            continue
          fi

          # b) the merge landed but the run died before recording it.
          if [ "$ST" != "done" ] && [ "$MERGED" = "yes" ]; then
            perl -0pi -e 's/^status: *[a-z_]+/status: done/m' "$f"
            echo "marked done (merge_commit is an ancestor of the trunk): $f"
            CHANGED=1
            continue
          fi

          # c) the PR merged and nothing local recorded it — recover the SHA from GitHub.
          if [ "$ST" != "done" ] && [ -n "$BR" ] && [ -z "$MC" ]; then
            PRSTATE=$(gh pr view "$BR" --json state -q .state 2>/dev/null || echo "")
            PRMC=$(gh pr view "$BR" --json mergeCommit -q '.mergeCommit.oid // ""' 2>/dev/null || echo "")
            if [ "$PRSTATE" = "MERGED" ] && [ -n "$PRMC" ] \
               && git merge-base --is-ancestor "$PRMC" "origin/trunk/$KEY" 2>/dev/null; then
              perl -0pi -e 's/^status: *[a-z_]+/status: done/m' "$f"
              if grep -qE '^merge_commit:' "$f"; then
                perl -0pi -e "s|^merge_commit:.*|merge_commit: $PRMC|m" "$f"
              else
                perl -0pi -e "s|^status: done|status: done\nmerge_commit: $PRMC|m" "$f"
              fi
              echo "recovered from a merged PR: $f ($PRMC)"
              CHANGED=1
            fi
          fi
        done

        # A stall counter belongs to one delivery attempt, not to the next run's fresh start.
        rm -f .claude/state/loop-*-stall.tmp 2>/dev/null || true

        if [ "$CHANGED" -eq 1 ]; then
          git add "$T" || true
          git diff --cached --quiet 2>/dev/null || \
            git commit --quiet -m "chore($KEY): reconcile ticket status with merge evidence"
          git push --quiet origin "HEAD:trunk/$KEY" || echo "warning: could not push the reconciliation"
        else
          echo "ticket state is consistent with the trunk — nothing to reconcile"
        fi

  # ── Preflight ────────────────────────────────────────────────────────────────────────────────────
  # Everything here is checkable in seconds and fatal to the whole run if absent — so it is checked
  # BEFORE the first agent, not discovered after one. Without it a run can break down the spec, write
  # tests, implement the ticket and review it three times — then die at push_pr with nothing merged.
  - id: preflight
    type: task.command
    config:
      timeoutSeconds: 120
      command: |
        set -u
        TWT=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^trunk_worktree=//p' | head -1)
        KEY=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^key=//p' | head -1)
        MISSING=""

        # The PR path: gh must exist AND be authenticated — `gh --version` alone doesn't catch an
        # unauthenticated gh, which fails only at the point of use.
        command -v gh >/dev/null 2>&1 || MISSING="$MISSING gh(not-installed)"
        if command -v gh >/dev/null 2>&1 && ! gh auth status >/dev/null 2>&1; then
          MISSING="$MISSING gh(not-authenticated)"
        fi

        # The build/test path — replace with your project's toolchain commands.
        command -v pnpm >/dev/null 2>&1 || MISSING="$MISSING pnpm"
        command -v git  >/dev/null 2>&1 || MISSING="$MISSING git"

        # Write access to the trunk. A read-only or missing worktree makes every ticket unbuildable.
        [ -n "$TWT" ] && [ -d "$TWT" ] || MISSING="$MISSING trunk-worktree"
        if [ -n "$TWT" ] && [ -d "$TWT" ] && ! touch "$TWT/.destaris-preflight" 2>/dev/null; then
          MISSING="$MISSING trunk-worktree-not-writable"
        fi
        rm -f "$TWT/.destaris-preflight" 2>/dev/null || true

        # The agent files this Loop's stages name, read from the repo ROOT at dispatch time. Rename
        # or delete this list to match whatever agent files you actually wrote — a missing one fails
        # its stage only after real spend; here it costs seconds.
        for a in ticket-breakdown test-author build-ticket review-code review-security review-spec; do
          [ -f ".destaris/agents/$a.md" ] || MISSING="$MISSING agent($a)"
        done

        if [ -n "$MISSING" ]; then
          echo "REFUSING to run — preflight failed:$MISSING"
          echo "  Each of these is fatal at some later stage. Stopping here costs seconds;"
          echo "  discovering it after the engineer and three critics costs a ticket's worth of spend."
          exit 3
        fi
        echo "preflight=ok gh=authenticated pnpm=present trunk=$KEY writable"

  - id: tickets
    # VERIFIER, not author. The feature-spec skill this Loop pairs with writes the initial ticket
    # breakdown as part of authoring the spec — a human approves the tickets together with the spec,
    # before either lands. This stage only verifies they exist and reports AC coverage; it never
    # authors the initial set itself, because an unattended breakdown with nobody watching is exactly
    # the failure mode that motivated moving it out of this Loop.
    #
    # NO captureExit, deliberately: a ticketless spec is a PRECONDITION failure — the run must stop
    # with the message, not burn passes building nothing. The loop still AMENDS tickets —
    # `trunk_tickets` raises trunk-feedback tickets, §7-style splits happen inside `engineer` — it
    # just never authors the initial set.
    type: task.command
    config:
      timeoutSeconds: 120
      command: |
        set -u
        exec 3>&1 1>&2
        KEY=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^key=//p' | head -1)
        TWT=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^trunk_worktree=//p' | head -1)
        [ -n "$KEY" ] && [ -n "$TWT" ] || { echo "bootstrap published no key/trunk_worktree"; exit 3; }
        T="$TWT/docs/specs/$KEY/tickets"
        N=0
        for f in "$T"/*.md; do
          [ -f "$f" ] || continue
          grep -q '^id: ' "$f" && N=$((N+1))
        done
        if [ "$N" -eq 0 ]; then
          echo "REFUSING to run: no ticket files at docs/specs/$KEY/tickets/."
          echo "The initial breakdown is authored by the feature-spec skill, before this Loop runs —"
          echo "this loop builds and amends tickets; it does not author the initial set."
          echo "Run the feature-spec skill on this spec to add tickets, then re-run this loop."
          exit 3
        fi
        # Coverage is WARN-ONLY, never a refusal: trunk-feedback tickets legitimately carry acs: [],
        # and in-flight features predate a coverage contract you might add later. The brain judges
        # feature completeness against the spec itself; this line just surfaces a gap early.
        SPEC="$TWT/docs/specs/$KEY/spec.md"
        UNCOVERED=""
        if [ -f "$SPEC" ]; then
          ACS=$(grep -E '^\- \*\*AC[0-9]+' "$SPEC" | grep -oE 'AC[0-9]+[a-z]?' | sort -u)
          TACS=$(for f in "$T"/*.md; do
            [ -f "$f" ] || continue
            grep -q '^id: ' "$f" || continue
            sed -n 's/^acs: *\[\(.*\)\]/\1/p' "$f"
          done | tr ',' '\n' | tr -d ' ' | sort -u)
          for ac in $ACS; do
            printf '%s\n' "$TACS" | grep -qx "$ac" || UNCOVERED="$UNCOVERED $ac"
          done
        fi
        [ -z "$UNCOVERED" ] || echo "warning: ACs with no owning ticket:$UNCOVERED — the brain judges completeness against the spec; trunk_tickets/the engineer can add coverage"
        echo "tickets_total=$N uncovered_acs=${UNCOVERED:-none}" >&3
        exit 0

  # Emits the pending tickets (basenames, rank order) that ticket_loop iterates. This list is the
  # ITERATION BUDGET and the DISPLAY — one foreach iteration per plausibly-pending ticket, with the
  # app showing which item/iteration the run is on. It is deliberately NOT the eligibility
  # authority: pick_ticket's scan stays authoritative (merge-evidence check, needs_human halt), and
  # a listed ticket that turns out resolved just costs one cheap skipped round.
  - id: list_pending
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 120
      command: |
        set -u
        KEY=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^key=//p' | head -1)
        TWT=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^trunk_worktree=//p' | head -1)
        [ -n "$KEY" ] && [ -n "$TWT" ] || { echo "bootstrap published no key/trunk_worktree" >&2; exit 3; }
        T="$TWT/docs/specs/$KEY/tickets"
        (cd "$TWT" && git pull --ff-only --quiet origin "trunk/$KEY" 2>/dev/null) || true
        for f in "$T"/*.md; do
          [ -f "$f" ] || continue
          grep -q '^id: ' "$f" || continue
          ST=$(sed -n 's/^status: *//p' "$f" | head -1 | tr -d "\"'")
          case "$ST" in done | dropped | superseded) continue ;; esac
          basename "$f"
        done
        exit 0

  # One iteration per pending ticket, so the app's foreach display shows WHICH ticket the run is on
  # and how far through the set it is. Safe because delivery_loop already resolves each ticket
  # within its round (merged, or stalled -> needs_human); pick_ticket enforces that this iteration
  # builds exactly its assigned item.
  - id: ticket_loop
    type: task.foreach
    config:
      # Split FIRST, then filter empties. $trim() NORMALIZES whitespace (newlines become spaces), so
      # trimming before splitting collapses the whole list into one mega-item. The outer [ ] keeps a
      # single pending ticket an ARRAY — a JSONata singleton sequence otherwise collapses to a scalar.
      items: '[$split(list_pending.stdout, "\n")[$trim($) != ""]]'

  - id: pick_ticket
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 900
      command: |
        set -u
        exec 3>&1 1>&2

        git_root() {
          _n=0
          while :; do
            if _err=$("$@" 2>&1); then
              [ -z "$_err" ] || printf '%s\n' "$_err"
              return 0
            fi
            case "$_err" in
              *"cannot lock ref"*.lock* | *"Unable to create"*.lock* | *"File exists"*.lock* | \
              *"index.lock"* | *"another git process"* | *"is already locked"*)
                _n=$((_n + 1))
                if [ "$_n" -gt 30 ]; then
                  printf '%s\n' "$_err"
                  echo "git_root: still locked after 30 retries — another loop run may be wedged"
                  return 1
                fi
                sleep 2
                ;;
              *)
                [ -z "$_err" ] || printf '%s\n' "$_err"
                return 1
                ;;
            esac
          done
        }

        # From bootstrap, never re-derived — bootstrap is what normalizes the input, so deriving a
        # second time here would disagree with it.
        KEY=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^key=//p' | head -1)
        SPEC=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^spec=//p' | head -1)
        TWT=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^trunk_worktree=//p' | head -1)
        [ -n "$KEY" ] && [ -n "$TWT" ] || { echo "bootstrap published no key/trunk_worktree"; exit 3; }
        REL="docs/specs/$KEY/tickets"
        T="$TWT/$REL"

        (cd "$TWT" && git pull --ff-only --quiet origin "trunk/$KEY" 2>/dev/null) || true
        ls "$T"/*.md >/dev/null 2>&1 || { echo "no ticket files in $T"; exit 1; }

        git_root git fetch --quiet origin "trunk/$KEY" || true

        # Lowest rank first — filenames are NN-slug, so a plain sort IS rank order, and rank is
        # execution order. Skip anything already merged: `status` is a claim and the merge commit is
        # the evidence, so a stale `in_progress` behind a real merge is not work.
        NEXT=""
        for f in "$T"/*.md; do
          [ -f "$f" ] || continue
          grep -q '^id: ' "$f" || continue
          ST=$(sed -n 's/^status: *//p' "$f" | head -1 | tr -d "\"'")
          case "$ST" in done | dropped | superseded) continue ;; esac
          MC=$(sed -n 's/^merge_commit: *//p' "$f" | head -1 | tr -d "\"'")
          if [ -n "$MC" ] && git merge-base --is-ancestor "$MC" "origin/trunk/$KEY" 2>/dev/null; then
            continue
          fi
          NEXT="$f"
          break
        done
        [ -n "$NEXT" ] || { echo "every ticket is done"; exit 1; }

        # RANK IS EXECUTION ORDER — later tickets probably depend on it. So the halt belongs on the
        # ticket actually selected, not on a scan of the whole directory: a needs_human at rank 7
        # must not stop rank 1 from being built.
        case "$(sed -n 's/^status: *//p' "$NEXT" | head -1 | tr -d "\"'")" in
          needs_human)
            echo "blocked (needs_human) at the next ticket in rank order: $NEXT" >&2
            sed -n 's/^why: *//p' "$NEXT" | head -1
            exit 2
            ;;
        esac

        # foreach truth-check: this iteration's assigned ticket is {{item}} (from list_pending, rank
        # order). The scan above stays authoritative, but the app displays the item — so this round
        # must build exactly that ticket, or say plainly why not and end as a cheap skip. Same
        # allowlist as every other value that reaches a shell string (CWE-78).
        ITEM=$(printf '%s' "{{item}}" | tr -d "\"'" | tr -d '[:space:]')
        case "$ITEM" in
          "" | *[!A-Za-z0-9._-]*)
            echo "unsafe foreach item: '$ITEM'" >&2
            exit 3
            ;;
        esac
        BASE_NEXT=$(basename "$NEXT")
        if [ "$BASE_NEXT" != "$ITEM" ]; then
          if [ "$BASE_NEXT" \> "$ITEM" ]; then
            echo "skipping $ITEM — already resolved (next open work is $BASE_NEXT)"
          else
            echo "waiting on $BASE_NEXT — it used its delivery rounds without merging or stalling; it resumes next pass ($ITEM not started this pass)"
          fi
          exit 1
        fi

        # A ticket whose name is not `NN-slug.md` is a breakdown bug, not something to silently
        # rename — reject rather than sanitize. Same allowlist reasoning as everywhere else in this
        # file (CWE-78): this value is not read out of the ticket file, it IS the ticket filename,
        # and it is published on this stage's stdout for downstream stages to interpolate.
        BASE=$(basename "$NEXT")
        case "$BASE" in
          "" | *[!A-Za-z0-9._-]*)
            echo "unsafe ticket filename: '$BASE'" >&2
            exit 3
            ;;
        esac
        TICKET_REL="$REL/$BASE"
        ID=$(sed -n 's/^id: *//p' "$NEXT" | head -1 | tr -d "\"'")
        [ -n "$ID" ] || ID=$(basename "$NEXT" .md)

        # The ticket's own `branch:` field WINS when it names one. A ticket whose work already lives
        # on a branch must go back to THAT branch — deriving the name from `id` instead would cut a
        # fresh branch off the trunk and silently rebuild work that already exists.
        BR=$(sed -n 's/^branch: *//p' "$NEXT" | head -1 | tr -d "\"'")
        [ -n "$BR" ] || BR="$ID"
        WT=".claude/worktrees/$ID"

        case "$ID" in
          ""|*[!A-Za-z0-9._-]*)
            echo "unsafe ticket id in $NEXT: '$ID'" >&2
            exit 3
            ;;
        esac
        case "$BR" in
          ""|*[!A-Za-z0-9._/-]*)
            echo "unsafe branch name in $NEXT: '$BR'" >&2
            exit 3
            ;;
        esac

        # Reviewers — FAIL CLOSED. A ticket that declares nothing gets all three critics, and
        # anything near a trust boundary gets the security critic whether it asked for it or not. A
        # breakdown that forgets the field must not be able to silently skip a mandatory gate.
        #
        # REBUILT from a fixed vocabulary rather than passed through, for two reasons:
        #  1. CWE-78: `reviewers:` is agent-written and interpolated downstream into shell strings —
        #     recognising tokens instead of forwarding text means there is nothing left to escape.
        #  2. FAIL-OPEN otherwise: a field naming something with no matching critic — a typo, an
        #     unrecognised word — must not silently run neither the security nor the spec critic.
        #     Unrecognised lands in the same branch as absent: all three.
        # TOKENISED, not substring-matched — that distinction IS the fail-closed property. `*spec*`
        # matches "specification"; a near-miss token would satisfy a substring test and quietly run a
        # partial critic set. Split on commas, compare whole words, one stranger sends it to all three.
        add_rev() {
          case "$REV" in
            "") REV="$1" ;;
            *) REV="$REV, $1" ;;
          esac
        }
        FM=$(awk 'NR>1 && /^---$/{exit} NR>1{print}' "$NEXT")
        RAW=$(printf '%s\n' "$FM" | sed -n 's/^reviewers: *//p' | head -1)
        TOKENS=$(printf '%s' "$RAW" | tr -d "[]\"'" | tr ',' '\n' \
          | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | sed '/^$/d' | tr '[:upper:]' '[:lower:]')
        REV=""
        if [ -n "$TOKENS" ] && ! printf '%s\n' "$TOKENS" | grep -qvxE 'correctness|security|spec'; then
          printf '%s\n' "$TOKENS" | grep -qx 'correctness' && add_rev correctness
          printf '%s\n' "$TOKENS" | grep -qx 'security' && add_rev security
          printf '%s\n' "$TOKENS" | grep -qx 'spec' && add_rev spec
        fi
        [ -n "$REV" ] || REV="correctness, security, spec"
        # A generic trust-boundary keyword list — tune this to your own domain (payments, health
        # data, whatever you actually hold) so a ticket that touches it always gets the security
        # critic, whether or not the breakdown remembered to ask for one.
        if printf '%s\n' "$FM" | grep -qiE 'auth|payment|billing|secret|token|credential|pii|tenant|permission'; then
          case "$REV" in *security*) ;; *) add_rev security ;; esac
        fi

        # Per-ticket worktree off the freshly fetched trunk — ADOPTING an existing one for this
        # branch if there is one. Git refuses a second checkout of the same branch, so a ticket whose
        # branch is already in a worktree from an earlier run (or hand-work) would fail every pass.
        EXIST=$(git worktree list --porcelain | awk -v b="refs/heads/$BR" '
          /^worktree /{p=substr($0,10)}
          /^branch /{if (substr($0,8)==b) {print p; exit}}')
        if [ -n "$EXIST" ]; then
          WT="$EXIST"
        elif [ -d "$WT" ]; then
          # A directory at the expected path is NOT proof of a worktree on $BR — refuse rather than
          # delete: an autonomous `rm -rf` of a directory that might hold unpushed work is not a
          # repair, that's for a human to look at.
          ON=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
          [ "$ON" = "$BR" ] || {
            echo "refusing to adopt $WT: expected branch '$BR', found '${ON:-not a git worktree}'" >&2
            exit 3
          }
          WT="$(cd "$WT" && pwd)"
        else
          # Reuse the branch if it already exists (local or on origin) before cutting a new one. The
          # origin probe FAILS CLOSED on a transport/auth error: guessing "absent" when origin was
          # merely unreachable cuts a fresh branch that shadows the real one.
          if git show-ref --verify --quiet "refs/heads/$BR"; then
            git_root git worktree add --quiet "$WT" "$BR" || { echo "could not attach worktree $WT to $BR"; exit 3; }
          else
            PROBE=0
            git ls-remote --exit-code --heads origin "$BR" >/dev/null 2>&1 || PROBE=$?
            if [ "$PROBE" = 0 ]; then
              git_root git worktree add --quiet -b "$BR" "$WT" "origin/$BR" || { echo "could not attach worktree $WT to origin/$BR"; exit 3; }
            elif [ "$PROBE" = 2 ]; then
              git_root git worktree add --quiet -b "$BR" "$WT" "trunk/$KEY" || { echo "could not create worktree $WT for branch $BR"; exit 3; }
            else
              echo "REFUSING to create a branch for $ID: could not query origin for $BR (exit $PROBE)." >&2
              exit 3
            fi
          fi
          WT="$(cd "$WT" && pwd)"
        fi
        # ONE validation for every path the selection above can produce: canonical (pwd -P, so a
        # symlink cannot dodge the lexical guard), CONTAINED in this repo (an external path would
        # have every Edit/Write denied under scope: workflow-folder), and OF THIS REPOSITORY (a
        # directory that merely matches $BR could be a nested, unrelated clone).
        ROOT_P=$(pwd -P)
        ROOT_GIT=$(git rev-parse --git-common-dir 2>/dev/null) || { echo "cannot resolve this repo's git common dir"; exit 3; }
        ROOT_GIT=$(cd "$ROOT_GIT" 2>/dev/null && pwd -P) || { echo "cannot resolve this repo's git common dir"; exit 3; }
        WT=$(cd "$WT" 2>/dev/null && pwd -P) || { echo "selected worktree unreadable for $BR"; exit 3; }
        case "$WT/" in
          "$ROOT_P"/*) ;;
          *)
            echo "refusing: the worktree for $BR is OUTSIDE this repo: $WT" >&2
            exit 3
            ;;
        esac
        WT_COMMON=$(git -C "$WT" rev-parse --git-common-dir 2>/dev/null || echo "")
        WT_COMMON=$(cd "$WT" 2>/dev/null && cd "$WT_COMMON" 2>/dev/null && pwd -P) || WT_COMMON=""
        [ "$WT_COMMON" = "$ROOT_GIT" ] || {
          echo "refusing: $WT is not a worktree of THIS repository" >&2
          exit 3
        }

        # An adopted branch can be BEHIND origin. Never a merge or a reset: if it will not
        # fast-forward the divergence is real and a human should see it now, not two rounds later as
        # a needs_human stall after all the spend of building it. The DECISION is pure ref math —
        # never a worktree operation — so a leftover dirty tree reads as "build as-is", not a false
        # DIVERGED stop.
        LSRC=0
        git ls-remote --exit-code --heads origin "$BR" >/dev/null 2>&1 || LSRC=$?
        if [ "$LSRC" = 0 ]; then
          if (cd "$WT" && git fetch --quiet origin "$BR"); then
            if (cd "$WT" && git merge-base --is-ancestor "origin/$BR" HEAD 2>/dev/null); then
              : # in sync, or local strictly ahead — the eventual push fast-forwards
            elif (cd "$WT" && git merge-base --is-ancestor HEAD "origin/$BR" 2>/dev/null); then
              (cd "$WT" && git merge --ff-only --quiet "origin/$BR" 2>/dev/null) \
                || echo "note: $BR is behind origin and would not fast-forward — building as-is" >&2
            else
              echo "REFUSING to build $ID: local and origin/$BR have DIVERGED." >&2
              echo "  1. compare: git log --left-right --cherry-pick --oneline origin/$BR...$BR" >&2
              echo "  2. verify the origin-only side is superseded, back it up to a branch," >&2
              echo "  3. push --force-with-lease the good side. Re-run." >&2
              exit 3
            fi
          else
            echo "note: could not fetch origin/$BR — building on the local branch as-is" >&2
          fi
        elif [ "$LSRC" != 2 ]; then
          echo "note: could not query origin for $BR (transport/auth failure) — building on the local branch as-is" >&2
        fi

        # Take the TRUNK into the ticket branch, for the same reason bootstrap takes main into the
        # trunk: agents read process docs and the ticket's spec from the WORKTREE by relative path,
        # so a long-running ADOPTED ticket keeps building against whatever process doc it started
        # with unless this catches it up. Never a rebase and never a reset: the branch may already be
        # pushed with a PR open. A conflict is NOT resolved here either — abort, leave the branch
        # exactly as it was, and say so.
        (
          cd "$WT" || exit 0
          GITDIR=$(git rev-parse --git-dir 2>/dev/null || echo "")
          [ -z "$(git status --porcelain)" ] || {
            echo "note: $WT has uncommitted work — not syncing it with the trunk this round"
            exit 0
          }
          if [ -n "$GITDIR" ] && [ -f "$GITDIR/MERGE_HEAD" ]; then
            echo "note: $WT is mid-merge already — not syncing it, and not touching that merge"
            exit 0
          fi

          git fetch --quiet origin "trunk/$KEY" 2>/dev/null || {
            echo "note: could not fetch trunk/$KEY — not syncing $BR this round"
            exit 0
          }
          TRUNK_REF="trunk/$KEY"
          git merge-base --is-ancestor "origin/trunk/$KEY" "trunk/$KEY" 2>/dev/null \
            || TRUNK_REF="origin/trunk/$KEY"

          git merge-base --is-ancestor "$TRUNK_REF" HEAD 2>/dev/null && exit 0
          if git merge --no-edit --quiet "$TRUNK_REF" 2>/dev/null; then
            echo "synced $BR with $TRUNK_REF"
          else
            if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
              git merge --abort 2>/dev/null || true
            fi
            if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
              echo "note: $BR is left MID-MERGE against $TRUNK_REF and the abort failed — a human must clear it."
              exit 3
            fi
            echo "note: $BR CONFLICTS with $TRUNK_REF — left untouched. A human should merge the trunk into it."
          fi
        )
        # Every ordinary outcome above exits 0 — a stale-but-intact branch is still buildable, so a
        # failed sync must not stop the ticket. The ONE exception is a worktree left mid-merge that
        # would not abort: nothing can be committed there.
        SYNC_RC=$?
        [ "$SYNC_RC" -ne 3 ] || exit 3

        # An adopted worktree may predate a dependency change; a fresh one has no node_modules at all.
        [ -d "$WT/node_modules" ] || (cd "$WT" && pnpm install --silent >/dev/null 2>&1) \
          || echo "warning: pnpm install failed in $WT"

        # todo -> in_progress the moment the worktree exists — the serialization gate, one ticket in
        # progress per feature. This edit belongs to the TICKET branch, so it is made and committed
        # in the ticket worktree, not on the trunk.
        TF="$WT/$TICKET_REL"
        if [ -f "$TF" ]; then
          if grep -qE '^status: *todo' "$TF"; then
            perl -0pi -e 's/^status: *todo/status: in_progress/m' "$TF"
          fi
          if grep -qE '^branch:' "$TF"; then
            perl -0pi -e "s|^branch:.*|branch: $BR|m" "$TF"
          else
            perl -0pi -e "s|^status: in_progress|status: in_progress\nbranch: $BR|m" "$TF"
          fi
          # >/dev/null as well as 2>/dev/null: `git commit` can print ordinary status text to STDOUT
          # when it has nothing to commit, and every downstream stage interpolates this stdout into a
          # shell command. Nothing but the key=value block may reach stdout.
          (cd "$WT" && git add "$TICKET_REL" && git commit --quiet -m "chore($ID): start — status in_progress") >/dev/null 2>&1 || true
        else
          echo "warning: $TICKET_REL is not in the ticket worktree yet" >&2
        fi

        printf 'ticket_rel=%s\nid=%s\nkey=%s\nbranch=%s\nworktree=%s\ntrunk_worktree=%s\ntrunk=trunk/%s\nspec=%s\n' \
          "$TICKET_REL" "$ID" "$KEY" "$BR" "$WT" "$TWT" "$KEY" "$SPEC" >&3
        # >&3 on the MARKERS too, not just the key=value block — anything a consumer reads is output
        # and belongs on fd 3, or `route_security`/`route_spec` (which test for these substrings)
        # silently take the "skip" branch and a mandatory critic never runs.
        case "$REV" in *correctness*) echo "review:correctness" >&3 ;; esac
        case "$REV" in *security*) echo "review:security" >&3 ;; esac
        case "$REV" in *spec*) echo "review:spec" >&3 ;; esac
        echo "reviewers=$REV" >&3

  # Nothing to build this round -> the body ends here and the guard stops the loop.
  - id: route_ticket
    type: task.branch
    config:
      expression: 'pick_ticket.exitCode = 0 ? "work" : "idle"'

  # Does this ticket actually NEED the test author? Two things mean yes, and both are decidable from
  # files: the branch carries no test changes at all vs the trunk, or an OPEN blocking finding names
  # tests/coverage. Anything else means tests exist and no one has objected, so skip straight to
  # running them — `verify_tests` still runs either way, so "skip" never means "unverified".
  #
  # Fails OPEN on purpose: any doubt (no worktree, unreadable ticket) routes to the author. Spending
  # an agent call we didn't need is a cost; skipping tests we did need is a defect.
  - id: tests_check
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 120
      command: |
        set -u
        exec 3>&1 1>&2
        WT=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^worktree=//p' | head -1)
        KEY=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^key=//p' | head -1)
        REL=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^ticket_rel=//p' | head -1)
        [ -n "$WT" ] && [ -d "$WT" ] && [ -n "$KEY" ] || {
          echo "no worktree or key — routing to the test author"
          echo "tests=author" >&3
          exit 0
        }
        cd "$WT" || { echo "tests=author" >&3; exit 0; }

        # NUL-delimited, ACMR-filtered: a DELETED test file still shows in --name-only, and a path
        # with a space would word-split.
        N=$(git diff -z --name-only --diff-filter=ACMR "trunk/$KEY...HEAD" -- \
              '*.test.ts' '*.test.tsx' '*.spec.ts' '*.spec.tsx' 2>/dev/null | tr -cd '\0' | wc -c | tr -d ' ')
        N=${N:-0}

        # Only UNCHECKED boxes count — a ticked finding is one the engineer already answered.
        OPEN=0
        if [ -n "$REL" ] && [ -f "$REL" ]; then
          # [^A-Za-z] guard: a bare substring matched "la**test**"/"fas**test**" in ordinary text.
          OPEN=$(grep -ciE '^- \[ \] BLOCKING.*[^A-Za-z](test|coverage)' "$REL" 2>/dev/null || true)
          case "$OPEN" in '' | *[!0-9]*) OPEN=0 ;; esac
        fi

        echo "test files vs trunk: $N   open test/coverage findings: $OPEN"
        if [ "$N" -eq 0 ]; then
          echo "no tests on this branch yet — routing to the test author"
          echo "tests=author" >&3
        elif [ "$OPEN" -gt 0 ]; then
          echo "$OPEN open test/coverage finding(s) — routing to the test author"
          echo "tests=author" >&3
        else
          echo "$N test file(s) already on this branch and nothing open against them — skipping the author"
          echo "tests=have" >&3
        fi

  - id: route_tests
    type: task.branch
    config:
      expression: '$contains(tests_check.stdout, "tests=author") ? "author" : "have"'

  # Runs the tests the author just wrote, so the AGENT doesn't have to — this replaces a self-report
  # (the agent's own claim about its own work) with evidence, and it means the ticket's evidence
  # exists even on the FIRST engineer round, before the engineer has run anything itself.
  #
  # Deliberately NOT a gate: "tests must be red first" is right for new behaviour and WRONG for a
  # pure refactor, whose tests should pass before and after. So this reports, and the engineer and
  # the correctness critic are handed the result to judge in context.
  - id: verify_tests
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 900
      command: |
        set -u
        WT=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^worktree=//p' | head -1)
        KEY=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^key=//p' | head -1)
        [ -n "$WT" ] && [ -d "$WT" ] || { echo "no worktree"; exit 3; }
        cd "$WT" || exit 3

        # NUL-delimited, and --diff-filter=ACMR. Two reasons, both of which produce confidently
        # wrong evidence rather than an error: a DELETED test file still appears in --name-only, and
        # a path with a space or a `*` would mis-split or glob.
        TMP=$(mktemp) || exit 3
        trap 'rm -f "$TMP" "$TMP.plan" "$TMP.norunner"' EXIT
        git diff -z --name-only --diff-filter=ACMR "trunk/$KEY...HEAD" -- \
          '*.test.ts' '*.test.tsx' '*.spec.ts' '*.spec.tsx' > "$TMP" 2>/dev/null

        COUNT=0
        echo "test files on this branch:"
        while IFS= read -r -d '' f; do
          COUNT=$((COUNT + 1))
          echo "  $f"
        done < "$TMP"
        [ "$COUNT" -gt 0 ] || { echo "  (none added or changed)"; exit 1; }

        # Map each file to the package that owns it, and skip packages with no real test script —
        # a package.json PATH is passed as an ARGUMENT, never interpolated into program text
        # (CWE-94: a crafted directory name must not be executed).
        : > "$TMP.plan"
        : > "$TMP.norunner"
        while IFS= read -r -d '' f; do
          d=$(dirname "$f")
          while [ "$d" != "." ] && [ "$d" != "/" ] && [ ! -f "$d/package.json" ]; do d=$(dirname "$d"); done
          [ -f "$d/package.json" ] || continue
          pkg=$(node -e 'process.stdout.write(String(require(process.argv[1]).name||""))' "$PWD/$d/package.json" 2>/dev/null) || continue
          [ -n "$pkg" ] || continue
          # replace with your project's test-script sniff — this looks for a vitest-flavoured script
          script=$(node -e 'const s=require(process.argv[1]).scripts;process.stdout.write(String((s&&s.test)||""))' "$PWD/$d/package.json" 2>/dev/null)
          rel=${f#$d/}
          if [ "${script#*vitest}" != "$script" ]; then
            printf '%s\t%s\0' "$pkg" "$rel" >> "$TMP.plan"
          else
            printf '  %s (%s)\n' "$pkg" "$f" >> "$TMP.norunner"
          fi
        done < "$TMP"

        if [ -s "$TMP.norunner" ]; then
          echo "no test runner in these packages — not run, and NOT counted as passing:"
          cat "$TMP.norunner"
        fi
        [ -s "$TMP.plan" ] || { echo "no changed test file belongs to a package with a runner"; exit 1; }

        PKGS=$(while IFS= read -r -d '' rec; do printf '%s\n' "${rec%%	*}"; done < "$TMP.plan" | sort -u)

        RED=""; GREEN=""
        for pkg in $PKGS; do
          # Rebuild this package's paths as ARRAY ELEMENTS, so a space or glob character in a
          # filename survives into the command instead of being re-split by the shell.
          paths=()
          while IFS= read -r -d '' rec; do
            [ "${rec%%	*}" = "$pkg" ] || continue
            paths+=("${rec#*	}")
          done < "$TMP.plan"
          [ "${#paths[@]}" -gt 0 ] || continue
          if pnpm --filter "$pkg" test "${paths[@]}" >/dev/null 2>&1; then
            GREEN="$GREEN $pkg"
          else
            RED="$RED $pkg"
          fi
        done
        [ -z "$RED" ]   || echo "failing (red):$RED"
        [ -z "$GREEN" ] || echo "passing (green):$GREEN"
        echo
        echo "Red is expected for NEW behaviour before it is implemented. Green is expected for a"
        echo "refactor ticket, where the behaviour already exists and the tests pin it during the"
        echo "move. Neither is a failure on its own — judge it against what this ticket is for."
        exit 0

  - id: test_author
    # A dead agent stage fails the whole PASS outright — the brain is never consulted, nothing is
    # judged, and every prior ticket's successful merge this pass buys no verdict. `onError:
    # continue` turns the error into a `{ error }` output instead: verify_tests (a CLI stage) still
    # counts what is actually on the branch, and delivery_status stalls a genuinely stuck ticket
    # after two such rounds with a diagnosis on the ticket file. Same outcome for a stuck ticket,
    # minus losing the whole run.
    onError: continue
    type: agent.run
    config:
      # POSTURE: access: full + scope: workflow-folder — full inside this repo, file-tool writes
      # outside it denied. See "Agent access posture" at the top of this file; change every agent
      # stage together if you change this.
      access: full
      scope: workflow-folder
      # A headless step has nobody to approve a prompt, so a refused command is a hard refusal, not
      # a pause. `access: full` ignores this list, but it stays as a record of what a stage
      # demonstrably needs — useful if you ever drop this stage back to `access: broad`.
      allowTools: &agent_tools
        - Bash(head:*)
        - Bash(tail:*)
        - Bash(sed:*)
        - Bash(awk:*)
        - Bash(wc:*)
        - Bash(sort:*)
        - Bash(uniq:*)
        - Bash(cut:*)
        - Bash(tr:*)
        - Bash(diff:*)
        - Bash(jq:*)
        - Bash(tee:*)
        - Bash(cd:*)
        - Bash(mkdir:*)
        - Bash(cp:*)
        - Bash(mv:*)
        - Bash(touch:*)
        - Bash(basename:*)
        - Bash(dirname:*)
        - Bash(xargs:*)
        - Bash(date:*)
        - Bash(true:*)
        - Bash(test:*)
      agent: test-author
      # Headroom, not a budget: `onError: continue` above means hitting this costs a round, not the
      # run — so this can afford to be generous.
      timeoutSeconds: 1800
      decision:
        type: object
        properties:
          tests_written: { type: number }
          files: { type: array }
          acs_covered: { type: array }
          all_failing: { type: boolean }
          why: { type: string }
        required: [tests_written, files, acs_covered, all_failing, why]
      prompt: |
        Write the tests for this ticket's acceptance criteria, before any implementation exists.
        Whether they should be failing at this point depends on what the ticket is for — see below.

        {{pick_ticket.stdout}}

        `cd` into the ticket worktree above and stay there. The ticket file is at
        `<worktree>/<ticket_rel>`; the spec is at `<worktree>/<spec>`.

        **Run only the test file you wrote, once, to see its result.** Do not run the package suite
        or a repo-wide build: a `task.command` stage runs your tests immediately after you and its
        output is authoritative — running the whole suite yourself is spend on a result already on
        its way.

        **What `all_failing` should be depends on what this ticket is for.** Decide that first, from
        the ticket's acceptance criteria, and say which in `why`:

        - **New behaviour** — the criteria describe something that does not exist yet. Your tests
          MUST fail before it is built, and `all_failing: true` is a claim that you ran them and
          watched them do so. A test that passes here is testing something already true — fix it now.
        - **A refactor** — the criteria describe behaviour that already works and must survive a move
          or an extraction. A characterization test that pins existing behaviour is SUPPOSED to pass
          before the change; report `all_failing: false` and say in `why` what the tests pin.

        Never make a refactor test fail artificially to satisfy the flag, and never report
        `all_failing: true` for tests you watched pass — either trades a true report for a tidy one,
        and the next stage reads this to decide what it is looking at.

        Commit them before you finish.

  - id: delivery_loop
    type: task.loop
    config:
      maxIterations: 6
      # 1 = go round again. 0 (merged) and 2 (stalled — see delivery_status) both stop it, so a
      # ticket that is not advancing costs two rounds, not six.
      until: prev.delivery_status.exitCode != 1

  - id: engineer
    onError: continue
    type: agent.run
    config:
      access: full
      scope: workflow-folder
      allowTools: *agent_tools
      # Highest-volume agent in the graph: up to one call per delivery round, per ticket. Headroom,
      # not a budget — `onError: continue` means hitting the timeout costs a round, not the run, but
      # a mid-report kill (final verification/reporting cut off after all work is already committed)
      # costs a whole recovery, so this stage's timeout should sit safely above your largest ticket's
      # honest build+verify time.
      agent: build-ticket
      timeoutSeconds: 3600
      decision:
        type: object
        properties:
          files_changed: { type: number }
          acs_met: { type: array }
          findings_resolved: { type: number }
          gates_run: { type: array }
          why: { type: string }
        required: [files_changed, acs_met, findings_resolved, gates_run, why]
      prompt: |
        Advance this ticket by one round.

        {{pick_ticket.stdout}}

        **The tests on this branch, run by a CLI stage (no agent spent tokens on this):**

        {{verify_tests.stdout}}

        **The gate result from the previous round, if there was one:**

        {{gates.stdout}}

        Empty on the first round — nothing has been built yet. **Check its first line: if
        `gates_for=` names a different ticket id than yours, it is a previous ticket's result — your
        gates have not run yet; ignore it.** Otherwise this is the authoritative output of your
        project's gate battery, run by a `task.command` stage that spends no tokens. **If it names a
        red gate, fixing that is this round's job** — the output above already tells you which one
        and why, so a round that ends with the same gate still red is a round wasted.

        `cd` into the ticket worktree above and stay there. The ticket file is at
        `<worktree>/<ticket_rel>`. Look at it first and work on whatever is actually outstanding:

        - Unresolved findings under `## Review findings` — lines starting `- [ ] BLOCKING`. Fix each
          one, then tick its box to `- [x]` in the same commit as the fix. If a finding is genuinely
          wrong, or a style preference whose "fix" would not improve the code, you may DISMISS it
          instead: tick the box and append ` — dismissed: <one concrete reason>` to the line. The
          correctness critic audits every dismissal next round and re-opens any that does not hold,
          so a lazy dismissal costs more than the fix. Ticking a box with neither a fix nor a written
          dismissal is the one thing here that cannot be recovered from — the merge gate trusts those
          boxes.
        - Otherwise: implement the acceptance criteria until the tests written for this ticket pass.

        The tests already in the ticket's files were written against the acceptance criteria before
        you started. **You may add tests. You may not weaken one.** Do not delete, skip, loosen or
        retarget an existing assertion to get to green — if you believe a test is genuinely wrong,
        leave it failing, write why in the ticket's `## Log`, and let the review round settle it. The
        reviewer is given your test diff specifically to check this.

        Add whatever tests new functionality needs to be fully covered. For each test you write:
        break the code it covers, confirm it goes red, revert — and record one concrete line per test
        under the ticket's `## Log`. The correctness critic reads that log and treats a missing or
        vague entry as a blocking finding.

        **Do NOT run the full gate battery yourself.** A dedicated `gates` stage runs it, repo-wide,
        immediately after you and refuses the push if anything is red — running it here duplicates
        multi-minute work and is the single most common way a round burns its budget without
        producing anything new. Run instead only what you need to know your change is right:

        - the tests covering the files you touched (`pnpm --filter <pkg> test <path>`), not the suite
        - a type check or the package build if you changed types
        - a quick mutation check on every test you wrote — break the code, see red, revert

        `gates` handles the rest and hands anything red straight back to you next round. Report what
        you actually ran in `gates_run`; an empty list is a fine answer if you only needed the
        targeted tests.

        Commit your work before you finish; the next stage refuses to push a dirty worktree. Report
        `files_changed` honestly — if it is 0, say so and say what stopped you.

  # Is there anything to review at all? A branch with no diff against the trunk means the engineer
  # produced nothing this round — out of tokens, a wedged tool, or a ticket it could not start.
  #
  # Asking "does this branch differ from the trunk" rather than "did HEAD move this round" matters: a
  # ticket whose work was already complete and pushed before the run started has a real diff, so it
  # still goes through review and still reaches a PR.
  - id: work_check
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 120
      command: |
        set -u
        WT=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^worktree=//p' | head -1)
        KEY=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^key=//p' | head -1)
        [ -n "$WT" ] && [ -d "$WT" ] || { echo "no worktree"; exit 1; }
        cd "$WT" || exit 1
        CHANGED=$(git diff --name-only "trunk/$KEY...HEAD" 2>/dev/null | wc -l | tr -d ' ')
        COMMITS=$(git rev-list --count "trunk/$KEY..HEAD" 2>/dev/null || echo 0)
        echo "files_vs_trunk=$CHANGED commits_ahead=$COMMITS"
        [ "${CHANGED:-0}" -gt 0 ] || {
          echo "nothing to review — this branch is identical to trunk/$KEY"
          exit 1
        }

  - id: route_work
    type: task.branch
    config:
      expression: 'work_check.exitCode = 0 ? "review" : "nowork"'

  # ── The merge gate ───────────────────────────────────────────────────────────────────────────────
  # Local gates before the PR opens, so CI is not the thing that discovers a lint error. This list
  # SHOULD mirror your CI workflow — a gate that is a subset of CI is worse than no gate: it reports
  # green, the ticket pushes, CI goes red, and the loop pays a whole push -> CI round to learn
  # something it could have known locally.
  - id: gates
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 1800
      command: |
        set -u
        WT=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^worktree=//p' | head -1)
        ID=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^id=//p' | head -1)
        [ -n "$WT" ] && [ -d "$WT" ] || { echo "no worktree"; exit 3; }
        cd "$WT" || exit 3

        # First line names the ticket this output belongs to. Node outputs persist across loop
        # rounds, so on the FIRST round of ticket N the engineer and critics would otherwise read
        # ticket N-1's final gate result presented as "this round's" — usually a stale green
        # claiming gates passed on a ticket that has not been built yet.
        [ -n "$ID" ] || ID=unknown
        echo "gates_for=$ID"

        # The gates VERIFY. They must not leave the working tree changed — some build steps
        # regenerate a committed file (a sitemap, a lockfile snapshot) as a side effect, which would
        # dirty the ticket worktree and make push_pr refuse a push that was actually clean. So:
        # snapshot what was already dirty, and afterwards restore anything the gates themselves
        # touched. Files the ENGINEER left uncommitted are in the snapshot and are NOT restored, so
        # push_pr still catches the case this check exists for.
        DIRTY_BEFORE=$(git diff --name-only HEAD | sort)
        restore_gate_noise() {
          AFTER=$(git diff --name-only HEAD | sort)
          NEWLY=$(comm -13 <(printf '%s\n' "$DIRTY_BEFORE") <(printf '%s\n' "$AFTER"))
          [ -n "$NEWLY" ] || return 0
          echo "restoring files the gates themselves regenerated:"
          printf '%s\n' "$NEWLY" | sed 's/^/  /'
          printf '%s\n' "$NEWLY" | while IFS= read -r p; do
            [ -n "$p" ] && git checkout -- "$p" 2>/dev/null || true
          done
        }

        # ── Replace every one of these with your own project's gates ─────────────────────────────
        FAILED=""
        pnpm format:check >/dev/null 2>&1 || FAILED="$FAILED format"   # replace with your project's gate
        pnpm lint         >/dev/null 2>&1 || FAILED="$FAILED lint"     # replace with your project's gate
        pnpm build        >/dev/null 2>&1 || FAILED="$FAILED build"    # replace with your project's gate
        # The test sweep's output is KEPT, unlike the other gates': the flake-retry below parses it
        # for WHICH packages failed, and the red-report at the bottom reuses it.
        TESTLOG=$(mktemp)
        pnpm test         >"$TESTLOG" 2>&1 || FAILED="$FAILED test"    # replace with your project's gate

        # The `test` gate gets ONE evidence-based retry before it can turn the round red — and the
        # retry must DECORRELATE from the first failure, not repeat it. A full suite is
        # timing-sensitive under load: with concurrent runs sweeping the same machine, a suite that
        # is green run alone can go red inside a parallel sweep. The old naive fix — re-run the FULL
        # sweep immediately, under the very load that caused the first red — just re-proves the
        # flake instead of ruling it out. So: parse WHICH packages failed out of the sweep log and
        # re-run only those, one at a time, at a fraction of the parallel load. Full re-run only when
        # the parse finds nothing. `test` only: every other gate is deterministic, and a
        # flaky-then-green BUILD would still deserve eyes.
        case "$FAILED" in *" test"*)
          FAILED_PKGS=$( { sed -n 's/^[[:space:]]*Failed:[[:space:]]*//p' "$TESTLOG" \
              | tr ',' '\n' | grep -oE '[^[:space:]]+#test' | sed 's/#test$//'
            grep -oE '^[^[:space:]:]+:test: ERROR' "$TESTLOG" | sed 's/:test: ERROR$//'
          } | sort -u)
          RETRY_RED=0
          if [ -n "$FAILED_PKGS" ]; then
            echo "test gate red in:$(printf ' %s' $FAILED_PKGS) — re-running those suites in isolation (load-flake check)"
            for p in $FAILED_PKGS; do
              pnpm --filter "$p" test >/dev/null 2>&1 || RETRY_RED=1
            done
          else
            echo "test gate red — could not parse the failing package from the sweep log; re-running the full sweep once"
            pnpm test >/dev/null 2>&1 || RETRY_RED=1
          fi
          if [ "$RETRY_RED" -eq 0 ]; then
            echo "test gate GREEN on isolated re-run — first failure was a load flake, not counted"
            NEWFAILED=""
            for g in $FAILED; do [ "$g" = "test" ] || NEWFAILED="$NEWFAILED $g"; done
            FAILED="$NEWFAILED"
          fi
        ;; esac

        if [ -n "$FAILED" ]; then
          echo "red gates:$FAILED"
          # Re-run the first failure with output, so the next engineer round has something to read.
          for g in $FAILED; do
            case "$g" in
              format) echo '--- pnpm format:check ---'; pnpm format:check 2>&1 | tail -40 ;;
              lint)   echo '--- pnpm lint ---';         pnpm lint 2>&1 | tail -40 ;;
              build)  echo '--- pnpm build ---';        pnpm build 2>&1 | tail -40 ;;
              test)   echo '--- pnpm test (first sweep) ---'; tail -60 "$TESTLOG" ;;
            esac
            break
          done
          restore_gate_noise
          rm -f "$TESTLOG"
          exit 1
        fi
        restore_gate_noise
        rm -f "$TESTLOG"
        echo "gates=green"

  - id: route_gates
    type: task.branch
    config:
      expression: 'gates.exitCode = 0 ? "green" : "red"'

  # ── Feed the stages: pre-compute the diff once, hand it to every critic ─────────────────────────
  # The three review stages below all need the same thing — this ticket's diff against the trunk —
  # and none of them is told to go get it with its own `git diff`. An agentic stage re-sends its
  # whole conversation on every turn, so an agent that has to run the command, wait, and then read a
  # possibly-large result pays for that diff many times over; this stage pays for it once, as a
  # `task.command` that costs no tokens, and every critic reads the same text from its stdout.
  - id: compute_diff
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 120
      command: |
        set -u
        WT=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^worktree=//p' | head -1)
        KEY=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^key=//p' | head -1)
        ID=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^id=//p' | head -1)
        [ -n "$WT" ] && [ -d "$WT" ] || { echo "no worktree"; exit 3; }
        cd "$WT" || exit 3
        [ -n "$ID" ] || ID=unknown
        echo "diff_for=$ID"
        echo
        echo "--- files changed vs trunk/$KEY ---"
        git diff --stat "trunk/$KEY...HEAD" 2>/dev/null | tail -60
        echo
        echo "--- full diff vs trunk/$KEY ---"
        # Capped: a diff this big is itself a finding (see the sizing contract in the feature-spec
        # skill's ticket schema) rather than something to solve by widening this cap.
        git diff "trunk/$KEY...HEAD" 2>/dev/null | head -c 60000
        echo
        echo "(diff capped at 60,000 characters — a ticket producing more than this is oversized; say so as a finding rather than reviewing a truncated diff as if it were complete)"

  - id: review_loop
    type: task.loop
    config:
      # ONE pass. Extra iterations here are pure waste — the engineer is not in this body, so
      # nothing between iterations could fix a finding; the same critics would just re-read
      # identical code and re-report the same findings. The engineer <-> critic iteration this might
      # look like it should do is what `delivery_loop` already does, across whole rounds.
      maxIterations: 1
      until: prev.collect.exitCode = 0

  # Correctness always runs — it is the baseline, and it anchors the chain the optional critics
  # reconverge onto.
  - id: review_correctness
    type: agent.run
    config:
      access: full
      scope: workflow-folder
      allowTools: *agent_tools
      # The deepest-reasoning stage in the graph and also one of the highest-volume (review loop x
      # delivery loop) — a stronger model here is worth buying deliberately.
      agent: review-code
      timeoutSeconds: 600
      decision:
        type: object
        properties:
          blocking: { type: number }
          findings: { type: array }
          tests_weakened: { type: boolean }
          why: { type: string }
        required: [blocking, findings, tests_weakened, why]
      prompt: |
        Correctness review of this ticket's diff against the trunk.

        {{pick_ticket.stdout}}

        The toolchain has ALREADY RUN this round. Here is its result — treat it as fact:

        {{gates.stdout}}

        And the result of running this branch's own test files, from the same CLI stage:

        {{verify_tests.stdout}}

        Use it when judging whether a test can fail. Green on a REFACTOR ticket is expected — the
        behaviour already existed and the tests pin it during the move. Green on a ticket adding NEW
        behaviour is the finding, because the test cannot be proving what it claims to.

        **Here is the diff, already computed — do not run `git diff`, `pnpm build`, `pnpm test` or
        any other command to re-derive it or the toolchain result above.** A `task.command` stage
        owns both and spends no tokens on them; running them yourself pays three times over — the
        tokens, the output flooding this context, and the wall-clock against this stage's timeout —
        for something already established:

        {{compute_diff.stdout}}

        Check the diff's first line: if `diff_for=` names a different ticket id than this one, it is
        a previous ticket's stale result — disregard it, and the same for the gate output above.

        Read the diff, read the tests, and judge. If a finding genuinely depends on running
        something the diff doesn't show, say so in the finding rather than running it.

        Also review the TEST diff specifically. The tests were written against the acceptance
        criteria before implementation started, and the engineer is permitted to add tests but not to
        weaken one. A deleted assertion, a skipped test, a loosened matcher, an assertion retargeted
        to whatever the code now does — each is a BLOCKING finding, and set `tests_weakened` true.

        Record every blocking finding in the ticket file (`<worktree>/<ticket_rel>`) under a
        `## Review findings` heading, one per line, exactly:

            - [ ] BLOCKING(correctness): <file>:<line> — <what breaks, concretely>

        Commit that edit. Leave non-blocking observations out of the list; put them in the ticket's
        `## Log`. The checkbox list IS the merge gate — a padded list stalls the ticket, a thin one
        ships the bug.

        Read the list that is already there before adding to it. Never duplicate an entry, ticked or
        unticked. A ticked line carrying `dismissed:` is the engineer's dismissal of a finding —
        AUDIT it: when the reason does not hold, or a ticked line's claimed fix is absent from the
        diff, add ONE new unticked line saying exactly why. Never untick history.

  - id: route_security
    type: task.branch
    config:
      expression: '$contains(pick_ticket.stdout, "review:security") ? "run" : "skip"'

  - id: review_security
    type: agent.run
    config:
      access: full
      scope: workflow-folder
      allowTools: *agent_tools
      # The one tier here never worth trading down, whatever you use for the others.
      agent: review-security
      timeoutSeconds: 600
      decision:
        type: object
        properties:
          blocking: { type: number }
          findings: { type: array }
          why: { type: string }
        required: [blocking, findings, why]
      prompt: |
        OWASP + STRIDE review of this ticket's diff.

        {{pick_ticket.stdout}}

        The toolchain has ALREADY RUN this round. Here is its result — treat it as fact:

        {{gates.stdout}}

        **Here is the diff, already computed — do not run `git diff`, `pnpm build`, `pnpm test` or
        any other command to re-derive it.** A `task.command` stage owns both this diff and the
        toolchain result above and spends no tokens on either:

        {{compute_diff.stdout}}

        Check the diff's first line: if `diff_for=` names a different ticket id than this one, it is
        a previous ticket's stale result — disregard it, and the same for the gate output above.

        Authn/authz on every new or changed entry point, input validation on every trust boundary,
        tenant/account isolation on every read and write, no secrets or credentials in logs, and
        signature/verification on anything a webhook or callback trusts before its payload is used.
        If a finding genuinely depends on running something the diff doesn't show, say so in the
        finding rather than running it.

        Record every blocking finding in the ticket file (`<worktree>/<ticket_rel>`) under
        `## Review findings`, one per line, exactly, and commit that edit:

            - [ ] BLOCKING(security): <file>:<line> — <the trust boundary that breaks, and how>

        Read the list already in the ticket first — never duplicate an entry, ticked or unticked.

        A ticked `BLOCKING(security)` line carrying `dismissed:` is the engineer's dismissal of a
        trust-boundary finding, and only YOU can audit it — the correctness critic does not carry the
        OWASP + STRIDE lens. Judge every one against the diff: when the reason does not hold, or the
        claimed fix is absent, add ONE new unticked `BLOCKING(security)` line saying exactly why.
        Never untick history.

  - id: route_spec
    type: task.branch
    config:
      expression: '$contains(pick_ticket.stdout, "review:spec") ? "run" : "skip"'

  - id: review_spec
    type: agent.run
    config:
      access: full
      scope: workflow-folder
      allowTools: *agent_tools
      # A deliberate step down from the other two critics: this compares a diff against a document
      # rather than hunting latent defects.
      agent: review-spec
      timeoutSeconds: 600
      decision:
        type: object
        properties:
          blocking: { type: number }
          findings: { type: array }
          acs_unmet: { type: array }
          why: { type: string }
        required: [blocking, findings, acs_unmet, why]
      prompt: |
        Spec-alignment review: does the built thing match the spec section that governs this ticket,
        including every state the spec names?

        {{pick_ticket.stdout}}

        The toolchain has ALREADY RUN this round. Here is its result — treat it as fact:

        {{gates.stdout}}

        **Here is the diff, already computed — do not run `git diff`, `pnpm build`, `pnpm test` or
        any other command to re-derive it.** A `task.command` stage owns both this diff and the
        toolchain result above and spends no tokens on either:

        {{compute_diff.stdout}}

        Check the diff's first line: if `diff_for=` names a different ticket id than this one, it is
        a previous ticket's stale result — disregard it, and the same for the gate output above.

        Record every blocking finding in the ticket file (`<worktree>/<ticket_rel>`) under
        `## Review findings`, one per line, exactly, and commit that edit:

            - [ ] BLOCKING(spec): <ac or section> — <how the build diverges>

        Read the list already in the ticket first — never duplicate an entry, ticked or unticked.

  # The review loop's guard. Reads the ticket FILE, not the critics' outputs, on purpose: a critic
  # that was routed around this round would otherwise leave a stale verdict behind for the next
  # ticket to trip over.
  - id: collect
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 120
      command: |
        set -u
        WT=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^worktree=//p' | head -1)
        REL=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^ticket_rel=//p' | head -1)
        TICKET="$WT/$REL"
        # Fail CLOSED, like `merge` does. Exiting 0 here would read a MISSING ticket file as "no
        # findings" and route the round to push_pr — a PR opened on a branch whose findings ledger
        # cannot even be counted. Exit 3 routes to `findings`, which ends the round instead.
        [ -n "$WT" ] && [ -f "$TICKET" ] || { echo "no current ticket file — refusing to report clean"; exit 3; }
        OPEN=$(grep -cE '^- \[ \] BLOCKING' "$TICKET" 2>/dev/null || true)
        OPEN=${OPEN:-0}
        echo "unresolved_blocking=$OPEN"
        grep -E '^- \[ \] BLOCKING' "$TICKET" 2>/dev/null | head -20 || true
        [ "$OPEN" -eq 0 ]

  - id: route_findings
    type: task.branch
    config:
      expression: 'collect.exitCode = 0 ? "clean" : "findings"'

  - id: push_pr
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 900
      command: |
        set -u
        exec 3>&1 1>&2
        WT=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^worktree=//p' | head -1)
        BR=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^branch=//p' | head -1)
        KEY=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^key=//p' | head -1)
        ID=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^id=//p' | head -1)
        REL=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^ticket_rel=//p' | head -1)
        cd "$WT" || exit 3

        # The gates must be green before anything is pushed. `gates` already said so; this is the
        # cheap re-assertion that keeps a red ticket from ever reaching a PR. Read the EXIT CODE, not
        # the text — gates prints raw build/test output on failure, and interpolating that into this
        # script is neither parse-safe nor injection-safe. The exit code is a single integer.
        [ "{{gates.exitCode}}" = "0" ] || { echo "gates not green — not pushing"; exit 1; }

        # Two failure modes: MODIFIED tracked files (uncommitted changes `git diff HEAD` sees) and
        # NEW untracked files (which it is blind to — a tests-first ticket whose work is entirely new
        # files would otherwise pass this check and open an empty PR). Everything that is NOT
        # untracked, rather than an allow-list of status codes, so an unmerged conflict state doesn't
        # slip through and get pushed mid-conflict.
        DIRTY=$(git status --porcelain)
        MODIFIED=$(printf '%s\n' "$DIRTY" | grep -vE '^(\?\?|$)' || true)
        NEW_SRC=$(printf '%s\n' "$DIRTY" | grep -E '^\?\? (apps|packages)/' || true)
        if [ -n "$MODIFIED" ] || [ -n "$NEW_SRC" ]; then
          echo "uncommitted work in $WT — the engineer must commit it before this can be pushed:" >&2
          printf '%s\n' "$DIRTY" | head -20 >&2
          exit 1
        fi
        git push --quiet -u origin "$BR" || exit 1

        PR=$(gh pr view "$BR" --json number -q .number 2>/dev/null || true)
        if [ -z "$PR" ]; then
          TITLE=$(sed -n 's/^title: *//p' "$WT/$REL" | head -1)
          [ -n "$TITLE" ] || TITLE="$ID"
          gh pr create --base "trunk/$KEY" --head "$BR" \
            --title "$ID: $TITLE" \
            --body "Ticket: \`$REL\`

        Built by the feature delivery Loop. Base is the feature trunk \`trunk/$KEY\`, never your default branch." \
            >/dev/null || exit 1
          PR=$(gh pr view "$BR" --json number -q .number 2>/dev/null || true)
        fi
        [ -n "$PR" ] || { echo "could not resolve a PR number for $BR"; exit 1; }
        echo "pr=$PR" >&3

  # ── Watch the ticket PR ──────────────────────────────────────────────────────────────────────────
  # Polls until CI settles and a review bot (CodeRabbit, here, as a concrete example — swap the login
  # below or drop this block for whatever you use) has actually reviewed. New findings are appended
  # to the ticket file as unchecked BLOCKING lines, which is what routes them back to the engineer on
  # the next delivery round — and what the merge gate reads.
  - id: watch_pr
    type: task.loop
    config:
      maxIterations: 20
      until: prev.poll_pr.exitCode != 2

  - id: wait_pr
    type: task.wait
    config:
      duration: 90s

  - id: poll_pr
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 600
      command: |
        set -u
        PR=$(printf '%s' "{{push_pr.stdout}}" | sed -n 's/^pr=//p' | head -1)
        WT=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^worktree=//p' | head -1)
        REL=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^ticket_rel=//p' | head -1)
        KEY=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^key=//p' | head -1)
        [ -n "$PR" ] || { echo "no PR to watch"; exit 1; }
        [ -n "$KEY" ] || { echo "pick_ticket published no key"; exit 3; }
        cd "$WT" || exit 3
        [ -f "$REL" ] || { echo "ticket file missing: $WT/$REL"; exit 1; }
        REVIEW_BOT="coderabbitai[bot]"   # set to your review bot's login, or blank to skip this

        # `bucket` is gh's normalized verdict (pass|fail|pending|skipping|cancel). The fallback must
        # not be `||` either: `gh pr checks` exits non-zero merely because checks are PENDING, so a
        # naive `||` fallback fires on healthy runs too. Capture the output, then require a number —
        # an unreadable CI state stops the stage instead of being guessed at. Empty output (no check
        # reported yet) is the normal state for the first seconds after a PR opens — treat it as
        # pending, not a fatal read error.
        CHECKS=$(gh pr checks "$PR" --json bucket 2>/dev/null)
        if [ -z "$CHECKS" ]; then
          PENDING=1
          FAILED=0
        else
          PENDING=$(printf '%s' "$CHECKS" | jq '[.[] | select(.bucket=="pending")] | length' 2>/dev/null)
          FAILED=$(printf '%s' "$CHECKS" | jq '[.[] | select(.bucket=="fail" or .bucket=="cancel")] | length' 2>/dev/null)
          case "$PENDING" in ''|*[!0-9]*) echo "could not read CI state for PR $PR"; exit 3 ;; esac
          case "$FAILED" in ''|*[!0-9]*) echo "could not read CI state for PR $PR"; exit 3 ;; esac
        fi

        CR=NONE
        if [ -n "$REVIEW_BOT" ]; then
          # Page-safe: one line per review state, last line wins. An empty result (no reviews yet, or
          # a failed query) reads as NONE, which lands in the "still settling" branch below and is
          # retried next poll.
          CR=$(gh api "repos/{owner}/{repo}/pulls/$PR/reviews" --paginate \
            --jq ".[] | select(.user.login==\"$REVIEW_BOT\") | .state" 2>/dev/null | tail -1)
          [ -n "$CR" ] || CR=NONE
        fi

        if [ -n "$REVIEW_BOT" ]; then
          # The ticket keeps ONE LINE per finding, because the merge gate counts those boxes. The
          # full body goes in a sidecar next to the ticket — an engineer handed only a one-line
          # summary can't actually fix the finding, and going to fetch it from the PR needs an
          # approval nobody unattended can give.
          RVW="docs/specs/$KEY/reviews/$(basename "${REL%.md}").md"
          mkdir -p "docs/specs/$KEY/reviews"
          grep -q '^## Review findings' "$REL" 2>/dev/null || printf '\n## Review findings\n' >> "$REL"
          [ -f "$RVW" ] || printf '# Review findings — full text\n\nRecorded from the PR by the Loop. This file is the SOURCE for fixing; the ticket carries the checkboxes.\n' > "$RVW"

          CRJ=$(mktemp)
          gh api "repos/{owner}/{repo}/pulls/$PR/comments" --paginate \
            --jq ".[] | select(.user.login==\"$REVIEW_BOT\") | {id, path, line, body}" 2>/dev/null \
            | jq -s '.' > "$CRJ" 2>/dev/null || printf '[]' > "$CRJ"

          for id in $(jq -r '.[].id' "$CRJ" 2>/dev/null); do
            [ -n "$id" ] || continue
            grep -qF "review-bot#$id)" "$REL" && continue
            grep -qF "## review-bot#$id " "$RVW" 2>/dev/null && continue
            body=$(jq -r --argjson i "$id" '.[] | select(.id==$i) | .body' "$CRJ")
            loc=$(jq -r --argjson i "$id" '.[] | select(.id==$i) | "\(.path):\(.line // 0)"' "$CRJ")
            msg=$(printf '%s\n' "$body" | grep -vE '^\s*$|^<!--|^</?details>|^<summary>' | sed -n '2p')
            [ -n "$msg" ] || msg=$(printf '%s\n' "$body" | grep -vE '^\s*$|^<!--' | head -1)
            # SEVERITY FILTER: only a comment your bot itself banners as a real defect gates the
            # merge — everything else lands in the sidecar for the engineer to skim, not a blocking
            # checkbox. Tune the pattern to your bot's own vocabulary.
            if printf '%s\n' "$body" | head -5 | grep -qE 'Potential issue|Critical|Major'; then
              printf -- '- [ ] BLOCKING(review-bot#%s): %s — %s\n' "$id" "$loc" "$msg" >> "$REL"
              SEV=blocking
            else
              SEV=non-blocking
            fi
            {
              printf '\n---\n\n## review-bot#%s (%s) — %s\n\n' "$id" "$SEV" "$loc"
              printf '%s\n' "$body" | grep -v '^<!--' | head -150
            } >> "$RVW"
          done
          rm -f "$CRJ"

          git add "$REL" "$RVW" >/dev/null 2>&1 || true
          git diff --cached --quiet 2>/dev/null || git commit --quiet -m "chore: record review-bot findings on PR #$PR" || true
        fi

        OPEN=$(grep -cE '^- \[ \] BLOCKING' "$REL" 2>/dev/null || true)
        OPEN=${OPEN:-0}
        echo "pr=$PR ci_pending=$PENDING ci_failed=$FAILED review=$CR unresolved_blocking=$OPEN"

        # 1 = actionable now (back to the engineer). 2 = still settling (poll again). 0 = clean.
        if [ "$FAILED" -gt 0 ] || [ "$OPEN" -gt 0 ]; then exit 1; fi
        if [ "$PENDING" -gt 0 ] || { [ -n "$REVIEW_BOT" ] && [ "$CR" = "NONE" ]; }; then exit 2; fi
        exit 0

  - id: merge
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 600
      command: |
        set -u
        PR=$(printf '%s' "{{push_pr.stdout}}" | sed -n 's/^pr=//p' | head -1)
        WT=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^worktree=//p' | head -1)
        KEY=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^key=//p' | head -1)
        REL=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^ticket_rel=//p' | head -1)
        [ -n "$PR" ] || { echo "no PR to merge"; exit 1; }
        cd "$WT" || exit 3

        # Fail CLOSED. Without the existence check a missing ticket file makes grep fail, `|| true`
        # swallows it, OPEN defaults to 0 and this gate — the one thing standing between unresolved
        # review findings and a merge — silently passes.
        [ -f "$REL" ] || { echo "not merging: ticket file not found at $WT/$REL"; exit 3; }
        OPEN=$(grep -cE '^- \[ \] BLOCKING' "$REL" 2>/dev/null || true)
        OPEN=${OPEN:-0}
        case "$OPEN" in ''|*[!0-9]*) echo "not merging: could not count blocking findings"; exit 3 ;; esac
        [ "$OPEN" -eq 0 ] || { echo "not merging: $OPEN unresolved blocking findings"; exit 1; }

        # The hard rule — nothing here governs an interactive session, so it lives right here.
        BASE=$(gh pr view "$PR" --json baseRefName -q .baseRefName 2>/dev/null || echo "")
        [ "$BASE" = "trunk/$KEY" ] || { echo "REFUSING to merge: base is '$BASE', not 'trunk/$KEY'"; exit 3; }

        # No checks reported is NOT green — it means CI has not run, so there is nothing to merge on
        # the strength of.
        CHECKS=$(gh pr checks "$PR" --json bucket 2>/dev/null)
        if [ -z "$CHECKS" ]; then
          echo "not merging: no CI checks reported for PR $PR yet"; exit 1
        fi
        UNGREEN=$(printf '%s' "$CHECKS" | jq '[.[] | select(.bucket!="pass" and .bucket!="skipping")] | length' 2>/dev/null)
        case "$UNGREEN" in ''|*[!0-9]*) echo "not merging: could not read CI state for PR $PR"; exit 3 ;; esac
        [ "$UNGREEN" -eq 0 ] || { echo "not merging: CI not green ($UNGREEN checks unresolved)"; exit 1; }

        # A conflicting PR is NOT a failure to retry — it is work, so it goes into the same ledger
        # the engineer already reads (`## Review findings`), exactly like a review-bot finding, and
        # the next round resolves it. UNKNOWN means GitHub has not finished computing the merge state
        # yet — ask again a few times rather than concluding "no conflict" from a value that only
        # means "not yet".
        MERGEABLE=UNKNOWN
        for _ in 1 2 3; do
          MERGEABLE=$(gh pr view "$PR" --json mergeable -q .mergeable 2>/dev/null || echo UNKNOWN)
          [ "$MERGEABLE" = "UNKNOWN" ] || break
          sleep 3
        done
        if [ "$MERGEABLE" = "CONFLICTING" ]; then
          if ! grep -q '^- \[ \] BLOCKING(merge-conflict)' "$REL" 2>/dev/null; then
            grep -q '^## Review findings' "$REL" 2>/dev/null || printf '\n## Review findings\n' >> "$REL"
            {
              printf -- '- [ ] BLOCKING(merge-conflict): this branch conflicts with trunk/%s. ' "$KEY"
              printf -- 'From inside the ticket worktree: git fetch origin, then git merge origin/trunk/%s, ' "$KEY"
              printf -- 'resolve every conflict, commit the merge and push.\n'
            } >> "$REL"
            git add "$REL" >/dev/null 2>&1 || true
            git diff --cached --quiet 2>/dev/null || git commit --quiet -m "chore: record merge conflict with trunk/$KEY on PR #$PR"
            git push --quiet origin HEAD 2>/dev/null || true
          fi
          echo "not merging: PR #$PR conflicts with trunk/$KEY — recorded as a blocking finding for the engineer"
          exit 1
        fi

        # `--delete-branch` is deliberately OFF while this Loop is being proven — a squash merge
        # collapses the branch's history into one trunk commit, so deleting the ref afterwards makes
        # the original commits reachable only through the reflog. Turn it on once you trust the Loop
        # and stale branches start piling up.
        gh pr merge "$PR" --squash || exit 1
        echo "merged pr=$PR into trunk/$KEY"

  # The delivery loop's guard, and the only thing that counts as done: a merge commit that is an
  # ancestor of the trunk. `status: done` is a claim; this is the evidence. The bookkeeping is
  # committed in the TRUNK worktree — by now the ticket branch is merged, so there is no branch left
  # to carry it.
  - id: delivery_status
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 600
      command: |
        set -u
        PR=$(printf '%s' "{{push_pr.stdout}}" | sed -n 's/^pr=//p' | head -1)
        ID=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^id=//p' | head -1)
        KEY=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^key=//p' | head -1)
        WT=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^worktree=//p' | head -1)
        TWT=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^trunk_worktree=//p' | head -1)
        REL=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^ticket_rel=//p' | head -1)

        # Exit codes drive the delivery loop's guard: 1 = go round again, anything else stops it.
        #   0 the ticket is merged (the only definition of done)
        #   1 not merged, but this round moved the branch — keep going
        #   2 STALLED: two consecutive rounds with no new commit. Mark it needs_human and stop,
        #     rather than spending the remaining rounds (and an engineer call each) on a ticket that
        #     is not advancing.
        # Anchored to the TRUNK worktree, because that is where `recover` clears it. Refuse to key
        # the counter on an empty id — one shared bucket would let one ticket's stall mark a
        # different one needs_human.
        [ -n "$ID" ] || { echo "no ticket id from pick_ticket — cannot track stalls"; exit 3; }
        SFILE="$TWT/.claude/state/loop-$ID-stall.tmp"
        mkdir -p "$TWT/.claude/state" 2>/dev/null || true
        HEAD_NOW=$( [ -n "$WT" ] && cd "$WT" && git rev-parse HEAD 2>/dev/null || echo none )
        LAST=$(sed -n '1p' "$SFILE" 2>/dev/null || echo "")
        STALL=$(sed -n '2p' "$SFILE" 2>/dev/null || echo 0)
        case "$STALL" in ''|*[!0-9]*) STALL=0 ;; esac

        # ── Stale-output guard, before anything reads $PR ────────────────────────────────────────
        # push_pr/poll_pr outputs PERSIST across rounds and across TICKETS. Unguarded, ticket N+1's
        # first no-work round parses ticket N's merged pr=N and this stage marks the CURRENT ticket
        # done with the PREVIOUS ticket's merge commit. So: a PR number only counts if that PR's head
        # is THIS ticket's branch.
        BR=$(printf '%s' "{{pick_ticket.stdout}}" | sed -n 's/^branch=//p' | head -1)
        if [ -n "$PR" ]; then
          PRHEAD=$(gh pr view "$PR" --json headRefName -q .headRefName 2>/dev/null || echo "")
          if [ "$PRHEAD" != "$BR" ]; then
            echo "ignoring stale pr=$PR (head '$PRHEAD' is not this ticket's branch '$BR')"
            PR=""
          fi
        fi

        # A round that ended with the PR still settling (CI or the review bot pending when watch_pr
        # ran out of polls) produced no commit because none was ASKED for — counting it as a stall
        # marks a healthy ticket needs_human for having slow CI. poll_pr's last exit is 2 exactly in
        # that state, guarded further by requiring the head it polled to still be the branch head.
        SETTLING=0
        if [ -n "$PR" ] && [ "{{poll_pr.exitCode}}" = "2" ]; then
          echo "not counted as a stall — PR #$PR was still settling at the last poll"
          SETTLING=1
        fi

        stall_check() {  # called on every not-merged path; may exit 2
          if [ "$SETTLING" -eq 1 ]; then
            return 0
          fi
          if [ "$HEAD_NOW" = "$LAST" ] && [ "$HEAD_NOW" != "none" ]; then
            STALL=$((STALL + 1))
          else
            STALL=0
          fi
          printf '%s\n%s\n' "$HEAD_NOW" "$STALL" > "$SFILE"
          [ "$STALL" -lt 2 ] && return 0
          echo "STALLED: $STALL rounds with no new commit on $ID (HEAD still $HEAD_NOW)"
          if [ -n "$TWT" ] && [ -f "$TWT/$REL" ]; then
            WHY="no new commit for $STALL delivery rounds — the build agent is not advancing this ticket (out of tokens, a wedged tool, or a ticket that needs splitting)"
            ( cd "$TWT" \
              && perl -0pi -e 's/^status: *[a-z_]+/status: needs_human/m' "$REL" \
              && { grep -q '^why:' "$REL" \
                   && perl -0pi -e "s|^why:.*|why: $WHY|m" "$REL" \
                   || perl -0pi -e "s|^status: needs_human|status: needs_human\nwhy: $WHY|m" "$REL"; } \
              && { [ -z "$BR" ] || perl -0pi -e "s|^branch:.*|branch: '$BR'|m" "$REL"; } \
              && git add "$REL" \
              && { git diff --cached --quiet 2>/dev/null || git commit --quiet -m "chore($KEY): $ID needs_human — stalled"; } \
              && git push --quiet origin "HEAD:trunk/$KEY" ) 2>/dev/null \
              || echo "warning: could not record needs_human on the trunk"
          fi
          exit 2
        }

        [ -n "$PR" ] || { echo "no PR yet"; stall_check; exit 1; }

        STATE=$(gh pr view "$PR" --json state -q .state 2>/dev/null || echo UNKNOWN)
        MC=$(gh pr view "$PR" --json mergeCommit -q '.mergeCommit.oid // ""' 2>/dev/null || echo "")
        if [ "$STATE" != "MERGED" ] || [ -z "$MC" ]; then
          echo "pr=$PR state=$STATE — not merged"
          stall_check
          exit 1
        fi

        git fetch --quiet origin "trunk/$KEY" || true
        if ! git merge-base --is-ancestor "$MC" "origin/trunk/$KEY" 2>/dev/null; then
          echo "pr=$PR claims merged as $MC but it is not an ancestor of origin/trunk/$KEY"
          stall_check
          exit 1
        fi

        # Merged for real — this attempt is over, so its stall counter goes with it.
        rm -f "$SFILE" 2>/dev/null || true
        cd "$TWT" || exit 3
        git pull --ff-only --quiet origin "trunk/$KEY" || true
        [ -f "$REL" ] || { echo "ticket file missing on the trunk: $TWT/$REL"; exit 1; }
        # Any status, not just in_progress — a ticket merged from needs_human (or reset to todo by
        # `recover`) would otherwise keep its old status, and because the insertion below anchors on
        # `^status: done`, the merge evidence would be dropped too.
        perl -0pi -e 's/^status: *[a-z_]+/status: done/m' "$REL"
        if grep -qE '^merge_commit:' "$REL"; then
          perl -0pi -e "s|^merge_commit:.*|merge_commit: $MC|m" "$REL"
        else
          perl -0pi -e "s|^status: done|status: done\nmerge_commit: $MC|m" "$REL"
        fi
        git add "$REL"
        git diff --cached --quiet 2>/dev/null || \
          git commit --quiet -m "chore($KEY): mark $(basename "$REL" .md) done, merged as $MC"
        git push --quiet origin "HEAD:trunk/$KEY" || echo "warning: could not push the bookkeeping commit"

        # The ticket worktree has served its purpose; leaving it confuses the next ticket's resume.
        git worktree remove --force "$WT" 2>/dev/null || true
        echo "done merge_commit=$MC"

  # ── Hard stop on a blocked feature ───────────────────────────────────────────────────────────────
  # pick_ticket exit 2 means the next ticket in rank order is needs_human — the feature pauses HERE.
  # Without this stage the pass would carry on regardless: trunk polling and ticket transcription
  # would still run, the brain would answer `continue`, and every remaining pass would repeat the
  # same bookkeeping. A blocked feature is a HUMAN gate, so the run stops here, now, with the
  # diagnosis where a human will look.
  #
  # NO captureExit, exactly like bootstrap: this exit is environmental to the run — there is nothing
  # for the brain to correct.
  - id: check_blocked
    type: task.command
    config:
      timeoutSeconds: 60
      command: |
        set -u
        # 0 = a ticket was picked (loop exhausted its rounds), 1 = every ticket is done — both
        # proceed to the trunk. 2 = needs_human at the front of rank order. 3 = pick_ticket itself
        # refused (an unsafe ticket id, a worktree it would not adopt, a merge-abort that failed) —
        # equally environmental, equally a human's to clear.
        case "{{pick_ticket.exitCode}}" in
          2)
            echo "FEATURE BLOCKED: the next ticket in rank order is needs_human." >&2
            echo "Its frontmatter carries the diagnosis in 'why'. Fix or split it, then re-run." >&2
            exit 2
            ;;
          3)
            echo "FEATURE BLOCKED: pick_ticket refused to proceed — its stderr above has the reason." >&2
            exit 3
            ;;
        esac
        echo "not blocked — proceeding to the trunk"

  # ── The trunk ────────────────────────────────────────────────────────────────────────────────────
  # ── The feature's shop window ────────────────────────────────────────────────────────────────────
  # Refreshes the trunk PR's body with a live tickets table. Runs once per foreach iteration — after
  # delivery_loop concludes (merged, stalled, or rounds exhausted) — which is exactly when a ticket's
  # status changes on the trunk. Purely cosmetic: every failure path exits 0, because a body refresh
  # must never cost a delivery round.
  - id: refresh_table
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 120
      command: |
        set -u
        KEY=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^key=//p' | head -1)
        SPEC=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^spec=//p' | head -1)
        TWT=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^trunk_worktree=//p' | head -1)
        [ -n "$KEY" ] && [ -n "$TWT" ] || { echo "bootstrap published no key/trunk_worktree" >&2; exit 0; }
        cd "$TWT" 2>/dev/null || exit 0
        PR=$(gh pr view "trunk/$KEY" --json number -q .number 2>/dev/null || true)
        [ -n "$PR" ] || { echo "no trunk PR yet — nothing to refresh" >&2; exit 0; }
        T="docs/specs/$KEY/tickets"
        TB=$(mktemp)
        {
          printf 'Feature trunk for `%s`. Spec: `%s`.\n\n' "$KEY" "$SPEC"
          printf 'Built by the feature delivery Loop, one ticket at a time. **This PR is for human review and human merge.**\n\n'
          printf '### Tickets\n\n'
          printf '| Rank | Ticket | Status |\n'
          printf '| --- | --- | --- |\n'
          for f in "$T"/*.md; do
            [ -f "$f" ] || continue
            grep -q '^id: ' "$f" || continue
            RK=$(sed -n 's/^rank: *//p' "$f" | head -1 | tr -d "\"'")
            TID=$(sed -n 's/^id: *//p' "$f" | head -1 | tr -d "\"'")
            TTL=$(sed -n 's/^title: *//p' "$f" | head -1 | tr -d "\"'" | tr '|' '-' | cut -c1-80)
            TST=$(sed -n 's/^status: *//p' "$f" | head -1 | tr -d "\"'")
            case "$TST" in
              done)        EM="done" ;;
              in_progress) EM="in progress" ;;
              needs_human) EM="needs human" ;;
              dropped)     EM="dropped" ;;
              superseded)  EM="superseded" ;;
              *)           EM="todo" ;;
            esac
            printf '| %s | `%s` — %s | %s |\n' "${RK:-?}" "$TID" "$TTL" "$EM"
          done
          printf '\n_Table refreshed automatically after every ticket round._\n'
        } > "$TB"
        gh api -X PATCH "repos/{owner}/{repo}/pulls/$PR" -F "body=@$TB" >/dev/null 2>&1 || echo "warning: could not refresh trunk PR #$PR body" >&2
        rm -f "$TB"
        exit 0

  # Opened as a DRAFT before any ticket is built, and refreshed at the top of every pass. Without
  # this the trunk PR only appears after the whole ticket loop finishes, so a run that is working
  # correctly looks identical to one that is wedged. It stays a DRAFT: marking it ready is a human
  # decision, and a draft cannot be merged by accident. This stage never fails the run — the PR is
  # observability, and losing it must not stop delivery.
  - id: trunk_draft
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 600
      command: |
        set -u
        exec 3>&1 1>&2
        KEY=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^key=//p' | head -1)
        SPEC=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^spec=//p' | head -1)
        TWT=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^trunk_worktree=//p' | head -1)
        [ -n "$KEY" ] && [ -d "$TWT" ] || { echo "no trunk worktree — skipping the draft PR"; exit 0; }
        cd "$TWT" || exit 0

        git push --quiet -u origin "HEAD:trunk/$KEY" 2>/dev/null || true

        BODY=$(mktemp)
        {
          printf 'Feature trunk for `%s`. Spec: `%s`.\n\n' "$KEY" "$SPEC"
          printf 'Built by the feature delivery Loop, one ticket at a time.\n'
          printf '**Draft on purpose** — this is for human review and human merge; the Loop never marks it ready.\n\n'
          printf '## Tickets\n\n'
          printf '| Ticket | Status | Merged into trunk | ACs |\n| --- | --- | --- | --- |\n'
          for f in "docs/specs/$KEY/tickets"/*.md; do
            [ -f "$f" ] || continue
            grep -q '^id: ' "$f" || continue
            ST=$(sed -n 's/^status: *//p' "$f" | head -1 | tr -d "\"'")
            MC=$(sed -n 's/^merge_commit: *//p' "$f" | head -1 | tr -d "\"'")
            AC=$(sed -n 's/^acs: *//p' "$f" | head -1)
            OPEN=$(grep -c '^- \[ \] BLOCKING' "$f" 2>/dev/null || true)
            case "$OPEN" in ''|*[!0-9]*) OPEN=0 ;; esac
            if [ -n "$MC" ] && git merge-base --is-ancestor "$MC" HEAD 2>/dev/null; then
              MERGED="\`$MC\`"
            else
              MERGED="—"
            fi
            [ "$OPEN" -eq 0 ] || ST="$ST ($OPEN open)"
            printf '| %s | %s | %s | %s |\n' "$(basename "$f" .md)" "$ST" "$MERGED" "${AC:-[]}"
          done
          printf '\nRefreshed at the start of every delivery pass.\n'
        } > "$BODY"

        PR=$(gh pr view "trunk/$KEY" --json number -q .number 2>/dev/null || true)
        if [ -z "$PR" ]; then
          CREATE_ERR=$(gh pr create --draft --base main --head "trunk/$KEY" \
            --title "$KEY" --body-file "$BODY" 2>&1 >/dev/null) || true
          PR=$(gh pr view "trunk/$KEY" --json number -q .number 2>/dev/null || true)
          [ -n "$PR" ] || echo "no draft trunk PR yet: ${CREATE_ERR:-nothing merged into trunk/$KEY so far}" >&2
        else
          gh pr edit "$PR" --body-file "$BODY" >/dev/null 2>&1 || echo "warning: could not refresh the draft PR body"
        fi
        rm -f "$BODY"
        [ -z "$PR" ] || echo "trunk_draft_pr=$PR" >&3
        exit 0

  - id: trunk_pr
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 600
      command: |
        set -u
        exec 3>&1 1>&2
        KEY=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^key=//p' | head -1)
        SPEC=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^spec=//p' | head -1)
        TWT=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^trunk_worktree=//p' | head -1)
        [ -n "$KEY" ] && [ -n "$TWT" ] || { echo "bootstrap published no key/trunk_worktree"; exit 3; }
        cd "$TWT" || exit 3

        # Keep the trunk mergeable into your default branch, which moves while a feature is being
        # built. The clean case is mechanical and worth doing automatically; the conflicted case is
        # NOT — it is resolved against `main`, which this Loop is never allowed to write to, so it
        # aborts, leaves the trunk untouched, and reports (`poll_trunk` records it for a human).
        GITDIR=$(git rev-parse --git-dir 2>/dev/null || echo "")
        if [ -n "$(git status --porcelain 2>/dev/null)" ] || { [ -n "$GITDIR" ] && [ -f "$GITDIR/MERGE_HEAD" ]; }; then
          echo "trunk_sync: skipped — $TWT has uncommitted work or a merge already in progress" >&2
        else
          git fetch --quiet origin main || true
          if ! git merge-base --is-ancestor origin/main HEAD 2>/dev/null; then
            if git merge --no-edit --quiet origin/main 2>/dev/null; then
              echo "trunk_sync: merged origin/main into trunk/$KEY cleanly" >&2
            else
              CONFLICTS=$(git diff --name-only --diff-filter=U 2>/dev/null | head -10)
              if [ -n "$GITDIR" ] && [ -f "$GITDIR/MERGE_HEAD" ]; then
                git merge --abort 2>/dev/null \
                  || echo "trunk_sync: WARNING merge --abort failed — $TWT is left mid-merge"
              fi
              if [ -n "$GITDIR" ] && [ -f "$GITDIR/MERGE_HEAD" ]; then
                echo "trunk_sync: WARNING $TWT still holds an unresolved merge — a human must clear it" >&2
              else
                echo "trunk_sync: CONFLICT merging origin/main into trunk/$KEY — trunk left untouched" >&2
              fi
              [ -z "$CONFLICTS" ] || printf '%s\n' "$CONFLICTS" | sed 's/^/  /' >&2
            fi
          fi
        fi

        git push --quiet origin "HEAD:trunk/$KEY" || true

        PR=$(gh pr view "trunk/$KEY" --json number -q .number 2>/dev/null || true)
        if [ -z "$PR" ]; then
          CREATE_ERR=$(gh pr create --draft --base main --head "trunk/$KEY" \
            --title "$KEY" \
            --body "Feature trunk for \`$KEY\`. Spec: \`$SPEC\`.

        Built by the feature delivery Loop, one ticket at a time. **This PR is for human review and human merge.**" \
            2>&1 >/dev/null) || true
          PR=$(gh pr view "trunk/$KEY" --json number -q .number 2>/dev/null || true)
          [ -n "$PR" ] || [ -z "$CREATE_ERR" ] || printf 'gh pr create failed: %s\n' "$CREATE_ERR" >&2
        fi
        [ -n "$PR" ] || { echo "no trunk PR for trunk/$KEY — see any gh error above; otherwise nothing has been merged into the trunk yet"; exit 1; }

        # Auto-ready when the feature is complete: every ticket file under the spec is
        # `status: done`. The draft exists so an IN-FLIGHT feature cannot be merged by accident; once
        # nothing is left to build, the PR is ready for human review. Readiness is judged on
        # PUBLISHED state, not the local claim — the push above is best-effort and can fail silently
        # — so the flip requires the local trunk to actually BE the published trunk, plus each
        # ticket's proof of completion. Anything unprovable skips the flip for this pass, never marks
        # ready on unpublished state.
        T="docs/specs/$KEY/tickets"
        READY_EVIDENCE=1
        git fetch --quiet origin "trunk/$KEY" || READY_EVIDENCE=0
        PUB=$(git rev-parse --verify "origin/trunk/$KEY" 2>/dev/null || echo "")
        { [ -n "$PUB" ] && [ "$(git rev-parse HEAD)" = "$PUB" ]; } || READY_EVIDENCE=0
        TOTAL=0; NOT_DONE=0
        for f in "$T"/*.md; do
          [ -f "$f" ] || continue
          grep -q '^id: ' "$f" || continue
          TOTAL=$((TOTAL+1))
          ST=$(sed -n 's/^status: *//p' "$f" | head -1 | tr -d "\"'")
          MC=$(sed -n 's/^merge_commit: *//p' "$f" | head -1 | tr -d "\"'")
          if [ "$ST" = "done" ] && [ -n "$MC" ] && git merge-base --is-ancestor "$MC" "origin/trunk/$KEY" 2>/dev/null; then
            :
          else
            NOT_DONE=$((NOT_DONE+1))
          fi
        done
        IS_DRAFT=$(gh pr view "$PR" --json isDraft -q .isDraft 2>/dev/null || echo "")
        [ -n "$IS_DRAFT" ] || echo "trunk_pr: WARNING could not read draft state for PR #$PR — auto-ready skipped this pass"
        if [ "$READY_EVIDENCE" -eq 1 ] && [ "$TOTAL" -gt 0 ] && [ "$NOT_DONE" -eq 0 ] && [ "$IS_DRAFT" = "true" ]; then
          if gh pr ready "$PR"; then
            echo "trunk_pr: all $TOTAL tickets done and published — marked trunk PR #$PR ready for review"
          else
            echo "trunk_pr: WARNING could not mark PR #$PR ready — it stays a draft"
          fi
        elif [ "$READY_EVIDENCE" -eq 0 ] && [ "$TOTAL" -gt 0 ] && [ "$NOT_DONE" -eq 0 ]; then
          echo "trunk_pr: auto-ready skipped — local trunk differs from origin/trunk/$KEY or fetch failed; retrying next pass"
        fi
        echo "trunk_pr=$PR trunk_worktree=$TWT" >&3

  # Same shape as the ticket PR watch. Feedback here does NOT get fixed in place — it becomes new
  # ticket files, which the next pass builds through the same pipeline. This Loop never merges the
  # trunk PR; that is a human's call.
  - id: watch_trunk
    type: task.loop
    config:
      maxIterations: 20
      until: prev.poll_trunk.exitCode != 2

  - id: wait_trunk
    type: task.wait
    config:
      duration: 120s

  - id: poll_trunk
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 600
      command: |
        set -u
        KEY=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^key=//p' | head -1)
        TWT=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^trunk_worktree=//p' | head -1)
        [ -n "$KEY" ] && [ -n "$TWT" ] || { echo "bootstrap published no key/trunk_worktree"; exit 3; }
        PR=$(printf '%s' "{{trunk_pr.stdout}}" | sed -n 's/^trunk_pr=//p' | head -1 | cut -d' ' -f1)
        [ -n "$PR" ] || { echo "no trunk PR to watch"; exit 1; }
        cd "$TWT" || exit 3
        FEEDBACK="docs/specs/$KEY/trunk-feedback.md"
        REVIEW_BOT="coderabbitai[bot]"   # set to your review bot's login, or blank to skip this

        CHECKS=$(gh pr checks "$PR" --json bucket 2>/dev/null)
        if [ -z "$CHECKS" ]; then
          PENDING=1
          FAILED=0
        else
          PENDING=$(printf '%s' "$CHECKS" | jq '[.[] | select(.bucket=="pending")] | length' 2>/dev/null)
          FAILED=$(printf '%s' "$CHECKS" | jq '[.[] | select(.bucket=="fail" or .bucket=="cancel")] | length' 2>/dev/null)
          case "$PENDING" in ''|*[!0-9]*) echo "could not read CI state for PR $PR"; exit 3 ;; esac
          case "$FAILED" in ''|*[!0-9]*) echo "could not read CI state for PR $PR"; exit 3 ;; esac
        fi
        CR=NONE
        if [ -n "$REVIEW_BOT" ]; then
          CR=$(gh api "repos/{owner}/{repo}/pulls/$PR/reviews" --paginate \
            --jq "[.[] | select(.user.login==\"$REVIEW_BOT\")] | last | .state // \"NONE\"" 2>/dev/null || echo NONE)
        fi
        # UNKNOWN means GitHub has not finished computing the merge state — not that the trunk is
        # clean. That matters more here than on a ticket, because this stage feeds the brain's
        # completeness verdict: an uncomputed answer must not let a genuinely conflicted trunk be
        # reported as done.
        MERGEABLE=UNKNOWN
        for _ in 1 2 3; do
          MERGEABLE=$(gh pr view "$PR" --json mergeable -q .mergeable 2>/dev/null || echo UNKNOWN)
          [ "$MERGEABLE" = "UNKNOWN" ] || break
          sleep 3
        done

        [ -f "$FEEDBACK" ] || printf '# Trunk feedback — %s\n\nUnresolved items become tickets. Tick a box only when a merged ticket has addressed it.\nA box rewritten to `- [~]` is human-owned — reported to the brain, not counted as open.\n\n' "$KEY" > "$FEEDBACK"

        # A trunk that cannot merge into your default branch is not "done", however green its CI is.
        # `trunk_pr` already tried the clean merge; reaching here means it genuinely conflicts, which
        # needs a human — resolving it means writing against `main`, which this Loop never does.
        if [ "$MERGEABLE" = "CONFLICTING" ]; then
          grep -q '^- \[ \] trunk conflicts with main' "$FEEDBACK" 2>/dev/null || \
            printf -- '- [ ] trunk conflicts with main — merge origin/main into trunk/%s and resolve by hand; a ticket cannot fix this, it branches off the trunk\n' "$KEY" >> "$FEEDBACK"
        fi
        if [ -n "$REVIEW_BOT" ]; then
          CRT=$(mktemp)
          gh api "repos/{owner}/{repo}/pulls/$PR/comments" --paginate \
            --jq ".[] | select(.user.login==\"$REVIEW_BOT\") | {id, path, line, body}" 2>/dev/null \
            | jq -s '.' > "$CRT" 2>/dev/null || printf '[]' > "$CRT"
          for id in $(jq -r '.[].id' "$CRT" 2>/dev/null); do
            [ -n "$id" ] || continue
            grep -qF "review-bot#$id:" "$FEEDBACK" && continue
            body=$(jq -r --argjson i "$id" '.[] | select(.id==$i) | .body' "$CRT")
            printf '%s\n' "$body" | head -5 | grep -qE 'Potential issue|Critical|Major' || continue
            loc=$(jq -r --argjson i "$id" '.[] | select(.id==$i) | "\(.path):\(.line // 0)"' "$CRT")
            msg=$(printf '%s\n' "$body" | grep -vE '^\s*$|^<!--|^</?details>|^<summary>' | sed -n '2p')
            [ -n "$msg" ] || msg=$(printf '%s\n' "$body" | grep -vE '^\s*$|^<!--' | head -1)
            printf -- '- [ ] review-bot#%s: %s — %s\n' "$id" "$loc" "$msg" >> "$FEEDBACK"
          done
          rm -f "$CRT"
        fi
        if [ "$FAILED" -gt 0 ]; then
          gh pr checks "$PR" --json name,bucket \
            --jq '.[] | select(.bucket=="fail" or .bucket=="cancel") | "ci:\(.name)"' 2>/dev/null \
          | while read -r chk; do
              grep -qF -- "$chk" "$FEEDBACK" || printf -- '- [ ] %s failed on the trunk PR\n' "$chk" >> "$FEEDBACK"
            done
        fi

        # ── Close feedback that reality has closed ───────────────────────────────────────────────
        # Nothing else ever ticks these boxes: without this, open_feedback stays > 0 forever and the
        # brain's "no unresolved reviewer feedback" condition can never pass. Three mechanical
        # closers, each keyed to evidence: the conflict marker (trunk is mergeable again), a
        # `ci:<name>` item (that check now passes), a `review-bot#<id>` item (a `done` ticket with
        # merge evidence quotes it). [ ~] in the class: tick the conflict marker even if it was
        # triaged human-owned.
        if [ "$MERGEABLE" = "MERGEABLE" ]; then
          perl -0pi -e 's/^- \[[ ~]\] (trunk conflicts with main)/- [x] $1/m' "$FEEDBACK" 2>/dev/null || true
        fi
        if [ -n "$CHECKS" ]; then
          CHK=$(mktemp)
          gh pr checks "$PR" --json name,bucket > "$CHK" 2>/dev/null || printf '[]' > "$CHK"
          grep -E '^- \[ \] ci:' "$FEEDBACK" 2>/dev/null | sed 's/^- \[ \] ci://; s/ failed on the trunk PR$//' \
          | while IFS= read -r name; do
              [ -n "$name" ] || continue
              B=$(jq -r --arg n "$name" '[.[] | select(.name==$n)][0].bucket // ""' "$CHK" 2>/dev/null)
              if [ "$B" = "pass" ] || [ "$B" = "skipping" ]; then
                CHKNAME="$name" perl -0pi -e 's/^- \[ \]( ci:\Q$ENV{CHKNAME}\E failed on the trunk PR)$/- [x]$1/m' "$FEEDBACK" 2>/dev/null || true
              fi
            done
          rm -f "$CHK"
        fi
        T="docs/specs/$KEY/tickets"
        grep -oE '^- \[ \] review-bot#[0-9]+' "$FEEDBACK" 2>/dev/null | grep -oE '[0-9]+$' \
        | while IFS= read -r cid; do
            [ -n "$cid" ] || continue
            hit=$(grep -lE "review-bot#$cid[):]" "$T"/*.md 2>/dev/null | head -1)
            [ -n "$hit" ] || continue
            ST=$(sed -n 's/^status: *//p' "$hit" | head -1 | tr -d "\"'")
            MC=$(sed -n 's/^merge_commit: *//p' "$hit" | head -1 | tr -d "\"'")
            [ "$ST" = "done" ] && [ -n "$MC" ] && git merge-base --is-ancestor "$MC" HEAD 2>/dev/null || continue
            CID="$cid" perl -0pi -e 's/^- \[ \]( review-bot#$ENV{CID}:)/- [x]$1/m' "$FEEDBACK" 2>/dev/null || true
          done

        git add "$FEEDBACK" >/dev/null 2>&1 || true
        git diff --cached --quiet 2>/dev/null || {
          git commit --quiet -m "chore($KEY): record trunk PR feedback"
          git push --quiet origin "HEAD:trunk/$KEY" || true
        }

        # `- [~]` items are human-owned: reported so the brain sees them, NOT counted as open —
        # otherwise an infrastructure item nothing in this pipeline can fix holds the feature open
        # forever.
        OPEN=$(grep -cE '^- \[ \] ' "$FEEDBACK" 2>/dev/null || true)
        OPEN=${OPEN:-0}
        HUMAN=$(grep -cE '^- \[~\] ' "$FEEDBACK" 2>/dev/null || true)
        HUMAN=${HUMAN:-0}
        echo "trunk_pr=$PR ci_pending=$PENDING ci_failed=$FAILED review=$CR mergeable=$MERGEABLE open_feedback=$OPEN human_owned=$HUMAN feedback_file=$FEEDBACK"

        if [ "$FAILED" -gt 0 ] || [ "$OPEN" -gt 0 ]; then exit 1; fi
        if [ "$PENDING" -gt 0 ] || { [ -n "$REVIEW_BOT" ] && [ "$CR" = "NONE" ]; } || [ "$MERGEABLE" = "UNKNOWN" ]; then exit 2; fi
        exit 0

  - id: trunk_tickets
    # Transcribing feedback into tickets is deferrable work: if this agent dies, the feedback file is
    # still on the trunk and the next pass (or run) transcribes it. `feature_status` and the brain
    # still run either way — the brain sees the error in this stage's output.
    onError: continue
    type: agent.run
    config:
      access: full
      scope: workflow-folder
      allowTools: *agent_tools
      agent: ticket-breakdown
      # Deliberately a lighter model than this agent's own default for a full breakdown — this is a
      # much narrower job (transcribe settled feedback into ticket files) and it runs once per pass.
      model: claude-sonnet-5
      effort: medium
      timeoutSeconds: 600
      decision:
        type: object
        properties:
          tickets_created: { type: number }
          why: { type: string }
        required: [tickets_created, why]
      prompt: |
        Turn unresolved trunk feedback into tickets. Do not fix anything yourself.

        {{poll_trunk.stdout}}
        {{bootstrap.stdout}}

        Work inside the trunk worktree and stay there. Read the feedback file named above. Every
        unticked `- [ ]` item that is not already covered by an existing ticket becomes a new ticket
        file in the same tickets directory, `status: todo`, ranked AFTER the highest existing rank,
        with the feedback item quoted in the body and a `reviewers` list matching what it touches.
        Commit and push them to the trunk. The next pass of this Loop will build them.

        The feedback file is the whole input. **Do not go to your git host to read the PR** — you are
        running unattended, fetching a URL needs an approval nobody is there to give, and the step
        dies on the refusal rather than on the work.

        Two kinds of item are not yours to ticket. The `trunk conflicts with main` marker: leave it
        exactly as it is — a ticket cannot fix it (resolving it means writing against main, which
        this pipeline never does), and it must keep blocking completion until a human clears it.
        Infrastructure failures that no code on this trunk caused: mark each one human-owned by
        rewriting its `- [ ]` to `- [~]` in the feedback file and committing that edit, then name it
        in `why` — `poll_trunk` reports `- [~]` items to the brain without counting them as open.

        If there is no unresolved feedback, create nothing and return `tickets_created: 0`. Inventing
        work here is worse than doing nothing — it makes the Loop never finish.

  # Everything the brain judges from, printed from the repo rather than summarised by a model. The
  # brain has no tools; this is its evidence.
  - id: feature_status
    type: task.command
    config:
      captureExit: true
      timeoutSeconds: 300
      command: |
        set -u
        KEY=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^key=//p' | head -1)
        SPEC=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^spec=//p' | head -1)
        TWT=$(printf '%s' "{{bootstrap.stdout}}" | sed -n 's/^trunk_worktree=//p' | head -1)
        [ -n "$KEY" ] && [ -n "$TWT" ] || { echo "bootstrap published no key/trunk_worktree"; exit 3; }
        cd "$TWT" || exit 3
        git pull --ff-only --quiet origin "trunk/$KEY" 2>/dev/null || true
        T="docs/specs/$KEY/tickets"

        echo "=== feature: $KEY ==="
        echo
        echo "--- acceptance criteria (from $SPEC) ---"
        awk '/^#+ *Acceptance criteria/{f=1;next} f && /^#+ /{exit} f' "$SPEC" | head -80
        echo
        echo "--- tickets ---"
        printf '%-34s %-13s %-10s %s\n' FILE STATUS MERGED ACS
        for f in "$T"/*.md; do
          [ -f "$f" ] || continue
          grep -q '^id: ' "$f" || continue
          ST=$(sed -n 's/^status: *//p' "$f" | head -1 | tr -d "\"'")
          MC=$(sed -n 's/^merge_commit: *//p' "$f" | head -1 | tr -d "\"'")
          ACS=$(sed -n 's/^acs: *//p' "$f" | head -1)
          PROOF="-"
          if [ -n "$MC" ]; then
            if git merge-base --is-ancestor "$MC" "origin/trunk/$KEY" 2>/dev/null; then PROOF="yes"; else PROOF="CLAIMED"; fi
          fi
          printf '%-34s %-13s %-10s %s\n' "$(basename "$f")" "${ST:-?}" "$PROOF" "${ACS:-}"
        done
        echo
        echo "(MERGED=CLAIMED means the ticket says done but its merge_commit is NOT an ancestor of the trunk — it is not done.)"
        echo
        echo "--- unresolved blocking findings ---"
        grep -HcE '^- \[ \] BLOCKING' "$T"/*.md 2>/dev/null | grep -v ':0$' || echo "none"
        echo
        echo "--- trunk ---"
        echo "{{trunk_pr.stdout}}"
        echo "{{poll_trunk.stdout}}"
        echo
        echo "--- trunk feedback still open ---"
        grep -E '^- \[ \] ' "docs/specs/$KEY/trunk-feedback.md" 2>/dev/null | head -30 || echo "none"

edges:
  - { from: input, to: bootstrap }
  - { from: bootstrap, to: recover }
  - { from: recover, to: preflight }
  - { from: preflight, to: tickets }
  - { from: tickets, to: trunk_draft }
  - { from: trunk_draft, to: list_pending }
  - { from: list_pending, to: ticket_loop }

  # one ticket per foreach iteration — the app shows the item (ticket filename) and n-of-N
  - { from: ticket_loop, to: pick_ticket, when: loop }
  - { from: pick_ticket, to: route_ticket }
  - { from: route_ticket, to: tests_check, when: work }
  # `when: idle` matches no edge on purpose — the round ends and the guard stops the loop.
  - { from: tests_check, to: route_tests }
  - { from: route_tests, to: test_author, when: author }
  # Tests already exist and nothing is open against them — straight to running them. `verify_tests`
  # runs on BOTH paths, so skipping the author never means skipping the evidence.
  - { from: route_tests, to: verify_tests, when: have }
  - { from: test_author, to: verify_tests }
  - { from: verify_tests, to: delivery_loop }

  # engineering -> PR feedback, until the ticket is merged
  - { from: delivery_loop, to: engineer, when: loop }
  # engineer -> is there a diff? -> the gate, or straight to the stall detector. Exclusive branch
  # labels, so exactly one path reconverges on delivery_status.
  - { from: engineer, to: work_check }
  - { from: work_check, to: route_work }
  - { from: route_work, to: gates, when: review }
  - { from: route_work, to: delivery_status, when: nowork }

  # gates BEFORE the critics, deliberately: the critics reading code that does not compile is spend
  # with no possible finding worth having, and a red gate ends the round here so the engineer gets
  # the output next round instead of three critics reading a build that was never going to pass.
  - { from: gates, to: route_gates }
  - { from: route_gates, to: compute_diff, when: green }
  - { from: route_gates, to: delivery_status, when: red }

  # the diff is computed ONCE, then handed to every critic that needs it
  - { from: compute_diff, to: review_loop }

  # engineer <-> internal critics. Exclusive branch labels, so exactly one path reconverges — a node
  # runs on its FIRST enqueue and never waits for a second predecessor.
  - { from: review_loop, to: review_correctness, when: loop }
  - { from: review_correctness, to: route_security }
  - { from: route_security, to: review_security, when: run }
  - { from: route_security, to: route_spec, when: skip }
  - { from: review_security, to: route_spec }
  - { from: route_spec, to: review_spec, when: run }
  - { from: route_spec, to: collect, when: skip }
  - { from: review_spec, to: collect }

  # …then the gate, the PR, and the merge
  # `done` fires when the loop's guard is satisfied OR when it runs out of iterations — and with
  # maxIterations 1 the second case is the NORMAL one the moment a critic finds anything. Branch on
  # the finding count rather than going straight to push_pr, which would open a PR carrying unchecked
  # BLOCKING findings — spending a CI run on work the merge gate would refuse anyway.
  - { from: review_loop, to: route_findings, when: done }
  - { from: route_findings, to: push_pr, when: clean }
  - { from: route_findings, to: delivery_status, when: findings }
  - { from: push_pr, to: watch_pr }
  - { from: watch_pr, to: wait_pr, when: loop }
  - { from: wait_pr, to: poll_pr }
  - { from: watch_pr, to: merge, when: done }
  - { from: merge, to: delivery_status }
  # after each ticket's delivery concludes (merged / stalled / rounds exhausted), refresh the trunk
  # PR's live tickets table — the last node of the foreach body for that iteration
  - { from: delivery_loop, to: refresh_table, when: done }

  # the trunk, once every ticket is built — via the needs_human hard stop
  - { from: ticket_loop, to: check_blocked, when: done }
  - { from: check_blocked, to: trunk_pr }
  - { from: trunk_pr, to: watch_trunk }
  - { from: watch_trunk, to: wait_trunk, when: loop }
  - { from: wait_trunk, to: poll_trunk }
  - { from: watch_trunk, to: trunk_tickets, when: done }
  - { from: trunk_tickets, to: feature_status }
  - { from: feature_status, to: brain }