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:
- 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.
- 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.
- 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.
- 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: " 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).
- 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".
- Push:
git push origin <branch_prefix>/<id>, only the feature branch, never the base.
- 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).
- 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.
- 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.
- 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.
- 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
- 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.
- 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.
- 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.
- 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
- 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.
- 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.
- 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.
- 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.
- 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
- 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.
- 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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
1---2name: auto-wave3description: 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.4---56# auto-wave78A **queue of waves, driven sequentially and automatically**. Every wave runs as its own,9freshly started `claude -p` process. The runner itself is a deterministic Python script,10**no LLM in the driver**: the intelligence sits in the per-wave processes it spawns.1112It replaces the manual loop of "open a new chat after every wave and paste the next one in".13`auto-wave : wave-planning` behaves like a loop runner to a single build step, one level14above building, at **wave** granularity rather than issue granularity.1516## Why a fresh process per wave1718This is the whole point: context **resets** at every wave and does **not** pile up over N19waves. Wave 10 is as sharp as wave 1. Four layers, all enforced in the wave prompt:20211. **Hard reset.** Every wave is a new claude process with an empty conversation. At the22 start it reads only what is curated: its issue specs, the project plan, the relevant23 rules files, the project context file, the previous wave's handover.242. **Heavy reading in throwaway sub-agents.** Searching, reading many files, reviewing all25 happen in `Explore` / `general-purpose` / `code-reviewer` sub-agents; only their26 conclusion comes back.273. **A one-page handover instead of a conversation history.** The baton between waves lies28 on disk (handover file plus your project's living documents), not in a chat.294. **Keep waves small.** Queue discipline: one to two days of work per slice.3031## Safety contract (not negotiable)3233- **Branch isolation and the merge stop line.** The runner creates34 `<branch_prefix>/<run-id>` off the base branch and builds ONLY there. **Never merge into35 the base branch. Never push to it. Never deploy.** ALLOWED: pushing the feature branch to36 origin and opening a PR, because neither triggers a production deploy in the usual setup37 where only a merge into the default branch deploys. For the runner itself the rule stays:38 **never `gh pr merge`.**39- **Proof of green instead of self-attestation.** Every `claude -p` wave has to PROVE its40 `green`: `executed_tests` > 0, `skipped_tests` == 0, and `proof` pointing at an existing,41 non-empty artefact (real test runner output, `wave-NN.proof.txt`). The runner42 (`enforce_green_proof` in run.py) demotes green without proof to `blocked` (exception43 stop) and PERSISTS that demotion into the status file, so a resume does not count the wave44 as green either. A non-empty `caveat` field is a stop signal, not prose, on every path45 including typed tasks. **The origin is measured, not theoretical:** a wave once wrote46 `green` with 0 executed tests and an honest caveat in the JSON that no parser read.47- **Exception stop.** The run goes through on its own and holds **by itself** only when: a48 wave does not turn green despite self-fixing (`blocked`), OR a real **product, design or49 strategy** decision comes up (`needs-decision`, because a human decides product calls,50 never the AI), OR the state is unclear (no status file, timeout). On a stop it names the51 branch and the run log and exits 2.52- **Research before `blocked`.** Before a wave writes itself off as `blocked` (a self-fix53 fizzled, the same failure repeats, the fix goes in circles), the prompt tells it to STOP54 rather than guess the next fix: put an adversarial `research` sub-agent on an ALTERNATIVE55 cause first ("do NOT confirm my assumption"), work the finding in, and only then, if it56 still does not hold, write `blocked`.57- **The status file is the truth, but green needs proof.** Every wave writes a58 `wave-NN.status.json` as its last action (`green` / `blocked` / `needs-decision`). Missing59 means stop, never drive on blind. The status file is the truth about what the wave60 CLAIMS; the proof check is the truth about whether that counts. After a green wave the61 **runner** additionally attaches the commit hash as a `commit` field (for the PR body and62 the report); the wave does not write that field itself.63- **Sequential, one worktree.** Waves run one after another on ONE branch. That is a64 deliberate choice against parallel worktrees: a shared checkout has one HEAD, and a second65 build stealing it mid-wave is a failure mode that costs more than the concurrency wins.6667## The queue6869One `## <slug>` section per wave, in build order. Template: `queue.example.md`.7071```72## backend-add-service-form73goal: Backend form to add and edit a service # MANDATORY, one sentence74specs: .scratch/phase-2/ # optional, path to the issue specs75review: code-review # optional, default from config.json76```7778A wave with specs builds noticeably more precisely than one working off a goal sentence, so79plan the issues first where you can.8081## Typed queue (solo / ops / gate + needs)8283Instead of build waves only, the queue can hold **mixed, typed tasks**: work that needs an84exclusive resource, ops and infrastructure fixes, and steps held for a human word, with85ordering dependencies. **One run drives all of it.** The runner detects the mode86**automatically** by the `lane:` field. Template: `queue.typed.example.md`.8788Every entry is `## <id>` plus fields:8990- **`lane:`** (mandatory) `solo` | `ops` | `gate`.91 - **`solo`** strictly **serial**, for tasks that need an exclusive resource (one shared92 test database, one device, one licence seat), so never two at once. Before each one the93 runner brings the shared checkout onto a clean base best-effort; uncommitted tracked94 work on a foreign branch stops the run instead of being clobbered.95 - **`ops`** a headless `claude -p` that reads and executes the spawn file. **Serial** by96 default, which is the safe answer. Concurrent **only opt-in** (`parallel: true`) **and**97 only when `ops_parallel_isolated` is on in `config.json`. Reason: a shared checkout has98 one HEAD. **The win of this runner is hands-off, not concurrency; do not over-promise it.**99 - **`gate`** **never** runs automatically. The runner holds, pushes a notification "ready100 for your word: <note>" and writes a report. Gates are deferred to the end of the run101 (after all solo and ops work) and reported as "ready" or "needs still open". The deploy102 is the typical one.103- **`spawn:`** mandatory for solo and ops (absolute or project-relative path to the spawn104 file, which **has to exist**, otherwise the run stops hard BEFORE it starts). Optional for105 a gate.106- **`needs:`** optional. The id of another task; this one runs only once that one is107 **green** (a needs DAG, topologically resolved and enforced). A non-gate entry may **not**108 depend on a gate: gates never run, so it would block forever, and validation refuses it.109- **`parallel:`** optional (ops only). See `ops_parallel_isolated`.110- **`note:`** optional free text, mostly for a gate: what your word triggers here.111112**Validation BEFORE the run** (a hard stop on any violation): known lane, existing spawn113file for solo and ops, needs pointing at an existing id, no self-needs and no gate-needs, no114duplicate ids, no needs cycle, `parallel` on ops only.115116**Runner behaviour:** resolve the needs order, drive solo and ops in plan order, and every117task writes ITS own report (from its spawn file) **plus** a `task-<id>.status.json` as its118last action. The runner adds a collected report, `RUN.md`, `PROGRESS.md` and a notification.119Exception stop unchanged. A typed run deliberately does **not** create a shared branch and120does **not** open a collected PR: every task manages its own git and PR through its spawn121file, and the gate entry refers to the PRs that are already open.122123## End-of-run hook (everything UP TO the merge, never the merge itself)124125Runs **only when ALL waves are green** (a full run or a resume to the end; **not** for126`--only` single-wave tests, not after a stop).1271281. **Commit check.** Does the branch have commits ahead of the base? At **0** (every wave a129 no-op) there is no push and no PR; the log and the report note "no changes, nothing to130 merge".1312. **Push:** `git push origin <branch_prefix>/<id>`, only the feature branch, never the base.1323. **PR:** `gh pr create`, title `auto-wave <id>: <N> waves`, body one line per wave out of133 its `status.json` (summary plus commit hash), ending in the do-not-merge warning. If the134 PR already exists (a resume), it is reused instead of duplicated. If `BOARD_CARD` is set,135 the resolved card reference goes into the body as an anchor (see "Board card" below).1364. **Fresh review (release chain step 1).** The WHOLE PR diff goes to ONE throwaway137 `claude -p` process with a fresh context, deliberately NOT the build context. Findings138 with a `[HIGH]` / `[MEDIUM]` / `[LOW]` prefix are posted as a PR comment; high findings139 are marked separately in the comment, the notification and the report.1405. **Frontend review (release chain step 2).** If the diff touches UI paths141 (`frontend_review_globs`), a SECOND throwaway process goes at the **running stack**: boot142 the app, click the touched surfaces for real. This is the class of check that catches a143 dead first-run path which green unit tests walk straight past. Findings go up as their144 own PR comment and high findings count like review highs. No UI in the diff means the145 step is skipped as n/a.146 **Mobile is mandatory, not a bonus:** the same surfaces are checked at the widths in147 `frontend_review_widths` (default 390, 375, 430 CSS pixels; the narrowest is your148 supported floor, anything below is out of scope). Four measured things instead of a149 feeling: no horizontal overflow when a LONG name is used instead of a short one · tap150 targets do not vanish under a dialog, a footer or the keyboard · sticky headers stay151 visible while scrolling · text is not squeezed into a few pixels. Screenshots land in the152 run's state directory. A pre-existing defect that also reproduces on the base branch is153 named AND marked as pre-existing, so it does not block the release by mistake.1546. **Deploy handoff.** Once the PR is open the hook writes a finished, **deterministic** (no155 LLM) handoff to `state/<id>/SPAWN-deploy.md`: a start line, the **release-chain gate**156 (mandatory before the merge: code-review comment present · frontend-review comment157 present or n/a · every `[HIGH]` fixed and RE-reviewed · key-function tests really158 executed with proof artefacts), the **scope** detected from the PR diff through159 `deploy_handoff.scope_globs`, and the **forced order** (backup, ONE human go, schema and160 server functions, merge = frontend LAST, verify), stop at any deviation, plus a mandatory161 closing report. **This is a briefing, nothing else.** auto-wave still merges and deploys162 NOTHING. On a stop or a no-op run, no handoff is written, because there is nothing to163 deploy.1647. **Staging instead of a deploy prompt.** If `deploy_queue_script` is configured, the hook165 enters the run into that queue and **ends there**. Push, PR and both reviews stay166 unchanged (a PR is not a deploy, and without a PR there would be no review comments).167 What falls away is the "reply deploy, it goes live" line. Reason: collecting several168 finished runs into ONE review round and ONE deploy beats deciding once per run.169170**Best-effort contract:** if push, `gh`, a review or the handoff fails, that does NOT crash171the run. Errors land in `state/<id>/end-of-run.log` and in the report, and the branch stays172usable locally.173174**A report at EVERY end of a run** (green OR stop, including `--only`):175`<project_root>/.scratch/pm-inbox/<YYYY-MM-DD-HHMM>-auto-wave-<run-id>.md` with front matter176(`topic` / `issue` / `pr` / `status: done|blocked` / `worktree`) and, per wave, ✅ or ✋ plus177the summary, the branch, the PR number and link, whether the review found anything high.178That is the same inbox the `pm` skill reads.179180**Teardown at EVERY end** (green, stop, crash; it hangs on `atexit`, not on the success181path): `teardown_local_stack()` stops the containers matched by `teardown_docker_filter`182again. `docker stop`, never `docker rm`, so no data loss. What a run brings up does not keep183running afterwards. Switch it off with `"teardown_after_run": false`.184185## Board card (optional adapter)186187The runner knows **nothing** about any board. If the env var `BOARD_CARD` is set, it looks188for an executable adapter at `<project_root>/scripts/board-card` and calls it best-effort:189190```191scripts/board-card find "<query>" -> prints ONE card reference192scripts/board-card built "<card>" --pr <n> --url <u> --branch <b>193```194195`find` resolves whatever you put in `BOARD_CARD` (an id, a short link, a title query) to the196stable reference that goes into the PR body. That anchor matters: a board watch can only197couple a card to a PR through a reference in the title, the body or the branch name, because198that is the only coupling that is a measurement rather than an interpretation. `built` moves199the card to whatever "built, waiting to be tested" means on that board. Board id,200credentials, column names and label vocabulary all live in the adapter, where the board201lives. **A project without an adapter gets one line and is done.** That is not an error: a202board being down is not a build failure and must never cost a green run. (Same cut as the203board watch in the `pm` skill.)204205## Preconditions (before every run)206207- `claude` CLI on PATH and logged in.208- The repository is on the base branch and has no uncommitted tracked changes (untracked is209 fine). The runner refuses otherwise.210- The queue file exists. Templates: `queue.example.md`, `queue.typed.example.md`.211- `config.json` points `project_root` at your project. Everything else has a working default.212213## Calls214215```216python3 ~/.claude/skills/auto-wave/scripts/run.py --status # queue plus last run217python3 ~/.claude/skills/auto-wave/scripts/run.py --dry-run # plan, branch, wave-1 prompt, do NOTHING218python3 ~/.claude/skills/auto-wave/scripts/run.py --run # real run (create the branch, drive the waves)219python3 ~/.claude/skills/auto-wave/scripts/run.py --run --name <id> --from <N> # resume after a stop, from wave N220python3 ~/.claude/skills/auto-wave/scripts/run.py --run --only <N> # wave N only (no push, no PR)221```222223`--config <path>` loads an alternative config file, which is what a sandbox test uses.224225## Progress: notifications plus PROGRESS.md (wave level)226227The runner reports at every **wave boundary**, deliberately at wave granularity only, with228no sub-wave steps (anything finer is spam):229230- **A notification** through `notify(title, message)`: a desktop banner (macOS `osascript`,231 fallback `terminal-notifier`) and, if `ntfy_topic` is configured, a phone push. Both fire232 independently and **best-effort, so they NEVER crash the run**. Exactly ONE notification233 per boundary: a wave turning green, the run finishing (with the PR number and the handoff234 path, or a warning when the review found something high, or "no changes, nothing to235 merge"), and, most important, the run **stopping** with the reason in the text.236- **A live checklist** at `<state_dir>/<id>/PROGRESS.md`, rewritten at every boundary: one237 line per wave, done `- [x] … ✅ green (HH:MM)`, running `▶ running (since HH:MM)`, stopped238 `✋ stopped: <reason>`, upcoming without a suffix. On a resume (`--from N`), waves with a239 green status file from the earlier partial run count as done. It is readable from any other240 chat, because it lives on disk.241242## How YOU (Claude) drive a run2432441. **Straight to `--run`, no dry run, no question.** No plan preview for confirmation, no245 "say go" gate. The only hard gate is the preconditions the runner checks itself (on the246 base branch, clean tree, queue not empty); it refuses with a clear message when something247 is missing, and only then do you relay to the user instead of building blind.248 `--dry-run` and `--status` stay available as manual tools when they are explicitly asked249 for, never as the default.250 **The exception is BUILDING the queue:** whoever assembles the queue asks which worker251 model the waves should run on, as multiple choice with a recommendation, and the252 recommendation depends on the work rather than defaulting to the strongest model253 everywhere. Heavy or risky waves (schema, permissions, anything close to production)254 deserve the strongest one; simpler waves (UI, content, docs) do not. The answer goes into255 `config.json` as `"model"` and applies to the WHOLE run, so a mixed queue is better split256 into two runs. The start itself stays question-free.2572. Start the run **in the background** (long runtime, hours are possible). Tee it into a258 durable log: the state on disk (`<state_dir>/<id>/RUN.md`) is readable at any time through259 `--status`, including from another chat.2603. If the run **stops** (exit 2), relay the reason in plain language. For `needs-decision`,261 put the question to the user as a multiple-choice question with a recommendation, work262 the answer into the wave (or extend the specs) and continue with `--from N`. The runner263 has already dropped a `status: blocked` report in the inbox, and there is no push and no264 PR after a stop.2654. If **all waves are through**, the end-of-run hook has already pushed, opened the PR,266 posted the review comments, written the deploy handoff and filed the report. You relay:267 the PR number and link, whether the review found anything high, and the handoff path.268 If push or PR failed (best-effort), name the branch and the log; the branch is usable269 locally. **Never merge (`gh pr merge`) and never deploy yourself.**270271## Mode "full": the whole chain behind one word272273> No new runner. The mode chains what already exists: an auto-wave run (this skill), then a274> pre-deploy step as its own spawn, then your deploy skill. The green path without "full" is275> byte-unchanged; new are this section and `scripts/full-lock.sh`.276277**Trigger:** the user says "full auto-wave" in a fresh chat.278279### The contract2802811. **The invocation IS the deploy go**, given in advance and conditioned on the abort list282 below. The ONE human go your deploy procedure demands before the first production283 mutation counts as given by the invocation, AS LONG AS nothing on the abort list hit.284 Outside a full run, your normal per-deploy go stays exactly as it was.2852. **Conductor principle.** The chat never works inline: waves run as fresh headless286 processes anyway, pre-deploy runs as **its own spawn**, and the deploy runs through your287 deploy skill as another spawn. Handovers happen only through files (queue, handoff,288 inbox). The conductor reads **end reports, never diffs or logs in full**, which is what289 keeps its context from filling up.2903. **Scope per run, measured rather than guessed.** A preflight measures the real state:291 open queue waves (`run.py --status`) plus every merge-ready staged PR (`gh pr list`,292 `gh pr checks`). Everything green that is not blocked, parked or needs-decision comes293 along. What cannot come this time stays staged and joins the next run automatically.2944. **Review findings are a hard gate.** Fix in the PR branch first, re-run the gates with295 proof, re-verify the finding in the same medium, then continue. Not autonomously fixable296 (a product decision, a production-data touch, a guardrail) means a needs-decision stop.297 It is NEVER deployed around an open finding.2985. **The abort list.** Every hit stops the chain BEFORE the next production mutation and299 sends a push with the reason in plain language:300 - a gate that is not green with proof (executed > 0, 0 skipped); self-attestation is red301 - needs-decision, or a product decision of any kind302 - a schema change or a function deploy that is not in the handoff303 - a smoke test after the deploy that is not green: roll back, then stop304 - a test-infrastructure-only PR (no deploy artefact): merge only, never deploy3056. **Every end is reported:** a push in plain language (on a stop, the question itself, not306 just "stopped"), a PM inbox report, board cards pulled forward best-effort. Nothing is307 left running, and the lock is ALWAYS released, including on a stop or a crash.3087. **Never two full runs at once.** Lock through `scripts/full-lock.sh acquire` (atomic309 `mkdir`; the lock path comes from `AUTO_WAVE_LOCK`). A fresh lock holding means stop with310 a message instead of a second run; a lock older than 24 hours is reported as stale and may311 be taken over after checking that nothing is really running. Same rule as "never two312 deploys of the same target".313314### Conductor protocol3153160. **Lock:** `bash ~/.claude/skills/auto-wave/scripts/full-lock.sh acquire`. Refused means317 stop, with the lock age in the message. From here on, releasing the lock belongs to EVERY318 ending.3191. **Preflight** (contract point 3): measure the scope, write it down as a run note (what320 comes along, what stays behind and WHY). If the user is away from the machine, keep the321 machine awake for the duration of the run.3222. **Step A, waves:** queue not empty means `run.py --run` in the background (the section323 above applies unchanged, including the end-of-run hook). Queue empty means the step is324 skipped. A stop of the runner is an abort-list hit, so the chain ends there: no325 pre-deploy, no deploy.3263. **Step B, pre-deploy as its own spawn:** a fresh headless process runs the pre-deploy327 convention over the COMPLETE staged batch (including the fresh auto-wave PR): preflight328 against the forge, ONE collected review, the findings gate (point 4), the deploy handoff,329 board and inbox. The conductor then reads ONLY the handoff and the report.3304. **Step C, deploy:** your deploy skill with the most recent handoff, as its own spawn. The331 human go is given in advance by the invocation (point 1) and the spawn is told so332 explicitly. ONLY what is in the handoff gets deployed; the smoke test afterwards is333 mandatory, and red means roll back and stop.3345. **The end** (point 6): push in plain language, the conductor's own report (waves ✅/✋,335 pre-deploy ✅/✋, deploy ✅/✋/skipped, PRs, what went live), board cards, teardown, and336 `full-lock.sh release`.337338**Anti-scope of the mode:** no building, fixing or reviewing in the conductor chat itself339(all of it in spawns), no scope beyond what was measured, no deploy around the abort list, no340second full run next to a running one.341342## Anti-scope (what the runner does NOT do)343344It does not merge into the base branch (never `gh pr merge`), does not push to it, does not345deploy, does not make a product decision, and does not skip a wave when things are unclear.346(Pushing the feature branch, opening the PR, posting the review comments and writing the347handoff IS the end-of-run hook's job, and that is allowed, see the safety contract. The348handoff is a **briefing**, not a deploy.) The per-wave review (`review:` field) is done by349the wave itself; the end-of-run fresh review is an ADDITIONAL independent instance with350fresh eyes. Planning the waves is a different job: a wave without specs plans minimal issues351for itself at the start, but well-specified specs give noticeably better results.352353## Self-maintenance354355When the call, the status protocol or the safety contract changes, update this SKILL.md and356the README in the same pass. Findings from real runs (what breaks, what the user corrects)357get appended here.358359## Learnings from real runs360361- **Collision with a parallel build in the same checkout.** The runner owns362 `git_repo_subdir` exclusively (one HEAD, one working tree). If ANOTHER build runs in the363 same checkout at the same time, its `git checkout` or rebase tears HEAD away and the364 running wave builds on the wrong branch or commits into foreign work. Symptom: after365 `--run`, `git branch --show-current` shows a FOREIGN branch instead of the wave branch.366 **Fix:** give the run its own worktree instead of the shared checkout (`git worktree add`,367 symlink the dependency directory, and a custom config with `"git_repo_subdir"` pointing at368 it plus `--config <path> --run`). Both builds then share only the `.git` objects, never369 HEAD. **Numbers that have to be unique across branches (migration numbers, for instance)370 must be coordinated by hand across the parallel branches**, because each branch only sees371 the remote base and both would otherwise grab the same next number.372- **A background run can die silently.** A run started in the background was killed373 externally while it blocked on its wave-1 worker: NO timeout (the wave timeout was hours374 away), NO exit status to the caller, NO `RUN.md` or `status.json` written. Symptom: no375 process, `PROGRESS.md` still says "wave 1 running", the branch exists with **0 commits**,376 but the wave-1 files sit in the working tree as uncommitted tracked changes, because the377 killed worker had already edited and never committed. **Diagnose first:** smoke-test a378 headless `claude -p` separately, so you know whether the nested worker is the cause at all.379 **Recovery is a fresh start, not a resume** (0 commits means there is nothing to resume):380 check out the base branch, **discard** the half-finished, unreviewed worker diff (a diff381 out of a killed process has not earned any trust, and the value of this runner is the382 *verified, reviewed, committed* result), delete the 0-commit orphan branch, verify a clean383 tree and start again. On the restart, tee into a durable log, because a harness's temporary384 output can be cleaned up when it dies.385- **A queue can be older than the repository.** A wave once found that its issue had already386 been merged and verified that instead of blindly rebuilding it. Good behaviour, keep it.387 The consequence is for whoever WRITES the queue: check every issue against the repository388 state first (`git log --all --grep=<ref>`, `gh pr list --state merged`), because a389 status file can lag behind.