specwright-commit

MANDATORY during the OpenSpec apply phase: /opsx:apply, openspec-apply-change, `openspec instructions apply`, or the user asks to implement/apply an OpenSpec change. Commits each completed task (with

SKILL.md

GitHub

Specwright Commit

Wraps the vanilla apply loop. The vanilla workflow still owns change selection, instructions and progress; this skill owns commits.

Commit type: the branch prefix, except bugfix → fix. Below, <type>(<change-name>) means that.

Every branch read and commit goes through [vcs] <sub>, which is bash <specwright-pr>/scripts/vcs.sh <sub> --repo <toplevel> --role code, and for the store --repo "<store toplevel>" --role store --code "<code toplevel>" (<specwright-pr> is that skill's folder). 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. A stop is not a commit: show it. state gives branch, main, clean and dirty_paths; commit --branch <branch> --message-file <msgfile> -- <paths> stages and commits exactly those paths, and refuses (wrong_branch) on any other branch. On jj the branch is a bookmark, and nothing_to_commit means the paths hold no change (no empty commit is made).

1. Before the first task

  • Note any files that are already untracked or modified and not part of the change ([vcs] state --scope <change-name>: dirty_paths). They belong to the user: never commit them, and do not count them against the clean-tree check later.
  • The same state call gives the branch. On <prefix>/<change-name> → use both parts in every message. On main → stop and offer to run specwright-branch now (the change's uncommitted artifacts carry over) or to continue without commits. Any other branch → confirm with the user first.
  • Planning repo (below). Store-backed: planning files are committed in the store, on its <prefix>/<change-name> branch; task code is committed here. Note the store's pre-existing files too. <store main>: planning_store.main_branch in openspec/specwright.yaml, else main, else master.
  • Store-backed: every commit, in either repo, starts with the store branch check: [vcs] state on the store must print branch = <branch>. If it does not, make no commit in either repo and stop, naming the store's current branch.
  • Interrupted jj commit. On jj, state --scope <change-name> can list behind commits: a commit was made and the bookmark was not moved. When they are all this change's own (<type>(<change-name>): ...), [vcs] advance --branch <branch> --scope <change-name> moves the bookmark to the working copy's parent and loses nothing; bookmark_behind (someone else's commit between) → stop and show its commits. On Git advance changes nothing.
  • Mode. Read commit_granularity from openspec/specwright.yaml in the code repo once per apply run. task, or no setting (the default), keeps one commit per task as section 2 says; group commits once per task group (Group mode in section 2). Any value other than task or group → stop before any commit, naming the value and task and group. Task mode is unchanged: one commit per task, no Specwright-Task trailers.
  • Store-backed, or group mode: Reconcile (below) before the planning commit, before ticking each task and before committing it. In group mode it also runs at the start, repo-local too, to find a pending group.
  • Uncommitted planning files → one [vcs] commit --branch <branch> --message-file <msgfile> -- <P>/changes/<change-name>/ in the planning repo (store-backed: its store form, after the store branch check) with subject <type>(<change-name>): add planning artifacts. Never stage all of <P>/: other files there belong to the user.
  • Group mode: the planning and amendment commits never stage tasks.md when its diff ticks a task with no evidence; that tasks.md waits for its group commit, so name the other planning files instead of the directory. An untracked tasks.md that already ticks a task stops apply before any commit: name the ticked tasks.

2. After each task is ticked [x]

One commit per task, in one call: [vcs] commit --branch <change branch> --message-file <msgfile> -- <paths>. It checks the change branch itself and refuses on another one (stop).

  • <paths>: the files this task changed plus tasks.md, by name. Store-backed: tasks.md is in the store, so the task gets two commits with the same subject: first the code files here, then <P>/changes/<change-name>/tasks.md in the store (cd "<store toplevel>" && plus the branch check). A task that changed no code gets the store commit alone, with a Code-Changes: none line before the trailers. Never name . or a directory you do not own as a path. An unexpected changed file is a question for the user, not something to sweep in.
  • Subject: <type>(<change-name>): task X.Y <task text> with the full task text. Write the full message (subject, body, trailers) to a temp file with your file tool, never through a shell, so backticks, quotes and $ stay literal. Then run bash <skill dir>/scripts/fit-subject.sh <msgfile> (<skill dir>: this skill's folder): it cuts the subject in the file at the last whole word within 72 characters and prints it. Commit with [vcs] commit --message-file <msgfile> as above. Store-backed: the store commit of the pair reuses the same file, so both commits carry the identical subject.
  • fit-subject.sh exit 1 (not even the first word fits): make no commit for this task, leave its files uncommitted, and report the task, the subject and the 72-character limit. Exit 2 (missing file, or line 1 is not a task subject): fix the message file and run it again.
  • Follow the user's commit identity and trailer rules (CLAUDE.md / AGENTS.md).
  • Nothing to commit (nothing_to_commit on jj; on Git a git_commit_failed that says so) → note the task as no-op for the final report.
  • Release Notes: with release.enabled true in openspec/specwright.yaml, a task that changes what the change ships updates the proposal's ## Release Notes section in the same task commit (the proposal is a planning file: store-backed, it goes in the store commit). Every commit that edits the section, pre-ship and PR fix commits included, first runs release.sh notes --proposal <proposal.md> (<specwright-release>/scripts/release.sh) and must exit 0. An edit confined to the section keeps the design review verdict.
  • When a specwright-implementer returns, before verifying its group: check for shells or background tasks it left running (your harness's task or process list), stop any you find and say so in the report, then run [vcs] state again. A late writer may have changed or added files, so verify only after this.
  • When a specwright-implementer did a group, commit its tasks one by one in order after verifying: each task's files with that task (a file two tasks touched goes with the later one). Re-run each task's green verification yourself; its report is not evidence. A red-confirmation task is the one exception: the test is green by now, so accept the report's exact command and failure output for it, and say so in the commit body.
  • Drift amendments to proposal, specs, design or review.md: commit them by name in the planning repo as <type>(<change-name>): amend <artifact> before resuming tasks. They carry no Specwright-Task: trailer, so they never count as evidence.

Group mode (commit_granularity: group)

Replaces the per-task commit above with one commit per task group. Task X.Y belongs to group N when its ID has exactly N before the dot (an integer match: 1.1 is group 1, never group 10).

  • Leave a group's work uncommitted until every task of the group is ticked and freshly verified by you, then commit it once, with the group's files and tasks.md by name in one [vcs] commit as above. No commit holds part of a group's work or ticks. When a specwright-implementer did the group, do not split it: re-run its tests yourself as above (a red-confirmation task is still accepted from the report's exact command and failure output, said so in the commit body).
  • Subject: <type>(<change-name>): group N <group heading text>, the text being the heading ## N. <text> of tasks.md. A group with no such heading stops: make no commit for it, and report the group and the missing ## N. heading. Write the full message to a temp file and run fit-subject.sh <msgfile> as above; on its exit 1, make no commit for the group, leave its files and ticks uncommitted, and report the group, the subject and the 72-character limit.
  • Trailers: One Specwright-Task: X.Y trailer per covered task of the group, in task order. They go in the final trailer block, before the user's Co-authored-by trailer (a line in a body paragraph is not a trailer). The commit covers only the group's tasks that have no evidence yet.
  • Store-backed: the group gets two commits: the code files here first, then the store's tasks.md (at <P>/changes/<change-name>/, plus any planning file the group edited) in the store with the identical message file (cd "<store toplevel>" && plus the branch check). A group without code gets the store commit alone, with a Code-Changes: none line before the trailers.
  • One group is pending at a time. A complete group is committed (both halves when store-backed) before any task of another group is ticked, parallel dispatch included: tick and commit one group at a time, never two at once. If a group's commit fails, no task of another group is ticked, whatever its work state; report the failed group commit.
  • Resume. Run task-cover.sh (Reconcile, below) at the start. No pending_groups → continue. Exactly one pending group → its ticked, uncovered tasks are its pending work: re-verify them, finish its open tasks, then commit once, covering all its uncovered tasks. A ticked red step (a test seen failing before the implementation) counts as re-verified by running its named test, which must exist and now pass; the commit body says its original red output is unavailable, and it stays ticked. If the named test is missing: no commit; report the task and the missing test. More than one pending group → no commit; list the groups and their tasks and ask the user how to record them. Never remove a tick. Files in the tree that the group's tasks do not account for are a question for the user, not something to sweep in.
  • Never amend, squash, rebase or regroup an existing commit to form a group commit, published or not. Earlier per-task commits keep their SHAs. A group that mixes per-task and group commits is reported once, naming the group. A PR fix round after group commits is a new commit.
  • Switching back to task mode with a pending group: give its ticked tasks per-task commits under the split rule above (store-backed: the task-mode Reconcile questions).

Reconcile (store-backed, and group mode)

A failed or interrupted step can leave a task ticked with no commit. Find these gaps before new work, so a later commit never absorbs an earlier tick. Read the coverage with one read-only call per repo: bash <skill dir>/scripts/task-cover.sh --repo <repo toplevel> --range <main>..HEAD --tasks <tasks.md> --change <change-name> (store-backed: once for --repo "<store toplevel>" --range <store main>..HEAD and once for the code repo, both with the store's tasks.md), plus git -C "<store toplevel>" diff HEAD -- <P>/changes/<change-name>/tasks.md (read-only Git; against HEAD, so a tick left staged by a failed commit counts too). It prints one JSON document: tasks (each with its commits), groups, pending_groups, uncovered, duplicates, cross_group, missing_headings. A failure (exit 1, 2 or 3) is no coverage data: make no commit and stop, showing its error.

  • A gap is a task ticked [x] in tasks.md that is uncovered in the store (store-backed; repo-local group mode: in this repo). The task this session just ticked and is committing now is not a gap. In group mode, ticks this session made in the group it has open (ticked, not yet committed) are not gaps either. The normal loop never asks about these. Ticks inherited from an earlier, interrupted session are still gaps.
  • Task mode: gap with a code commit (the task covered in the code repo) and the uncommitted tasks.md diff ticks only that task → commit tasks.md in the store with the code commit's subject, then continue.
  • Task mode: gap without a code commit → never assume it changed no code. Commit nothing, tick nothing, stop and ask whether task X.Y changed code. No code → the store commit with its original subject and Code-Changes: none. Changed code → commit its code files by name first, then the store tick.
  • Task mode: the diff ticks more than one gap task → make no commit, list the tasks and ask how to record them.
  • Group mode: a code commit whose trailers name exactly the store's ticked, uncovered tasks, with the tasks.md diff ticking exactly those → commit tasks.md in the store with that commit's full message: git log -1 --no-show-signature --format=%B <sha> written to the message file with your file tool. The code repo gets no second commit.
  • Group mode: ticks with no code commit and no uncommitted code change the group's tasks account for → stop and ask whether the group changed code. No code → the store commit with the group message and Code-Changes: none. Code uncommitted → the group is pending work: resume it as above (code files first, then the store, one message).
  • Group mode: ticks in more than one group, or a diff no single code commit's trailers name exactly → no commit; list the tasks and ask. duplicates, cross_group or missing_headings entries are reported, never fixed by rewriting a commit.

3. When every task is [x]

One call: git log <main>..HEAD --oneline and [vcs] state (clean, dirty_paths), and the coverage read: bash <skill dir>/scripts/task-cover.sh --repo <toplevel> --range <main>..HEAD --tasks <tasks.md> --change <change-name>. Evidence is the script's, not your reading of the log: a task is covered by a subject starting <type>(<change-name>): task X.Y or a Specwright-Task: X.Y trailer, matched exactly, so 1.1 never matches 1.10. Every ticked X.Y needs exactly one covering commit (none uncovered, none in duplicates, covered twice), no cross-group trailer (cross_group), and the tree must be clean apart from the files noted in step 1 (and build or test byproducts such as __pycache__, which you may delete if you created them). If the script fails (exit 1, 2 or 3) there is no coverage data: make no commit and stop. Report <N> tasks, <M> task-work commits, tree clean, listing the pre-existing files you left alone. Store-backed: Reconcile first; run the check against each repo (git -C "<store toplevel>" log <store main>..HEAD --oneline and [vcs] state on the store, and the script with --repo "<store toplevel>" --range <store main>..HEAD); then every ticked task needs a code commit or a store commit with Code-Changes: none, and exactly one store commit covering it. Report each repo on its own line: code: <N> tasks, <M> task-work commits, tree clean and store: .... No handoff below until both repos pass. On a gap, stop and offer: commit the leftovers (by name), amend (only if the user picks it), or investigate; list any task with no evidence or covered twice.

Pre-ship review. Once the check passes, load the specwright-review skill and run its Pre-ship review on the finished change. The hand-off below waits until the review passes (no unwaived P0/P1 finding); its fix rounds and amendments are committed under these rules. With review.preship: false in openspec/specwright.yaml, skip it: no pre-ship review, and the hand-off follows the check.

Then read finish in openspec/specwright.yaml (default local):

  • local → suggest /opsx:archive; the user runs it.
  • pr → resolve the run login (github.login, else one bash <specwright-pr>/scripts/as.sh --resolve, <specwright-pr> being that skill's folder; exit 3 stops and tells the user), hand off to specwright-pr ship stating run login: <login>, then offer watch. Archive after review settles, on the same branch, before the PR is merged: watch offers it once the PR is ready.

Never commit on main, push from this skill, batch tasks into one commit (only a group commit under commit_granularity: group holds several, and only the tasks of its own group), amend unprompted, or start archive yourself.

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.