# Repomatic Ship

> Prepare a release. Reconcile the changelog, code and docs to the net release state. Then commit, push, and babysit CI until the release PR is built and `main` is green. Stop before the merge. Ask for review in normal use; run fully autonomous under `--dangerously-skip-permissions`.

- Skill: `kdeldycke/repomatic-ship` (Agent Skill)
- Install (CLI): `npx skillmds@latest add kdeldycke/repomatic-ship`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kdeldycke/repomatic-ship/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: kdeldycke (https://skillmd.com/u/kdeldycke)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kdeldycke/repomatic-ship

---


## Context

!`grep -m1 'version' pyproject.toml 2>/dev/null`
!`awk '/^## \[/{n++} n==2{exit} {print}' changelog.md 2>/dev/null`
!`git tag --sort=-v:refname | head -3 2>/dev/null`
!`git log --oneline -25 2>/dev/null`
!`git status --short 2>/dev/null`
!`[ -f repomatic/__init__.py ] && echo "CANONICAL_REPO" || echo "DOWNSTREAM"`

## Instructions

You drive a release from a working tree to a ready-to-merge release PR: reconcile the tree to its **net state since the last tag**, validate it locally, commit and push, then babysit CI until the auto-generated release PR is green. You stop there: the human marks the draft release PR ready for review and performs the final "Rebase and merge".

The release is push-driven: the `prepare-release` job in `changelog.yaml` runs `repomatic prepare-release` on push to `main` to build the freeze and unfreeze commits and open the release PR. **Do not run `prepare-release` yourself**: a local run previews a freeze that must not be committed (it marks the changelog "released", and on the canonical repo rewrites every workflow action ref). Your job is to make `main` clean enough that the auto-generated release PR is correct, then keep `main` green.

### How this skill runs

- **The review gate is the permission system, not a behavioral stop.** Normal runs prompt on each `git commit`, `git push`, and subagent write; step 4 shows the consolidated changelog diff *before* the first commit prompt, so approving that commit is the review gate and denying it stops the run. `--dangerously-skip-permissions` mutes the prompts so the full sequence runs autonomously; the skill cannot detect the mode and does not need to.
- **Invocation method.** When the context shows `CANONICAL_REPO`, use `uv run repomatic`. Otherwise use `uvx --exclude-newer '1 week' --exclude-newer-package repomatic=P0D -- repomatic`, which applies the supply-chain cooldown to repomatic's dependency tree while keeping a fresh release installable. References to `<cmd>` below resolve to one or the other.
- **Delegate the substance edits, apply the changelog yourself.** The sweep agents own the code, docs and bundled-asset lanes, so leave those files to them even when you do hold `Edit`/`Write`. The changelog is the exception: a skill is instructions rather than an actor, and `/repomatic-changelog` tells whoever loaded it to apply the consolidation directly, so you write `changelog.md` inline.

### Sub-agent rules

The sweep agents (step 1) and the babysitter (step 6) all follow these rules. Restate them in every spawn prompt: a spawned agent only sees what the prompt carries.

- **Commit attribution.** Every commit this skill or any spawned agent makes carries a `Co-Authored-By: Claude <noreply@anthropic.com>` trailer **by default**, so unattended changes stay traceable, and that default holds even where a downstream `CLAUDE.md` says nothing about commit attribution. It is a default, not an absolute: a maintainer's explicit standing rule against AI attribution outranks it, because the trailer lands in *their* repository's permanent history and that call is theirs. Check for such a rule **before the first commit**, not after the push: stripping a trailer from an already-pushed commit needs a force-push, which is off-limits, so the anomaly is then permanent. When an exemption applies, write it into the spawn prompt of every agent that may commit. An agent sees only what its prompt carries, so a withdrawal issued mid-run can arrive after it has already committed.
- **Reports are sent, not written.** A background agent's end-of-turn text is never delivered, so every spawn prompt must require the final report as a `SendMessage` to the **spawning (main) session**, naming that recipient explicitly: the spawning session answers to `main`, while an invented label like "orchestrator" or "main-orchestrator" resolves to nothing, leaving the agent to guess where to route the report. A "return a report" instruction alone yields a silent idle even when the report was composed. On an idle notification without a report, chase once; the tree (`git diff`) stays the authoritative record either way. Do not pair the message with a "write the same report to a file" fallback: the harness refuses a spawned agent that write, so the instruction only spends the agent's attention on something it will report back as blocked.
- **Expand `<cmd>` before it reaches a spawn prompt.** `<cmd>` is this document's placeholder, not a shell command, and a prompt that carries it verbatim (or half-expanded) hands the agent something that cannot run. The trap is `run <tool>`: the tool registry supplies most of them, so dropping the `repomatic run` prefix does not merely drift from the pinned version, it fails outright — a `7.8.0` spawn brief that expanded `<cmd> run mypy --` into `uv run --frozen -- mypy --` died on `Failed to spawn: mypy`, since mypy is not a project dependency. Write the invocation out in full (`uv run repomatic run mypy --`) and let the agent report back if it does not resolve.
- **Trust the tree, not the report.** A mid-run message to a busy agent is delivery without receipt: it can land after the agent composed its final report and be silently dropped. After tasking a running agent, confirm the tree reflects the request (`git diff` the target file) before moving on. Read fresh every time rather than reusing an earlier capture — an edit can land in the gap between two checks, and a stale capture then reports a live fix as still missing. Grep for the *absence* of the old text (`grep -c '<old phrase>' <file>` returning `0`) instead of eyeballing a diff: an empty match is unambiguous where a diff read mid-scroll is not.
- **Never revert the shared working tree.** The agents share one tree, so disjoint lanes do not make them race-free: no agent may run a working-tree-reverting git command (`checkout`, `restore`, `stash`, `reset`, `clean`), which silently discards the *other* agents' uncommitted edits. For full isolation instead, spawn with `isolation: "worktree"` and merge on join: disjoint files won't conflict.
- **Fix commits stage narrowly** (`git commit <path>`, never `-a`). Uncommitted files an agent did not create are the maintainer's in-progress work: never revert them and never sweep them into a commit.
- **Degrade gracefully.** A cross-referenced skill excluded from this repo is a fallback path, not a blocker: apply its principle via an `Agent` or inline. When an `Agent` spawn itself fails (a terminal API error), do the work in the main thread.

### 1. Reconciliation sweep

A release materializes the **net state since the last tag**, not the path taken to reach it: after a long cycle, the changelog, code, and docs all drift toward describing the journey. Reconcile all three against `git diff v<last>..HEAD`. Order matters: the changelog *describes* the net change, so reconcile the substance first (code and docs in parallel), then summarize it (changelog). A change introduced and then reverted before release is a no-op for users: no changelog entry, no scaffolding, no docs mention.

**Before spawning, capture the unstaged diff** (`git diff` against `HEAD`): those lines are the maintainer's in-progress drafts, not cycle work. Pass both diffs to each agent with the rule: preserve every line present only in the unstaged set (a curated TODO, a scratch note in a docstring) unless the maintainer explicitly asked for cleanup. Without the guard an agent strips unstaged scratch as "cycle scaffolding" and the draft silently vanishes. When in doubt, leave it.

**Capture the job-level red inventory in the same breath**: the latest *conclusive* run of each monitored workflow on `main` (`gh api 'repos/<owner>/<repo>/actions/workflows/tests.yaml/runs?branch=main'`, then `gh run view <id> --json jobs`), listing every job at `conclusion == "failure"`, `⁉️` probes included: `continue-on-error` folds their crashes into a green run-level conclusion, so no run-level read ever surfaces them. Under step 6's genuinely-green goal those reds are release work, they are visible *now* from history, and every one fixed before the first push saves a 40-90-minute babysit round-trip: seed the code agent's brief with the list.

**Read runs through the API, not the `gh run list` wrappers.** Both the branch-wide form and the per-workflow form have put runs weeks old at the top of the list, on a repository whose workflow had in fact run on every recent commit. That reads as "this workflow has not run in a month" and invents a red inventory out of nothing, which then sends the code agent chasing a failure that never happened. Two API calls are the authoritative reads: `gh api 'repos/<owner>/<repo>/actions/runs?head_sha=<sha>'` answers "what ran on this commit", and `gh api 'repos/<owner>/<repo>/actions/workflows/<file>/runs?branch=<branch>'` answers "what ran on this branch". Give the `head_sha` form the **full** 40-character SHA: an abbreviated one matches nothing and returns an empty list, which looks identical to "nothing ran".

**Scan the open autofix PRs in the same breath, and read their diffs rather than their titles.** An unattended `fix-*`/`format-*`/`sync-*` PR is a pending write to `main` that no one reviewed, and merging one mid-release silently reverts committed work: `gh pr list --state open --json number,headRefName,title` then `gh pr diff <n>` on every automated branch. What you are looking for is a *false positive*, a "fix" that is wrong in this repository and that the job will keep re-proposing until the underlying rule is taught otherwise. The tell is a diff that undoes something a human deliberately wrote. Fix it at the rule, not the file: add the word to `[tool.typos] default.extend-words`, the path to the linter's ignore list, the pattern to `extend-ignore-re`. Reverting the file alone guarantees the same PR returns on the next run. The archetype: `fix-typos` rewrote a `10b-quater` check label to `10b-quarter`, breaking the Latin ordinal series (`bis`, `ter`, `quater`) numbering a run of sibling checks; the maintainer reverted it by hand, the job re-proposed it, and the second PR **merged during a release run**, undoing the revert.

**Judge that diff against current `main`, not against the PR's head.** An open PR is pinned to the commit it branched from, so its diff can faithfully describe a file the maintainer has since fixed. Rewording is the third option beside allowlisting and reverting, and it is the one that leaves nothing behind: dropping the trigger word entirely also stops the job re-proposing, so a rule landed on top of it allowlists a word that now appears nowhere — dead config that reads as a live exception and quietly contradicts the fix the maintainer chose. So `git grep` the trigger before writing a rule for it, and when they have already solved it their way, leave it alone. Closing or merging the PR is theirs to decide: surface it, land the rule-level fix **only while the trigger is still live in the tree**, and say either way in the step-7 report.

**Always read that run's `head_sha`, and treat a red as live only if nothing since it could have fixed it.** This is why the query above reads the run's `head_sha` and not just its `conclusion`. Most monitored workflows carry a `paths:` filter, so a commit that touches nothing in the filter triggers no run — and if *that* commit is the one that fixed the red, the latest conclusive run stays pinned to the superseded parent and keeps reporting a failure that no longer exists. It can sit there for hours looking like live release work. The failure mode is not hypothetical and it is expensive: it sends the code agent chasing a fixed bug, and it makes the whole matrix look broken. Before adding a red to the brief, run `git log --oneline <run head_sha>..HEAD` and check whether an intervening commit touched the failing area; when the answer is unclear, settle it for the cost of one dispatch (`gh workflow run <workflow> --ref main`, no commit, no PR churn) rather than reasoning about it. The archetype: a docs-only commit regenerated a checked-in generated block, fixing the test that asserts it is in sync, but `tests.yaml`'s `paths:` filter meant no Tests run ever observed the fix.

**A green conclusive run proves nothing when supersession cancelled every run between it and `HEAD`.** The rule above guards the direction where history over-reports a red; this is the direction where it under-reports, and it is the more expensive one, since a false red costs a wasted round-trip while a false green ships the break. Walking back past `cancelled` runs to reach the newest conclusive one skips exactly the commits a busy cycle pushed most recently, so that `success` can predate every line the cycle added. Before recording an empty red inventory, diff the gap: `git log --oneline <newest success head_sha>..HEAD`. When cycle commits sit inside it, the workflow has never run on them and the green is stale by construction. Settle it with a dispatch (`gh workflow run <workflow> --ref main`, no commit, no PR churn) or by waiting out `HEAD`'s own run, never by recording "no reds". The archetype: a feature commit interpolated a metadata value straight into a `run:` block, the two pushes behind it cancelled its Lint run before it ever dequeued, and the newest conclusive Lint run sat back on the post-release bump, green, while `main` was already failing `🔒 Lint workflow security`.

The three substance passes own disjoint lanes (code owns Python including docstrings, docs owns prose under `docs/` and `readme.md`, bundled assets owns `.claude/`), so spawn them as **three `Agent` calls in a single tool-call block**: sequential spawns waste the wall-clock of whichever finishes first.

1. **Code**: an `Agent` that reviews every file changed since the last tag for reuse, quality, simplification, and deduplication, and fixes what it finds, simplifying before adding: existing code or a tool often already covers the case. Two layers: first strip scaffolding from reverted or superseded work within the cycle diff (abandoned workarounds, dead branches, WIP notes that never shipped); then harmonize what remains (collapse duplication, lift repeated literals to their canonical source, align new code with module patterns). Its constraints:

   - Every edit stays behavior-preserving: step 2 is the safety net, a failing test vetoes.
   - Type checks use the CI-equivalent `<cmd> run mypy` (pinned version and `--python-version`), never a bare `mypy` whose newer interpreter raises false positives CI never sees. Pass it **no arguments**, exactly as step 2's Types gate does: the runner then resolves the same tracked-`*.py` list CI's lint job checks, `tests/` and `docs/conf.py` included, and the two cannot diverge. A downstream `CLAUDE.md` "type checking" command is often the narrow dev-loop form scoped to the package only; do not inherit that scope when prompting the code agent, since a package-only run stays green on a `tests/` or `docs/` type error that reddens Lint post-push.
   - Failures the pass believes pre-existing get *reported*, not silently scoped out: that verdict belongs to step 2's CI check.
   - Adopting features from upgraded dependencies stays in `/repomatic-deps modernize`.
   - On the canonical repo, workflow invocations reading `uv --no-progress run --frozen -- repomatic` are the intended unfrozen state (the freeze commit rewrites them to a `uvx 'repomatic=={version}'` PyPI pin at release): never flag the local form as a pin regression or downstream breakage. The invariant to check instead is that every `uv`-invoking job provisions `setup-uv` in its own steps. Do not "restore" an isolated `uvx --from .` here: the lockfile path is deliberate, since an index resolution can be made unsatisfiable by the install cooldown while a lockfile cannot.
   - Docstring rendering belongs to this pass: build the docs and fix any broken cross-reference role a docstring introduced (the docs pass can surface but not fix them). Build only into the gitignored `docs/_build`, never an ad-hoc path: a stray build tree pollutes `git status` and trips tool scans like `run typos`.
   - Shortening an over-long workflow line to satisfy yamllint's 120-char limit must not lift `hashFiles(...)` (or any `runner.*`) into a workflow-level `env:` var: that context exposes only `github`/`secrets`/`inputs`/`vars`, so the expression resolves to empty at run-init before checkout and silently breaks the value — a cache `key:` shortened this way ships a broken key to every downstream repo. Shorten the literal itself instead (trim a shared key prefix, say).
   - The red inventory from the pre-spawn capture is part of its brief: root-cause each repo-fixable red (a chronic platform break, a flaky live-registry install, a crashing `⁉️` probe) and fix it at the source per step 6's taxonomy, even when the failing file saw no change this cycle.
   - Pending work the cycle introduced belongs in a ```` ```{todo} ```` admonition where Sphinx renders it, not in a bare `# TODO`/`XXX` comment: a comment never reaches the published todo list, which is the project's inventory of what it owes. This applies to **committed** cycle-diff lines only, and does not loosen the unstaged-scratch guard above: a `# TODO` present only in the unstaged set is the maintainer's draft and stays untouched.
   - Retire a `{todo}` whose trigger fired, deleting the shim it guards in the same edit. Bound the check to what moved: `git diff v<last>..HEAD -- pyproject.toml uv.lock` plus the tool-version registry names the upstream releases this cycle actually adopted, and a todo naming one of them is the only kind worth re-reading. Never poll every linked ticket, and leave a todo whose trigger has not fired alone. A published todo list advertising work already done is worse than no list.

2. **Docs**: an `Agent` that verifies prose docs against current behavior, not the journey (version references, CLI output, removed or renamed features go stale every cycle). Its constraints:

   - Manually-maintained version examples (install commands, binary download URLs, `uses:` refs) track the latest *released* tag, never the version being prepared, because the docs site deploys on every push to `main`. "Manually-maintained" is the load-bearing word and it is not a synonym for "under `docs/`": the freeze reaches into that tree in at least one repo (the canonical one rewrites `docs/install.md`), so decide file by file from the freeze's actual scope per the next bullet, never from the path. The tracking runs both ways: advance a sample that *lags* the released tag (still at N-1 after release N published) up to it, applying the bump directly rather than deferring it as a version advisory; only bumping a sample forward to the not-yet-released version is off-limits. A stale sample hides in plain sight, so grep every version string in `docs/` and `readme.md` rather than trusting a sub-agent's list.
   - **What the freeze rewrites varies by repo.** The canonical repo pins workflow refs and CLI invocations; a downstream freeze may touch only `changelog.md`, `citation.cff`, `__init__.py`, and `pyproject.toml`. Read the last freeze commit's actual diff (`git show <last-freeze-sha>`, never just `--stat`) and treat every version sample it does not rewrite, `readme.md` quick-start output included, as hand-maintained tracking the released tag: samples presumed freeze-managed have shipped stale through a release.
   - **Freeze-management is per line, not per file.** A file list is exactly the wrong granularity to decide it: a file the freeze touches can still carry hand-maintained samples it never rewrites, so `--stat` clears the whole file and the stale ones survive. The archetype: `docs/install.md` sits in the freeze commit, which rewrites precisely one line of it (the `uvx <cli>@X.Y.Z` pin), while the `>>> <pkg>.__version__` REPL capture further down the same page is hand-maintained and had been a full release behind since the previous cycle.
   - **Settle each sample individually** with `git log -S '<the literal string>' -- <file>`, which shows whether a freeze commit or a human last moved it.
   - **On a cycle that migrated the release tooling itself, the historical freeze under-predicts the new one.** A pre-repomatic freeze touching only `changelog.md` says nothing about the repomatic freeze, which also rewrites `citation.cff`, `__init__.py`, and `pyproject.toml`: treat every version sample as hand-maintained until the regenerated release PR's diff shows the new freeze's actual scope.
   - The mirror of the hand-maintained rule: a freeze-managed field legitimately shows the *dev* version between releases, so never flag one as stale against the released tag or as a dead link. `pyproject.toml`'s `urls.Download` reading `.../releases/tag/vX.Y.Z.devN` (a tag that has no GitHub release yet) is the expected post-bump state that the freeze rewrites to the release tag at cut. Hand-maintained samples track the *released* tag while freeze-managed fields track the *dev* version, so classify a version string by which mechanism owns it before judging it stale.
   - A third owner sits beside hand-maintained and freeze-managed: artifacts regenerated by `_release-engine.yaml`'s `update-dep-graph` job, whose `if:` gates on `release_commits_matrix`, so it fires **only on release commits** (never on ordinary pushes, to avoid noise from transitive dependency churn). It sits in the release engine rather than `autofix.yaml` because a release push is its only firing moment, and that is exactly the push `autofix.yaml` now skips wholesale, so do not go looking for it there. `docs/assets/dependencies.mmd` therefore lags `pyproject.toml` for the whole cycle: a runtime dependency added since the last release is *expected* to be missing from it, and the graph catches up through its own PR once the release lands. Never hand-forge the file, and never run `<cmd> update-dep-graph` to "fix" it mid-sweep: a local run uses whatever repomatic version the sweep resolved rather than the version the job pins, so it produces churn the next regeneration reverts. Classify a stale-looking generated artifact by the job that owns it before reporting it as drift.
   - Executable doc blocks fail open: a `{click:run}` invocation that no longer parses renders the usage error into the published page instead of failing the build (`docs.yaml` stayed green while a stale option printed `No such option`; only `click:tree` and `click:config` hard-error). Verify each `{click:run}` invocation against the current CLI, or grep the built HTML for `Error: No such option`-class output.
   - Correcting one description of a convention means correcting *every* description of it in the same pass: a rule restated in more than one place (an overview line and its worked example, two docs pages) drifts as a set, so grep for the sibling statements and align them together — fixing one in isolation leaves the others contradicting the fix (a freeze-cutoff overview still said "the day after" while its worked example had been corrected to "the second day after", reconciled only on a second pass).
   - The same "align the siblings" rule governs *quantities*, not just wording. A cycle that measured the same thing twice ships two answers, so cross-check every timing, size, count and ratio a page states against the other statements of it, the changelog's copy included (a performance page opened "every timing below comes from one batch of runs" while two of its tables disagreed `2.3x` on the same step at the same settings, each table internally consistent, because two commits had each measured their own batch; the changelog repeated the wrong figure). Surrounding prose that quotes a ratio the tables no longer support is the tell. Report the contradiction with the conflicting values and which one the other statements corroborate, rather than silently re-deriving numbers on hardware that is not the maintainer's: the figures are theirs to own, but they cannot fix what nobody flagged.
   - **Commit provenance decides whether a clashing figure is reported or fixed, and the two need opposite handling.** Blame each one (`git log -S`, or read the commit that introduced it). When the older figure entered *earlier in this same cycle* and a later commit measured the thing again, it is not a rival result: it is superseded intra-cycle scaffolding, which layer 1 of the code pass strips like any other. Replace it, and cite the surviving measurement's source beside it so the two cannot drift apart again. Only when neither figure obsoletes the other are they independent measurements, and only then does the report-don't-touch rule above apply. Getting the direction wrong fails both ways: it either ships a stale number dressed as a competing result, or silently overwrites a real measurement nobody asked you to re-take.
   - The docs build has a single owner, the code agent (which already builds for docstring cross-references): verify prose against that build instead of launching a second `sphinx-build` into the same output dir.
   - Any docs-pass edit touching a `.py` file (typically `docs/conf.py`) is re-verified with a bare `<cmd> run mypy` before the agent returns: `docs/conf.py` may import from the docs group's higher Python floor while mypy checks the project minimum, and the break otherwise surfaces only in CI's lint job. Give it no arguments here too — naming `docs/` as a directory is the form step 2 warns against, since a directory changes module resolution enough that mypy follows an installed dependency's own source.
   - Changelog *released* sections (`## [X.Y.Z]` blocks) are immutable history: a command, option, or config key named there was correct for that release, so never flag or rewrite a since-renamed name in one. When the changelog seeds the checklist for a rename, reconcile only the unreleased section (the docs pass once flagged `update-deps-graph` in three released sections that a `7.4.0` rename had superseded).
   - A ```` ```{todo} ```` on a docs page follows the same two rules the code pass applies to docstrings: pending work the cycle introduced is written as one rather than as a loose closing sentence, and one whose trigger fired this cycle goes, along with the paragraph it qualified. The todo list page publishes on every push, so a stale entry there is a public claim about work already done.

3. **Bundled assets**: an `Agent` (use `qa-engineer`, the gatekeeper for agent and skill definitions) that checks `.claude/skills/**` and `.claude/agents/**` against what the cycle actually changed. These files deploy verbatim to every downstream repo through `repomatic init`, so a claim this cycle falsified ships as confidently as it did when it was true, and neither substance lane owns them: the code pass sees no Python and the docs pass is scoped to `docs/`. Its constraints:

   - **Scope it to claims the cycle invalidated**, not a general review. Grep the cycle diff for the behaviors these files describe (cadences, gating conditions, job names, default values, config keys) and verify each surviving statement against its source, rather than reading for style.
   - **Verify against the workflow or module, never against the summary in the brief.** The point of the pass is that prose drifted from code; a second-hand description is the same failure one level up.
   - Fix the claim, do not rewrite the surrounding strategy: a stale fact inside good advice is a fact bug.
   - A pre-existing error in the same class, found while checking, is in scope — these ship downstream too, and nothing else audits them. Report it separately from the cycle-caused ones so the maintainer can tell which the release introduced.
   - Cross-references must degrade gracefully; skills stay self-contained (no upstream-only `docs/` URLs or paths), since downstream repos have neither.

   The archetype: `7.9.0` narrowed ordinary pushes to a canary binary subset, and two `babysit-ci` claims plus a `repomatic-deps` cadence line kept describing the old behavior. Nothing in the code or docs lanes would have caught them.

4. **Changelog**: once the three passes settle, invoke `/repomatic-changelog consolidate` through the `Skill` tool, so the consolidated entries (and the version advisory reading them) reflect the reconciled tree, renames included. It collapses superseded values and drops intra-cycle reverts. Consolidation assumes the entries already exist, though: when the unreleased section under-represents the net cycle (a maintainer left one stub bullet for a multi-feature cycle), run `add` first, or the bare `/repomatic-changelog` default that runs `add` then `consolidate`, so the shipped changes are drafted before they are collapsed. When both `add` and consolidation legitimately find nothing user-facing (a purely mechanical cycle), consolidation now backfills one generic maintenance bullet rather than leaving the section empty (`/repomatic-changelog` consolidation rule 08): a published release heading with no bullets reads as broken. If the skill is excluded here, degrade gracefully (sub-agent rules).

### If the sweep made no edits

A clean cycle, where every change since the last tag is already at its net end-state, is a normal outcome. With **no working-tree edits**, the commit-and-push spine collapses and three steps change shape:

- **Step 2** becomes redundant: CI already ran on this exact commit (it is `HEAD` of `main`), so verify that run's conclusion (`gh api 'repos/<owner>/<repo>/actions/runs?branch=main'`) instead of paying for a fresh gate. Still quick-run the time-dependent external smoke checks (`<cmd> run typos`, `<cmd> audit --fix`): re-published binaries and new CVEs drift independently of code.
- **Step 5** is a no-op: never force an empty commit.
- **Step 6** reduces to verifying the existing run. When `gh pr list --head prepare-release` shows a PR whose freeze commit sits on the current `HEAD`, confirm every stable job on `HEAD` is green and go to step 7, spawning `/babysit-ci` only on a real failure. When no current PR exists (the last push missed `changelog.yaml`'s `paths:` filter), trigger one with `gh workflow run changelog.yaml --ref main`, still with no commit.

Steps 3, 4, and 7 are unchanged: the version advisory and the (empty) changelog diff still inform the maintainer.

### If the sweep touched only prose

When the sweep's edits are confined to prose and Markdown (`docs/`, `readme.md`, `changelog.md`, `.claude/`; no `.py`, no `pyproject.toml`, no `uv.lock`), the full step-2 gate is disproportionate: tests, mypy, ruff, the binary self-test, and fresh resolution have no new surface to check. Narrow to what step 1's docs and bundled-asset passes do not already own: `<cmd> run mdformat --verify -- <file>` over the changed Markdown, plus `<cmd> lint-changelog` when `changelog.md` changed. Run it in the **same position** as the full gate — before the step-5 commit and push, never after. A lighter gate is still a pre-push gate: verifying format only once the push is already out defeats the point.

### 2. Validate locally (pre-push gate)

When the sweep rewrote code, prove it green **before** paying for a CI round-trip (no edits: see above). This is the same fast local channel `/babysit-ci` polls, run ahead of the first push. Launch the slow checks (tests, types, changelog lint) in parallel in the background, act on the fastest failure first (mypy and ruff in seconds, pytest in minutes), fix in the working tree, re-run only what failed, and iterate until every check is green. A check earns a *blocking* seat only while it reports faster than CI would surface the same failure: the push is what starts the 40-90-minute matrices, so holding it for a check CI's fast platforms reproduce at the same latency delays the release without adding earliness.

**First read CI's conclusions on `HEAD`** (`gh api 'repos/<owner>/<repo>/actions/runs?head_sha=<full-sha>'`): every red job there is cycle work this release must fix, and no "pre-existing failure" claim from the sweep is valid until checked against it.

**Read at the *job* level (`gh run view <run> --json jobs`), never the run level.** `continue-on-error` hides a crashed `⁉️` probe inside a green run conclusion, and this read doubles as the check that the step-1 red-inventory fixes actually landed.

**"The source did not change" never proves "the check still passes".** An in-cycle lockfile bump can invalidate `type: ignore` comments and override signatures with zero source changes: a dependency re-lock once widened a parent method, and CI Lint was red with exactly the 7 mypy errors the sweep had rationalized as pre-existing.

**When HEAD's own runs are still `queued`/`in_progress`, read an ancestor instead.** Rapid pin/lock auto-commits plus hosted-runner backlog routinely leave them unfinished here, so HEAD has no conclusions to read: take the latest *conclusive* run of each monitored workflow on an ancestor (`gh api 'repos/<owner>/<repo>/actions/workflows/tests.yaml/runs?branch=main'`, skipping the `cancelled`/`skipped` supersession noise a busy cycle piles up). A `failure` there is a pre-existing red carried on `main` for several commits: fix it before the first push. A `success` there is not the mirror verdict, though: read it against step 1's rule on a stale green, since the supersession noise you just skipped is where the cycle's newest commits were tested. Miss it and a platform-gated failure the single-OS local gate cannot run surfaces only in step 6 (babysit), still fixed but at the cost of an extra CI round-trip.

The checks:

- **Tests**: `uv run pytest --no-header -q`. Exception: an integration-heavy suite driving real external tooling can outrun a local background timeout and need tools not installed locally, so it is not a fast gate. Skip it, keep the rest of the gate, and treat the CI matrix on the exact commit as the authoritative test signal (step 6 covers dispatching one). Between the extremes, a suite whose local runtime approaches CI's fast platforms (~5-8 minutes from push) stops blocking. Start it with the gate, push once every fast check is green, and fold the still-running suite into step 6 as the first babysit channel: a failure lands as an immediate tight-loop fix at the same absolute time CI would have reported it, while a pass cost zero wall-clock.
- **Types**: `<cmd> run mypy`, with no arguments. The runner resolves the same tracked-file list CI's lint job checks, `docs/conf.py` included. Do **not** pass directory names instead (`<cmd> run mypy -- repomatic tests docs`): directories change module resolution enough that mypy follows an installed dependency's own source, so a package pulled in by the `docs` group and written for a newer Python fails the run under `--python-version 3.10` with a syntax error in a file this project does not own, which reads as a real failure and is not one.
- **Changelog**: `<cmd> lint-changelog`. A `⚠ X.Y.Z: not found on PyPI` warning for the still-unreleased version is expected and not a blocker.
- **Shippable deps**: `<cmd> lint-deps`. Offline and instant, and it covers the one release failure nothing else in this gate can see: a `[tool.uv.sources]` override never reaches the published metadata, so tests, types, formatting and the build all pass on a tree whose wheel every user then fails to install. Run it even on a docs-only cycle, where a lockfile bump can still have moved a source. A blocker naming a git source paired with a `.dev` floor is the `sync-dep-sources` idiom mid-flight: the fix is to wait for that swap PR, not to edit `pyproject.toml` by hand. The release lane carries the same check as a hard gate, but it fires after the freeze commit is already on `main`, where the only recovery is to burn the version and ship the next one, so a red here is cheap and a red there is not.
- **Formatting**, reproduced with the **pinned** tools, never the dev-env `uv run ruff` (a newer local ruff once silently disagreed on a `PERF401` fix):
  - **Run autopep8 over the cycle's changed Python files**: `git diff --name-only HEAD -- '*.py' | xargs <cmd> run autopep8 --`, which wraps long-line comments ruff leaves. Never pass a shell variable holding the space-separated list, which the runner takes as one literal path and rejects with `[Errno 2] No such file or directory: 'a.py b.py c.py '`.
  - **Then `<cmd> run ruff -- check` and `<cmd> run ruff -- format`, and read `git diff`.** Both write in place, but `check` only does so because the resolved ruff config sets `fix = true` — the runner injects no flag of its own, so a repo carrying a `[tool.ruff]` section without that key gets a read-only `check` and an empty diff that means nothing. An empty diff past your reconciliation edits is green; fold a legitimate style fix into the reconciliation.
  - **For any Markdown the reconciliation touched (`changelog.md`, `docs/`), ask `<cmd> run mdformat --verify -- <file>`.** It reports what the write path would change without touching the tree. Never a bare `mdformat`/`mdformat --with mdformat-myst`, whose plugin set rewrites MyST directive colon-options (a `{list-table}`'s `:header-rows:`/`:widths:`) to `---` frontmatter form, diverging from CI's autofix.
  - Landmine: autopep8 relocates a trailing `# type: ignore[...]` off a >88-char line onto its own line, voiding the suppression (Lint red under `warn_unused_ignores`); ruff format usually reverts the relocation, so only wraps that survive the full pinned sequence are real formatting debt. Never commit the relocation: fix the line length at the source so the comment rides the opening line.
- **Workflow YAML**, whenever the cycle touched `.github/workflows/`: `<cmd> run actionlint --`, `<cmd> run zizmor -- .`, and `<cmd> run yamllint --` over every workflow. Nothing else in this gate reads workflow YAML, yet step 1's code pass is told to edit it, so a regression the sweep itself introduces stays invisible until CI — and on this repo a bundled workflow carries it to every downstream repo, which gets the workflow without the conformance tests guarding it here. The class worth knowing, because a reviewer's eye slides straight past it: GitHub Actions evaluates an `if:` as an expression only when the value is *entirely* one `${{ … }}`. A folded block scalar (`if: >`) wrapped around one appends a trailing newline, so the value interpolates to a non-empty string and the step is truthy forever, gating nothing. Write a multi-line condition **bare**, with no wrapper (a leading `!` rules out the bare single-line form, since YAML reads it as a tag indicator). Eleven `autofix.yaml` steps stopped gating this way and sat red on `main` across two commits before a release run caught them. A second class the same gate has to catch: these three tools contradict each other, so a fix clearing the one that complained can redden one that was green. zizmor `1.30.0` added a `self-repository` audit demanding `uses: $/…` over `uses: ./…`, the very form GitHub's own docs recommend for a same-repo call, while the pinned actionlint rejects `$/` as a malformed ref in both the reusable-workflow and the step-level position. So re-run all three after adopting any linter's advice, and settle a conflict by measuring rather than reasoning: put the proposed form in a scratch workflow outside the repo and run the other linters against it. When they genuinely disagree, keep the working form, silence the complaining tool inline (`# zizmor: ignore[rule]`), and leave an `XXX` beside it naming the blocking tool and its upstream ticket, with the swap recorded as a `{todo}` in whichever docstring owns the concern, since a YAML comment reaches no published todo list.
- **Autofix externals**: smoke-run `<cmd> run typos`, **every formatter that downloads a checksum-pinned binary** (`<cmd> run biome` and peers), and the vulnerable-deps scan `<cmd> audit --fix` (parses live `uv audit` output). An upstream re-publish flips a pinned SHA-256 and kills the step;

…(truncated)
