specwright-finish
MANDATORY after the OpenSpec archive workflow completes: /opsx:archive, /opsx:bulk-archive, openspec-archive-change, `openspec archive`, or the user asks to archive/finish a change. Commits the archiv
SKILL.md.claude/skills/specwright-finish/SKILL.md
GitHubSpecwright Finish
Start only after the vanilla archive workflow reports success (for bulk archive: after all of it). If it failed, do nothing. Then resolve the Planning repo (below); a stop there writes nothing.
Commit type: the branch prefix, except bugfix → fix. Archive paths, in the planning repo: <P>/changes/<change-name>/ (now removed), <P>/changes/archive/<archived-name>/, and for each delta specs/<capability-path>/spec.md inside the archived change, the main spec <P>/specs/<capability-path>/spec.md it updated or created - by file, never the whole <P>/specs/ directory. Other files are the user's: never stage them.
Run login. [as] below is bash <specwright-pr>/scripts/as.sh <run login> (<specwright-pr> being that skill's folder). The run login is the one this step was handed (run login: <login>, or run login: none: no gh account, so local refs only); otherwise github.login, and with that empty, when the user started this step, the one bash <specwright-pr>/scripts/as.sh --resolve prints once, run only when a GitHub call below first needs it (the pr path, or a fetch or pull from a github.com remote). The local merge makes no GitHub call, so it never resolves; with no login there, the run login is none, and a failed --resolve on that path is not a stop. A handed-off step that cannot find it stops before any GitHub call and asks the user; it never resolves again. That run login is the code repo's: in the store, [as] takes the store login, planning_store.login, else that run login, never a login handed for the code repo in its place. Every fetch and pull below goes through [vcs] pull --login <run login> (or fetch-main), which fences the call when the fetch URL of the remote (git remote get-url <remote>) is on github.com (github.com., the trailing-dot form, counts as github.com), whatever its push URLs and not by pr-pair.sh identity, and runs plain for any other host; with run login: none make no such call to a github.com remote. A non-zero exit stops the step: delete no branch and show the error message.
Branch reads and writes. [vcs] <sub> is bash <specwright-pr>/scripts/vcs.sh <sub> --repo <toplevel> --role code, and for the store --repo "<store toplevel>" --role store --code "<code toplevel>". It works on Git and on a colocated jj repo alike and prints one JSON document (ok, backend, and on a stop error and message; exit 1 stops, exit 3 is a network or fence refusal). state reads branch, main, on_main, clean, dirty_paths; switch, branch-create, commit, merge, delete-branch and pull write. Read-only git (log, show, ls-tree, merge-base, rev-parse) stays as written. Never run git checkout, git commit, git merge, git branch -d, git branch -D or git pull yourself. On jj the branch is a bookmark: delete-branch forgets it locally, and --force is the guarded delete that the branch-check result allows.
Resume
Applies when the change directory is gone and finish runs again: the user asks to finish it, or the archive workflow reports the change already archived. Resolve the Planning repo first. On any branch, a repo where A (below) is found resumes here instead of running steps 1-3. Never repeat a done step.
For each repo of the change (repo-local: one; store-backed: the store, then the code repo), find <branch> from <main>: <prefix>/<change-name> by the prefix rule of specwright-branch step 6, else a local chore/archive-<change-name>, else the one local branch matching */<change-name>; several candidates → list them and ask. Then read three facts:
- A - an archive commit: a subject matching
^[a-z]+\(<change-name>\): archive change$ingit log --format=%s <main>..<branch>, or on<main>; or, on the branch, the commit that added the archive directory,git log --diff-filter=A --format=%H <main>..<branch> -- <P>/changes/archive/<archived-name>/review.md(the newest, first line if it prints several: an older add is an archive that was reverted and archived again; never the newest commit touching the directory, which can be a later review fix; a bulk archive commit, whose subject names several changes, is found only this way); or, on<main>, the archive directory:git ls-tree -d --name-only <main> -- <P>/changes/archive/lists<archived-name>(the Archive name rule), even with no such subject (an archive squash-merged or bulk-archived).ls-treeprints full paths, so match the last path component: a change name without a date matches exactly^\d{4}-\d{2}-\d{2}-<change-name>$, a dated name the dated name itself, never a substring. The directory is planning repo only (repo-local: the one repo; store-backed: the store) and never gives the code repo an A. - M - the exact subject
merge: <change-name>ingit log --first-parent --format=%s <main>, and, when B, the branch tip is on<main>(git merge-base --is-ancestor <branch> <main>): an archive-recovery branch made after the change merged holds a new archive commit, so the earlier merge does not count for it. - B -
<branch>exists.
The branch's PR is the newest one from [as] bash scripts/pr-pair.sh discover --repo <owner>/<name> --branch <branch> --base <main> (no --state, so every state counts; the specwright-pr conventions), and the rows' "the branch's PR merged" means that newest PR is merged. In pr mode discover runs once per repo whenever a rule needs the branch's PRs (the no-archive lookup below, or the table), and the reused-name check reads that same result immediately. An older merged PR from the branch with a newer closed-unmerged PR, or a newer open PR with B false, means the branch name was reused: report every such PR and ask, touch nothing, and never closed-check the older PR. This is checked before the no-archive report, any pull, or any table row, so a reused branch name never reaches a merged-PR row, a done row or the no-archive report; only when B holds and the newest PR is open do the ship rows apply as usual, since that open PR is the current change's.
None of A, M, B in any repo → in pr mode, first look up each repo's PR (discover, above), since a cleanup can delete the branch before main is pulled; for each repo whose newest PR is merged, run [vcs] state before any switch or pull (clean false → stop, list dirty_paths and ask the user; switch, pull and merge nothing), then [vcs] switch --branch <main> and [vcs] pull --login <run login>, and read the three facts again, then continue with the table. Still none (or local mode) → report that no archive of <change-name> was found, and do nothing. state showing dirty_paths under <P>/changes/<change-name>/ or the archive directory (with A found) → stop, list the files and ask the user; merge nothing. Otherwise continue at the first step not done, per repo. Before any table row, run step 0 in the planning repo whenever the archive paths are uncommitted or A is on the branch, and the change is not on <main> yet (no M, and the branch's PR not merged), wherever finish runs, <main> included: a committed archive gets the same check as an uncommitted one, so a VERDICT: DEFERRED stops before any merge, ship or push. Rows are checked top to bottom, and the first match wins:
| Facts | Next |
|---|---|
| archive paths uncommitted, no A | start at step 0 (deferred check), then steps 1-3 in order (branch check and recovery branch, planning-only test, archive commit) |
A on branch, not M, B, local |
merge (## local) |
code repo, the newest store A's body has the line Archive-Scope: store-only |
out of scope: report the code branch and its PR state, touch nothing; the repo counts as done |
pr, the branch's PR merged, B |
After the PR is merged cleanup, not ship |
pr, the branch's PR merged, not B |
first refresh main, unless this run already pulled the repo: [vcs] state (clean false → stop and ask, switch and pull nothing), [vcs] switch --branch <main>, [vcs] pull --login <run login>. Planning repo (repo-local: the one repo; store-backed: the store): read A again on the refreshed main. A present → closed-check (specwright-pr Cleanup after merge), no branch deletion; repo done. A absent → report that the merged PR did not bring the archive to <main>, no backstop; repo not done. A store-backed code repo never has A: after the refresh, closed-check, repo done. closed-check exits 3 → repo not done, report that the backstop did not run |
| M and B | [vcs] switch --branch <main>, then [vcs] delete-branch --branch <branch>, no merge. In pr mode (reached only with no merged PR, since the merged-PR rows come first: the change was merged locally) [vcs] switch --branch <main>, then [vcs] pull --login <run login>, then [as] bash scripts/pr-pair.sh branch-check --dir <toplevel> --branch <branch> --main <main> without --head, since M already puts the tip on main; delete: true → [vcs] delete-branch --branch <branch> --force, otherwise keep the branch and report its lacking commits. No merge, and no closed-check: no PR merged |
| M, not B | repo done |
local, A on main through the archive directory, not M, not B |
a reused change name can match an older change's archive, so show the directory and the commit that added it (git log -1 --format='%h %ad %s' --date=short <main> -- <P>/changes/archive/<archived-name>/) and ask whether it is this change's archive: yes → repo done (another change's merge carried the archive); no → report that this change's archive is not on main and touch nothing |
code repo with no A, M or B; store done; the store's archive is marked code_changes: none |
repo done (a planning-only change) |
| code repo with no A, M or B; store done; no such marker | report and ask; touch nothing |
store done; code branch with commits, code not M, local |
code merge (## local store-backed step 2) |
| store done; code branch without commits | [vcs] switch --branch <main>, then [vcs] delete-branch --branch <branch>, no merge |
pr, A on branch, PR not merged |
## pr ship; push and PR are idempotent |
pr, store done; code branch with commits, code not M, no PR or an open PR |
## pr ship for the code branch; push and PR are idempotent. Merge nothing, delete nothing |
pr, store done; code branch with commits, the newest PR closed unmerged (no older merged PR: the reused-name rule above covers that) |
report the closed PR and ask. Merge nothing, delete nothing |
Report Nothing to finish - archive is on <main> only when every repo is done; a backstop closed-check exit 3 is not done.
- Planning-only test: it runs only before an archive commit. A resume that finds archive paths uncommitted and no A runs steps 1-3 in order, the test included; once A exists, a resume never writes the marker.
- Store-only recovery: step 3 writes the body line
Archive-Scope: store-onlywhen the change is store-backed and the store is onchore/archive-<change-name>. Read it from the newest store A:git log -1 --format=%B <A>, matching that exact line. When it is there, the code repo is out of scope: no code merge, ship or branch deletion; report the code branch's state and leave it to its own PR or finish. This counts as done forNothing to finish. - Planning-only, already finished: a local finish of a planning-only change deletes the empty code branch and merges nothing, so the code repo shows no A, M or B. The marker's path comes from the store's A:
git show --name-only --format= <A>lists<P>/changes/archive/<archived-name>/specwright-change.yamlwhen one was written. With the store done, the code repo is done whengit show <main>:<that path>(in the store) has a line matching^code_changes:\s*none\s*$; otherwise report and ask.
Steps
Deferred check, before any commit, merge or push: for each archived change (bulk archive: every one), read
<P>/changes/archive/<archived-name>/review.md(the Archive name rule); when that directory is not in the working tree (a resume on<main>or another branch), readgit show <branch>:<P>/changes/archive/<archived-name>/review.md. A whole lineVERDICT: DEFERREDin any → stop: no commit, no merge, no push. Tell the user that change must not be archived, and run no restore yourself (it could drop their own edits). Instead run the undo plan once, in the planning repo:bash <specwright-finish>/scripts/finish.sh undo-archive-plan --change <change-name> --archived-name <archived-name> --prefix <P> --main <main> --branch <branch>(<specwright-finish>being this skill's folder;<branch>the change branch, as Resume finds it, else the current branch; store-backed:--dir <store toplevel>beforeundo-archive-planand--code <code toplevel>after it, so the store's own backend is probed). It only reads the repo and prints one JSON document:- exit 0 → show its
script, itsafter_rerunwhen not null, and everywarningsline. Tell the user to save each script to a file and run it withbash <file>:scriptfirst, then, for a bulk archive,after_rerun, which archives the other members again and merges the saved main-spec amendments back; then finish again. Run neither script yourself. The undo of a committed archive is one new commit on<branch>(an uncommitted one makes none), never a revert, a reset or a force-push. A bulk archive is undone as a whole and archived again without the deferred change, never restoring only the deferred change's files; - exit 1 → show its
errorandmessage, and stop; - exit 2 → a usage error in this call: fix the call and run it again.
- exit 0 → show its
Branch:
[vcs] stategivesbranchandon_main. If the branch's change name differs from the archived change, confirm with the user. On main:- No archive changes left uncommitted (the archive already reached main) → Resume; report
Nothing to finish - archive is on <main>only when it finds every repo done. - Otherwise (the PR merged before archive ran; normally
specwright-prwatch archives on the PR branch first) → offer[vcs] branch-create --branch chore/archive-<change-name>; the uncommitted archive carries over. Continue from step 2 on that branch, or stop if the user declines. Never commit on main. - Store-backed: check each repo. The archive lives in the store, so the recovery branch is created there only (
[vcs] branch-create --branch chore/archive-<change-name>, store form), never a code branch or code PR for it; the code repo stays as it is. Step 3 then marks the archive commit with the body lineArchive-Scope: store-only, so a later resume leaves the code repo alone. It carries only the archive: the planning-only test below still decides the marker, and step 4 then finishes only the store branch - the code repo's branch and PR go their own way.
- No archive changes left uncommitted (the archive already reached main) → Resume; report
Planning-only test (store-backed only), before the archive commit, with
<prefix>/<change-name>as the code branch (on a recovery branch, the code repo's local branch ending in/<change-name>, if any). The change has code work when any of these holds, and is planning-only otherwise:git -C "<code toplevel>" log --oneline <main>..<prefix>/<change-name>lists a commit (an absent branch lists none);git -C "<code toplevel>" log --first-parent --format=%s <main>has the exact subjectmerge: <change-name>- this change's own merge, so the code was already merged, as on a recovery branch. A subject that merely contains(<change-name>):is not proof: another change or branch can carry that scope;prmode only: a code PR from that branch into the code main branch exists in any state (a PR into another base, such asintegration, is not this change's code PR). Parse<owner>/<repo>from the code checkout'sgit remote get-url origin, then from the code checkout withGH_REPOunset:[as] gh api --paginate "repos/<owner>/<repo>/pulls?state=all&head=<owner>:<prefix>/<change-name>&per_page=100" --jq '.[] | select(.head.repo.full_name == "<owner>/<repo>" and .base.ref == "<main>") | .number'([as]as inspecwright-pr). A non-zero exit means unknown: stop, write no marker and say why.localmode skips this lookup.
Planning-only → write
<P>/changes/archive/<archived-name>/specwright-change.yamlcontainingcode_changes: noneand add it to the archive paths. Otherwise never write it, on the change branch or achore/archive-<change-name>branch alike.Archive commit, one call in the planning repo (store-backed: its store form):
[vcs] commit --branch <branch> --message-file <msgfile> -- <archive paths>with<type>(<change-name>): archive change; it refuses on any other branch (wrong_branch). Store-backed with the store onchore/archive-<change-name>: add the body lineArchive-Scope: store-onlyafter a blank line (it is written on a first run and on a resume alike). Then[vcs] state: nothing indirty_pathsmay be left under<P>/changes/<change-name>/or the archive directory, and none of the listed spec files (else stop and list it); other leftovers, including other files under<P>/, are the user's - list them and continue. Bulk archive of several changes on one branch → one archive commit naming them all.Finish per
finishinopenspec/specwright.yaml(defaultlocal). Main branch:main_branchsetting, elsemain, elsemaster, else ask. Thelocalfinish ignorespr.merge: it sends no GitHub merge request.
local
- Pre-ship gate first, unless
review.preship: false: run thespecwright-reviewskill'sbash scripts/review-run.sh gate --change <change-name> --prefix <P>(the planning prefix; store-backed:--store <store toplevel>).pass→ merge.missing→ run the pre-ship review (specwright-review, Pre-ship review), then the gate again.blocked→ stop before any merge: list theopenfindings and continue at the review's step 3. A failed review (exit 3) also stops the merge. - Version check, with
release.enabledtrue, before the merge:bash <specwright-release>/scripts/release.sh check --main <main>(plain git, no login; store-backed: once, from the code repo, before either repo's merge). A refusal (version_not_ahead_of_main) stops beforegit mergewith main unchanged: show its message and tell the user to bump again with a greater version. - Write the merge message to a file first - subject
merge: <change-name>plus any trailers the user's commit rules require - because fixing a commit afterwards means amending, which this workflow never does unprompted. Then[vcs] switch --branch <main>and[vcs] merge --branch <branch> --message-file <msgfile>(the merge needs main checked out and makes the--no-ffmerge commit). On conflict (merge_failedon Git;conflictedon jj, where the merge was abandoned and nothing changed): stop, list the files, and on Git offer manual resolution orgit merge --abort. Never resolve conflicts yourself. [vcs] delete-branch --branch <branch>(never--forcehere). Report the merge commit andLocal only - push <main> when ready.Never push.- Store-backed: the store first, then the code repo, each with the steps above run in that repo (store:
[vcs]in its store form, main fromplanning_store.main_branch, else main, else master):- Run the Version check above from the code repo; a refusal stops with both mains unchanged. Then merge and delete the store branch. On conflict, stop there: list the store files, offer manual resolution or
git merge --abort(Git), and report that the code repo was left unmerged on<branch>. - Code branch with commits after
<main>→ merge and delete it the same way. On conflict, stop: list the code files, offer manual resolution orgit merge --abort(Git), and report the split state - the store's main has the change, the code repo's main does not. - Code branch with no commits →
[vcs] switch --branch <main>and[vcs] delete-branch --branch <branch>, no merge. Report that the change had no code changes. - Report both repos: each merge commit (or "no code changes"), the deleted branches, and
Local only - push <main> when ready.
- Run the Version check above from the code repo; a refusal stops with both mains unchanged. Then merge and delete the store branch. On conflict, stop there: list the store files, offer manual resolution or
pr
- Run
specwright-prship with the run login (run login: <login>): it pushes the archive commit (pinning the GitHub identity and runningpr.validate) and opens the PR if there is none, leaving an existing PR's description alone. - If
specwright-prwatch started this archive, hand back to it: it waits for CI and reviews on the archive head before reporting ready. Otherwise readmergefrombash scripts/pr-pair.sh context. Withpr.merge: agent, startspecwright-prwatch on the PR with the run login (run login: <login>): it waits, then merges the PR at the ready head. Do not printReady to merge on GitHub. Withpr.merge: user(the default, and any other value), report the PR URL andReady to merge on GitHub once checks and reviews are green., and offer watch. A failed ship stops with its error and starts no watch. Never merge the PR withpr.merge: user; in agent mode only watch merges. - After the PR is merged (the user says so, or the check shows MERGED:
[as] gh pr view <branch> --repo <owner>/<name> --json state, or[as] bash scripts/pr-pair.sh discover ...as above; exit 3 means GitHub could not be read: stop, and treat the PR as neither merged nor unmerged):[vcs] switch --branch <main>, then[vcs] pull --login <run login>(it runs plain for any other host, one whose fetch URL is not on github.com). A non-zero pull stops: delete no branch and show the error. Then[as] bash scripts/pr-pair.sh branch-check --dir <toplevel> --branch <branch> --main <main> --head <head_sha>, with<head_sha>from the merged PR'shead_shaindiscover ... --state merged(several merged PRs: thenewestone's), never fromgh pr view.delete: true→[vcs] delete-branch --branch <branch> --force; otherwise keep the branch and report itslackingcommits, orhead_unknown. Withpr.merge: agentanddelete_branchtrue (both frompr-pair.sh context; this includes a resume after a session died post-merge), then[as] bash scripts/pr-pair.sh delete-remote --repo <owner>/<name> --branch <branch> --head <head_sha>, with the same<head_sha>:deletedandgoneare done;movedandbased_onkeep the remote branch, and the report says why (the new sha, or the open PRs based on it);kept(exit 1) shows GitHub's message and cleanup continues; exit 3 keeps the branch and reports it unknown. Withpr.merge: userordelete_branchfalse, never delete the remote branch. Then followspecwright-prCleanup after merge for the issues the merged PR's closing lines name: itsclosed-checklists any that GitHub left open and closes the ones the user confirms. Store-backed: that section also cleans both repos, so follow it instead of the commands above.
Release. With release.enabled true, once the change is on main (after the local merge, or after the PR is merged and cleanup finished) and before the roadmap offer, read bash <specwright-release>/scripts/release.sh untagged, fenced with the run login in finish: pr (specwright-release publish step 2 gives the call and its arguments: it reads origin's tags). In finish: local read bash <specwright-release>/scripts/release.sh untagged --local instead: unfenced, with no login and no network call, so the list is from local tags. Nothing listed, or releases disabled: say nothing about a release. Otherwise offer specwright-release publish for each version, oldest first; with release.auto publish them oldest first without asking, stopping at the first failure. A publish failure reports its error and undoes neither the merge nor the cleanup; the roadmap offer still follows, as it does when untagged fails (skip the offer, show the error). finish: local never publishes, even with release.auto: the merge is not pushed, so report each version as untagged from local tags and that publish runs after main is pushed (v0.5.0 can be published after main is pushed); publish then reads origin's tags itself.
If the roadmap (project.roadmap) exists, once the change is on main (after the local merge, or after the PR is merged), offer exactly one specwright-roadmap mode: close when every change of the Now milestone is then done, derived as specwright-roadmap derives it; otherwise next. Never offer both. Hand the run login (run login: <login>, or run login: none when the run has none) to the mode taken.
Planning repo
OpenSpec decides where planning lives: in this repo, in a folder nested in it, or in a store (a separate git repo). Resolve it at the start of every run, before any write; never assume ./openspec/.
- Root:
openspec list --jsonfrom the code checkout, plus--store <id>when the session selected a store. It needs no change name, writes nothing and still works after archive.rootnull with an error whosemessagestarts withDeclared inorInvalid store declaration in→ stop before any git or file write and show thatmessageandfix. - Repo, in one call:
git rev-parse --path-format=absolute --git-common-dir --show-toplevel && git -C "<root.path>" rev-parse --path-format=absolute --git-common-dir --show-toplevel.- The second fails (root not in a git work tree) → stop and ask: initialise git there, or abort.
- Different common dir → store-backed: the planning repo is the store checkout at its toplevel.
- Same common dir and toplevel → repo-local (including a root nested in this checkout, such as
planning/): one repo, as before. - Same common dir, different toplevel → the root is in another worktree of this repo, where a commit would land on that worktree's branch. Stop before any write, name both worktrees (path and branch) and ask.
- Announce once:
Code repo: <toplevel>; planning repo: <toplevel> (store <root.store_id>)or(same repo).
Paths. <P> is <root.path>/openspec made relative to the planning repo's toplevel (openspec in the usual layout, planning/openspec for a nested root). Stage and ls-tree planning files as <P>/... in the planning repo.
Where commands run. Git, gh, as.sh and vcs.sh commands on the store run in one shell call that starts with cd "<store toplevel>" && (as.sh only fences the repo it starts in). openspec templates and openspec schema validate have no root selection: run them with cd "<root.path>" &&. git -C is for read-only queries only (rev-parse, status, log, ls-tree).
Archive name. Use archivedAs from the archive output when you have it. Otherwise it is the change name if that starts with YYYY-MM-DD-, else YYYY-MM-DD-<change-name>: <P>/changes/archive/<archived-name>/.
Referenced stores. A references: list in the root's config.yaml does not move the planning root: a repo with its own root stays repo-local. Referenced stores are read-only: never branch, commit, stash, push, open a PR or write a file in one. Read them only through openspec context --json (each members entry with role: referenced_store gives its path and a fetch command such as openspec show <spec-id> --type spec --store <id>). A member whose status has reference_unresolved (the store is not registered on this machine) is named in the report as unresolved reference: <id> with its fix, and the step continues without it; never guess its path.