# Auto Wave

> Sequential wave runner. Drives a queue of "waves" automatically, one after another, each wave as a FRESH headless `claude -p` process (empty context, so nothing piles up), branch isolated so nothing auto-deploys, with an exception stop: it holds only when a wave is not green or a real product decision comes up. A wave counts as green ONLY with proof (executed_tests > 0, no skips, a real proof artefact; a non-empty caveat is a stop signal), self-attestation counts as red. After a green run the end-of-run hook does everything UP TO the merge: push, open the PR, post a fresh code review, write a deploy handoff, notify, report. It NEVER merges and NEVER deploys. Use when the user says "/auto-wave", "run the waves", "start auto-wave", "work through the queue", "start the wave runner", "build the waves automatically". Mode "full" chains waves, pre-deploy and deploy behind one word, with a fixed abort list as the gate. NOT for planning a single wave, building a single issue, reviewing, or deploying on its own.

- Skill: `mfraunhofer/auto-wave` (Agent Skill, multi-file: 9 files)
- Install (CLI): `npx skillmds@latest add mfraunhofer/auto-wave`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mfraunhofer/auto-wave/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: mfraunhofer (https://skillmd.com/u/mfraunhofer)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mfraunhofer/auto-wave

---


# auto-wave

A **queue of waves, driven sequentially and automatically**. Every wave runs as its own,
freshly started `claude -p` process. The runner itself is a deterministic Python script,
**no LLM in the driver**: the intelligence sits in the per-wave processes it spawns.

It replaces the manual loop of "open a new chat after every wave and paste the next one in".
`auto-wave : wave-planning` behaves like a loop runner to a single build step, one level
above building, at **wave** granularity rather than issue granularity.

## Why a fresh process per wave

This is the whole point: context **resets** at every wave and does **not** pile up over N
waves. Wave 10 is as sharp as wave 1. Four layers, all enforced in the wave prompt:

1. **Hard reset.** Every wave is a new claude process with an empty conversation. At the
   start it reads only what is curated: its issue specs, the project plan, the relevant
   rules files, the project context file, the previous wave's handover.
2. **Heavy reading in throwaway sub-agents.** Searching, reading many files, reviewing all
   happen in `Explore` / `general-purpose` / `code-reviewer` sub-agents; only their
   conclusion comes back.
3. **A one-page handover instead of a conversation history.** The baton between waves lies
   on disk (handover file plus your project's living documents), not in a chat.
4. **Keep waves small.** Queue discipline: one to two days of work per slice.

## Safety contract (not negotiable)

- **Branch isolation and the merge stop line.** The runner creates
  `<branch_prefix>/<run-id>` off the base branch and builds ONLY there. **Never merge into
  the base branch. Never push to it. Never deploy.** ALLOWED: pushing the feature branch to
  origin and opening a PR, because neither triggers a production deploy in the usual setup
  where only a merge into the default branch deploys. For the runner itself the rule stays:
  **never `gh pr merge`.**
- **Proof of green instead of self-attestation.** Every `claude -p` wave has to PROVE its
  `green`: `executed_tests` > 0, `skipped_tests` == 0, and `proof` pointing at an existing,
  non-empty artefact (real test runner output, `wave-NN.proof.txt`). The runner
  (`enforce_green_proof` in run.py) demotes green without proof to `blocked` (exception
  stop) and PERSISTS that demotion into the status file, so a resume does not count the wave
  as green either. A non-empty `caveat` field is a stop signal, not prose, on every path
  including typed tasks. **The origin is measured, not theoretical:** a wave once wrote
  `green` with 0 executed tests and an honest caveat in the JSON that no parser read.
- **Exception stop.** The run goes through on its own and holds **by itself** only when: a
  wave does not turn green despite self-fixing (`blocked`), OR a real **product, design or
  strategy** decision comes up (`needs-decision`, because a human decides product calls,
  never the AI), OR the state is unclear (no status file, timeout). On a stop it names the
  branch and the run log and exits 2.
- **Research before `blocked`.** Before a wave writes itself off as `blocked` (a self-fix
  fizzled, the same failure repeats, the fix goes in circles), the prompt tells it to STOP
  rather than guess the next fix: put an adversarial `research` sub-agent on an ALTERNATIVE
  cause first ("do NOT confirm my assumption"), work the finding in, and only then, if it
  still does not hold, write `blocked`.
- **The status file is the truth, but green needs proof.** Every wave writes a
  `wave-NN.status.json` as its last action (`green` / `blocked` / `needs-decision`). Missing
  means stop, never drive on blind. The status file is the truth about what the wave
  CLAIMS; the proof check is the truth about whether that counts. After a green wave the
  **runner** additionally attaches the commit hash as a `commit` field (for the PR body and
  the report); the wave does not write that field itself.
- **Sequential, one worktree.** Waves run one after another on ONE branch. That is a
  deliberate choice against parallel worktrees: a shared checkout has one HEAD, and a second
  build stealing it mid-wave is a failure mode that costs more than the concurrency wins.

## The queue

One `## <slug>` section per wave, in build order. Template: `queue.example.md`.

```
## backend-add-service-form
goal: Backend form to add and edit a service          # MANDATORY, one sentence
specs: .scratch/phase-2/                              # optional, path to the issue specs
review: code-review                                   # optional, default from config.json
```

A wave with specs builds noticeably more precisely than one working off a goal sentence, so
plan the issues first where you can.

## Typed queue (solo / ops / gate + needs)

Instead of build waves only, the queue can hold **mixed, typed tasks**: work that needs an
exclusive resource, ops and infrastructure fixes, and steps held for a human word, with
ordering dependencies. **One run drives all of it.** The runner detects the mode
**automatically** by the `lane:` field. Template: `queue.typed.example.md`.

Every entry is `## <id>` plus fields:

- **`lane:`** (mandatory) `solo` | `ops` | `gate`.
  - **`solo`** strictly **serial**, for tasks that need an exclusive resource (one shared
    test database, one device, one licence seat), so never two at once. Before each one the
    runner brings the shared checkout onto a clean base best-effort; uncommitted tracked
    work on a foreign branch stops the run instead of being clobbered.
  - **`ops`** a headless `claude -p` that reads and executes the spawn file. **Serial** by
    default, which is the safe answer. Concurrent **only opt-in** (`parallel: true`) **and**
    only when `ops_parallel_isolated` is on in `config.json`. Reason: a shared checkout has
    one HEAD. **The win of this runner is hands-off, not concurrency; do not over-promise it.**
  - **`gate`** **never** runs automatically. The runner holds, pushes a notification "ready
    for your word: <note>" and writes a report. Gates are deferred to the end of the run
    (after all solo and ops work) and reported as "ready" or "needs still open". The deploy
    is the typical one.
- **`spawn:`** mandatory for solo and ops (absolute or project-relative path to the spawn
  file, which **has to exist**, otherwise the run stops hard BEFORE it starts). Optional for
  a gate.
- **`needs:`** optional. The id of another task; this one runs only once that one is
  **green** (a needs DAG, topologically resolved and enforced). A non-gate entry may **not**
  depend on a gate: gates never run, so it would block forever, and validation refuses it.
- **`parallel:`** optional (ops only). See `ops_parallel_isolated`.
- **`note:`** optional free text, mostly for a gate: what your word triggers here.

**Validation BEFORE the run** (a hard stop on any violation): known lane, existing spawn
file for solo and ops, needs pointing at an existing id, no self-needs and no gate-needs, no
duplicate ids, no needs cycle, `parallel` on ops only.

**Runner behaviour:** resolve the needs order, drive solo and ops in plan order, and every
task writes ITS own report (from its spawn file) **plus** a `task-<id>.status.json` as its
last action. The runner adds a collected report, `RUN.md`, `PROGRESS.md` and a notification.
Exception stop unchanged. A typed run deliberately does **not** create a shared branch and
does **not** open a collected PR: every task manages its own git and PR through its spawn
file, and the gate entry refers to the PRs that are already open.

## End-of-run hook (everything UP TO the merge, never the merge itself)

Runs **only when ALL waves are green** (a full run or a resume to the end; **not** for
`--only` single-wave tests, not after a stop).

1. **Commit check.** Does the branch have commits ahead of the base? At **0** (every wave a
   no-op) there is no push and no PR; the log and the report note "no changes, nothing to
   merge".
2. **Push:** `git push origin <branch_prefix>/<id>`, only the feature branch, never the base.
3. **PR:** `gh pr create`, title `auto-wave <id>: <N> waves`, body one line per wave out of
   its `status.json` (summary plus commit hash), ending in the do-not-merge warning. If the
   PR already exists (a resume), it is reused instead of duplicated. If `BOARD_CARD` is set,
   the resolved card reference goes into the body as an anchor (see "Board card" below).
4. **Fresh review (release chain step 1).** The WHOLE PR diff goes to ONE throwaway
   `claude -p` process with a fresh context, deliberately NOT the build context. Findings
   with a `[HIGH]` / `[MEDIUM]` / `[LOW]` prefix are posted as a PR comment; high findings
   are marked separately in the comment, the notification and the report.
5. **Frontend review (release chain step 2).** If the diff touches UI paths
   (`frontend_review_globs`), a SECOND throwaway process goes at the **running stack**: boot
   the app, click the touched surfaces for real. This is the class of check that catches a
   dead first-run path which green unit tests walk straight past. Findings go up as their
   own PR comment and high findings count like review highs. No UI in the diff means the
   step is skipped as n/a.
   **Mobile is mandatory, not a bonus:** the same surfaces are checked at the widths in
   `frontend_review_widths` (default 390, 375, 430 CSS pixels; the narrowest is your
   supported floor, anything below is out of scope). Four measured things instead of a
   feeling: no horizontal overflow when a LONG name is used instead of a short one · tap
   targets do not vanish under a dialog, a footer or the keyboard · sticky headers stay
   visible while scrolling · text is not squeezed into a few pixels. Screenshots land in the
   run's state directory. A pre-existing defect that also reproduces on the base branch is
   named AND marked as pre-existing, so it does not block the release by mistake.
6. **Deploy handoff.** Once the PR is open the hook writes a finished, **deterministic** (no
   LLM) handoff to `state/<id>/SPAWN-deploy.md`: a start line, the **release-chain gate**
   (mandatory before the merge: code-review comment present · frontend-review comment
   present or n/a · every `[HIGH]` fixed and RE-reviewed · key-function tests really
   executed with proof artefacts), the **scope** detected from the PR diff through
   `deploy_handoff.scope_globs`, and the **forced order** (backup, ONE human go, schema and
   server functions, merge = frontend LAST, verify), stop at any deviation, plus a mandatory
   closing report. **This is a briefing, nothing else.** auto-wave still merges and deploys
   NOTHING. On a stop or a no-op run, no handoff is written, because there is nothing to
   deploy.
7. **Staging instead of a deploy prompt.** If `deploy_queue_script` is configured, the hook
   enters the run into that queue and **ends there**. Push, PR and both reviews stay
   unchanged (a PR is not a deploy, and without a PR there would be no review comments).
   What falls away is the "reply deploy, it goes live" line. Reason: collecting several
   finished runs into ONE review round and ONE deploy beats deciding once per run.

**Best-effort contract:** if push, `gh`, a review or the handoff fails, that does NOT crash
the run. Errors land in `state/<id>/end-of-run.log` and in the report, and the branch stays
usable locally.

**A report at EVERY end of a run** (green OR stop, including `--only`):
`<project_root>/.scratch/pm-inbox/<YYYY-MM-DD-HHMM>-auto-wave-<run-id>.md` with front matter
(`topic` / `issue` / `pr` / `status: done|blocked` / `worktree`) and, per wave, ✅ or ✋ plus
the summary, the branch, the PR number and link, whether the review found anything high.
That is the same inbox the `pm` skill reads.

**Teardown at EVERY end** (green, stop, crash; it hangs on `atexit`, not on the success
path): `teardown_local_stack()` stops the containers matched by `teardown_docker_filter`
again. `docker stop`, never `docker rm`, so no data loss. What a run brings up does not keep
running afterwards. Switch it off with `"teardown_after_run": false`.

## Board card (optional adapter)

The runner knows **nothing** about any board. If the env var `BOARD_CARD` is set, it looks
for an executable adapter at `<project_root>/scripts/board-card` and calls it best-effort:

```
scripts/board-card find "<query>"                      -> prints ONE card reference
scripts/board-card built "<card>" --pr <n> --url <u> --branch <b>
```

`find` resolves whatever you put in `BOARD_CARD` (an id, a short link, a title query) to the
stable reference that goes into the PR body. That anchor matters: a board watch can only
couple a card to a PR through a reference in the title, the body or the branch name, because
that is the only coupling that is a measurement rather than an interpretation. `built` moves
the card to whatever "built, waiting to be tested" means on that board. Board id,
credentials, column names and label vocabulary all live in the adapter, where the board
lives. **A project without an adapter gets one line and is done.** That is not an error: a
board being down is not a build failure and must never cost a green run. (Same cut as the
board watch in the `pm` skill.)

## Preconditions (before every run)

- `claude` CLI on PATH and logged in.
- The repository is on the base branch and has no uncommitted tracked changes (untracked is
  fine). The runner refuses otherwise.
- The queue file exists. Templates: `queue.example.md`, `queue.typed.example.md`.
- `config.json` points `project_root` at your project. Everything else has a working default.

## Calls

```
python3 ~/.claude/skills/auto-wave/scripts/run.py --status     # queue plus last run
python3 ~/.claude/skills/auto-wave/scripts/run.py --dry-run    # plan, branch, wave-1 prompt, do NOTHING
python3 ~/.claude/skills/auto-wave/scripts/run.py --run        # real run (create the branch, drive the waves)
python3 ~/.claude/skills/auto-wave/scripts/run.py --run --name <id> --from <N>   # resume after a stop, from wave N
python3 ~/.claude/skills/auto-wave/scripts/run.py --run --only <N>               # wave N only (no push, no PR)
```

`--config <path>` loads an alternative config file, which is what a sandbox test uses.

## Progress: notifications plus PROGRESS.md (wave level)

The runner reports at every **wave boundary**, deliberately at wave granularity only, with
no sub-wave steps (anything finer is spam):

- **A notification** through `notify(title, message)`: a desktop banner (macOS `osascript`,
  fallback `terminal-notifier`) and, if `ntfy_topic` is configured, a phone push. Both fire
  independently and **best-effort, so they NEVER crash the run**. Exactly ONE notification
  per boundary: a wave turning green, the run finishing (with the PR number and the handoff
  path, or a warning when the review found something high, or "no changes, nothing to
  merge"), and, most important, the run **stopping** with the reason in the text.
- **A live checklist** at `<state_dir>/<id>/PROGRESS.md`, rewritten at every boundary: one
  line per wave, done `- [x] … ✅ green (HH:MM)`, running `▶ running (since HH:MM)`, stopped
  `✋ stopped: <reason>`, upcoming without a suffix. On a resume (`--from N`), waves with a
  green status file from the earlier partial run count as done. It is readable from any other
  chat, because it lives on disk.

## How YOU (Claude) drive a run

1. **Straight to `--run`, no dry run, no question.** No plan preview for confirmation, no
   "say go" gate. The only hard gate is the preconditions the runner checks itself (on the
   base branch, clean tree, queue not empty); it refuses with a clear message when something
   is missing, and only then do you relay to the user instead of building blind.
   `--dry-run` and `--status` stay available as manual tools when they are explicitly asked
   for, never as the default.
   **The exception is BUILDING the queue:** whoever assembles the queue asks which worker
   model the waves should run on, as multiple choice with a recommendation, and the
   recommendation depends on the work rather than defaulting to the strongest model
   everywhere. Heavy or risky waves (schema, permissions, anything close to production)
   deserve the strongest one; simpler waves (UI, content, docs) do not. The answer goes into
   `config.json` as `"model"` and applies to the WHOLE run, so a mixed queue is better split
   into two runs. The start itself stays question-free.
2. Start the run **in the background** (long runtime, hours are possible). Tee it into a
   durable log: the state on disk (`<state_dir>/<id>/RUN.md`) is readable at any time through
   `--status`, including from another chat.
3. If the run **stops** (exit 2), relay the reason in plain language. For `needs-decision`,
   put the question to the user as a multiple-choice question with a recommendation, work
   the answer into the wave (or extend the specs) and continue with `--from N`. The runner
   has already dropped a `status: blocked` report in the inbox, and there is no push and no
   PR after a stop.
4. If **all waves are through**, the end-of-run hook has already pushed, opened the PR,
   posted the review comments, written the deploy handoff and filed the report. You relay:
   the PR number and link, whether the review found anything high, and the handoff path.
   If push or PR failed (best-effort), name the branch and the log; the branch is usable
   locally. **Never merge (`gh pr merge`) and never deploy yourself.**

## Mode "full": the whole chain behind one word

> No new runner. The mode chains what already exists: an auto-wave run (this skill), then a
> pre-deploy step as its own spawn, then your deploy skill. The green path without "full" is
> byte-unchanged; new are this section and `scripts/full-lock.sh`.

**Trigger:** the user says "full auto-wave" in a fresh chat.

### The contract

1. **The invocation IS the deploy go**, given in advance and conditioned on the abort list
   below. The ONE human go your deploy procedure demands before the first production
   mutation counts as given by the invocation, AS LONG AS nothing on the abort list hit.
   Outside a full run, your normal per-deploy go stays exactly as it was.
2. **Conductor principle.** The chat never works inline: waves run as fresh headless
   processes anyway, pre-deploy runs as **its own spawn**, and the deploy runs through your
   deploy skill as another spawn. Handovers happen only through files (queue, handoff,
   inbox). The conductor reads **end reports, never diffs or logs in full**, which is what
   keeps its context from filling up.
3. **Scope per run, measured rather than guessed.** A preflight measures the real state:
   open queue waves (`run.py --status`) plus every merge-ready staged PR (`gh pr list`,
   `gh pr checks`). Everything green that is not blocked, parked or needs-decision comes
   along. What cannot come this time stays staged and joins the next run automatically.
4. **Review findings are a hard gate.** Fix in the PR branch first, re-run the gates with
   proof, re-verify the finding in the same medium, then continue. Not autonomously fixable
   (a product decision, a production-data touch, a guardrail) means a needs-decision stop.
   It is NEVER deployed around an open finding.
5. **The abort list.** Every hit stops the chain BEFORE the next production mutation and
   sends a push with the reason in plain language:
   - a gate that is not green with proof (executed > 0, 0 skipped); self-attestation is red
   - needs-decision, or a product decision of any kind
   - a schema change or a function deploy that is not in the handoff
   - a smoke test after the deploy that is not green: roll back, then stop
   - a test-infrastructure-only PR (no deploy artefact): merge only, never deploy
6. **Every end is reported:** a push in plain language (on a stop, the question itself, not
   just "stopped"), a PM inbox report, board cards pulled forward best-effort. Nothing is
   left running, and the lock is ALWAYS released, including on a stop or a crash.
7. **Never two full runs at once.** Lock through `scripts/full-lock.sh acquire` (atomic
   `mkdir`; the lock path comes from `AUTO_WAVE_LOCK`). A fresh lock holding means stop with
   a message instead of a second run; a lock older than 24 hours is reported as stale and may
   be taken over after checking that nothing is really running. Same rule as "never two
   deploys of the same target".

### Conductor protocol

0. **Lock:** `bash ~/.claude/skills/auto-wave/scripts/full-lock.sh acquire`. Refused means
   stop, with the lock age in the message. From here on, releasing the lock belongs to EVERY
   ending.
1. **Preflight** (contract point 3): measure the scope, write it down as a run note (what
   comes along, what stays behind and WHY). If the user is away from the machine, keep the
   machine awake for the duration of the run.
2. **Step A, waves:** queue not empty means `run.py --run` in the background (the section
   above applies unchanged, including the end-of-run hook). Queue empty means the step is
   skipped. A stop of the runner is an abort-list hit, so the chain ends there: no
   pre-deploy, no deploy.
3. **Step B, pre-deploy as its own spawn:** a fresh headless process runs the pre-deploy
   convention over the COMPLETE staged batch (including the fresh auto-wave PR): preflight
   against the forge, ONE collected review, the findings gate (point 4), the deploy handoff,
   board and inbox. The conductor then reads ONLY the handoff and the report.
4. **Step C, deploy:** your deploy skill with the most recent handoff, as its own spawn. The
   human go is given in advance by the invocation (point 1) and the spawn is told so
   explicitly. ONLY what is in the handoff gets deployed; the smoke test afterwards is
   mandatory, and red means roll back and stop.
5. **The end** (point 6): push in plain language, the conductor's own report (waves ✅/✋,
   pre-deploy ✅/✋, deploy ✅/✋/skipped, PRs, what went live), board cards, teardown, and
   `full-lock.sh release`.

**Anti-scope of the mode:** no building, fixing or reviewing in the conductor chat itself
(all of it in spawns), no scope beyond what was measured, no deploy around the abort list, no
second full run next to a running one.

## Anti-scope (what the runner does NOT do)

It does not merge into the base branch (never `gh pr merge`), does not push to it, does not
deploy, does not make a product decision, and does not skip a wave when things are unclear.
(Pushing the feature branch, opening the PR, posting the review comments and writing the
handoff IS the end-of-run hook's job, and that is allowed, see the safety contract. The
handoff is a **briefing**, not a deploy.) The per-wave review (`review:` field) is done by
the wave itself; the end-of-run fresh review is an ADDITIONAL independent instance with
fresh eyes. Planning the waves is a different job: a wave without specs plans minimal issues
for itself at the start, but well-specified specs give noticeably better results.

## Self-maintenance

When the call, the status protocol or the safety contract changes, update this SKILL.md and
the README in the same pass. Findings from real runs (what breaks, what the user corrects)
get appended here.

## Learnings from real runs

- **Collision with a parallel build in the same checkout.** The runner owns
  `git_repo_subdir` exclusively (one HEAD, one working tree). If ANOTHER build runs in the
  same checkout at the same time, its `git checkout` or rebase tears HEAD away and the
  running wave builds on the wrong branch or commits into foreign work. Symptom: after
  `--run`, `git branch --show-current` shows a FOREIGN branch instead of the wave branch.
  **Fix:** give the run its own worktree instead of the shared checkout (`git worktree add`,
  symlink the dependency directory, and a custom config with `"git_repo_subdir"` pointing at
  it plus `--config <path> --run`). Both builds then share only the `.git` objects, never
  HEAD. **Numbers that have to be unique across branches (migration numbers, for instance)
  must be coordinated by hand across the parallel branches**, because each branch only sees
  the remote base and both would otherwise grab the same next number.
- **A background run can die silently.** A run started in the background was killed
  externally while it blocked on its wave-1 worker: NO timeout (the wave timeout was hours
  away), NO exit status to the caller, NO `RUN.md` or `status.json` written. Symptom: no
  process, `PROGRESS.md` still says "wave 1 running", the branch exists with **0 commits**,
  but the wave-1 files sit in the working tree as uncommitted tracked changes, because the
  killed worker had already edited and never committed. **Diagnose first:** smoke-test a
  headless `claude -p` separately, so you know whether the nested worker is the cause at all.
  **Recovery is a fresh start, not a resume** (0 commits means there is nothing to resume):
  check out the base branch, **discard** the half-finished, unreviewed worker diff (a diff
  out of a killed process has not earned any trust, and the value of this runner is the
  *verified, reviewed, committed* result), delete the 0-commit orphan branch, verify a clean
  tree and start again. On the restart, tee into a durable log, because a harness's temporary
  output can be cleaned up when it dies.
- **A queue can be older than the repository.** A wave once found that its issue had already
  been merged and verified that instead of blindly rebuilding it. Good behaviour, keep it.
  The consequence is for whoever WRITES the queue: check every issue against the repository
  state first (`git log --all --grep=<ref>`, `gh pr list --state merged`), because a
  status file can lag behind.

