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

GitHub

Specwright 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$ in git 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-tree prints 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> in git 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-only when the change is store-backed and the store is on chore/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 for Nothing 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.yaml when one was written. With the store done, the code repo is done when git show <main>:<that path> (in the store) has a line matching ^code_changes:\s*none\s*$; otherwise report and ask.

Steps

  1. 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), read git show <branch>:<P>/changes/archive/<archived-name>/review.md. A whole line VERDICT: DEFERRED in 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> before undo-archive-plan and --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, its after_rerun when not null, and every warnings line. Tell the user to save each script to a file and run it with bash <file>: script first, 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 error and message, and stop;
    • exit 2 → a usage error in this call: fix the call and run it again.
  2. Branch: [vcs] state gives branch and on_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-pr watch 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 line Archive-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.
  3. 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 subject merge: <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;
    • pr mode only: a code PR from that branch into the code main branch exists in any state (a PR into another base, such as integration, is not this change's code PR). Parse <owner>/<repo> from the code checkout's git remote get-url origin, then from the code checkout with GH_REPO unset: [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 in specwright-pr). A non-zero exit means unknown: stop, write no marker and say why. local mode skips this lookup.

    Planning-only → write <P>/changes/archive/<archived-name>/specwright-change.yaml containing code_changes: none and add it to the archive paths. Otherwise never write it, on the change branch or a chore/archive-<change-name> branch alike.

  4. 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 on chore/archive-<change-name>: add the body line Archive-Scope: store-only after a blank line (it is written on a first run and on a resume alike). Then [vcs] state: nothing in dirty_paths may 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.

  5. Finish per finish in openspec/specwright.yaml (default local). Main branch: main_branch setting, else main, else master, else ask. The local finish ignores pr.merge: it sends no GitHub merge request.

local

  • Pre-ship gate first, unless review.preship: false: run the specwright-review skill's bash 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 the open findings and continue at the review's step 3. A failed review (exit 3) also stops the merge.
  • Version check, with release.enabled true, 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 before git merge with 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-ff merge commit). On conflict (merge_failed on Git; conflicted on jj, where the merge was abandoned and nothing changed): stop, list the files, and on Git offer manual resolution or git merge --abort. Never resolve conflicts yourself.
  • [vcs] delete-branch --branch <branch> (never --force here). Report the merge commit and Local 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 from planning_store.main_branch, else main, else master):
    1. 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>.
    2. Code branch with commits after <main> → merge and delete it the same way. On conflict, stop: list the code files, offer manual resolution or git merge --abort (Git), and report the split state - the store's main has the change, the code repo's main does not.
    3. 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.
    4. Report both repos: each merge commit (or "no code changes"), the deleted branches, and Local only - push <main> when ready.

pr

  • Run specwright-pr ship with the run login (run login: <login>): it pushes the archive commit (pinning the GitHub identity and running pr.validate) and opens the PR if there is none, leaving an existing PR's description alone.
  • If specwright-pr watch started this archive, hand back to it: it waits for CI and reviews on the archive head before reporting ready. Otherwise read merge from bash scripts/pr-pair.sh context. With pr.merge: agent, start specwright-pr watch on the PR with the run login (run login: <login>): it waits, then merges the PR at the ready head. Do not print Ready to merge on GitHub. With pr.merge: user (the default, and any other value), report the PR URL and Ready 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 with pr.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's head_sha in discover ... --state merged (several merged PRs: the newest one's), never from gh pr view. delete: true → [vcs] delete-branch --branch <branch> --force; otherwise keep the branch and report its lacking commits, or head_unknown. With pr.merge: agent and delete_branch true (both from pr-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>: deleted and gone are done; moved and based_on keep 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. With pr.merge: user or delete_branch false, never delete the remote branch. Then follow specwright-pr Cleanup after merge for the issues the merged PR's closing lines name: its closed-check lists 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/.

  1. Root: openspec list --json from the code checkout, plus --store <id> when the session selected a store. It needs no change name, writes nothing and still works after archive. root null with an error whose message starts with Declared in or Invalid store declaration in → stop before any git or file write and show that message and fix.
  2. 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.
  3. 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.