war-campaign · git:20260817.26d8424 · 2026-08-17 · sha256 bdbc7a4212642a0d

war-campaign git:20260817.26d8424A

Immutable. This exact content never changes and is served at /api/v1/blob/bdbc7a4212642a0d.

---
name: war-campaign
description: Run a queue of WAR plans unattended — the hopper. Seeds a campaign ledger from an explicit plan list or a roadmap file, then loops sweep+select, stack-and-plow provisioning, /red-team hardening, /war execution, and atomic record-keeping until the queue drains or a plan halt-and-holds. Explicitly-invoked-only; never auto-triggers.
disable-model-invocation: true
---

# /war-campaign — the hopper

You run a **campaign**: a queue of WAR plans executed one at a time, unattended, each landing as a stacked branch/PR on top of the last. State lives in the **campaign ledger** (uncommitted, single-writer, atomic) plus an **inbox** (uncommitted, multi-writer, maildir-style) — never in git. The core CRUD + contention check is [`assets/campaign-ledger.mjs`](assets/campaign-ledger.mjs) (Node stdlib only, tested by [`assets/campaign-ledger.test.mjs`](assets/campaign-ledger.test.mjs)) — shell out to it, don't reinvent the ledger shape; the helper + its test are the ledger shape's maintained home. Branch model: [ADR 0011](../../docs/adr/0011-campaign-stack-and-plow-branch-model.md).

## Invocation

```
/war-campaign <plan…|roadmap-path> [--wait-for-merge]   # start — seed the ledger from plans or a roadmap
/war-campaign                                           # resume the latest unfinished campaign
/war-campaign add <plan-path> [<ref>]                  # from any chat — drop a plan into inbox/ only (ref defaults origin/master, consulted only when the local path is missing)
```

- **Start** seeds the ledger via `campaign-ledger.mjs init --campaign <dir>` (`<dir>` anchored at the main checkout, see State & resume) from an explicit plan-file list or a roadmap file. Default mode is `stack` (each plan's branch is cut from the previous plan's tip); `--wait-for-merge` waits for the prior PR to merge and rebases off fresh `origin/master` instead.
- **Bare resume** re-reads the latest unfinished campaign's ledger + inbox and continues where it stopped.
- **`add`** only ever appends one file to `inbox/` — it never touches the ledger. Any chat (or a human, or a cron) can drop a plan mid-run; it is picked up at the next plan boundary (a drop for a plan already in the ledger refreshes that entry instead of queueing a second one — see step 1). The optional `[<ref>]` (default `origin/master`) is consulted **only when the local path is missing** — it lets a plan authored on another branch and PR'd onto master be added to a running campaign.

When invoked as `/war-campaign add`, read [references/add-resolution.md](references/add-resolution.md).

Passes `--afk --ace` to `/war` by default for every plan in the queue.

## Lifecycle (per plan, in queue order)

1. **Materialize, then sweep + select.** Ordering is forced: `sweep` reads each drop's plan file (`extractFilesFromPlanFile`), so materialization runs **first** — the lifecycle within this step is `[Materialize → sweep() → next]`.
   - **Materialize (before `sweep`).** Read the inbox drops. For each whose line-1 path is missing locally **and** whose drop carries a `ref:` line (line 2), `git fetch` the ref's branch, then `git show <ref>:<repo-relative-path> > <path>` into the Lead checkout (untracked at this point). Then scan each materialized file for referenced repo paths — backticked path-shaped tokens and markdown link targets that resolve inside the repo (same `isPathShaped` precision as the footprint parser; `[[wikilinks]]` excluded) — and materialize any that are missing locally and present on the ref, **transitively** (each newly materialized file gets the same scan; terminates because a file materializes at most once). Report every materialized path loudly in this step's report. Never merge or rebase master into the stack — identical-content files merge clean when the stack lands.
   - **Sweep + select.** `campaign-ledger.mjs sweep --campaign <dir>` — contention-checks every dropped `inbox/` plan against the existing queue and against each other, inserts in dependency-safe (deterministic) order, deletes the consumed inbox files. A drop whose resolved path already has an entry — in the ledger (`landed` or not), or from an earlier drop in the same sweep — is **not** inserted again: `sweep` refreshes the existing entry's `files` and reports it under `skipped: [{ plan, reason }]` (`'refreshed'` for a non-`landed` match, `'landed'` for a landed one, whose recorded fields are left byte-intact apart from `files` — so `record()`'s first-match can never overwrite a landed run and no permanently-`queued` sibling can keep `is_active` true); a deliberate re-run of a landed plan is an explicit operator act, never a sweep side effect. Then `campaign-ledger.mjs next --campaign <dir>` to pick the plan to run. The **fail-loud backstop** stays: a drop whose line-1 path is still missing at `sweep` time throws ENOENT (a skipped materialization can never pass silently) — the executable owner of End-state #3, without moving git I/O into the stdlib helper.
2. **Provision the branch — stack-and-plow ([ADR 0011](../../docs/adr/0011-campaign-stack-and-plow-branch-model.md)).** Plan 1: `dev/<slug-1>` cut from fresh `origin/master`. Plan N: `dev/<slug-N>` cut from `dev/<slug-(N-1)>`'s tip. `--wait-for-merge` mode: wait for PR N-1 to merge, then cut from fresh `origin/master` instead. Immediately after `dev/<slug>` is created and **before** `/war` runs, `git add` + commit the files materialized in step 1 (the plan file and its pulled references) onto `dev/<slug>`. This is the load-bearing handoff: `/war`'s worker worktrees branch off `dev/<slug>` and only see the plan if it is committed there, never as an untracked file in the Lead checkout. Never merge or rebase master into the stack — identical-content files merge clean when the stack lands, so ADR 0011 is untouched.
3. **Harden — the three-arm triage.** Run `/red-team <plan>`, self-adjudicating under AFK, then triage its verdict + gate output:
   - **(a) Proceed** — `CLEARED`, `CLEARED-WITH-NOTES`, or `ADJUDICATED` (`ADJUDICATED` proceeds unattended under `--afk`): `git add` + commit the patched plan **and** the red-team report onto `dev/<slug>` — the same load-bearing-handoff rationale as step 2: `/war`'s worktrees and any resumer only see what is committed on the branch, never the Lead checkout's uncommitted patches. Then `campaign-ledger.mjs record --campaign <dir> --plan <path> --redteamRounds <n>` (the report's `**Rounds:**` header) and proceed to step 4. A proceed verdict commits-and-continues regardless of any routing metadata — the gate's invariant makes `routeUpstream: true` imply `BLOCKED`, so this arm and arm (b) never collide.
   - **(b) Route upstream** — the gate emits `routeUpstream: true`, or `BLOCKED` with residual open questions: halt-and-hold (Failure section) with `stopPoint: redteam-route-upstream`, stamping `--redteamRounds <n>` (the report's `**Rounds:**` header) alongside `--stopPoint` in the same `campaign-ledger.mjs record` call — the wrap-up's hardening row sources rounds from the ledger, and the halted plan is exactly the one whose round count it exists to surface. CAMPAIGN-STATE.md carries the `/war-strategy` regrill command plus the report's `## Route upstream` agenda (the handoff block — [loop-budget doctrine](../red-team/references/loop-budget.md)); re-entry is `/war-campaign add <plan>` after the regrill (the entry is still queued; `sweep` refreshes it idempotently, never duplicating — a bare `/war-campaign` — its resume invocation — equally re-picks it) — never skip-and-continue: the stack lands plans in order ([ADR 0011](../../docs/adr/0011-campaign-stack-and-plow-branch-model.md)).
   - **(c) Persistent `INCOMPLETE`** — the same halt arm as (b): halt-and-hold, `stopPoint: redteam-route-upstream`, stamping `--redteamRounds <n>` in the same `record` call as `--stopPoint`, regrill command + agenda in CAMPAIGN-STATE.md, re-entry via `/war-campaign add` (the entry is still queued; `sweep` refreshes it idempotently and never duplicates — a bare `/war-campaign`, its resume invocation, equally re-picks it).
4. **Execute.** Run `/war <plan> --working dev/<slug-N> --landing dev/<slug-(N-1)> --campaign-ordinal <N> --afk --ace` (plan 1 lands to `master`). The ordinal is the plan's **1-based position in the ledger's `plans[]` queue at selection time** (the step-1 `next` pick — the same position the `dev/<slug-N>` stacking derives from); it is **display identity only** — `/war` uses it solely to stamp the `war-c<K>-…` staged phase-script basenames and never scans campaign ledgers itself; a plain `/war` run omits the flag and the `c<K>` segment is simply absent.
5. **Record.** `campaign-ledger.mjs record --campaign <dir> --plan <path> --status landed --branch <b> --pr <n> --sha <sha> --stopPoint <token> --backstops <json>` — atomic temp+rename, so a laptop-close mid-write never corrupts the queue. Omitted flags leave the entry's existing values untouched (`--stopPoint` is set on halt-and-hold, not on a clean land; `--redteamRounds` was already stamped by step 3 — every arm stamps it — and survives here). `--backstops` carries that plan's `/war` handoff `backstops[]` (the validations its run deferred, schemas.md) as a JSON array string, stamped onto the plan's ledger entry for the wrap-up aggregate; omit it for a plan that deferred nothing.
6. **Context hygiene.** Rewrite the **write-ahead checkpoint** (`CAMPAIGN-STATE.md`, see State & resume) at the plan boundary — mandatory, not best-effort. Compaction itself stays with the harness (user-invoked or auto; the model cannot trigger it), and whenever it fires the `SessionStart(compact|clear|resume)` hook re-injects the checkpoint into the fresh window. Lead thrift is the only real lever on window growth in between: offload verbose verification to subagents and keep notification handling terse.
7. **Loop** to the next queued plan.

## State & resume

- **Anchor at the main checkout, never the invoking worktree.** All campaign state — the ledger, the inbox, and `CAMPAIGN-STATE.md` — lives under `.claude/campaigns/<id>/` at the **main checkout**, never the invoking worktree's `.claude/` (a per-worktree session would strand the queue — the rationale skills/war/SKILL.md's Run-manifest anchor states). Resolve the main checkout from any linked worktree via `git rev-parse --path-format=absolute --git-common-dir` (the main `.git`'s parent) — e.g. `MAIN=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")`, campaign dir `$MAIN/.claude/campaigns/<id>` — and pass that anchored `<dir>` as `--campaign` in **every** `campaign-ledger.mjs` invocation below (`init`/`add`/`sweep`/`next`/`record`). This anchors the *campaign dir*; it coexists with, and is distinct from, the add-resolution protocol's step-0 `git rev-parse --show-toplevel` anchoring, which anchors the *dropped plan's path* token, not the campaign dir.
- **Campaign ledger** (`.claude/campaigns/<id>/ledger.json`) — the plan queue + per-plan status/branch/PR/SHA/stop-point/backstops/red-team rounds. Single-writer (only the campaign Lead's `sweep`/`record` touch it); every write atomic. The entry shape's maintained home is the `campaign-ledger.mjs` helper header + its test ([ADR 0046](../../docs/adr/0046-specs-are-posterity-skills-cite-maintained-surfaces.md)).
- **Inbox** (`.claude/campaigns/<id>/inbox/`) — the multi-writer add path, one file per plan, maildir-style (atomic by construction, no locks). Only `sweep` drains it.
- **Roadmap** — authoring input + on-demand snapshot (Rev 1), never the live feed. `init` ingests one. *"I'm switching machines"* → render the ledger out as a committable roadmap, then `init` from it on the new machine. Render/ingest beyond `init` is agent prose using the helper's read surface, not helper code.
- **Resume** re-reads ledger + inbox + `CAMPAIGN-STATE.md` — the real guarantee. Before trusting the ledger, **reconcile it toward git** (`git ls-remote`, `gh pr view`) — the ADR 0008 discipline.

### Checkpoint — CAMPAIGN-STATE.md

Path `.claude/campaigns/<id>/CAMPAIGN-STATE.md` — sibling of `ledger.json`, uncommitted, single-writer (the Lead), plain markdown. It is the Lead's curated resume brief: the human-readable NOW of the campaign, distinct from the machine-readable ledger.

**Write-ahead protocol** — rewrite the file *before* each of:

- launching `/red-team` on a plan,
- launching each `/war` phase,
- entering a `--wait-for-merge` wait,

and at **every plan boundary**. The point of write-ahead is that the brief is already fresh *before* you dispatch the thing you'll wait on — so its freshness never depends on when compaction fires, which nobody controls.

**Invariant** — the file must always let a fresh context resume from NOW: current queue status, in-flight run/task ids, the continuation sequence (what to do next), and any gotchas a resumer would otherwise rediscover the hard way.

**Brief, not authority** — resume still reconciles toward git (`git ls-remote`, `gh pr view`) before trusting anything; the checkpoint is a brief toward git truth, never the source of truth, and it is not the ledger. This is the ADR 0008 discipline.

**Honest boundary** — the write-ahead half is a prompt directive: nothing forces the Lead to rewrite the file on time. The code-enforced half is [`hooks/inject-campaign-state.sh`](../../hooks/inject-campaign-state.sh) — a campaign-gated `SessionStart(compact|clear|resume)` hook that deterministically re-injects this file after compaction. See [ADR 0016](../../docs/adr/0016-campaign-compaction-survival.md).

## Failure

**Halt-and-hold** when step 3's triage lands in arm (b) or (c) — `routeUpstream: true`, `BLOCKED` with residual open questions, or persistent `INCOMPLETE` under AFK, recorded as `stopPoint: redteam-route-upstream` — or `/war` hard-halts (`audit-blocked`, `conflict`, dead phase, `land_stale`, `held:submodule-pr`): checkpoint → record the stop point → `PushNotification` → stop. Nothing above the failed plan starts; everything below it has already landed as stacked PRs.

The final report **always** states the bottom-up merge order — the stacked PRs must be merged in the order they were stacked, lowest first.

## Campaign wrap-up — Unexecuted backstops & per-plan hardening

At the end of a campaign (queue drained or halt-and-hold), the final report renders the **aggregate of every validation the whole campaign deferred** — the union of each landed plan's handoff `backstops[]`. The Lead's wrap-up render calls the module's `aggregateBackstops(campaignDir)` export (it is a module export, not a CLI subcommand): it flattens each plan's recorded `backstops`, tags every entry with its origin plan slug, and tolerates plans that predate the field (a `null`/absent `backstops` contributes nothing — an in-flight or pre-ratified-backstop plan is not an error). Render each entry as *plan · check · why deferred · runner*; entries carrying `aiDeclared: true` render their **AI-declared** marker (ADR 0014 provenance — an AI-declared waiver is never surfaced as operator-ratified). An empty aggregate renders literal `None`.

The wrap-up also renders a **per-plan hardening row** — *rounds · blockers · adjudications · report path* — one row per plan: rounds from the ledger's `redteamRounds` (`n/a` when null or absent — a pre-hardening or in-flight entry), blockers and adjudications counted from that plan's red-team report, and the report path (`docs/red-team/YYYY-MM-DD-<plan-slug>.md`). Any value without a source renders `n/a`, never fabricated.

<!-- bundled-routine note: step 6 above is our own write-ahead checkpoint rewrite, not an external ecc:strategic-compact invocation and not a compaction trigger — the model cannot invoke /compact; compaction stays with the harness and the SessionStart hook re-injects the checkpoint afterward. -->