Skip to main content

A rule you can't follow gets skipped, so we scoped it to what a commit contains

· 6 min read
Anchor maintainers

Anchor's hard rule used to read: run /commit-prep before any commit. /review had been ignoring it for months.

Not out of laziness — the skill referenced commit-prep exactly zero times in either the Claude or the Grok copy, while running four separate paths that touch git. It created merge commits in both directions, and it staged plan-lane renames and review notes without ever committing them.

That was invisible here, because this repo gitignores its entire .plans/ tree. A project scaffolded from Anchor tracks it — the scaffold ignores only **/*.local.md and .leases/ — so over there, every /review left staged plan bookkeeping lying around for whatever unrelated commit came next to swallow.

The rule was the problem

The obvious fix is to make /review run the full three-gate prep. That is also the wrong fix. A lane move is a git mv from one directory to another; it cannot break a test, it needs no CHANGELOG entry, and it has nothing to announce. Demanding tests, a changelog and a blog decision before renaming a file is how you teach an operator that the skill is optional.

So the rule is now scoped by what a commit contains, not by the fact that it is a commit:

  • Any commit touching a path outside .plans/ — prep first.
  • Any merge commit — prep first.
  • A commit whose paths are entirely under .plans/ — light path: say what moved and why, then commit.

The light path has a load-bearing flag order

git add .plans/
git commit -m "Plans: <slug> → <lane> (/review Approve)" -- .plans/

The pathspec keeps an unrelated pre-staged file out of an ungated commit. But note where it sits. Everything after -- is a pathspec, so the reversed form — git commit -- .plans/ -m "…" — treats -m and the message as filenames. It exits without complaint and commits nothing — a command that reads correctly but silently does nothing when run, caught in review before it reached anyone. There is now a test that runs the command exactly as the skills ship it, against a throwaway repo, and fails if it succeeds without producing a commit.

This is also the one commit allowed to land on an integration branch rather than a feature branch — /review, /work and /draft --promote each make it on whichever branch the lane move exists on. And it must never run while a merge is staged: git refuses a partial commit mid-merge, and the tempting repair of dropping -- .plans/ would commit the whole staged merge under a Plans: message.

A merge commit is gated before it exists

A clean textual merge can still be semantically broken, and the merged tree is state neither branch was ever prepped in. So the merge is staged, prepped, and only then committed:

git merge --no-ff --no-commit feature/<slug>
# run /commit-prep against the merged working tree
# green → git add -A && git commit -m "Merge feature/<slug>: <title>"
# red → git merge --abort || git reset --hard HEAD

Both halves of that are more subtle than they look. git add -A matters because prep edits the working tree — a bare git commit commits only the index and silently drops prep's own output. And git merge --abort refuses, with error: Entry '<path>' not uptodate, once prep has modified a file involved in the merge — which is precisely what its fix-the-tests gate does. Hence the reset fallback.

One nuance worth stating plainly: the reset does not discard everything prep did. It restores tracked files, so a new blog post prep created survives as untracked. Check git status and decide about it deliberately, rather than assuming a clean slate.

A fast-forward creates no commit at all, and its content is byte-identical to what was already prepped on the branch, so it needs no additional gate.

merge_feature.py was making the exact commit this rule forbids

Anchor's own scoped-merge tool — the machinery behind /work's "merge to dev now" answer — was bound by the same rule and broke it outright. When a merge could not fast-forward, land() ran git merge --no-ff <branch> -m … directly: a real merge commit, on the merged tree, with no /commit-prep pass over it at all. The merged tree is state neither branch was ever prepped in, and this is exactly the case the rule above exists for.

land() now stages a non-fast-forward merge instead of committing it, and stops there. The CLI reports that outcome with exit 6 — neither success nor failure — and says so:

STAGED: dev has the merge staged, NOT committed.
Run /commit-prep against the merged tree, then finish it:
green -> python scripts/merge_feature.py --root . --commit-staged
red -> python scripts/merge_feature.py --root . --abort-staged

--commit-staged checks the merge is genuinely still in progress on the recorded target branch, then stages prep's own edits along with the merge, commits both, and returns you to the branch you started on. --abort-staged unwinds it, with the reset --hard fallback for the refusing case above. The pending merge is recorded under the repository's real git directory — resolved with git rev-parse --absolute-git-dir rather than assumed as <root>/.git, so it also works inside a linked worktree, the topology /work itself recommends per agent. The fast-forward path is unchanged and still reports a real SHA.

Both finishers refuse rather than guess when the record and reality have parted company. MERGE_HEAD existing is not evidence the staged merge is yours — it is checked against the exact commit that was merged, because a stale record plus somebody else's hand-staged merge would otherwise be committed under your plan's name, having passed none of the scope, provenance or --expect-head checks. What is recorded is the commit, not the branch name: names move, and the move that matters here is the one you make fixing whatever the red prep caught. The asymmetry between the two finishers is deliberate: a reset --hard is only an undo while the merge it describes is actually in front of you. Resolve the merge by hand in between and the same command becomes a destroy — so --abort-staged clears the stale record and touches nothing, rather than resetting a tree it no longer describes. It is the same lesson as git add -A: the dangerous version and the correct version of these commands look identical in a document, and only differ in the state you run them against.

Tests that read the documents

Every one of these defects recurred in a file no test read. So the anti-rot test now reads the platform briefs and the docs mirrors too, not just the skills — and it checks proximity per command rather than per file. One /commit-prep mention next to the first git command used to satisfy an entire document, leaving every later commit-creating command unguarded. That is exactly how the merge path shipped ungated in the first place.