---
name: execution-batch
description: "The batch driver loop — resolve+freeze a task set, topologically order by dependencies, run each task's pipeline in order by default or fan out a proven-independent subset on request, inspect terminal verdicts, decide continue/halt, and emit a structured batch report. Owns R1–R5 for batch execution; per-task execution reuses task-pipeline.yaml verbatim (ADR-022: orchestration is a loop in the skill, not a new FSM)."
see_also:
  - spur-dev
  - execution-workflow
  - dev-operations
---

# Execution Batch

`/sp:dev-runall` runs a **set** of task files through their pipelines in one operation, in
dependency-correct order. This file owns the batch algorithm: selector resolution, set freeze,
topological ordering, the per-task run loop, optional parallel fan-out, the failure policy, and the
report shape.

Single-task execution is documented in **[execution-workflow.md](execution-workflow.md)** — this file
extends that procedure to the batch case. Read that file first for the single-task pipeline contract;
everything here assumes a task runs through `task-pipeline.yaml` unchanged.

**Zero engine code, zero schema changes (ADR-022).** The batch is orchestration over existing seams —
the status vocabulary (`packages/domain/src/planning/schema.ts`), the `dependencies[]` frontmatter
field, and the per-task task-pipeline driver. Per ADR-022 ("orchestration is configuration / loops in
the skill"), the batch driver is a loop in the host session for interactive sequential omit/inline,
or in `sp:super-planner` for explicit/parallel subprocess execution — never a new meta-workflow FSM.
HITL surfacing, per-task verdict inspection, and continue/halt decisions need judgment between runs
that a flat FSM cannot express.

```
/sp:dev-runall ─┬─ interactive sequential omit/inline → host batch loop → inline YAML driver (task N)
                └─ explicit/parallel/headless → sp:super-planner → spur workflow run (task N)
                                                                       └─ agent.run spawns vars.agent
```

The active batch orchestrator owns the spaces **between** task runs: resolve+freeze the set,
topo-sort, run each task's pipeline, inspect terminal state, decide continue/halt, and emit the
report. It does not redefine individual steps. Interactive sequential omit/inline delegates each
ready WBS to [inline-pipeline-driver.md](inline-pipeline-driver.md); explicit/parallel/headless paths
delegate to the workflow engine and `vars.agent` resolution.

## Step 1 — Selector resolution (R1)

The batch accepts either `--tasks <value>` or the convenience `--feature <id>`.

**Normalization helper (pseudo-code / comment for implementers):**

```ts
// In command layer or batch resolver (before passing to super-planner)
function normalizeArgs(raw: Args): Args {
  const args = { ...raw };
  if (args.feature && !args.tasks) {
    args.tasks = `feature:${args.feature}`;
    // optional: delete args.feature; or keep for reporting
  }
  if (args.feature && args.tasks) {
    // explicit --tasks wins (per Option A)
    console.warn(`--feature ignored because --tasks was provided`);
  }
  return args;
}
```

**Normalization rules (performed by the command layer before the skill sees $ARGUMENTS, or by the batch resolver):**

- If `--feature FOO` is present and `--tasks` is absent, treat the effective selector as `feature:FOO`.
- If both are present, `--tasks` wins (with a one-line note in the batch report).
- **Per-command admission filters (I33 1023).** Step 1 is the shared baseline; commands may layer
  stricter grammar on top — e.g. `/sp:dev-review` rejects `ready`/status pseudo-lists and the mixed
  `--tasks` + `--feature` combination (exit 2), and accepts multi-id `--feature <id>,<id>` as
  caller-level sugar expanded by the command layer before the resolver.

**Feature-derived strict preflight (R2, task 0510).** After normalization, if the **effective
selector** is `feature:<id>` (whether via `--tasks feature:<id>` or the `--feature <id>` sugar),
run a source-local strict feature check **once** before any task-list resolution, freeze,
dependency resolution, or worktree task execution:

```bash
# monorepo source; installed projects use their resolved `spur` binary
bun run apps/cli/src/index.ts feature check <id> --strict --json
```

- **Abort shape.** A non-zero check aborts the batch immediately: verdict `aborted`, zero attempted
  tasks, and the structured feature findings (the `--json` finding list) reported verbatim. This is
  the same abort vocabulary as cycle / unknown selector (Step 4/Step 5). **Scoped to structural
  findings:** `L4.scenario-unverified` is the expected state of any not-yet-run feature (its
  covering tasks have no PASS verdicts yet) and `--strict` elevates it to error, so it is reported
  verbatim but does **not** abort the batch; every other error finding (L1–L3 structural,
  `L4.malformed-verdict-artifact`, `L4.uncovered-feature-scenario`) still aborts. The terminal
  feature-transition gate enforces scenario verification after the batch runs, so pre-run unverified
  scenarios are transient, not defects (dogfood 2026-08-11, feature I2).
- **Exactly once.** The check runs once per batch, before `task list`; it is not re-run per task.
- **Non-feature exclusion.** Explicit WBS lists, status pseudo-lists, and `ready` selectors add no
  feature check — only an effective `feature:<id>` selector is feature-derived. When explicit
  `--tasks` overrides `--feature`, the effective selector is not feature-derived, so no check runs.
- **Why.** `FeatureCheckService` emits `L3.scope-delineation` (Scope lacking an In/Out split) as a
  **warning**; feature-scoped batches never ran a strict check before freezing, so the late feature
  transition became the first blocking check. A selector-local strict preflight catches a known
  strict finding before any task pipeline action without changing corpus-wide severity. A not-yet-run
  feature always fails `L4.scenario-unverified` under `--strict`, so that class is
  reported-not-aborting per the Abort shape above — the preflight targets defects, not the
  expected pre-run state.
- **Scope.** This preflight is advisory to severity policy: it does not alter `FeatureCheckService`,
  `L3.scope-delineation` severity, `feature sync`, or batch-create.

`--tasks <value>` (or the effective value after normalization) resolves to a frozen set of task WBS numbers. Resolution happens **once, at
kickoff** — the driver never re-queries `spur task list` to recompute membership mid-batch (R2.1).

| Selector form | Regex / match | Resolution |
| --- | --- | --- |
| Explicit WBS list | `^[0-9, ]+$` | Split on comma; validate each token is a 4-digit WBS; collect the explicit set. (R1.1) |
| `feature:<id>` (via `--tasks` or `--feature <id>`) | literal `feature:` prefix or `--feature` flag | `spur task list --feature <id> --json`; collect `wbs` from each row. The `--feature` flag is sugar that becomes `--tasks feature:<id>` at the command layer. (R1.3) |
| `ready` | literal `ready` | Resolve the union of `spur task list --status todo --json` + `spur task list --status backlog --json`, drop tasks with open children (R1.5, umbrella-parent exclusion below), then keep only tasks whose every `dependencies[]` entry resolves to `status == done` (via `spur task show <dep> --json | jq '{wbs, status, dependencies, feature_id}'` — R5 metadata-only). Report each excluded task with its unmet dependency. (R1.4) |
| Status pseudo-list | `todo` \| `backlog` \| `wip` \| `blocked` \| `testing` | `spur task list --status <value> --json`; collect `wbs` from each row. (R1.2) |
| *(else)* | no match | Error: "unknown selector `<value>`" — list the valid forms and halt before running anything. |

**Dedup:** an explicit list with a repeated WBS (`--tasks 0040,0040`) collapses to a single entry;
the frozen set is a set, not a multiset.

**Umbrella-parent exclusion:** a `ready` candidate whose `spur task list` shows at least one child
task (any non-`done`/non-`cancelled` task with `parent_wbs == <wbs>`) is dropped from the `ready`
set. By decomposition contract a parent "implements nothing itself" — running it would
re-implement a task that is the abstraction over its children. `spur task batch-create` now
auto-transitions decomposed parents to `wip` and refreshes their `## Plan` roster (task 0178
F1/F2), so a `todo` umbrella with open children is a near-impossible-by-construction state;
this rule is belt-and-braces for the rare case where the parent is re-opened or a child was
created outside `batch-create`. Each excluded parent is reported in the batch report with
`reason: "umbrella parent — <n> open children (<child-wbs-list>)"`.

**`ready` edge note:** a `ready` candidate whose dependency is **out-of-set** is resolved here by
status lookup (satisfied → included). In-set dependencies (a task in the set depending on another
task in the set) are NOT pre-validated by the `ready` selector — they are handled by the ordering
algorithm in Step 2, which guarantees the dep runs first. The `ready` selector only filters on
**out-of-set / already-done** deps.

## Step 2 — Freeze + dependency ordering (R2)

### 2.1 Freeze (R2.1)

The resolved set is **frozen** into an ordered plan before the first `spur workflow run`. The driver
iterates this frozen plan; it never shrinks or re-queries membership mid-batch. Even if a task
transitions to `wip` or `testing` as it runs, every originally-selected task is still attempted in
plan order.

### 2.2 Build the dependency graph

Build a directed graph over the **frozen set** using each task's `dependencies[]` frontmatter. An
edge `A → B` means "A depends on B" (B must complete before A runs). Only edges whose target is
**in the set** contribute to the topological sort; out-of-set deps are resolved by status lookup
(Step 2.3).

### 2.3 Out-of-set dependency resolution

For each dependency edge to a task **outside** the frozen set, resolve its current status via
`spur task show <dep-wbs> --json | jq '{wbs, status, dependencies, feature_id}'` (R5 metadata-only):

- status `done` → edge satisfied, drop it from the graph (R2.5). The dependent is unblocked.
- status ≠ `done` → mark the dependent **blocked**. Transitively mark its in-set descendants blocked
  too (fixpoint propagation: any task depending on a blocked task is itself blocked). Exclude all
  blocked tasks from execution and record the unmet dependency + the blocked subtree for the report
  (R2.4). Independent (non-blocked) tasks in the set still run.

### 2.4 Topological sort

Topological-sort the remaining in-set, non-blocked tasks using Kahn's algorithm:

1. Seed the queue with zero-indegree nodes, **sorted WBS-ascending** (deterministic tie-break).
2. Repeatedly dequeue the lowest-WBS zero-indegree node, emit it, decrement its successors'
   indegree, and enqueue any newly-zero nodes — preserving WBS-ascending order on each enqueue.
3. If Kahn exhausts the queue with nodes still unsorted, a **cycle** exists.

**Cycle handling (R2.3):** a cycle aborts the **entire batch** before any task runs. Reconstruct a
representative cycle path via DFS over the remaining unsorted nodes and report it (e.g.
`0040 → 0042 → 0040`). Do not run any task in a cyclic batch — running a prefix would partially
execute work whose ordering is undefined.

### 2.5 Result

The ordered execution plan: a WBS-ascending-topological list of tasks to run, plus a `blocked` list
(with unmet-dep reasons) and (on cycle) an `aborted` flag with the cycle path.

### 2.6 Preflight — TABLE A STOP rows (task 0279 / next-router consumer)

**Before** each `spur workflow run` for a WBS still on the plan, re-check readiness with the pure
helper (preferred) or `sp:next-router` dry-run:

```bash
node "$(superskill script path sp batch-preflight.mjs)" \
  --wbs <wbs> --status <status> \
  --deps <comma-deps> --dep-status <wbs:status,...> --json
```

| Result | Batch action |
| -------- | ---------------- |
| `action: run` | Launch `task-pipeline.yaml` for this WBS (happy path **unchanged**) |
| `action: skip` code **A2** | Do not launch; report `preflight-skip` + unmet deps (mirrors TABLE A2) |
| `action: skip` code **A7** | Do not launch; report blocked (handover is operator-side) |
| `action: skip` code **A8**/**A9** | Do not launch; already done / cancelled |

**Invariants:** Preflight never replaces the pipeline with a loop of `/sp:dev-next`. TABLES A/B/C
remain SSOT in `next-router/references/routing-table.md`. Step 2.3 already pre-blocks many unmet
out-of-set deps; 2.6 is belt-and-braces for status STOP rows and a uniform report shape
(`dev-next:`-style reasons). Parallel mode: preflight each WBS before fan-out.

## Step 3 — The driver loop (R3, R4)

```
plan = resolve(--tasks) → freeze → order(deps)        # may abort (cycle) or pre-block (unmet dep)
report = []
for wbs in plan:                                       # default sequential mode
    if any dependency of wbs failed earlier in THIS batch:
        report += skipped(wbs, reason); continue       # only relevant under --keep-going
    preflight = batch-preflight(wbs)                   # Step 2.6 — TABLE A STOP
    if preflight.action == skip:
        report += preflight-skip(wbs, preflight); continue
    run: if interactive sequential omit/inline:
             inline-pipeline-driver(task-pipeline.yaml, wbs)
         else:
             spur workflow run task-pipeline.yaml --vars <vars> --async --json
             follow trace until terminal    # spur workflow trace "$RUN" --follow --timeout 600000
                                            # (timeout → one checkpoint, exit 1; run continues — never cancel/relaunch)
    if run paused (escalate HITL ask (0933) or standard-profile approve):
        surface the paused prompt (escalate: .spur/run/<wbs>-question.md via workflow.hitl.ask)
        operator answers: spur workflow continue --answer-text <answer> "$RUN" (escalate) | continue "$RUN" (approve)
        resume the SAME run (never relaunch); maxEscalations (default 2) bounds the ask loop
    inspect terminal state + .spur/run/<wbs>-verdict.json
                                           # accept only if trace .run.runId == $RUN AND verdict mtime ≥ .run.startedAt
                                           # (else outcome = stale-evidence, non-done; failure policy applies)
    report += outcome(wbs)
    if terminal == failed OR stuck status:
        recovery = recoveryHint(status, wbs)           # Step 3.3b — at most once
        report += recovery-hint(wbs, recovery)
        # optional: if batch --auto and cardinality==1, dispatch recovery.command once
    if terminal == failed:
        if --keep-going: mark wbs + in-batch dependents as failed/skipped; continue
        else:            HALT; remaining → not-attempted; break    # stop-the-batch default (R3.1)
emit batch report (per-task outcome + preflight skips + recovery hints + batch verdict)
```

Parallel mode keeps the same lifecycle but swaps the inner loop for the per-task-worktree fan-out
specified in [§ Parallel isolation](#parallel-isolation---mode-parallel): identify a zero-edge,
non-overlapping subset; **preflight each** selected task; run each ready task's `task-pipeline.yaml`
invocation in its own create-mode worktree; integrate by rebase + `--ff-only` as tasks finish;
recovery stays **sequential** (one WBS). If any decision-framework check fails, serialize and
record the reason.

### 3.1 Per-task execution reuses the pipeline verbatim (R4)

Each task runs through the **standard single-task pipeline** — `task-pipeline.yaml`
— with no new FSM and no step edits. Interactive sequential omit/inline invokes the host
[inline pipeline driver](inline-pipeline-driver.md), which interprets that file; explicit/parallel
execution invokes the workflow engine. The batch loop inspects the result and never redefines a
step.

**Explicit/parallel path: launch async, then one bounded follow** (per execution-workflow.md §"Step 2"): a pipeline with
`agent.run` stages runs for many minutes. Always use `--async` + `spur workflow trace`. The watch is a
single bounded call — `--timeout 600000` (10 minutes) is the one bound; a hand-rolled poll loop (and the
retired "10 minutes or 20 polls" rule) is not needed:

```bash
RUN=$(spur workflow run task-pipeline.yaml \
  --vars '{"wbs":"<wbs>","profile":"auto","agent":"claude"}' --async --json | jq -r '.runId')
spur workflow trace "$RUN" --follow --timeout 600000   # single bounded watch to terminal; checkpoint + exit 1 on timeout
spur workflow trace "$RUN" --json | jq '.run | {runId, status, startedAt}'   # inspect: identity, status, freshness anchor
```

A timed-out follow prints one checkpoint naming the run id and last status and exits 1; the run itself
continues — never cancel or relaunch it over a watch timeout. Resume the watch with the same command.

### 3.2 Flag → `--vars` passthrough (R4.2, R4.3)

Only two flags cross the orchestrator→pipeline boundary; both are merged into the per-task
`--vars` JSON:

| Flag | Effect on per-task `--vars` |
| --- | --- |
| `--auto` | sets `"profile":"auto"` (skips the HITL approve gate). Omitting it forwards nothing, so the pipeline uses its default profile (standard — HITL pause surfaces to the operator). (R4.2) |
| `--agent <value>` | omit/`inline` in interactive sequential mode selects the host driver and is not forwarded. `auto` or a name sets **both** `"agent":"<value>"` and `"implementAgent":"<value>"` so every workflow `agent.run` step — including implement — spawns that executor. Headless omit/inline falls through the executor precedence chain. To pin ONLY implement, pass `--vars '{"implementAgent":"..."}'` separately; that explicit var selects the subprocess path. (R4.3, tasks 0483/0503) The opt-in `fleet` value instead maps the run to `"executor":"fleet"`: every `agent.run` stage dispatches through the agent fleet control plane (0942/ADR-126) and neither `agent` nor `implementAgent` is pinned. |

The host session remains the orchestrator for interactive sequential omit/inline. `sp:super-planner`
owns explicit-executor and parallel paths; there the flag pins the per-task step executor, not the
orchestrator.

### 3.3 Terminal-state inspection

Each pipeline run ends in one of two terminal states:

- **`done`** → the task's `## Testing` / `## Review` sections were filled by the pipeline's `record`
  step; the verdict artifact at `.spur/run/<wbs>-verdict.json` confirms `verdict == PASS`. Record
  `done` in the report.
- **`failed`** → the pipeline hit a gate failure (precheck, verify verdict ≠ PASS, or an
  `onEnter` exception). Record `failed` with the blocking reason from the trace. This triggers the
  failure policy.

**Verify-answer table contract (0948 R9).** A verify stage's
`.spur/run/<wbs>-verify-answer.txt` AC table is exactly four columns:
`| AC | Status | Evidence Type | Evidence |`. The evidence-type token
(`test`, `command`, `static-ref`, `manual-review`, `llm-judge`, `n/a`, or a `+`
compound) is isolated in cell 3. A token merged into the evidence cell fails the
`spur task verdict` answer lint.

**Driver acceptance (0930 R3).** The trace row and `.spur/run/<wbs>-verdict.json` are accepted as
terminal evidence only if BOTH hold:

1. **Identity** — the accepted run is the dispatched run: the **trace's** `.run.runId` equals
   `$RUN` (compare against `spur workflow trace "$RUN" --json | jq -r '.run.runId'`). The verdict
   artifact itself carries no runId — it is WBS-keyed, so it binds to this run only through the
   trace identity plus the freshness check below.
2. **Freshness** — the verdict file's mtime is at least the trace's `.run.startedAt` (a verdict
   written before this run started is a stale child result, not completion evidence).

Otherwise record outcome **`stale-evidence`** (non-`done`); the failure policy applies. A stale or
mismatched artifact never authorizes cancelling or relaunching the run.

### 3.3b One-shot recovery (task 0279 — next-router consumer)

After a non-PASS terminal state (or when the task status is stuck at `wip`/`testing` without a clean
verdict), consult **one** recovery hop:

```bash
node "$(superskill script path sp batch-preflight.mjs)" --wbs <wbs> --status <status> --recovery
# → e.g. /sp:dev-verify 0042 --auto --next
```

| Rule | Detail |
| ------ | -------- |
| Budget | **≤ 1** recovery consult per WBS per batch — never loop until done |
| Default | Print the exact child command in the batch report |
| `--auto` batch | May dispatch the child **once** when cardinality is 1 and the hop is a single lifecycle command |
| Multi-candidate | HITL stop — do not silent-pick (batch `--auto` does not break ties) |
| Forbidden | Replacing the whole batch with repeated `/sp:dev-next` (deep-merge) |

Helper: `recoveryHint(status, wbs)` in `plugins/sp/scripts/batch-preflight.ts`. Tables remain SSOT
in next-router; this only maps status → primary TABLE A hop for recovery.

### 3.3c Feature-sync retry suppression (task 0411; 1004 R3 moved it into the sync service)

During a batch, the per-task `record` step and the wrap-up `feature-transition` step each invoke
feature status sync. When a feature is L4-gate-blocked (e.g. not all linked tasks are `done`), the
identical blocked proposal repeats on every call with no intervening input change — in the H9
dogfood, 4 redundant sync calls produced the same blocked result. The service fixes this, not the
engine.

Both `task-pipeline.yaml` (`record` step) and `wrapup-pipeline.yaml` (`feature-transition` step)
invoke `spur feature sync <feature-id> --json` directly — retry suppression lives inside the
`FeatureService.syncFeature` implementation:

1. On a **blocked** result (`gateBlocked` checked first — a partial hop can have `applied: true`
   while still gate-blocked — then an unapplied from≠to deferral), the service persists
   `.spur/run/feature-sync-blocked-<id>.json` keyed by an input fingerprint (feature file content
   hash, linked task statuses, verdict artifact mtimes).
2. On the next call with an **identical fingerprint**, the service suppresses the redundant sync
   and replays the prior blocked result (`suppressed: true`) without re-deriving hops.
3. On **applied** or **no-op** results, the state file is cleared (no suppression).
4. When the fingerprint **changes** (a task completed, a verdict file updated), suppression is
   invalidated and a fresh sync runs.
5. `--force` (and an explicit confirm re-attempt) bypass the replay and re-derive live; dry-run
   never reads or writes the state.

**Batch driver contract:** the orchestrator does **nothing extra** — the suppression lives inside
the pipeline's `record` step and the wrap-up's `feature-transition` step. The driver still
launches `task-pipeline.yaml` verbatim (R4.1). Suppression is transparent: the sync emits the same
`FeatureSyncResult` JSON shape (`suppressed: true` added on replay), so downstream report logic is
unchanged. The only observable difference is fewer redundant `feature sync` derivations.

### 3.4 Metadata-only host controller (R5, task 0510)

The batch **orchestrator** reads status, ordering, and terminal state — never task bodies or trace
output. Task `content`, section bodies (Solution/Testing/Review), and full workflow `output` are
stage/subagent data and must not enter the host context on the green path; the controller that
dispatches native subagents must not defeat that isolation by ingesting the very bodies the
subagents are meant to hold (task 0508's dispatch contract is preserved unchanged).

**Green-path projections — every controller-side read is projected to metadata:**

- `task show --json` reads pipe to `{wbs, status, dependencies, feature_id}` only:

  ```bash
  spur task show <wbs> --json | jq '{wbs, status, dependencies, feature_id}'
  ```

  Use this shape for out-of-set dependency resolution (Step 2.3), the `ready` selector's
  dep-status lookups (Step 1), and any other controller-side `task show`. A status-only lookup may
  narrow further (`| jq '.status'`), but never widen.

- Green-path trace observation projects to `.run | {runId, status, startedAt}` only:

  ```bash
  spur workflow trace "$RUN" --json | jq '.run | {runId, status, startedAt}'
  ```

  The controller decides continue/halt from `.run.status` (ADR-044: judge a run by
  `status === 'done'`, never by string-matching a `finalState` name) plus the accepted verdict
  artifact `.spur/run/<wbs>-verdict.json` under the §3.3 acceptance rule (identity + freshness).
  It never streams or re-reads a full trace merely to summarize status.

**Failure-path reads are bounded.** On a failed/blocked task, request only the terminal error and
the minimal anchor set the batch report needs (e.g. the blocking finding line, the unmet-dep WBS,
the verdict line) — never the entire trace. If a fuller trace is needed for diagnosis, that read
belongs to a subagent or the operator, not to the batch controller's report loop.

## Step 4 — Failure policy (R3)

### 4.1 Stop-the-batch (default) (R3.1)

By default, the **first** pipeline failure halts the batch. Remaining tasks in the plan are reported
as `not-attempted`. The report lists succeeded, failed, and not-attempted tasks.

### 4.2 `--keep-going` (R3.2)

With `--keep-going`, a failed task does **not** halt the batch. Instead:

- The failed task's **in-batch dependents** (tasks in the plan that transitively depend on it) are
  marked `skipped` with the failed dependency as the reason — they cannot run because their dep did
  not reach `done`.
- **Independent** tasks (no dependency path to the failed task) still run.

This requires the driver to track, per failed task, which later plan entries depend on it —
derivable from the same dependency graph built in Step 2.

## Step 5 — Batch report (R5.2)

When the batch finishes — clean (all `done`), halted (default failure policy), parked (`paused`:
one or more tasks sit on an operator question (escalate, 0933) or an approval gate), or aborted
(cycle / unknown selector) — emit a structured report. The report is the orchestrator's sole
output; it does not mutate the corpus (the pipeline's `record` step already wrote per-task
results). A `paused` task is non-terminal — the report marks it `paused` (never `done`) and names
the resume command. Unless `--no-summary`, follow it with the measured
[execution summary](dev-operations.md#execution-summary), one `--progress <wbs>=<file>` per attempted run.

```
## Batch Report — <selector>

**Selector:** <value>
**Plan:** <n> tasks (ordered: <wbs-list>) · <m> blocked · <p> not-attempted
**Mode:** stop-the-batch | --keep-going | --auto
**Verdict:** clean | halted | aborted

| WBS | Status | Reason |
|-----|--------|--------|
| 0040 | done | — |
| 0042 | failed | verify verdict PARTIAL (see .spur/run/0042-verdict.json) |
| 0050 | not-attempted | batch halted after 0042 (stop-the-batch) |
| 0051 | skipped | dependency 0040 failed (--keep-going) |
| 0060 | blocked | unmet out-of-set dep: 0099 is wip |

**Next:** <one-line action — pick up halted run / resolve 0099 / all green, feature H1 complete>

**Excluded from wrap** (only under `--wrap`/`--next`, when some batch tasks are not `done`):

```
| WBS | Status | Recovery |
|-----|--------|--------|
| 0042 | failed | C6 recovery: /sp:dev-run 0042 (residual report: .spur/run/0042-residual-report.md) |
| 0050 | not-attempted | /sp:dev-next 0050 |
```

A task with a residual report (failing `residual-sweep` check in its verdict artifact) uses the
C6 recovery line (re-run the pipeline); every other excluded task uses the next-router A-row
command for its status (F96 task 0952 R2).

The per-task outcome vocabulary: `done` | `failed` | `blocked` | `skipped` | `not-attempted`,
plus the resume-only `recheck` (stale/mismatched evidence — pipeline re-run), `not-admitted`
(post-freeze selector match, never executed — task 0919 BC-1/BC-2), and the parallel-only
`integration-conflict` (rebase conflict retained for manual integration — task 0931 R4,
[§ Parallel isolation](#parallel-isolation---mode-parallel)).
The batch verdict: `clean` (all attempted tasks `done`) | `halted` (a failure stopped the batch) |
`aborted` (cycle or selector error before any run).

**Zero-task rule (task 0701 R7b).** A selector that resolves to an **empty set after the status
filter** is an `aborted` verdict (`aborted (empty set after filter)`), matching dev-operations.md
§5a. Under `--worktree`, **WT-2 is skipped entirely**: no worktree is cut and no WT-3 marker is
written for a batch with nothing to run. The early-exit report carries zero per-task rows,
`Steps: 0 derived, 0 executed`, and the `aborted` verdict; no WT-3b commit step and no WT-4/WT-5
terminal action runs. A contract test pins this
(`plugins/sp/tests/dogfood-testing/execution-batch-contract.test.ts`).

**Evidence persistence (worktree batches — task 0720 R3).** Copy-out is mandatory.
A worktree batch's Step 5 report, verdict artifacts, and the batch's own
`.spur/run` records live in the worktree while the batch runs — exactly the tree
create-mode WT-4 deletes. Before any WT-4 removal, persist them into the **invoking** tree, which
survives removal:

- Write the emitted batch report to `.spur/run/worktree-<marker-id>-batch-report.md`.
- Copy each attempted task's `.spur/run/<wbs>-verdict.json` from the worktree to
  `.spur/run/worktree-<marker-id>-verdicts/<wbs>-verdict.json`.
- Make the report's per-task verdict references use those persisted invoking-tree paths, not the
  worktree-local paths that removal deletes.

Evidence persistence precedes destructive cleanup: a persistence failure (unreadable verdict file,
disk-full, missing directory) routes to **WT-5** — the worktree and branch are retained so a green
batch can never destroy its own evidence. Reuse mode retains its operator-owned tree but still
persists the Step 5 report under the invoking tree; the reused tree's `.spur/run/` remains the live
copy while that tree lives on. The per-run provenance — the worktree DB's run/action rows and the
`.spur/memory/runs/<runId>.md` + `.state.json` records — is persisted mechanically by WT-4a's
`inline-run-setup.ts --persist-out --from <worktree> [--task-file <merged-task>]...` call,
not by hand.

**Stage records are worktree-local too (0948 R9, E7 Finding 5; persisted by 0975 R1).** Each task's
own per-stage run record (`.spur/memory/runs/<runId>.md` + `.state.json`) and the worktree DB's run rows are
written inside the worktree and **are removed with it** in create mode — the E7 batch lost exactly
this evidence. Copying them out is no longer a manual audit-time duty: WT-4a (create-mode block
below) runs `inline-run-setup.ts --persist-out --from "$WT_PATH"` **before** WT-4b holder cleanup,
which copies the run/action/phase/transition/workflow-state rows and both record files into the
invoking tree.

**Cited evidence rides the same call (0984 R1/R2).** The driver forwards each merged task file with
repeatable `--task-file <path>` (paths resolved in the invoking tree after the FF merge, e.g.
`spur task show <wbs> --json` → `.filePath`), and persist-out then copies/verifies every literal
`.spur/run/<file>` that file cites — WBS verdicts, check receipts, test-gate logs, run-ID records —
so a merged task file never anchors a path that died with the tree. Abbreviated references
(`fadca099-…`, `run-*-ac87.log`, `{batch-report.md,…}`) are not literal files and carry no
obligation; neither do root-qualified paths (`knowledge-kit/.spur/run/…`, `/abs/.spur/run/…`),
which cite another project's evidence — cite foreign run artifacts that way, never bare. A citation missing in BOTH trees, a divergent cited file (never overwritten — reconcile
by hand), an unreadable task file, or more than 64 distinct cited files fails the pass → WT-5.

**Durable planes ride it too (E71).** Persist-out copies canonical task verdicts, feature run/latest receipts, nested run artifacts and owned sessions before teardown. Artifact and task-run-link rows are exported with run rows, and stored path references are redirected to the invoking tree. Conflicting or unreadable retained evidence refuses teardown.

**Owned evidence rides it too (1012).** With at least one `--task-file`, persist-out also treats as
copy obligations the worktree's `.spur/run/` direct children named `<wbs>-…` (the WBS is each
forwarded task file's leading four digits before `_`) or `<runId>-…` (every run row in the worktree
DB, whichever task it ran) — `<wbs>-verdict.json`, check receipts, route reasons — whether or not
the task file cites them. They join the cited set: same copy / byte-identical no-op /
divergent-refuse handling. The 64-file cap bounds citations alone; owned names are bounded per
owner (each `<wbs>-` / `<runId>-` prefix gets its own 64-file budget, task 1034), so the bound
scales with the batch and one runaway owner refuses by name before any write. `<runId>.md` /
`<runId>.state.json` stay with the record copy (a conflict is reported and the delegate refuses teardown). Files
matching neither a citation nor an ownership prefix are left behind. An absent worktree `.spur/run/`
means nothing is owned; any other listing failure (not a directory, permission denied) fails the
pass before the invoking tree is written → WT-5. Without `--task-file` nothing is enumerated.

The shapes are pinned (task 0975 R1; `record-missing` and citation behavior per 0984): idempotent on re-persist;
success exits 0 printing
`{"ok":true,"persisted":<n>,"skipped":[{"id":<run-id>,"reason":"id-exists"|"external-key-conflict"|"record-conflict:<file>"|"record-missing:<file>"|"cited-directory:<name>"|"cited-symlink:<name>"|"cited-non-file:<name>"}]}`
— an `external-key-conflict` skip leaves the target run unchanged **and fails the pass (1049)**: the delegate exits 1 printing `{"ok":false,"error":…}` naming the skipped source run ids, because that (workflow, external key) identity already belongs to a different receiving run — the batch was not persisted, so reconcile the source worktree by hand (it stays the provenance owner of record) before any teardown: auditable reconciliation names the original archived DB snapshot and the skipped source run identities, verifies the archive by file hash and by merged-commit ancestry of the source branch, and treats any residual deletion as explicit operator-authorized cleanup under the strict canonical evidence rules above. An `id-exists` replay repairs missing owned artifacts and task links without duplicating them. A
`record-conflict:<file>` never overwrites a divergent invoking-tree record and causes the delegate to exit 1, retaining the worktree. A
`record-missing:<file>` skip is a known `task-lifecycle`/`feature-lifecycle` row with no record file
at all (its inserted DB row still counts in `persisted` — 0984 R5). Any failure
exits 1 printing `{"ok":false,"error":<message>}` (a worktree DB run id that is not a single safe
filename component is rejected before any target write). Any persist-out failure
routes to **WT-5** — worktree and branch retained — the same copy-out-first contract as the
verdict persistence above. After a green persist-out, `spur workflow progress --json` in the
invoking tree shows the merged run `done` with its per-action rows.

## Step 6 — Batch wrap (`--wrap` / `--next`) (F96 task 0952 R1)

Run **once for the batch**, after the Step 5 report — never per task (dev-operations.md §runall
agrees; the old "per task" flag-row wording in dev-runall.md was a contradiction, now fixed).

The wrap receives only what it would accept:

1. `vars.tasks` = the JSON-encoded array of batch WBS whose terminal status is `done`. A task that
   is `failed`/`blocked`/`skipped`/`not-attempted`/`recheck`/`not-admitted` is **excluded** —
   `wrapup task-resolve` refuses non-done members (wrapup-steps.ts task-resolve), so the driver
   simply stops handing the wrap tasks it would refuse.
2. `vars.feature` = the batch feature **only when every task in the frozen batch is `done` or
   `cancelled`**. Otherwise omit it and print `feature lifecycle not advanced: <n> task(s)
   unfinished` — advancing a feature while some of its batch tasks are unfinished would overstate
   completion. Learnings/metrics capture still happens for the done subset.
3. When the done subset is **empty**, skip the wrap entirely with the reason (e.g. `batch wrap
   skipped: no done tasks`) instead of invoking wrapup-pipeline on an empty set.

**Repo-wide tripwire (1037).** After the doc-sync exits converge, the pipeline's `doc-tripwire` hop
runs the TRUSTED CONFIG ONLY `docTripwireCmd` over the still-uncommitted wrap diff before
metrics-record. The default probes `package.json` for a `test-repo-wide` script and runs
`bun run test-repo-wide` only when it is declared (a no-op in other projects), so the batch driver
passes no extra vars. Batch callers override it like any wrap var (`docTripwireCmd` in `--vars`);
an empty string disables the check while still recording PASS. A FAIL routes the wrap to `failed`
with already-written learnings/docs preserved — fix the flagged working-diff violation and re-run
the wrap.

Filtering lives here, in the batch driver — no change to wrapup-pipeline.yaml or wrapup-steps.ts;
the wrap's refusal of non-done tasks remains the hard invariant.

## Worktree isolation (`--worktree [<name>]`)

When a batch command (`dev-runall`, `dev-refineall`, `dev-verifyall`) is invoked with
[`--worktree [<name>]`](flag-glossary.md#flag-worktree), the entire driver loop runs inside an
isolated git worktree instead of the operator's working directory. This section owns the worktree
lifecycle for the sequential batch loop. `--mode parallel` has its own per-task isolation — see
[§ Parallel isolation](#parallel-isolation---mode-parallel); `--worktree --mode parallel` is
rejected there (parallel mode already isolates each task in its own worktree).

**Startup ordering (task 0814 R3).** Resolve the selector/status filter and run the quick
command-aware readiness (the `quickReadiness` contract in `batch-preflight.ts`) **before** creating
or adopting the tree. The admission decision is what determines whether a tree should be cut at all;
all subsequent tools, agents, task/feature writes, and run artifacts use the confirmed execution
tree's cwd. A stale or empty selector, an unsupported mode, or an invalid target creates no tree and
no marker (WT-2/WT-7), and the required Git safety checks (WT-1) still precede creation.

> **Command wiring (task 0814 R3).** The four task-set worktree commands (`dev-run`, `dev-runall`,
> `dev-refineall`, `dev-verifyall`) each call `quickReadiness` with their operation (`run`/`refine`/
> `verify`), the resolved selector/status, and the filtered-set size **before** WT-1/WT-2. The
> admission outcome gates the tree: an invalid/empty selector, unsupported mode, or a target that
> quickReadiness marks `blocked`/`invalid` creates no tree and no marker (WT-2/WT-7); a
> `needs-refinement` refine batch is still work to do (the tree is created, the gaps are the work).
> The required Git safety checks (WT-1) still precede creation, and ownership/identity is confirmed
> before any tool, agent, corpus write, or run artifact. A later failure retains the tree with
> recovery information (WT-5).

**Single-task `dev-run` (batch of one).** `/sp:dev-run <wbs> --worktree [<name>]` runs this same
lifecycle with a one-task loop: WT-1…WT-6 apply unchanged, the marker's `command` is `dev-run` and
its `selector` is the `<wbs>` (so WT-6's command+selector fallback resolves the resume), and the
derived branch/directory slug is the WBS — `sp/run-<wbs>-<short-id>`. The WT-4 success condition
"no failed task" reads as "the task reached terminal `done` with no failed stage"; a failing gate, a
non-PASS verify verdict, or a HITL pause that ends the run take the WT-5 retention path. Only the
full pipeline is eligible — `--worktree --mode implement` is rejected (WT-7), because that mode is
the pipeline's implement stage and already runs in the driver's tree.

**Review triage `dev-review` (run of one).** `/sp:dev-review [--tasks <selector> | --feature <id>[,<id>] | --scope <path>[,<path>]] --triage --worktree [<name>]`
runs this lifecycle around one review-plus-triage pass: WT-1…WT-6 apply unchanged, the marker's
`command` is `dev-review` and its `selector` records the full normalized target list, and the slug
is `sp/review-<first>-and-<N>-<short-id>` for a multi-target run (N = target count) or
`sp/review-<slug>-<short-id>` for a single target (the WBS or the path's basename). It skips
`quickReadiness` (there is no task set; admission is "every target resolves" — each WBS/path must
resolve before the tree is cut). Under `--triage` the findings are bucketed across all targets once
(identical `file:line` findings deduped). WT-4 success reads as "every direct fix passed its check and the
project gate is green"; anything else takes WT-5. Contract: [dev-operations.md § 2. review](dev-operations.md#2-review).

One flag, two modes (see the glossary entry for the ownership rule). Bare `--worktree` is **create
mode** (cut a fresh branch + sibling tree). `--worktree <name>` is **reuse mode** (attach to a tree
that already exists); name resolution (§ WT-2 below) runs before WT-1. The deltas each mode applies
are stated inline in WT-1…WT-4. `--continue` re-entry (WT-6) resolves by explicit name first, then
falls back to the command+selector marker scan.

The lifecycle wraps Steps 1–5 unchanged: a precheck creates or adopts the worktree before selector
resolution runs, the loop executes with the worktree as process cwd, and a terminal action merges or
retains after the batch report is emitted. Steps 1–5 themselves are not modified — only their cwd
differs.

**Per-call tree pinning (task 1058).** A host shell call starts in an arbitrary directory and
inherits no cwd from the previous call, so "the loop runs with the worktree as process cwd" is a
bootstrap convenience, never a carried guarantee. Every host command boundary in this lifecycle —
corpus reads/writes, the WT-3b commit, WT-4's merge and evidence persistence, the WT-4a
persist-out — re-selects its intended tree within the same call via the canonical per-call pin
protocol ([inline-pipeline-driver.md](inline-pipeline-driver.md) § "Per-call execution-tree
pin"): `cd --` to the quoted absolute tree, verify `pwd -P` against the recorded physical tree,
verify Git top-level and expected branch, then execute the already-resolved Spur invocation. The
identity is consumed, not re-derived: it is the WT-3 marker's confirmed `path` + `branch` (or the
resolved worktree's physical path + checked-out branch in reuse mode). The one deliberate tree
change is WT-4: the terminal merge actions select the **invoking** tree, not the execution tree —
and pin it explicitly the same way. FF ancestry, marker and cleanup sequencing remain owned by
1059.

**Portability (R10).** Use portable `git worktree` commands only. Do **not** depend on the Claude
Code `EnterWorktree`/`ExitWorktree` tools — the `sp` plugin ships to Codex, Gemini CLI, pi, omp, and
OpenCode. The underlying git mechanics (create / list / remove / prune, sibling-directory naming,
disk-space awareness) are reused from [worktree-patterns.md](../../branch-workflow/references/worktree-patterns.md);
this section does not re-author them.

### WT-1 — Dirty-tree precheck (R3)

`git worktree add` branches from a ref, so uncommitted changes in the main tree do **not** carry
into the worktree — a batch would silently run against different tree state than the operator sees.
Before creating the worktree, check the main tree:

```bash
git status --porcelain
```

- **Clean tree** → proceed to WT-2.
- **Dirty tree** → **abort** before any worktree is created. Name the offending files (from
  `git status --porcelain`) and instruct the operator to commit or stash. No task work has run.
- **`--force`** → proceed past a dirty tree with a divergence warning that names the uncommitted
  files. The worktree is created and the batch proceeds against the committed base ref, not the
  operator's working-directory state.

**Reuse-mode delta.** The main-tree precheck is unchanged — the divergence hazard is identical (the
batch still runs somewhere the operator is not standing). The *target* worktree being dirty is
**expected** (it holds retained partial work from a prior halt) and must **not** abort: report the
file list once and proceed. The batch runs against the target worktree's working-directory state, not
a clean checkout — which is exactly the point of reuse mode.

### WT-2 — Worktree creation or adoption

**Name resolution runs first** for both modes — bare `--worktree` skips it (no name to resolve);
`--worktree <name>` must resolve before WT-1's precheck touches anything. Resolution is specified
in [§ Name resolution](#name-resolution---worktree-name) below; this section covers creation
(create mode) and adoption (reuse mode).

#### Create mode (bare `--worktree`)

Create one worktree on a new branch cut from the current HEAD's ref (the **base ref** — often a
`feat/…` branch, not literally `main`). Location follows the sibling-directory convention in
[worktree-patterns.md](../../branch-workflow/references/worktree-patterns.md):

**Location rule (0948 R9) — never under `.spur/`.** The default root is the **sibling** directory
(`../<repo>-<command>-<selector-slug>-<short-id>`). A worktree nested under `.spur/` breaks Biome's
vcs-root detection: `bunx biome check .` inside it reports `Checked 0 files`, so lint/format silently
no-op for the whole run. Verified in both directions — a sibling worktree reports a non-zero file
count (`Checked 1076 files` at the time of the fix) while a `.spur/`-nested one reports
`Checked 0 files`. If a project's tooling needs a custom root, keep it **outside** any path that a
VCS-root-detecting tool treats as ignorable.

```bash
BASE_REF=$(git rev-parse --abbrev-ref HEAD)
BASE_SHA=$(git rev-parse HEAD)
BRANCH="sp/<command>-<selector-slug>-<short-id>"     # e.g. sp/runall-h1-a3f2
# `git worktree add -b` creates the branch BEFORE the directory, so a failed create leaves a
# dangling branch and the natural retry dies on "a branch named ... already exists"
# (task 0701 R2b). Wrap the create: on failure, delete the branch — or derive a fresh
# short-id per attempt — before surfacing the error.
git worktree add "../<repo>-<command>-<selector-slug>-<short-id>" -b "$BRANCH" "$BASE_REF" \
  || { git branch -D "$BRANCH"; false; }
```

**Worktree root is outside `.spur/` (0948 R9).** The default create path is that
sibling directory, which sits next to the repository and not under it. Do not
put the default root at `.spur/worktrees` or any other gitignored path: Biome's
`vcs.useIgnoreFile` then treats the checkout as empty ("Checked 0 files") and
the quality gate cannot see the tree. Same class as the eval-pipeline worktree
move off `.spur/tmp/` (task 0610).

Branch and directory names are derived (command + selector slug + short id); the create path never
takes an operator-supplied name (R8.3 — no create-with-name; `--worktree <name>` where `<name>` does
not resolve is an error, not a create).

A fresh worktree has no `node_modules` (gitignored), so the first `bun test` or
typecheck fails on the first workspace import. Install before any task work:

    cd "../<worktree-dir>" && bun install --frozen-lockfile --ignore-scripts

`--frozen-lockfile` pins the worktree to `bun.lock` rather than re-resolving,
so the worktree's dependency tree matches the base ref's. `--ignore-scripts` is required, not
stylistic (task 0701 R2a): worktrees share the main tree's `.git`, and this repo's `prepare`
script is `lefthook install` (`package.json`) — a bare install rewrites the operator's
main-repo hooks from inside the "isolated" tree. Scripts are skipped only at this call site;
a normal clone keeps `prepare`. The worktree still gets a usable dependency tree — the install
exists so the first `bun test` resolves workspace imports.

#### Reuse mode (`--worktree <name>`)

Creation is **skipped entirely**. `$BRANCH` is the resolved worktree's already-checked-out branch; a
detached HEAD aborts (no branch can serve as `$BRANCH` for WT-4's FF-merge). `BASE_REF` is the
invoking tree's current HEAD ref (not the worktree's branch) and `BASE_SHA` is
`git merge-base <BASE_REF> <BRANCH>` — so WT-4's FF-merge lands the worktree's accumulated commits
onto the invoking tree's base ref, exactly as create mode does.

`bun install --frozen-lockfile --ignore-scripts` runs **only when `node_modules` is absent** in
the resolved worktree (same `--ignore-scripts` rationale as create mode — task 0701 R2a). A warm reused tree does not re-pay the install; a cold one (hand-made, or a retained tree
whose deps were removed) installs exactly once before the first task. This is the R3 conditional
install rule (source: task 0481) — create mode always installs because a fresh tree is always cold.

After creation or adoption, immediately write/adopt the state marker (WT-3), then run the
existing batch loop (Steps 1–5) with the worktree as process cwd. `spur workflow run` resolves cwd
from the process (`apps/cli/src/commands/workflow.ts:124`), so no CLI change is needed — `cd` into
the worktree directory before launching the loop.

#### Evidence staging before the first task (create and reuse mode)

`.spur/memory/evidence/` and `.spur/run/` are **untracked per-tree** planes, so a fresh worktree
starts with neither while the invoking tree holds every earlier task's verdict. Both
`preflightFeature` (`plugins/sp/scripts/wrapup-steps.ts` → `spur feature check --strict --as done`
with cwd = the worktree) and `runMetrics` resolve verdict artifacts relative to the tree they run
in, so without this step an already-`done` linked task reads as missing evidence
(`L4.evidence-not-recoverable`, `L4.scenario-unverified`) and its metrics row degrades to
`UNKNOWN`.

Stage the invoking tree's verdict artifacts into the worktree **before the first task runs** —
from the invoking tree (cwd), with `$WT` the resolved absolute worktree path. `cp -n` is
load-bearing: reuse mode may be re-entering a tree that already owns divergence-checked evidence,
and this step must never clobber it. Only `*-verdict.json` travels in each direction; receipts and
other scratch artifacts are not staged, so WT-4a's identity classification is unaffected.

```bash
# Run from the INVOKING tree; $WT is the worktree (create- or reuse-mode) absolute path.
for d in .spur/memory/evidence .spur/run; do
  mkdir -p "$WT/$d"
  for f in "$d"/*-verdict.json; do [ -f "$f" ] && cp -n "$f" "$WT/$d/"; done
done
```

The position relative to the dependency install is immaterial (this is a plain copy into an
untracked plane); the position relative to the **first task** is not — a preflight or metrics read
that runs before this step sees an empty evidence plane.

#### Name resolution (`--worktree <name>`)

<a id="name-resolution---worktree-name"></a>

Authority: **git**, not the marker store — a marker may be stale, git is not. A foreign worktree
with no marker is a valid target (R3 synthesizes one). Resolve `<name>` against
`git worktree list --porcelain`, matching in this order and stopping at the first tier that yields
≥1 hit:

1. **exact `worktree <path>`** (after path normalization against the invoking tree)
2. **`basename(<path>)`**
3. **checked-out branch** — accept both `<name>` and the full `refs/heads/<name>` form

Then require exactly one survivor across the tiers:

- **0 hits** → abort before any task work. Print each candidate worktree as
  `<basename>  <branch>  <path>`, and the line: *"`--worktree <name>` selects an existing worktree;
  it never creates one. Use bare `--worktree` to create."*
- **≥2 hits** → abort naming the candidates and require the path form (tier 1).
- **1 hit, but** the worktree is `locked`, `prunable`, or belongs to a different repo → abort naming
  the condition.

### `spur` on PATH is not this checkout

`spur` (`~/.bun/bin/spur`) resolves to a *published* bundle in `~/node_modules/`,
not to the repo you are standing in and not to the worktree. `resolveSpurBin()`
propagates whichever binary you entered through into `vars.spurBin`, which the
`task-lifecycle.yaml` guards run as `$spurBin task check` — so one wrong entry
point silently gate-checks against the published bundle.

Inside a worktree, and in the monorepo whenever CLI behavior is under test, invoke
the tree's own source:

    cd "<worktree>" && bun apps/cli/src/index.ts task check <wbs> --json

Run that as a per-call pin subshell (task 1058) — `cd --` plus the `pwd -P` / git-top-level /
branch identity checks of the canonical protocol (inline-pipeline-driver.md § "Per-call
execution-tree pin") — rather than relying on a persistent `cd` between host tool calls.

Confirm isolation by making a distinctive change in the worktree and checking that
the command reflects it.

**General rule — every path-resolving tool, not just `spur`.** The same failure class is not
limited to the CLI on PATH. A host-agent file-edit or hash/`hashline` tool may resolve main-repo
paths while the shell `cwd` is the worktree, silently acting on the wrong tree. Before relying on any
path-resolving tool inside a `--worktree` batch, verify it is acting on the worktree — for example
by making a distinctive change and confirming the path the tool reports matches the worktree. When a
tool cannot be pointed at the worktree, fall back to `perl -i` in-place edits (or the agent's `write`
verb) whose path argument you control.

### WT-3 — Crash-safe state marker (R6)

Worktree identity lives on disk under `.spur/run/`, not only in the orchestrator's memory, so a
session that dies mid-batch is recoverable. Write the marker at creation and update it at the
terminal transition (merged / retained). The marker is written to the **invoking** tree's
`.spur/run/` (task 0701 R2c) — the tree where the driver process started, not the worktree's own
`.spur/run/` — so WT-6's resume scan finds it regardless of where the operator stands. Schema:

```json
{
  "id": "<marker-id>",
  "path": "../<repo>-<command>-<selector-slug>-<short-id>",
  "branch": "sp/<command>-<selector-slug>-<short-id>",
  "baseRef": "feat/example",
  "baseSha": "<sha-at-creation>",
  "command": "dev-runall",
  "selector": "feature:H1",
  "createdAt": "<iso-8601>",
  "status": "active"
}
```

`status` transitions: `active` → `merged` (WT-4 success) | `retained` (WT-5 failure/halt/non-FF),
and `retained` → `active` on a reuse re-entry (WT-6). The marker file is named
`.spur/run/worktree-<marker-id>.json`. It is the authority for WT-6 resume and for operator recovery
after a crash: a killed session leaves the marker at `status: active`, which the operator reads to
find the worktree path, branch, and base ref.

**Reuse-mode marker adoption.** Two new optional fields record when a run did not create the tree:

```json
{
  "adopted": true,
  "adoptedAt": "<iso-8601>"
}
```

`adopted` is what WT-4 reads to decide retain-vs-remove, so it is set whenever the current run did
not create the tree — including when reuse mode adopts a marker that create mode originally wrote.
It records "this run did not create this tree", not "this tree was never created by the flag".

Reuse mode resolves the marker by the resolved worktree's `path` (not by `command`+`selector`):

- **Existing marker for that path** → adopt it in place: update `command`/`selector` to the current
  invocation, set `status` to `active`, set `adopted: true` + `adoptedAt`, **preserve `baseRef` and
  `baseSha`**. This makes cross-command resume — `dev-runall` halted, now `dev-verifyall` over the
  same tree — a supported path (R5.2).
- **No marker** (hand-made or foreign worktree) → synthesize one with `baseRef` = the invoking
  tree's current HEAD ref, `baseSha` = `git merge-base <baseRef> <BRANCH>`, `adopted: true`.
- **Marker already at `status: active`** → **abort** — another session may own that tree
  (AGENTS.md one-writer-per-tree; task 0487 R5). Overridable with `--force` (the operator can tell
  a crashed-session marker from a live-session one; the harness cannot).

### WT-3b — Commit the batch's writes on `$BRANCH` (task 0701 R1)

Before any terminal action, commit the batch's corpus writes **on `$BRANCH`, inside the
worktree** — including the generated task files under `docs/tasks*/` and the kanban index. The
commit runs as a per-call pinned subshell (task 1058): it selects the execution tree inside the
same call, verifies the WT-3 identity, and never relies on a persistent `cd` or a trailing
`cd -` to restore the caller's directory:

```bash
(
  cd -- "../<worktree-dir>" || { echo "tree missing: expected ../<worktree-dir>" >&2; exit 1; }
  [ "$(git branch --show-current)" = "$BRANCH" ] \
    || { echo "branch mismatch: expected $BRANCH, got $(git branch --show-current)" >&2; exit 1; }
  git add <files-the-batch-wrote>
  git commit -m "<type>(<scope>): <command> <selector> batch writes"
)
```

The FF-only git merge carries only commits — uncommitted writes in the worktree would be left
behind by the merge and then destroyed by create mode's `git worktree remove`. WT-3b exists so
that can never happen.

### WT-4 — Success path (R4)

When the batch completes with **no failed task**, fast-forward-merge the worktree branch onto the
base ref. The terminal action after the merge differs by mode (ownership rule: *the flag removes
only what it created*):

#### Create mode — merge, remove, delete

```bash
# Run these from the main tree (not inside the worktree) - you merge the worktree branch
# back onto the base ref there. Per-call tree pinning (task 1058, § above): every command
# below selects the INVOKING tree (where the WT-3 marker lives), re-pinned with the canonical
# per-call pin protocol in its own subshell — never the execution tree. The whole sequence is
# fail-stop: checkout, guard, ancestry check, merge, landed verify, persistence, and cleanup
# each stop explicitly. Every tip/ancestry read uses local refs — no fetch runs anywhere:
MARKER=".spur/run/worktree-<marker-id>.json"
git checkout "$BASE_REF" \
  || { echo "WT-4 halt: checkout $BASE_REF failed - nothing merged, marker stays \"active\"" >&2; exit 1; }   # -> WT-5
# Guard (task 0701 R1): a zero-commit branch makes the FF-only git merge exit 0
# ("Already up to date") while merging nothing — the lines below would then delete
# the worktree holding the only copy of the batch's writes. Refuse instead:
[ "$(git rev-list --count "$BASE_REF..$BRANCH")" -gt 0 ] \
  || { echo "WT-4 halt: branch carries no commits - nothing to merge" >&2; exit 1; }   # -> WT-5, marker stays "active"
# Capture the batch tip BEFORE any cleanup or branch work: every later step (landed verify,
# mergeCommit, retained report) reads this captured value — a removed branch is never queried.
BATCH_TIP="$(git rev-parse "$BRANCH")" \
  || { echo "WT-4 halt: cannot read $BRANCH" >&2; exit 1; }
# Fresh LOCAL base tip, re-read now; the marker's baseSha may be stale and never governs this
# decision (an advanced-but-ancestor base still FF-merges, a divergent base halts):
BASE_TIP="$(git rev-parse "$BASE_REF")" \
  || { echo "WT-4 halt: cannot read $BASE_REF" >&2; exit 1; }
# Ancestry check immediately before the sole mutation (the FF merge below). Command/read
# errors are distinguished from ordinary non-ancestry; both fail closed:
ANCESTRY_RC=0
git merge-base --is-ancestor "$BASE_TIP" "$BATCH_TIP" || ANCESTRY_RC=$?
[ "$ANCESTRY_RC" -gt 1 ] \
  && { echo "WT-4 halt: ancestry check could not run (git exit $ANCESTRY_RC) - failing closed" >&2; exit 1; }
[ "$ANCESTRY_RC" -eq 1 ] && {
  echo "WT-4 halt: divergent - batch tip $BATCH_TIP vs base tip $BASE_TIP" >&2
  git log --oneline "$BASE_TIP..$BATCH_TIP" >&2   # batch-side divergent commits
  git log --oneline "$BATCH_TIP..$BASE_TIP" >&2   # base-side divergent commits
  exit 1; }   # -> WT-5: tree + branch retained, marker stays "active"
git merge --ff-only "$BRANCH"          # FF-only: never rebase, merge-commit, or resolve conflicts
[ $? -eq 0 ] \
  || { echo "WT-4 halt: FF-merge failed - the concurrent-writer race survives every precheck; marker stays \"active\"" >&2; exit 1; }   # -> WT-5
# Landed verification before anything else runs: the captured BATCH_TIP must be an ancestor of
# the base ref as it now stands (base tips advancing again later stay valid — this binds the
# merge result, not the end state):
LANDED_BASE_TIP="$(git rev-parse "$BASE_REF")" \
  || { echo "WT-4 halt: cannot read landed base tip" >&2; exit 1; }
git merge-base --is-ancestor "$BATCH_TIP" "$LANDED_BASE_TIP" \
  || { echo "WT-4 halt: landed verify failed - $BATCH_TIP is not an ancestor of $LANDED_BASE_TIP" >&2; exit 1; }
# --- Landed from here on. Any halt below is landed-but-incomplete: it records status
# --- "retained" + mergeCommit "$BATCH_TIP" via write_marker and stops — never a branch query.
write_marker () {   # $1 = merged | retained; read-modify-write: preserve WT-3 fields (id, command, selector, createdAt, adopted, adoptedAt); jq already assumed by WT-3/task-resolution flows
  mkdir -p "$(dirname "$MARKER")"
  if [ -f "$MARKER" ]; then
    TMP_MARKER="$(mktemp)"
    jq --arg s "$1" --arg m "$BATCH_TIP" '.status = $s | .mergeCommit = $m' "$MARKER" > "$TMP_MARKER" \
      || { echo "WT-4 halt: cannot rewrite marker $MARKER" >&2; rm -f "$TMP_MARKER"; exit 1; }
    mv "$TMP_MARKER" "$MARKER"
  else
    printf '{ "baseRef": "%s", "baseSha": "%s", "branch": "%s", "path": "%s", "status": "%s", "mergeCommit": "%s" }\n' \
      "$BASE_REF" "$BASE_SHA" "$BRANCH" "$WT_PATH" "$1" "$BATCH_TIP" > "$MARKER"
  fi
}
# WT-4a evidence persistence runs FIRST after the landed verify (Step 5, task 0720 R3):
# persist the batch report + verdict artifacts AND the per-run provenance (task
# 0975 R1) into the invoking tree before anything below touches the worktree.
# Any persistence failure routes to WT-5 — landed-but-incomplete: retained marker + mergeCommit.
WT_PATH="$(cd "../<worktree-dir>" && pwd)"   # hoisted: needed by WT-4a AND WT-4b below
# WT-4a provenance persist-out (task 0975 R1): copy the worktree DB's run rows plus
# the .spur/memory/runs/<runId>.md + .state.json records into THIS tree. Run from the main
# tree (cwd = the invoking tree). --task-file (0984 R2) forwards each merged task
# file (post-merge path) so the cited .spur/run/<file> evidence is copied/verified
# too. Resolve the merged path(s) BEFORE this block — an empty value exits 2:
#   TASK_FILE="$(spur task show <wbs> --json | jq -r .filePath)"   # per done task;
#   build TASK_FILE_ARGS=(--task-file "$TASK_FILE")                # one flag each
# Idempotent; conflicts are reported, never overwritten. A non-zero exit —
# including an unresolved or divergent citation, or a half-readable worktree — must
# NOT proceed to WT-4b removal:
SETUP_SCRIPT=plugins/sp/scripts/inline-run-setup.ts; [ -f config/plugin-scripts.json -a -f "$SETUP_SCRIPT" ] || SETUP_SCRIPT="$(superskill script path sp inline-run-setup.mjs 2>/dev/null)"
bun "$SETUP_SCRIPT" --persist-out --from "$WT_PATH" "${TASK_FILE_ARGS[@]}" \
  || { echo "halt: worktree run-record persist-out failed - worktree retained (WT-5)" >&2; write_marker retained; exit 1; }
#
# WT-4b — bounded CWD-holder cleanup (task 0720 R1). $WT_PATH above is the EXACT
# absolute worktree path; a relative path or a stale entry matches the wrong processes.
# Holders = processes with any open fd under the worktree tree (lsof +D walks the
# tree; CWD holders are the common case but +D also catches open-file holders —
# over-match errs toward removal success; a plain -t <dir> matches only the
# directory itself). Orphaned `serve` proof daemons (PPID 1) are exactly this
# class: they defeat `git worktree remove` (ENOTEMPTY), defeat rm -rf, while
# `git worktree prune` still deregisters the tree. Note +D is a full-tree walk,
# so the wait loop below bounds ITERATIONS (6 × 1s ticks + walk cost), not
# wall-clock.
HOLDERS="$(lsof -t +D "$WT_PATH" 2>/dev/null | sort -u)"
if [ -n "$HOLDERS" ]; then
  kill -TERM $HOLDERS 2>/dev/null            # 1) TERM first, all holders (unquoted — word-split PID list)
  for _ in 1 2 3 4 5 6; do                   # 2) bounded wait: 6 × 1s ticks
    sleep 1
    [ -z "$(lsof -t +D "$WT_PATH" 2>/dev/null)" ] && break
  done
  SURVIVORS="$(lsof -t +D "$WT_PATH" 2>/dev/null | sort -u)"
  if [ -n "$SURVIVORS" ]; then
    kill -KILL $SURVIVORS 2>/dev/null       # 3) KILL only the survivors (unquoted — one arg per PID)
    sleep 1
  fi
fi
# 4) Re-query: only an EMPTY holder set may proceed to remove/prune/branch delete.
FINAL="$(lsof -t +D "$WT_PATH" 2>/dev/null | sort -u)"
if [ -n "$FINAL" ]; then
  PORTS="$(lsof -nP -a -p "$(echo "$FINAL" | paste -sd, -)" -iTCP -sTCP:LISTEN 2>/dev/null \
    | awk 'NR>1 {print $9}' | sort -u | paste -sd' ' -)"
  echo "halt: worktree still held by PID(s): $FINAL ${PORTS:+listening: $PORTS}" >&2
  write_marker retained                      # landed-but-incomplete: retained + mergeCommit
  exit 1                                     # -> WT-5: retain worktree + branch,
fi                                           #   NO prune/remove/branch delete
# Pre-removal evidence assertion (task 1067 R1, E71 enforcement): refuse to remove the
# worktree while it still owns evidence absent from (or divergent from) the invoking tree —
# the D63 landing skipped persist-out and silently lost verdicts + receipts. WBSes ride the
# same TASK_FILE_ARGS resolved for WT-4a; run ids are a separate `--run-id <id>` per run and
# belong to THIS check only — `inline-run-setup.ts --persist-out` accepts `--from` and
# `--task-file` alone and exits 2 on `--run-id` (1089 driver note).
CHECK_SCRIPT=plugins/sp/scripts/persist-out-check.ts; [ -f config/plugin-scripts.json -a -f "$CHECK_SCRIPT" ] || CHECK_SCRIPT="$(superskill script path sp persist-out-check.mjs 2>/dev/null)"
bun "$CHECK_SCRIPT" --from "$WT_PATH" "${TASK_FILE_ARGS[@]}" \
  || { echo "WT-4 halt: worktree evidence would be abandoned - persist-out missing or stale (WT-5)" >&2; write_marker retained; exit 1; }
git worktree remove "../<worktree-dir>" \
  || { echo "WT-4 halt: worktree remove failed after landed merge $BATCH_TIP" >&2; write_marker retained; exit 1; }
# Branch deletion runs only after the merge landed, persisted, and the worktree is gone —
# and it is existence-guarded, so a removed branch is never queried. -D over -d: the landed
# verify already proved the merge landed, so an unmerged-refusal would only block proven-merged cleanup.
if git rev-parse --verify --quiet "$BRANCH" >/dev/null; then
  git branch -D "$BRANCH" || { echo "WT-4 halt: cannot delete branch $BRANCH after landed merge $BATCH_TIP" >&2; write_marker retained; exit 1; }
fi
# WT-4c — clean up registry entry in ~/.config/spur/projects.json (task 0924)
spur projects remove "$WT_PATH" 2>/dev/null || spur projects clean --json 2>/dev/null || true
# Success marker, written once and only here: status "merged" + mergeCommit "$BATCH_TIP" —
# after landed verification, required persistence, and cleanup:
write_marker merged
```

Every halt above stops explicitly and falls through to **WT-5** — zero-commit branch (task 0701
R1), failed checkout, divergent base, failed FF, persist-out failure (task 0975), or surviving CWD
holders (task 0720 R1). Pre-merge failures leave the marker at `status: active` and the worktree +
branch untouched. Landed-but-incomplete failures record `status: retained` + `mergeCommit`
BATCH_TIP: the merge landed, and the retained report reads the captured tip only. While any holder
remains, do **not** run `git worktree prune`, `git worktree remove`, or branch deletion — only an
EMPTY holder set may proceed to those steps. The holder
halt report names every surviving PID; the listening port is best-effort — a CWD holder may own no
socket, and `lsof` port discovery failing must not hide the PIDs.

#### Reuse mode — merge, retain

The fail-stop merge sequence runs identically — same guard, captured `BATCH_TIP`/`BASE_TIP`,
ancestry check immediately before FF, `git merge --ff-only`, landed verification (per-call pinned
to the invoking tree, task 1058) — but no WT-4a/4b/4c and no removal: the worktree and branch are
**retained**. Pre-merge halts leave the marker `active`; a persistence failure after the landed
merge records `retained` + `mergeCommit`:

```bash
# Same fail-stop sequence as create mode (zero-commit guard -> BATCH_TIP/BASE_TIP -> ancestry
# rc check -> git merge --ff-only -> landed verify); local refs only — no fetch.
MARKER=".spur/run/worktree-<marker-id>.json"
git checkout "$BASE_REF" \
  || { echo "WT-4 halt: checkout $BASE_REF failed - nothing merged, marker stays \"active\"" >&2; exit 1; }   # -> WT-5
[ "$(git rev-list --count "$BASE_REF..$BRANCH")" -gt 0 ] \
  || { echo "WT-4 halt: branch carries no commits - nothing to merge" >&2; exit 1; }
BATCH_TIP="$(git rev-parse "$BRANCH")" || { echo "WT-4 halt: cannot read $BRANCH" >&2; exit 1; }
BASE_TIP="$(git rev-parse "$BASE_REF")" || { echo "WT-4 halt: cannot read $BASE_REF" >&2; exit 1; }
ANCESTRY_RC=0
git merge-base --is-ancestor "$BASE_TIP" "$BATCH_TIP" || ANCESTRY_RC=$?
[ "$ANCESTRY_RC" -gt 1 ] \
  && { echo "WT-4 halt: ancestry check could not run (git exit $ANCESTRY_RC) - failing closed" >&2; exit 1; }
[ "$ANCESTRY_RC" -eq 1 ] && {
  echo "WT-4 halt: divergent - batch tip $BATCH_TIP vs base tip $BASE_TIP" >&2
  git log --oneline "$BASE_TIP..$BATCH_TIP" >&2
  git log --oneline "$BATCH_TIP..$BASE_TIP" >&2
  exit 1; }   # -> WT-5: tree + branch retained, marker stays "active"
git merge --ff-only "$BRANCH"          # FF-only: never rebase, merge-commit, or resolve conflicts
[ $? -eq 0 ] \
  || { echo "WT-4 halt: FF-merge failed - the concurrent-writer race survives every precheck; marker stays \"active\"" >&2; exit 1; }
LANDED_BASE_TIP="$(git rev-parse "$BASE_REF")" \
  || { echo "WT-4 halt: cannot read landed base tip" >&2; exit 1; }
git merge-base --is-ancestor "$BATCH_TIP" "$LANDED_BASE_TIP" \
  || { echo "WT-4 halt: landed verify failed - $BATCH_TIP is not an ancestor of $LANDED_BASE_TIP" >&2; exit 1; }
WT_PATH="<resolved-worktree-path>"   # reuse mode: the adopted tree's physical path
write_marker () {   # $1 = merged | retained; read-modify-write: preserve WT-3 fields (id, command, selector, createdAt, adopted, adoptedAt); jq already assumed by WT-3/task-resolution flows
  mkdir -p "$(dirname "$MARKER")"
  if [ -f "$MARKER" ]; then
    TMP_MARKER="$(mktemp)"
    jq --arg s "$1" --arg m "$BATCH_TIP" '.status = $s | .mergeCommit = $m' "$MARKER" > "$TMP_MARKER" \
      || { echo "WT-4 halt: cannot rewrite marker $MARKER" >&2; rm -f "$TMP_MARKER"; exit 1; }
    mv "$TMP_MARKER" "$MARKER"
  else
    printf '{ "baseRef": "%s", "baseSha": "%s", "branch": "%s", "path": "%s", "status": "%s", "mergeCommit": "%s" }\n' \
      "$BASE_REF" "$BASE_SHA" "$BRANCH" "$WT_PATH" "$1" "$BATCH_TIP" > "$MARKER"
  fi
}
# Step 5 evidence persistence (task 0720 R3) — required BEFORE the success marker:
if printf 'batch report %s\n' "$BATCH_TIP" > ".spur/run/worktree-<marker-id>-batch-report.md"; then :; else
  echo "WT-5 halt: persistence failed after landed merge $BATCH_TIP; recovery reads captured tips only" >&2
  write_marker retained
  exit 1
fi
# Success marker: status "merged" + mergeCommit "$BATCH_TIP" — written once, only after landed verification and required persistence.
# The worktree and branch are intentionally NOT removed.
write_marker merged
```

The operator supplied the tree, so the operator owns its lifetime. After a green reuse batch
`baseRef == $BRANCH`, so the same worktree keeps fast-forwarding on the next invocation instead of
having to be rebuilt — the continue-the-work loop is stable.

**Fast-forward only.** `git merge --ff-only` is the sole mutation of the terminal sequence; every
other step is a read, a check, a persistence write, or a cleanup, and every tip/ancestry read uses
local refs — no fetch runs anywhere in this lifecycle. If the fresh `BASE_TIP` is not an ancestor of
`BATCH_TIP`, the ancestry check halts before the merge: the report names both tips and both
divergent commit ranges (`$BASE_TIP..$BATCH_TIP` and `$BATCH_TIP..$BASE_TIP`) and falls to the
retention path (WT-5) with the tree and branch retained. A concurrent writer that lands between the
ancestry check and the merge still trips `--ff-only` — the race survives every precheck, and a
failed FF leaves the marker at `status: active`. Do **not** rebase, merge-commit, or resolve
conflicts. The corpus files (`docs/tasks*/`, kanban/index) are
auto-generated and conflict-prone; automated conflict resolution over generated files is exactly the
wrong thing to attempt unattended. FF-only means the merge either is trivially correct or does not
happen.

**Auto-decision carve-out.** The create-mode success path runs unattended on a fully-passing
`--worktree --auto` batch — it does **not** pause even though it performs a merge and a branch
deletion. That is the explicit single exception to Auto-Decision Principle #6 (`cross-cutting.md`),
which otherwise pauses any `--merge` / branch-deletion action regardless of `--auto`. The exception
is safe because `git merge --ff-only` fails closed (it refuses rather than risk losing work), the
branch deletion runs only after the merge has landed, persisted, and cleaned up — and is
existence-guarded (`git rev-parse --verify --quiet`), so a removed branch is never queried;
WT-5 retains the worktree and branch whenever FF is impossible or any task fails.
Reuse mode is **narrower** than the carve-out (it merges but does not delete the branch), so the
carve-out text needs no widening.

**Lifecycle-DB disposition (task 0701 R2d, amended by 0720).** The worktree has its own `.spur`
lifecycle DB, and WT-4/WT-5 remove or retain that tree — the DB state does **not** travel with the
merge. One contract, no alternatives:

- **Committed task files own lifecycle state.** The **committed task file is authoritative**: after
  a green merge the branch's task files already read `done`/`testing` in the invoking tree; no
  `spur task update` or `spur task record` replay runs post-merge. Replay is not "one of two
  options" — it is removed: it writes `updated_at`-only churn and can never restore worktree-only
  DB rows.
- **The persisted invoking-tree artifacts own evidence.** The Step 5 batch report at
  `.spur/run/worktree-<marker-id>-batch-report.md` and the copied verdict JSONs under
  `.spur/run/worktree-<marker-id>-verdicts/` (written before WT-4 removal) are the batch/verdict
  record.
- **Per-worktree lifecycle DB rows intentionally do not travel.** No `task_run_links` import, no
  cross-database provenance synthesis — the DB is per-tree by design.
- **No timestamp-only corpus churn.** Post-merge the invoking tree's DB statuses may read stale
  relative to the committed files; that divergence is accepted, not repaired. Do not run
  `task update`/`task record` to "catch up" the DB, and do not repair churn with
  `git checkout -- docs/tasks*/`.

This is a deliberate choice over auto-migrating DB state: the committed corpus files are the durable
record and the persisted run artifacts are the evidence record.

### WT-5 — Failure path: retain and report (R5)

On any per-task failure, batch halt, HITL pause that ends the run, or a WT-4 halt, the
worktree directory and branch are left **intact**. No destructive automation on this path under
any flag combination (`--auto`, `--force`, `--keep-going` — all leave the worktree in place). The
marker records which class of halt occurred (frozen vocabulary):

- **Pre-merge failure** (failed checkout, zero-commit guard, divergent base, failed FF): the merge
  never landed, so the marker **stays at `status: active`** — the WT-3 marker stands and WT-5 is
  its disposition. Nothing was merged onto the base ref.
- **Landed-but-incomplete failure** (persist-out, holder cleanup, or worktree removal failed after
  a landed merge): the marker records `status: retained` + `mergeCommit` BATCH_TIP — a truthful
  report that the merge landed. The retained path reads the captured `BATCH_TIP` only; a removed
  branch is never queried.

The worktree's own `.spur` lifecycle DB is retained with the tree,
so nothing is lost on either path (see the WT-4 lifecycle-DB disposition for the merged case —
task 0701 R2d). Emit a retention report in the existing halt-report shape:

**Persist-out skip recovery (task 1067 R2, E71).** When evidence was lost to a landing that
skipped persist-out (or the pre-removal assertion fired too late), verdicts remain derivable
from the task records — `spur task show <wbs> --json` carries the recorded verdict and its
proof block, so a lost `<wbs>-verdict.json` is regenerated by re-recording or copying from
the task store. Feature run/latest receipts are re-runnable, not recovered: rerun the
feature-verification workflow in the invoking tree —
`spur workflow run feature-verification.yaml --vars '{"featureId":"<id>"}'` — which rewrites
the receipt files it owns. Divergent files are never auto-overwritten; reconcile by hand.

```
## Worktree retained — <command> <selector>

**Halt cause:** <one-line cause — batch halted at task <wbs> / non-FF base ref / HITL pause / landed-but-incomplete cleanup failure>
**Worktree path:** ../<worktree-dir>
**Branch:** sp/<command>-<selector-slug>-<short-id>
**Base ref:** <base-ref> (<base-sha>)
**Merge state:** pre-merge failure — nothing landed | landed-but-incomplete — mergeCommit <batch-tip-sha>

The worktree and its branch are intact.
pre-merge failure: nothing was merged onto the base ref.
landed-but-incomplete: the batch merge LANDED as <batch-tip-sha> on <base-ref>; retention covers post-merge persistence/cleanup only.
Resume, merge, or discard:

  resume:  cd <worktree-path> && <command> --continue --worktree <worktree-path>
  merge:   git checkout <base-ref> && git merge <branch>     # resolve conflicts manually
  discard: git worktree remove <worktree-path> && git branch -D <branch> && spur projects remove <worktree-path>
```

When the halt cause is `non-FF base ref`, the report replaces the one-line `merge:` hint with this
ordered divergence recipe, run by the operator — the driver never merges, rebases, or resolves
conflicts itself. Other halt causes (task failure, HITL pause) keep the hint as printed:

```
# 1. decide whether conflicts need resolving FIRST, because that decides the merge form:
#      git merge-tree $(git merge-base <base-ref> <branch>) <base-ref> <branch> | grep -q '^<<<<<<<' \
#        && echo "conflicts — use the two-phase form below" || echo "clean — use the atomic form"
#
# 1a. CLEAN (the common case): integrate as ONE atomic merge commit. Never a rebase — task
#     evidence cites the branch's commit SHAs. This is the file's sanctioned merge commit.
#     Do NOT split this into a two-phase merge + commit when nothing needs resolving: that spans
#     the whole gate run, and anything in that window that touches the repository's git state
#     (a test, a hook, a tool invocation) discards MERGE_HEAD silently. The failure is invisible
#     until the history is read — it produced a single-parent commit in run ada5a36c (task 1090),
#     which then needed an extra empty ancestry merge to repair. A cheap probe could not
#     reproduce it (MERGE_HEAD survived `bun run lint`, the rule preset and a test-file subset),
#     so the cross-command dependency is removed rather than diagnosed.
git checkout <base-ref> && git merge --no-ff -m "<prepared message>" <branch>

# 1b. CONFLICTING: the two-phase form is correct here, because the resolution work must land
#     between the merge and the commit. Everything in steps 2–4 belongs to this branch only.
#     A divergent branch cannot fast-forward, so the merge flag from 1a is not needed here.
#     git checkout <base-ref> && git merge --no-commit <branch>
# 2. resolve source conflicts by hand; generated files are then regenerated with the project's
#    generator, never hand-merged
#    (this repo: bun run build:plugin-lib && bun run --filter @gobing-ai/spur build:bundle)
# 3. stage every resolved path and regenerated bundle (git add …) — an unmerged or
#    unstaged path makes step 5 abort
# 4. run qualityGateCmd once, after ALL conflicts are resolved
# 5. commit the merge with the prepared message file — CHECK THE PARENTS FIRST (step 5a)
#    git commit -F <message-file>
#
# 5a. ALWAYS verify the merge really is a merge before treating it as landed:
#       git log -1 --format='%h parents=%p'   # must print TWO parents
#     One parent means MERGE_HEAD was lost somewhere between steps 1 and 5 — the content is on
#     the base ref but the branch is not an ancestor, so `git branch -d` would refuse and the
#     retained worktree's provenance would not be readable from the base ref. Recover by
#     re-running step 1a against the already-landed tree (a content-free merge commit restores
#     ancestry).

# 6. persist evidence out (WT-4a), then WT-4b/4c cleanup, and set the marker to merged
inline-run-setup --persist-out --from <worktree> --task-file …
```

The report reuses the [`--next` chain contract](flag-glossary.md#--next-chain-contract) halt-report
shape (halt cause + where + why), not new vocabulary. Retention is the right default: these batches
are long and already resumable via `--continue`; auto-deleting is data loss, auto-merging is a
partial result presented as a whole. The answer to "what happens if it fails" is "nothing happens,
and we tell you where the work is."

**Partial worktree-removal recovery is inspection-only.** If a create-mode run halted partway
through removal (leftover directory, stale registration), the recovery pass gathers facts and
nothing else: `git worktree list` (is the tree still registered?), `ls` of the path (what remains
on disk?), and the marker / `~/.config/spur/projects.json` registration state. Before removing
anything, verify the leftover path is the batch's **owned** path — match it against the marker's
`path`/`branch` (or the resolved `--worktree <name>` identity), never a guessed sibling directory.
Recursive deletion of leftover directories that survived `git worktree remove` is **never
automatic** — it requires explicit operator authorization. No automatic rebase, recovery merge,
`git worktree prune`, or holder kill runs on this path.

### WT-6 — `--continue` re-entry (R7)

A `--continue` resume of a batch started with `--worktree` must re-enter the existing worktree via
its WT-3 marker rather than creating a second one. Marker lookup tries two paths in order:

1. **Name-resolution path (when `--worktree <name>` is present)** — run the [§ Name resolution](#name-resolution---worktree-name)
   algorithm against `<name>`. The resolved worktree's path identifies the marker file to adopt.
   This path covers the common resume shapes: the operator remembers the name used last time, or
   passes the path (tier-1 match).
2. **Command+selector fallback (bare `--worktree` or absent flag)** — scan
   `.spur/run/worktree-*.json` **in the invoking tree** (where WT-3 wrote the marker —
   task 0701 R2c) for a marker whose `command` + `selector` match the current
   invocation and whose `status` is `active` or `retained`. Create-mode runs that did not name their
   tree resolve here.
3. **Found by either path** → `cd` into the marker's `path`, skip WT-1/WT-2 (no new worktree), and
   resume the loop from the checkpoint (Steps 1–5 with `--continue` semantics).
4. **Not found** → fail loudly: "no resolvable worktree marker for `<command> <selector>` under
   `.spur/run/`; cannot resume a `--worktree` batch without one. Re-run without `--worktree` to
   start a new batch in the main tree, or inspect `.spur/run/` for prior markers." Do **not**
   silently run in the main tree.

Name resolution failing at resume (0 or ≥2 hits) has the same abort semantics as a fresh run: it
names candidates and requires the path form — it does **not** fall through to the command+selector
fallback, because `<name>` was explicit and unambiguous intent.

### WT-7 — Exclusions (R8)

- **`dev-next`** does not get `--worktree` — it dispatches a single step; per-step isolation is not
  worth the worktree cost. `dev-run` is different: it drives a whole task pipeline, so it does get
  the flag.
- **`--mode parallel`** is rejected when combined with `--worktree` — parallel mode already
  isolates each task in its own worktree
  ([§ Parallel isolation](#parallel-isolation---mode-parallel)), and reuse mode
  (`--worktree <name>`) has no per-task meaning.
- **`--mode implement`** is rejected when combined with `--worktree` on `dev-run` — that mode *is*
  the pipeline's implement stage (bug-742) and runs in whatever tree the driver set up; a second
  worktree would split one task's evidence across two trees.
- **`dev-review` without `--triage`** is rejected when combined with `--worktree` — a read-only
  review writes nothing worth isolating; review another worktree by passing a path inside it.
- **No** create-with-name (`--worktree <name>` never creates; an unresolvable name is an error),
  no `--worktree-keep` variant, no auto-cleanup of stale worktrees or markers from prior runs.

### Corpus visibility note

While the batch runs, corpus writes (`spur task update`, `spur feature update`) land in the
**worktree copy**; the operator's main tree still shows pre-run task statuses. This is expected —
the merge (WT-4) or manual integration (WT-5) propagates the writes back. Worth one line in each
command doc so it does not read as a bug.

## Gate preflight (dogfood 2026-08-21, feature A3)

The A3 batch burned multiple full `spur-check-new` runs (~2 min each) that failed only at the tail
gates. The cheap rule gates fail fast when run first — the full-gate run is dominated by the ~65 s
test suite, so a gate run that dies at `test-post-check` or `corpus-check` wasted most of its wall
time. Before launching a full `spur-check-new`:

1. **Run the two rule gates first** — `bun run test-pre-check` (43 rules: `no-console-output`,
   `no-direct-process-spawn`, `cli-*`, `require-corresponding-test`) and `bun run test-post-check`
   (`every-export-has-tsdoc`, `coverage-gate`). They catch boundary/TSDoc violations in seconds.
2. **Promoted code must satisfy the boundary rules `scripts/` never enforced.** A command module
   moving from `scripts/` into `apps/cli/src` must route output through the `CommandOutput` seam (no
   `console.*`), spawn processes via `NodeProcessExecutor` (no `Bun.spawnSync`), get a
   `runtime-boundaries` fs-io exemption for sync reads (mirrors `task.ts`), and a non-command helper
   must not live in `apps/cli/src/commands/` (the noun scan treats every file there as a noun).
3. **Doc/TSDoc edits shift `file:line` anchors** cited by other tasks — the per-task gate surfaces
   them as `L4.anchor-subject-mismatch` (0775: the corpus sweep retired; run `spur task check <wbs>`
   on touched tasks). Repoint the shifted citations (via `spur task update --section`)

## AC traceability

| AC | Where satisfied |
| --- | --- |
| R1.1–R1.4 (selector grammar) | Step 1 — selector resolution table |
| R1.5 (umbrella-parent exclusion) | Step 1 — "Umbrella-parent exclusion" paragraph |
| R2.1 (freeze at kickoff) | Step 2.1 |
| R2.2 (topological order) | Step 2.4 (Kahn, WBS-ascending tie-break) |
| R2.3 (cycle aborts) | Step 2.4 cycle handling |
| R2.4 (unmet out-of-set dep blocks subtree) | Step 2.3 + Step 4.2 |
| R2.5 (satisfied out-of-set dep allowed) | Step 2.3 |
| R3.1 (stop-the-batch default) | Step 4.1 |
| R3.2 (`--keep-going` skips subtree) | Step 4.2 |
| R4.1 (each task reuses the pipeline verbatim) | Step 3.1 |
| R4.2 (`--auto` → profile=auto) | Step 3.2 |
| R4.3 (`--agent` merged into per-task vars) | Step 3.2 |
| R5.1 (orchestrator boundary) | "Zero engine code" preamble + Step 3 |
| R5.2 (structured batch report) | Step 5 |
| 0411 (bounded feature-sync retry suppression) | Step 3.3c — wrapper lives in pipeline `record` + wrap-up `feature-transition`; driver unchanged |
| 0510 R2 (feature-derived strict preflight) | Step 1 — "Feature-derived strict preflight (R2, task 0510)" |
| 0510 R5 (metadata-only host controller) | Step 3.4 + projected `task show` / trace snippets in Step 1, 2.3, 3.1 |

## Parallel isolation (`--mode parallel`)

Under `--mode parallel` (task 0931) every concurrently running task gets its **own create-mode git
worktree** (`sp/run-<wbs>-<short-id>`, cut from the current base-ref tip). Two task pipelines never
share a working tree, and the main tree receives no task writes while the batch runs. The per-task
pipeline (`task-pipeline.yaml`) is unchanged — only where it runs and when dependents may start
differ. The fan-out decision framework (independence, overlap, budget) stays owned by
[sp:parallel-execution](../../parallel-execution/SKILL.md); this section owns the isolation +
integration lifecycle and reuses WT-1…WT-5 by reference — it defines no new worktree mechanics.

### Driver loop

```
WT-1 once on the main tree (dirty → abort)          # one precheck for the whole batch, not per task
ready = topo frontier; running = {}; integrated = set()
while ready or running:
    while |running| < CONCURRENCY and ready has t with deps(t) ⊆ integrated:
        WT-2 create  sp/run-<wbs>-<short-id> (branch sp/run-<wbs>-<short-id>) from BASE_REF tip
        WT-3 marker  {command: dev-runall, selector: <wbs>, batchId: <batchId>}
                     # WT-3 schema fields (path, branch, baseRef, baseSha) + batchId shared by every marker
        RUN[t] = (cd "$WT" && spur workflow run task-pipeline.yaml \
                  --vars '{"wbs":"<wbs>","deferFeatureSync":"true",...}' --async --json)
    wait for any RUN terminal        # trace poll --follow --timeout 600000; stale-evidence rule applies
    on done:    WT-3b commit on $BRANCH → integrate(t)
    on failed:  WT-5 retain (marker retained); failure policy — stop-the-batch default / --keep-going subtree skip
    on timeout/paused:  WT-5 retain; run is non-terminal (HITL approve pause or poll bound) — stop-the-batch
                        default / --keep-going subtree skip; the report marks the task `non-terminal`, never `done`
post: feature sync + refresh per touched feature, one chore(corpus) commit (generated regions, R5)
emit batch report (per-task outcomes + preflight skips + recovery hints + batch verdict)
```

- **Bound (R2).** `CONCURRENCY` is the [`--concurrency <n>`](flag-glossary.md#flag-concurrency)
  value: default **2**, `n ≥ 1`; at most that many pipelines run at once.
- **Eligibility (R2).** A task becomes eligible only when all of its in-set dependencies are
  **integrated** onto the base ref — pipeline-terminal is not enough, because its branch must
  rebase over theirs. Omitting `--mode` stays sequential; this loop is entered only via
  `--mode parallel` on `/sp:dev-runall`.
- **Workdir.** `spur workflow run` records the launch workdir (0784 R1), so launch with `cwd` = the
  task worktree: resume and `.spur/run/` artifact paths resolve there. Read the verdict from
  `$WT/.spur/run/<wbs>-verdict.json` before integration; WT-4a persists it into the invoking tree.

### Integration — rebase, then fast-forward only (R3)

Pipeline-terminal is not terminal under parallel mode. The orchestrator integrates each succeeded
task from the main tree; integrations are **serialized** (one at a time) in completion order:

```
integrate(t):
  git -C "$WT" rebase "$BASE_REF"                # replay onto the CURRENT base tip (re-read per integration)
  ├─ rebase fails → git -C "$WT" rebase --abort → conflict path (R4) below
  └─ rebase clean → git checkout "$BASE_REF" → zero-commit guard → git merge --ff-only "$BRANCH"
                    → WT-4a verdict persist → WT-4b holders → WT-4c registry
                    → git worktree remove → existence-guarded git branch -D → marker merged
```

The merge is always `--ff-only`: a base that will not fast-forward after a clean rebase is a
conflict (R4). The pipeline never creates a merge commit, and no conflict is ever resolved
automatically. `BASE_REF` is re-read at each integration so a task that finished late rebases over
everything integrated before it.

### Conflict — retain, report, block (R4)

A failed rebase is aborted (`git -C "$WT" rebase --abort`), which leaves the worktree clean on its
original branch tip. The worktree and branch are **retained** (WT-5, marker `retained`) and the
task's outcome is `integration-conflict`. There is no auto-resolution — not for generated paths,
not for anything. The batch report's row names the worktree path, the branch, and the manual
commands:

```
resume:  cd <worktree-path> && git rebase <BASE_REF>   # the operator resolves, never the driver
merge:   git checkout <BASE_REF> && git merge --ff-only <branch>
discard: git worktree remove <worktree-path> && git branch -D <branch> && spur projects remove <worktree-path>
```

The task's dependent subtree is blocked under the normal failure policy (Step 4). Resuming a
retained parallel batch with `--continue` is future work; today a retained task is resumed
per-task with `/sp:dev-run <wbs> --worktree <branch>`.

### Generated regions — defer the sync, regenerate once (R5)

The only per-task writer of feature files is the `record` step's post-record feature sync
(`task-pipeline.yaml`). Parallel launches set
the pipeline var `deferFeatureSync: "true"` (default `"false"`): the record step appends
`feature sync deferred to batch integration` to the task report and skips the sync, so task
branches never touch feature files or `docs/features/INDEX.md`. After the last integration, on the
base ref, the orchestrator runs `spur feature sync <f> --json` (service-level suppression) plus `spur feature refresh --feature <f>`
once per touched feature and commits the result as one `chore(corpus)` commit. Sequential and
inline runs keep the default `"false"` and are unchanged. Any rebase conflict — on a generated
path or any other — is an R4 `integration-conflict`; there is no path-based exception.

**Report.** Step 5's per-task outcome vocabulary gains the parallel-only `integration-conflict`;
its row carries `worktree`, `branch`, and the manual commands above. Under parallel mode `done`
means **integrated onto the base ref**, not merely pipeline-terminal.

**`--worktree` is rejected under parallel mode.** `--worktree --mode parallel` fails with
"parallel mode already isolates each task in its own worktree" — reuse mode (`--worktree <name>`)
has no per-task meaning (WT-7). Run parallel batches without `--worktree`, or run them sequentially
with it.

**See also:** `sp:parallel-execution` skill (fan-out decision framework), `sp:super-planner` agent
(parallel mode), `execution-batch.md` § Worktree isolation (WT-1…WT-7 mechanics).

## Subagent execution disciplines

Parallel fan-out and any subagent dispatch obey the four disciplines owned by
[sp:parallel-execution](../../parallel-execution/SKILL.md) (its "Subagent execution disciplines" section is the SSOT):

- **File-handoffs** — pass the artifact as a file path, never bulk context in the dispatch prompt.
- **Durable progress ledger** — per-task status + result location recorded in a file/the batch report so a resumed or compacted run knows what already ran.
- **Per-role model selection** — the cheapest model that fits each role (`--agent` pins the executor; the discipline picks the model per role).
- **Never pre-judge the reviewer** — verify/review subagents receive artifact + contract only; no pre-rated severity, no "do not flag X".

## Batch continuation (`--continue`) — identity binding + checkpoint reconciliation (task 0919)

`--continue` resumes an interrupted batch against the batch's **original identity** — never
whatever a fresh selector resolution or a newer unrelated checkpoint would suggest.

### BC-1 — Re-bind the original frozen identity (R1; AC1/AC5)

Resume identity comes from persisted batch artifacts, in priority order:

1. **WT-3 marker** (worktree batches): the marker's `command` + `selector` and worktree
   name/branch re-derive the original launch (the WT-6 fallback already resolves this way).
2. **Persisted batch report** (`.spur/run/worktree-<marker-id>-batch-report.md`, or the
   invoking-tree Step 5 report of a non-worktree batch): its `Plan:` row IS the frozen ordered
   membership — the resumed loop iterates exactly those WBS rows.

Re-running the selector is a **validation, not a re-definition**: tasks that newly match the
selector but are absent from the frozen plan are reported `not-admitted` and never executed —
freshly listed tasks do not join a resumed batch implicitly. A frozen task whose dependencies
changed after freeze may invalidate its admission: report `blocked (admission invalidated —
re-plan required)` and stop for operator decision. The authorized frozen set is never silently
rewritten (Design).

### BC-2 — Checkpoints are hints (R2; AC2)

Do **not** pick the resume point by mtime alone: `ls -t … | head -1` over `.spur/memory/sessions/`
is identity-blind, so an unrelated later session (different feature or task) must not become
authoritative. Select candidate checkpoints by identity first, newest first within the match:

- frontmatter `workflow` is `task-pipeline` (or the batch's own workflow), and
- `feature_id` equals the batch's feature selector, or `task_wbs` is a member of the frozen plan.

Checkpoints matching neither rule are ignored regardless of recency. A matched checkpoint only
**hints** where the loop left off (`phase`, `next_action` — surface them to the operator); the
driver then reconciles against authoritative state before skipping or repeating any task:

- **Skip** requires ALL of: task file status `done` AND a persisted PASS verdict artifact
  (`.spur/run/<wbs>-verdict.json`, or the worktree-persisted
  `.spur/run/worktree-<marker-id>-verdicts/<wbs>-verdict.json`) consistent with the task's
current metadata. Anything less is not a valid skip.
- **Stale or mismatched evidence** — checkpoint claims done but the verdict artifact is missing,
  the task file moved backwards, or the recorded `source_commit`/`digest` no longer matches —
  yields outcome `recheck`: re-run that task's pipeline. When reconciliation cannot proceed
  safely (lost worktree, unresolvable marker), report `blocked` with the reason. Never treat an
  unverified claim as done.

### BC-3 — Ordering, write ownership, terminal mix, evidence (R3/R4; AC3/AC4)

Sequential dependency-correct execution remains the default; opt-in `--mode parallel` keeps the
existing proven-independence requirements with one writer per tree (Step 3). Mixed terminal
results resume under the unchanged failure policy (Step 4). The Step 5 report keeps per-task
outcomes distinct — `done | failed | blocked | skipped | not-attempted` plus the resume-only
`recheck` and `not-admitted` — and the batch verdict stays `halted`/`aborted`: a resumed,
partially-complete batch is never reported `clean`. Worktree evidence survives cleanup via the
invoking-tree persistence in Step 5 (task 0720 R3); a persistence failure routes to **WT-5**
(retain tree + branch), so partial outcomes are never silently lost and never read as a completed
batch.

See [cross-cutting.md](cross-cutting.md) § "Session Checkpoint Convention" for the canonical
frontmatter fields and the per-task owner-mismatch semantics
(`packages/app/src/workflow/checkpoint-contract.ts`).
