specwright-branch
MANDATORY gate when a new OpenSpec change starts: /opsx:new, /opsx:propose, /opsx:ff, openspec-new-change, openspec-propose, openspec-ff-change, `openspec new change`, or the user asks to start/propos
SKILL.md.claude/skills/specwright-branch/SKILL.md
GitHubSpecwright Branch
Insert a branch gate in front of the vanilla change-creation workflow. Run no OpenSpec command that writes until the gate passes (openspec list --json only reads). Stop and ask at every failed check; never proceed silently.
- Planning repo (below) first: a stop there creates no branch. Inspect the code repo with
[vcs] state.[vcs] <sub>isbash <specwright-pr>/scripts/vcs.sh <sub> --repo <toplevel> --role code, and for the store--repo "<store toplevel>" --role store --code "<code toplevel>"; every call prints one JSON document withokandbackend(gitorjj), a stop addserrorandmessage.stategivesbranch,main,on_main,clean,dirty_paths. Errornot_repository→ stop and ask (recommend abort); any othererror(tool_missing,tool_too_old,not_managed,not_colocated) → stop and show itsmessage: the repo asks forvcs: jjand is not ready. On Git withjj_present: truethe repo holds jj metadata butvcs: jjis not set: say oncethis repo has jj metadata; set vcs: jj in openspec/specwright.yaml to route writes through jj, and go on with Git; it is a note, not a stop. - Change name: use the user's kebab-case name verbatim; otherwise derive one from the description (lowercase, spaces to hyphens, drop other characters). This name is authoritative: pass it verbatim to
openspec new changeand the triggering workflow; never let them derive another. - Main branch:
[vcs] statereportsmain(main_branchinopenspec/specwright.yaml, elsemain, elsemaster); none of them exists → ask.- Used once: an earlier change may have been merged from another checkout, so refresh the planning repo's main first when it has an
origin. Read its fetch URL withgit remote get-url origin(not its push URLs): when that URL is on github.com (any scheme;github.com.counts as github.com), run[vcs] fetch-main --quiet --login <login>, decided by the fetch URL and not bypr-pair.sh identity, which reports a mismatch for an origin that pushes to a fork; on any other host run[vcs] fetch-main --quiet, which runs plain.<specwright-pr>is that skill's folder;<login>is the one this gate was handed (run login: <login>, asspecwright-roadmapnext does), otherwisegithub.login, else the onebash <specwright-pr>/scripts/as.sh --resolveprints, once, for this gate only when it is user-started; a handed login is never resolved again, and a handedrun login: none(no gh account) means local refs only, with no--resolve. When planning is in a store, its fetch takes the store login,planning_store.login, else that run login (resolved only when it is needed). A refusal (exit 3, including a failed--resolve: no gh account) or a failed fetch is not a stop: saylocal refs only (no gh account)and continue on local refs. The fetch moves only the remote's main and prints itsref:origin/<main>on Git, a commit id on jj. Then list its archive on<main>and, when the fetch succeeded, on thatref(git ls-tree -d --name-only <ref> -- <P>/changes/archive/; read-only Git works on jj too), with the planning repo's main: this step's main when planning is in this repo; when store-backed, run it in the store with the store's main (planning_store.main_branchin this repo'sopenspec/specwright.yaml, elsemain, elsemaster, as in store step 2). An entry named<change-name>orYYYY-MM-DD-<change-name>means a change of this name was already archived → stop, since PR, merge and archive proofs match changes by name and would take the earlier one for this one. Offer another name (suggest<change-name>-2) or abort.
- Used once: an earlier change may have been merged from another checkout, so refresh the planning repo's main first when it has an
- Not on main (
on_mainfalse) → stop. Offer: switch to main ([vcs] switch --branch <main>, only if the tree is clean apart from step 5's carry-over), or abort. - Dirty tree:
dirty_pathsonly under<P>/changes/<change-name>/are this change's artifacts, created before the gate ran (or when apply found the change on main): they carry over to the new branch, so continue. Anything else → stop and show those paths. Offer: commit first or abort; on the Git backend also stash (git stash push -u -m "specwright-branch: pre-<change-name>" -- <those paths>, report the ref). On jj never stash, and never offer it: jj has no stash, and its working copy follows the branch. - Prefix from the name's first token: add/feat/feature/implement/introduce →
feat; fix/bugfix/hotfix/patch/resolve →bugfix; refactor/restructure/cleanup →refactor; docs/doc/document →docs; chore/bump/deps/ci/build →chore; anything else →feat(say so in one line). - Existing branch
<prefix>/<change-name>(a local branch, on jj a bookmark:git rev-parse --verifysees both) → stop (store-backed: note it and go on; store step 2 decides once both repos are inspected). Offer: switch to it and resume the change (do not re-scaffold), pick another name, or abort. - Store-backed → continue with Store-backed changes (below) instead of this step. Otherwise
[vcs] branch-create --branch <prefix>/<change-name>, announceOn branch <prefix>/<change-name> (from <main>), then hand back to the triggering workflow. If the change directory already exists, tell the workflow to continue it rather than create it again.
If the roadmap (project.roadmap, default openspec/roadmap.md) exists and the user did not say which milestone this change serves, ask, so the proposal can name it.
One working tree holds one change at a time: this skill switches the branch of the whole checkout. When several agents work on the project at once, each change needs its own git worktree.
Never commit or push here.
Store-backed changes
When the Planning repo is a store, the gate covers both repos and both get the same branch name. Steps 1–7 run on the code repo as written, except that an existing branch in step 7 is only noted: whether it stops depends on the store too. Then:
- Lock the store gate before touching the store. Every command that uses the lock starts with
cd "<store toplevel>" && L="$(git rev-parse --path-format=absolute --git-common-dir)/specwright-gate.lock" &&: shell variables do not survive between calls, so$Lis set again in each one. Take it in one call:cd "<store toplevel>" && L="$(git rev-parse --path-format=absolute --git-common-dir)/specwright-gate.lock" && mkdir "$L" && printf 'time: %s\nchange: %s\ncode: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<change-name>" "<code toplevel>" > "$L/owner".mkdiris atomic and fails when the lock exists. When it succeeds, you hold the lock: go on to step 2. Whenmkdirfails, stop before touching either repo: showowner(when the lock was taken, for which change, from which checkout) and say another session may be in its gate for this store. Whenowner'schange:starts withspecwright-update, a store update holds the lock instead: say an update from the checkout on itscode:line holds the store, tell the user to rerun the update from <code> (thatcode:line), and do not offer removal. For every other holder, a lock left by an interrupted session is never removed automatically: ask the user to confirm that no other session is in its gate, and only then remove it (cd "<store toplevel>" && L=... && rm -r "$L",Lset as above) and rerun the gate. - Store checks, with the lock held: run steps 1, 3, 4, 5 and 7 on the store (
[vcs]in its store form; the store'sstateprobes the store's own backend, so aplanning_store.vcs: jjstore without.jjstops here with the lock held, before any branch exists; read-only Git runs incd "<store toplevel>" &&calls). Main isplanning_store.main_branchin this repo'sopenspec/specwright.yaml, elsemain, elsemaster. Step 5's carry-over is<P>/changes/<change-name>/in the store.- Store on another change's branch → busy: stop, name that branch, and say one store holds one change in progress at a time on this machine (finish or archive that change first). Do not offer to switch it.
- Store dirty → stop and show the store's
git status --short, with the same offers as step 5, run in the store. - Store version: in one shell call read the project's
<code toplevel>/openspec/.specwright/VERSIONand the store's<store toplevel>/openspec/.specwright/STORE(test -ffirst, thensed -n 's/^version: *//p'andsed -n 's/^owner: *//p'on it;STOREis two lines,version: <x.y.z>andowner: <id>), and comparemajor.minoronly. NoSTORErecord passes. An unreadableSTORE(it exists but has no version or no owner) or a missingVERSION(no completed install in this project) → stop: a missingVERSIONnames the store's version and says to run the install in this project, or the update when it already has one. A mismatch → stop, naming both versions and the owner: project behind the store → update this project to the store'smajor.minor; store behind the project → the store's owner updates the store first. Each of these stops, and a store probe error, releases the lock first (step 4). <prefix>/<change-name>exists in both repos → stop with step 7's offers.<prefix>/<change-name>exists in only one repo (the code repo's, noted in step 7, or the store's) → stop. Offer to resume it and create the missing one, pick another name, or abort.
- Create only after both repos pass:
[vcs] branch-create --branch <prefix>/<change-name>in the code repo, then in the store. If anybranch-createerrors, in the code repo or the store, roll back before anything else; a failingpost-checkouthook makes it error after the branch was created and switched to, so never assume nothing was made. In each repo where this gate ranbranch-createand<prefix>/<change-name>exists: on jj (stateshowsbackendjj), switch nothing:branch-createonly added a bookmark at main's commit, whichstatereports as the current branch until it is gone, so delete it with[vcs] delete-branch --branch <prefix>/<change-name>and judge it bystate(on_maintrue). On Git, when[vcs] stateshows the repo on<prefix>/<change-name>, run[vcs] switch --branch <main>(that repo's main) and ignore its exit status (the same hook may fail again), then judge the switch bystate(branchis<main>oron_mainis true), not by the exit status; once it is, delete the branch with[vcs] delete-branch --branch <prefix>/<change-name>(nothing is committed on it). A repo still on<prefix>/<change-name>keeps it: delete nothing there, but still roll back the other repo. Then release the lock (step 4) and stop, showing the original error; when a repo could not switch back, name that repo,<prefix>/<change-name>and its main. - Release the lock right after both branches exist, in one call that sets
Lagain:cd "<store toplevel>" && L="$(git rev-parse --path-format=absolute --git-common-dir)/specwright-gate.lock" && rm -r "$L". Every stop from step 1 onwards releases it first, unless the stop is that the lock was held by someone else. - Announce
On branch <prefix>/<change-name> in the code repo (<code toplevel>, from <main>) and the store (<store toplevel>, from <store main>), then hand back to the triggering workflow. It scaffolds the change in the store, since the root resolves there.
Once a store branch exists, any later gate sees the store as busy, so the lock only covers the gate itself.
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.