gro-nass
v0.5.0
Published
A change harness: scan, pre-flight, verify out of context, and write a scored ledger row. No database, no model.
Readme
gro-nass
A commit driver that refuses. It reads the message and the staged tree, runs the repository's own verification in a clean worktree somewhere else, writes a row either way, and only then commits.
The row is the product. A refusal is a row; a landing is a row and a stamp. Nothing is decided by parsing output — the exit code is the verdict.
The twenty-six rules, each with the refusal or the measurement that wrote it, are in RULES.md.
Install
From inside the repository you want to guard:
npx gro-nass initThat adds the package to your devDependencies, writes .githooks/commit-msg and
.githooks/pre-push, sets core.hooksPath, and merges the rule set into AGENTS.md, CLAUDE.md
and any vendor file your repository already uses. It prints every path it wrote. Run it twice and the
second run reports unchanged.
The entry it writes into your manifest is the registry package at the version of the checkout that
installed it — "gro-nass": "^0.3.9" — and never a filesystem path. npm, pnpm and yarn all resolve
that anonymously, on any machine, with no credential. The git+ssh:// form it used to write is
retired: a build machine has no ssh key for a private repository, and on 2026-09-19 that blocked a
customer's deploys for 49 minutes. --from <path> still takes a local checkout for testing this
package against itself, and says so in what it prints, because a local spec resolves on exactly one
machine.
Your lockfile picks the installer. pnpm-lock.yaml → pnpm add -D, yarn.lock → yarn add -D,
package-lock.json or no lockfile → npm install --save-dev. The chosen installer is printed. This
is not a convenience: running npm in a pnpm repository writes a package-lock.json nobody asked for,
and npm refuses peer-dependency trees that pnpm installs without complaint.
A failed install does not stop the gate going in. If your dependency tree has a peer conflict —
yours, not this package's — init prints the installer's own message verbatim, writes the manifest
entry anyway, and continues. The hooks resolve the driver through .harness/config.json, not through
node_modules, so they work either way. A conflict in your tree is a finding about your tree, not a
reason this tool cannot be installed.
Removal
npx gro-nass removeOne line out. It deletes the hooks it wrote, unsets core.hooksPath if it was the one that set it,
takes its section back out of your rules files — deleting a file only when it created it — and removes
the dependency. A hook without its marker is yours and is named rather than deleted; a file you wrote
keeps every byte you wrote. .harness/ survives unless you pass --purge, because removing the tool
is not removing the ledger.
That symmetry is a test, not a promise. test/init-remove.test.ts hashes every file in a
throwaway repository, runs init, runs remove, and compares both the hashes and the file list.
A byte changed or a file left behind fails the suite. Rule 21.
Developing against a checkout
git clone <remote> gro-nass && cd gro-nass
npm ci
git config core.hooksPath .githooksThe installed hook prefers node_modules/.bin/gro-nass and falls back to GRO_NASS_HOME or
.harness/config.json for this case. If it can find neither it exits 2 and says so, rather than
passing quietly — "could not look" is not "looked and it was clean".
Use
bin/gro-nass wrap --check-message MSG.md # the five message checks alone: no worktree, no suite
bin/gro-nass wrap -F MSG.md --dry # every check, verify included; commits nothing
bin/gro-nass wrap -F MSG.md # …and commits if every check passes
bin/gro-nass wrap --stats # read the ledger for this repository
bin/gro-nass replay --last 20 # run the checks over history; writes its own file--check-message is the fast half — pre-registration, binding, verification lines, stamps, paths —
and it calls the same function the driver calls. It says in words what it did not run.
replay records and never refuses: every commit in history predates the rules, so enforcing them
backwards would measure the date a rule was written, not the commits.
Exit codes are the interface: 0 allowed, 1 refused, 2 the command could not be understood.
Environment
| | |
|---|---|
| HARNESS_REPO | the repository to wrap (default: the current directory) |
| HARNESS_AGENT | who is committing (default: unknown) |
Where the rows live
.harness/ledger.jsonl in the wrapped repository, append-only. A verdict row carries the tree, the
staged files, the pre-registration and its binding, every check and every defect; a landing adds a
separate one-line commit-stamp. replay writes .harness/replay.jsonl and never touches the
ledger.
Scoring (paid tier)
gro-nass wrap --stats prints a calibration section per author when @winans/harness-scoring is
installed (npm i @winans/harness-scoring), and the line scoring: not installed — paid tier when it
is not. Nothing on the write path reads it: the gate's verdicts are identical either way.
Install the gate in your repository
From the repository you want guarded:
gro-nass install-hookIt also writes the agent-facing rules — AGENTS.md and CLAUDE.md always, plus .cursor/rules/harness.mdc and .github/copilot-instructions.md where that convention is already present — generated from RULES.md into a marked section, appended to any file that exists and never overwriting a line it did not write.
It writes .githooks/commit-msg there, sets core.hooksPath to .githooks, and records the path to
this checkout in .harness/config.json (or set GRO_NASS_HOME). The hook forwards to this package's
checker — the rule is never copied into your repo. A second run reports unchanged, and a
commit-msg this tool did not write is refused rather than overwritten.
What leaves the machine
gro-nass report writes a bundle of claims: counts, verdicts, check names, defect classes, and
each pre-registered item's probe, comparator, predicted value and whether it hit. It carries no commit
subjects, no message bodies, no file paths, no defect surfaces, no evidence strings, no command output
and no diffs — those fields have no name in the schema, so they cannot travel. The repository key and
the developer are hashed with a salt kept in that repository's own config unless you pass
--identify. A canary test seeds every free-text field in the ledger with a unique token and asserts
none of them appears in the serialized bundle. The one honest limit: a value you type into your own
pre-registration is your text, and it comes home with the claim.
When it leaves, and the two commands
Only if you set a home. A repository with no home posts nothing, ever. You set one at install:
gro-nass init --home https://example.invalid --token <token>Both are stored in .harness/config.json. The token is not printed again — it travels in an
Authorization header and appears in no output of this package.
After every landed commit, and never before one. The driver builds ONE row — the same whitelist as
the bundle, the same function — POSTs it to <home>/api/claims, and prints a line:
✓ committed 3461731ed4
row reported homeIf the post fails for any reason the claim is appended to .harness/outbox.jsonl, the line reads
row queued — <reason>, and the next commit drains the outbox oldest-first before sending its own row.
A failed post never changes a verdict and never delays one past a three-second timeout. The commit
is already made and the row is already on disk before any of this runs; a gate that refused commits
because a server somewhere was down would not be worth keeping installed.
The two commands:
gro-nass report --show # the exact bytes that would be sent, for the last row
gro-nass report --off # stop sending; prints what it turned off. --on restores it--show writes the body to stdout with no framing, and the suite asserts it is byte-identical to
what the driver posts. Read it before you decide. --off keeps the URL and token so --on works
without a new token; remove --purge deletes them with the rest of .harness/.
