# `recovery-paths.md`, the 8 recovery scenarios

`<plugin-root>` below is `$CLAUDE_PLUGIN_ROOT` in Claude Code; the plugin's installed directory
in Codex.

> Load on any F-N1 / pre-flight failure, for a batch push, or for post-release
> recovery. Each scenario: the **shape** (what the repo state looks like), the
> **resolution** (commands + judgment calls), and what to record. Every scenario
> is grounded in a real past incident; the durable record of a recovery is the
> fix commit's message + the PR description, write the "what happened / root
> cause / fix" there.

---

## §Scenario 0, Recon: classifying an unclear starting state

`git branch --show-current` (must be `main`) · `git status --short` ·
`git log origin/main..HEAD --oneline` · `git tag --list 'vX.Y.*'` ·
CHANGELOG heads for `## [Unreleased]`. One unpushed `release(*):` commit →
deploy handoff · several → batch push (Scenario 2) · `[Unreleased]`, no
bump → author from scratch · removed/renamed API symbol → breaking cut,
guide owed · target under `packages/plugins/*` → independent release.
Ambiguous → surface it, don't guess.

## §Scenario 1, Version-skip correction

**Shape:** a peer's release commit mislabels the version, package.json bumped 0.6.X → 0.6.X+2, skipping X+1; the CHANGELOG body may narrate the work as two releases. No tags yet, npm latest still 0.6.X, commit unpushed. (Real case: a "v0.6.13" cut that was actually v0.6.12's work.)

**Resolution:**

1. Verify the skip: `npm view <pkg> versions --json | tail` (no X+1 on npm) + `git tag --list 'vX.Y.*'` (no tags). Run pre-flight at the peer's commit to confirm it's shippable.
2. Correct the version via a **new commit on top** (not amend, the peer's commit stays for history).
3. Sweep every occurrence of the wrong version: 10 × package.json, 10 × CHANGELOG (headers + body refs, the lockstep roster, `scripts/package-paths.mjs`), any doc/CSS-comment refs, then regenerate the lockfile. Preserve filename references that intentionally encode the original label.
4. Commit as `fix(release): correct vX.Y.Z+1 version-skip → vX.Y.Z` documenting the discovery, then resume [`cut-procedure.md`](cut-procedure.md) at Step 5.

## §Scenario 2, Batch push

**Shape:** multiple unpushed `release(*): vX.Y.Z` commits since the last published tag; operator asks to "publish what's accumulated."

`release-pack.mjs --mode batch` hard-rejects and points here rather than attempting this, each version tags at its OWN release-commit SHA (not `main`'s single post-merge HEAD) and publish order must be enforced across versions, which the single-version orchestrator has no model for. This IS the manual procedure; there is no mechanized alternative.

**Resolution:**

1. Order the release commits oldest → newest.
2. Per version: **tag at that version's release-commit SHA** (the batch-push exception to tag-at-HEAD), run F-N1 against each tag candidate.
3. Push `main` once; push tags one-at-a-time (Scenario 7 prevention).
4. **Publish in version order, oldest first, waiting for each version's workflows to settle** before dispatching the next, `npm dist-tag latest` is set by publish order. `dispatch-publish.mjs --after <prev-version>` gates on `dist-tag latest` to enforce it. If mis-ordered: `npm dist-tag add @adia-ai/<pkg>@<newest> latest` per package.
5. One GH release note per version; site deploy once at the END (reflects HEAD).

## §Scenario 3, Author from scratch (`[Unreleased]` promotion)

**Shape:** source + CHANGELOG entries landed under `## [Unreleased]`, no bump, no release commit.

**Resolution:** [`cut-procedure.md`](cut-procedure.md) Variant B; `` `<plugin-root>/skills/package-release/scripts/promote-unreleased.mjs` `` mechanizes the heading swap; fresh blocks per [`changelog-discipline.md`](changelog-discipline.md) §Authoring.

## §Scenario 4, `[Unreleased]` extension (early cut + entangled fix)

**Shape:** a peer pre-cut the release commit early, then landed more commits including a fix that **completes the early cut's own scope** (e.g. the demo-shell import its feature broke) entangled with unrelated `[Unreleased]` work. The early commit fails a release-blocking gate (`check:demo-shells`).

**Resolution, two options:**

1. Cherry-pick the fix onto the release commit (off-mainline tag; messy archaeology). Only when the operator explicitly wants minimal scope.
2. **Extend the release to HEAD** ← default. Verify HEAD passes all gates; promote the `[Unreleased]` content into `[vX.Y.Z]`; author fresh blocks for changed-but-unlogged packages; commit the CHANGELOG merge with a message documenting the boundary decision; tag at the new HEAD.

## §Scenario 5, Stale test detection

**Shape:** a pre-flight test fails, but it asserts a behavior a peer *deliberately* changed (the change is CHANGELOG-documented); the test was never updated.

**Resolution:** read three things, the assertion, the CHANGELOG entry, the production source. Code matches the CHANGELOG's described behavior → the test is stale: update the assertion, add an inline comment naming the vX.Y.Z change, optionally pin the new contract with an extra assertion, include the test update in the release allowlist. Code matches the test → real regression (or the CHANGELOG is wrong), fix that instead.

## §Scenario 6, Concurrent peer mid-cycle

**Shape:** the working tree shifts between commands; `git stash pop` reports a conflict or "kept the stash."

**Resolution:** don't fight the peer. First confirm the release itself is fully shipped (tags pushed + npm + GH releases + site). Then for a kept stash: `git stash show -p stash@{0}` vs the file's recent `git log -p`, if the peer committed the same content, the stash is redundant → `git stash drop`; if not, leave the stash for the peer/operator to decide. Never blind-drop.

## §Scenario 7, Tags pushed, ZERO publish workflows fired

**Shape:** all 11 tags exist on origin (umbrella + one per lockstep-roster package, `scripts/package-paths.mjs`), but no `publish-<pkg>.yml` run exists for them; npm latest unchanged; nothing errored.

**Root cause:** pushing many tags in **one** `git push` fires a single batched create event that GitHub Actions routinely drops. Re-pushing is a no-op (the tags already exist remotely).

**Resolution:**

```bash
node "<plugin-root>/skills/package-release/scripts/dispatch-publish.mjs" \
  --version X.Y.Z --verify-triggered   # re-dispatches packages with no run OR a dead (cancelled/failed/timed-out) run; registry-gated; idempotent
```

For a batch, preserve npm-latest ordering (`--after <prev>`). Verify against the registry, not the workflows. **Prevention:** push tags one-at-a-time ([`cut-procedure.md`](cut-procedure.md) §Step 8); `` `<plugin-root>/skills/package-release/scripts/release-pack.mjs` `` does this automatically and follows with `--verify-triggered`.

## §Scenario 8, Cut on the wrong branch

**Shape:** the cut landed on a peer's local feature branch instead of `main`; caught at push (origin/main at a mid-window commit, tags pointing at the feature tip). (Real case: v0.7.4.)

**Resolution:** recovery is clean **while nothing has published**, publish is `workflow_dispatch`-driven, so npm stays put through the tangle. If the feature tip fast-forwards from main: `git branch -f main <release-sha>` → `git checkout main` → `git push origin main`. Verify `origin/main == vX.Y.Z tag == HEAD` **before** dispatching any publish. Moving tags while local is free (`tag-lockstep.mjs --delete` + re-tag); moving them after a publish is not. **Prevention:** the Step-1 `branch --show-current` check.

---

## §Scenario 9, Step 9 registry poll times out with every publish run green (npm E409)

**Shape:** `release-pack.mjs --mode handoff` exits 1 at Step 9, but `gh run list --workflow=publish-<pkg>.yml` shows every run `success` and `npm view <scope>/<pkg> version` eventually returns the target version, it just took longer than the poll waited. A large tarball can take up to ~25 minutes to become visible on `npm view` after npm accepts it (asynchronous staged publish); the v0.8.59 cut's poll window was 10 minutes.

**A re-dispatch made while a package is in this state fails its own `npm publish` step with `npm error code E409` / "Cannot publish over previously staged version".** That error is not a real failure, npm rejected the duplicate publish precisely because the real one already landed server-side. It is the staged-not-lost signal, never grounds to re-dispatch a third time.

**Resolution:**

- `release-pack.mjs`'s Step 9 (gh#3342) already extends its own poll to `REGISTRY_POLL_MINUTES` (default 30, override via env) and, for any package still stale after that, checks its latest `publish-<pkg>.yml` run for the E409 signature before deciding, an E409-confirmed package gets one more extended, isolated poll instead of an immediate hard-fail. Nothing to do by hand in that case; let it finish.
- If Step 9 already exited 1 and you've confirmed by hand (registry + `gh run list`) that every package is actually published, don't re-run the full handoff, it would re-run the ~15min pre-flight (Step 3) and re-tag at HEAD (Step 6, wrong if anything merged since the original tag). Resume from Step 10 only:

  ```bash
  node "<plugin-root>/skills/package-release/scripts/release-pack.mjs" \
    --mode handoff --version X.Y.Z --date YYYY-MM-DD --previous-version X.Y.Z-1 \
    --gh-notes-file <path> --from-step10
  ```

  `--from-step10` skips Steps 1/3/4/5/6/7/8/9 entirely and runs only Step 10 (GH releases + site deploy), the exact shape a hand-rolled one-off script (`/tmp/cut-0859-step10.sh`, never checked in) worked around live on the v0.8.59 cut. Before jumping, it hard-verifies the umbrella tag AND every per-package tag (`vX.Y.Z`, `<pkg>-vX.Y.Z`) already exist on origin and resolve to HEAD, without that check, `gh release create` on a tag that was never actually made would silently mint a NEW lightweight tag at whatever HEAD happens to be, attaching the release notes to the wrong commit. A missing or mismatched tag refuses the resume outright, naming which tag and why (either Step 8 never pushed it, or HEAD moved since); re-tag/push for real, or checkout the tagged commit, before retrying.
- **Never** re-dispatch a package a third time once it shows E409, a third attempt only 409s again. Wait for the registry; it always converges.

## §Decision flowchart

```text
Pre-flight or F-N1 gate failed?
├── check:demo-shells fails at the release-commit candidate?
│   └── a later fix exists but is entangled with [Unreleased] work → Scenario 4
│       otherwise → author the fix in this cycle (Variant B)
├── check:lockstep fails → peer edited an internal range mid-PATCH → fix in place
├── verify:corpus fails → corpus drift → route to the A2UI-pipeline skill
├── check:embeddings-fresh fails → npm run build:embeddings:chunks; stage; note in
│   the a2ui-corpus CHANGELOG
├── test:unit fails → Scenario 5 (stale test) OR real regression
│   (assertion vs CHANGELOG vs source tells them apart)
└── F-N1 cosmetic warns → enrichment pass (changelog-discipline.md)

Multiple unpushed release commits            → Scenario 2 (batch push)
Commit's CHANGELOG version ≠ its bump        → Scenario 1 (version skip)
Tags on origin, no publish runs, npm stale   → Scenario 7 (batched tag push)
branch --show-current ≠ main                 → Scenario 8 (wrong branch)
Working tree dirty at cycle start            → classify + stash per
                                               cut-procedure.md §Step 2
```
