@descent-vtt/spec-harness
v0.16.0
Published
The agent-facing member of the spec-* tools: context for a round, a guard on every write, an audit when it ends, probes that prove a defect before it is filed, and rulings a person signs.
Maintainers
Readme
spec-harness
Rounds of agent work under a brief a person approved: what the agent needs to start, a guard on every write, an escalation a person signs when the brief is not enough, and an audit when it ends.
spec-harness is the agent-facing member of the spec-* tools. It calls no model and writes no content - the agent is the model. It gives the agent the contract and the facts, and answers, the same way every time, whether the agent stayed inside the lines. No runtime dependencies, no account, no network.
The family's words - brief, round, wave, ruling, premise, the two kinds of plugin - are defined in concepts, and the tutorial takes one round from the brief to the archive in ten steps.
npm install --save-dev @descent-vtt/spec-harness @descent-vtt/spec-brief
npx --no-install @descent-vtt/spec-harness init # the plan: what it would configure, and why
npx --no-install @descent-vtt/spec-harness init --write # apply itNames
The package is @descent-vtt/spec-harness, and the command it installs is spec-harness. The name without the scope is not this project: on npm, spec-harness is another publisher's package, and it installs a command of the same name.
npx fetches and runs the package of whatever name it is given when the project has none installed - a fresh clone, a worktree before npm ci, a CI job without the install step - and without a terminal, as under an agent, it does not ask first. So give npx the full name:
npx --no-install @descent-vtt/spec-harnessin a project that installed it: it runs that install, the version the lockfile pins, and where there is none it stops with an error that names this package. Every command in this README and in the skills is written so.npx @descent-vtt/spec-harness, without--no-install, fetches this package where nothing is installed. The harness works from a project's install, its own and spec-brief's, so that is for a look at--helpand little else.
Never npx spec-harness, and not npx --no-install spec-harness either: --no-install stops a download, and npm still runs a copy of the other package that an earlier fetch left in its cache, and where nothing is installed it reports that package, by its own version, as the one to install. If the bare name was ever run through npx on a machine, in a tree without the install, empty npm's cache of fetched commands there: the _npx directory below the path npm config get cache prints.
The same holds for the siblings: @descent-vtt/spec-brief, @descent-vtt/spec-guard and @descent-vtt/spec-graph, whose names without the scope belonged to nobody on npm on 2026-10-07. The hooks, the MCP server and git's hook go through no npx at all: they run node with the path of the installed file (ADR-0012). The family's adopting guide has what was measured, under npm 10, 11 and 12.
Requirements
- spec-brief 0.2.0 or later, required: the brief is the contract, spec-brief is its reader, and 0.2.0 is the first whose archive asks this package's spec-brief plugin about signed rulings.
- spec-guard 0.12.0 and spec-graph 0.9.0 or later, used when they are installed. Their absence is reported, never assumed clean.
- Claude Code 2.1.139 or later runs the guard hooks and the Claude Code plugin's server: the hooks pass the project's path in a hook's
args, which Claude Code reads from that release on, and the plugin's server names${CLAUDE_PROJECT_DIR}, which a Claude Code plugin may from the same release (ADR-0012). An older Claude Code runs every write unguarded, sodoctorchecks the one onPATH. - git 2.31 or later: spec-harness asks git where its shared directory and its hooks are with
rev-parse --path-format, which that release added (2021).
A sibling installed below its minimum is never run: doctor and every command that needed it name the minimum (ADR-0011).
A round
person approves brief 012 ─▶ agent on branch brief/012-rotate-tokens
│
spec-harness context ◀─┘ the brief, its scope, signed rulings,
│ the rules in force, the documents it cites
▼
agent edits ── hook: protected? refused, with the next step
│ outside the scope? a warning after the write
│
├── needs a protected file ─▶ escalate ─▶ person rules, signs
▼
spec-harness audit ─▶ spec-brief archive --dry-run ─▶ person archivesWhich brief
The first of these names the round's brief; it is never guessed (ADR-0004):
--brief <id>;SPEC_BRIEF;- the branch name:
brief/{id},brief-{id},*/brief/{id},*/brief-{id}.
CI checks out a commit on no branch, so on a detached HEAD the branch is the one the forge's CI names, the first of:
| Variable | Set by |
| --- | --- |
| GITHUB_HEAD_REF | a GitHub pull request |
| GITHUB_REF_NAME, when GITHUB_REF_TYPE is branch | a GitHub push |
| CI_MERGE_REQUEST_SOURCE_BRANCH_NAME | a GitLab merge request pipeline |
| CI_COMMIT_BRANCH | a GitLab branch pipeline |
A branch checked out always wins, and doctor says which variable named the branch. Without them, a round's own CI run would name no brief, and premises would report the premise the round retires as stale.
A name that names no brief is not passed over for the next (ADR-0004):
--briefgiven nothing,--brief "", is refused, as--brief "$ID"is where the variable is not set: it was read as the option left out, and the branch's brief answered.SPEC_BRIEFset and empty, or to space alone, is as unset. A hook's settings and a pipeline'senvblock set it whether or not a round is under way, and the branch then names the brief, whichdoctorshows.- An id spec-brief does not know, or one it has archived, is not passed over either, whichever of the three names it: a command that acts on the brief stops with exit 2, and
doctorsays so in its report. The line says what named it:the flag names brief 999, and spec-brief knows no such brief, orthe environment,the branch, orthe argumentforcontext 999. context,rulings,auditandprobetake the brief's id as their argument as well, which comes before all three. The argument beside--briefis refused, whatever the two hold: one of them would not be read.
Commands
In the order a round meets them; init and doctor set a repository up and check it.
context [brief]
The packet an agent starts a round with: the brief in full - it is the contract - then what it implies:
- the files the round may write and must not change, as the guard reads them;
- the rulings already signed;
- the briefs it depends on, and whether they are done;
- the architecture rules spec-guard holds that code to;
- the documents the brief links to, whole, until the packet reaches its budget (
context.budget, 60,000 characters). Documents left out are named by path, not dropped. Each is headed by its first level-one heading and the status its front matter gives understatus, in English as every spec-* tool reads it, whatever language the document is written in.
--base names the base the rulings are verified against.
The rules over the scope. spec-guard is asked about where affectedFiles can reach, not the whole repository (ADR-0001):
- for a pattern, the directory it reaches:
srcforsrc/**orsrc/; - for a name with no glob syntax that exists,
srcorsrc/a.ts, that name; - for a name the round has yet to create, the directory that will hold it, the root for a name at the top, since it may become a directory as well as a file.
Rules spec-guard could not read are named as unread, with what it said, never as none; a repository where no spec file matches spec-guard's patterns has none.
A spec pattern of spec-guard's that matches no file beside others that do is neither. spec-guard 0.22.0 and later refuse the question for it, exit 2, and still answer with the rules of every spec file that did match. The packet lists those rules as it lists any, under one sentence that names the pattern: spec-guard's pattern `rfcs/*.md` matched no spec file, so a rule kept under it is not listed here; remove the pattern from spec-guard's `specs`. context --format json and start_round name the patterns as unmatchedSpecs, an empty list where spec-guard names none. An older spec-guard says nothing of such a pattern, and its rules are listed as they were.
What it could not read is named, never left out. A cited document whose front matter opens on its first line and is never closed is one spec-core's scanner reads as having no front matter: its status was not read, and the packet says so, with the fix - close the block with --- on a line of its own - rather than showing a document without one. So is each line of closed front matter that is not key: value, such as status accepted without its colon, or an indented line with no key above it: spec-core's reader passes over it, so a status written on it was not read, and the packet names the document, the line as written and spec-core's reason. context --format json and start_round list them as unreadableFrontMatter, each with its path, line, text and reason. The notes fail nothing; context exits 0.
A pattern the guard cannot read - malformed, too large to compile, or naming no path, such as {./,src} - is named as unread where it is listed, with spec-core's reason, the one the guard gives when it passes over it:
| Unreadable in | What the packet says of it | In context --format json and start_round |
| --- | --- | --- |
| affectedFiles | It puts no path in the scope, and spec-guard is not asked about it. When no pattern in affectedFiles can be read, the packet says the scope could not be read, never that spec-guard holds no rule over it. | unreadableScope, each with its pattern and reason |
| protectedFiles | Until it is fixed, the guard refuses every write but to the brief, and no ruling waives the refusal. | unreadableProtections, each with its pattern and reason |
| a signed ruling's paths | The guard passes over it, so it allows nothing. | unreadableRulingPaths, each with its pattern, reason and ruling |
A pattern a leading / roots at the filesystem's root, alone or on a brace alternative, such as /docs or {/docs,src/**}, can be read, and names no path the guard decides, all of which are repository-relative. Where it is listed, it is marked:
- in the scope, as putting no path in it: spec-guard is not asked about it, so the rules over the rest of the scope stand;
- as a protection, as protecting no path;
- as a ruling's path, as allowing nothing.
guard <path...> and the hooks
May the round write this?
| The path | The answer |
| --- | --- |
| one the brief protects | Refused, unless a signed ruling allows it. |
| one in affectedFiles | Allowed. |
| anything else | Outside the scope: a warning by default, or a question to the person, or a refusal (outOfScope: warn, ask, deny). |
Paths are compared as the filesystem spells them, links resolved and case corrected, so SRC/db/schema.ts does not walk past a protection on src/db/schema.ts on Windows.
spec-harness hook claudeanswers Claude Code's PreToolUse and PostToolUse hooks: a refusal before the write, with the reason and the next step; a warning after a write outside the scope. It never answersallow, which would skip the person's own permission prompt. When it cannot answer - the configuration does not load, the briefs cannot be read, the harness meets an error it did not expect - it exits 2, and before a write Claude Code then holds the write.spec-harness hook gitis a pre-commit hook for any agent or none.init --git-hookwrites it; in a hook of your own it is the linenode node_modules/@descent-vtt/spec-harness/bin/spec-harness.js hook git.hook claudepasses over whatever else is on its line that it does not read, as every release has, where every other command refuses it: its line is in settings a later release cannot rewrite, and exit 2 from it holds every write.- A
--rooton the hook's line that is a file, or is not there, is a guard that was set up and is off:hook claudesays so on stderr and exits 1, which Claude Code shows as a hook error while the write goes on. It refuses nothing for it. No release wrote a--rootthere; without one the hook reads the project Claude Code names, then the session's directory, and outside a git work tree it has nothing to guard and says nothing. - A line the hook cannot read - an option no command has, an option left without its value, a
--formator an--atthat is none - is said the same way: one line on stderr, exit 1, and no write held for it. Exit 2 fromhook claudeis for a write it could not check, never for what is wrong with its own line.hook gitstops the commit on such a line, exit 2, where a person reads why.
A guard is a guardrail - an agent that writes through a shell passes it - so the audit and spec-brief's archive are the gates (ADR-0005).
escalate, rule, rulings
# the agent, when the round cannot be done without a protected file
npx --no-install @descent-vtt/spec-harness escalate --path src/db/schema.ts \
--reason "Rotation needs a rotated_at column." \
--option "Allow: one additive column" --option "Refuse: rotation keeps no timestamp" \
--recommend "Allow; the column is additive."
# the person
npx --no-install @descent-vtt/spec-harness escalate --show E-012-1
npx --no-install @descent-vtt/spec-harness rule E-012-1 --allow --note "Add rotated_at only."
git commit -S -m "ruling R-012-1: allow" -- briefs/012_rotate-tokens.mdA memo is what a person rules on, so none is written with a part that says nothing: a path, the reason, a recommendation or an option that is empty is refused, by escalate and by the server's request_escalation alike. An option is "<label>: <cost>": the label names the choice and may not be empty, and the cost may be left out, the memo then giving the choice alone.
escalate --list prints every request that waits, whichever round raised it, and escalate --show <id> one memo. Neither raises a request, so neither reads --path, --reason, --option, --recommend or --brief, and either beside one of them is refused: the request was never raised, and the list looked like an answer to it.
A ruling is a row in the brief's ## Rulings table. It counts when the commit that last changed the row is signed by a key the base branch's .github/allowed_signers lists. The file is rulings.allowedSigners in .spec-harness.json, .github/allowed_signers by default.
- An agent can write a row and compute any hash; it cannot produce the person's signature, and an edit to the row moves it to a commit that must be signed again (ADR-0006).
- Use a FIDO2 key (
ssh-keygen -t ed25519-sk) where the agent runs as you: its signature needs a touch no process can supply.
The spec-brief plugin. spec-brief's archive refuses a round that changed a protected file, and learns that a signed ruling allows it only from this package's spec-brief plugin: init adds "plugins": ["@descent-vtt/spec-harness/spec-brief-plugin"] to spec-brief's configuration.
- Without it, the archive refuses the file whatever was signed.
doctorsays whether spec-brief loads the spec-brief plugin, andauditnames it as the reason for such a refusal.- spec-brief loads a plugin by a path as well, one that starts with
.or is absolute: a path to this package's spec-brief plugin file counts as loading it.
Merge a round with a merge commit
A squash or a rebase writes new commits: the forge signs them with its own key or not at all, so on the base branch a ruling's row blames to a commit no allowed signer signed, and it no longer counts. Rulings are verified on the round's branch, before the merge; keep them verifiable after it:
- GitHub: allow merge commits for the repository, and turn off Allow squash merging and Allow rebase merging in its settings, or in the rules for the branch rounds merge into.
- GitLab: in Settings > Merge requests, set Merge method to Merge commit and Squash commits when merging to Do not allow. Merge commit with semi-linear history and Fast-forward merge need the branch rebased, which the Rebase button does on the server, writing new commits.
audit [brief]
One report on a round, in four parts:
| Part | What it reports |
| --- | --- |
| The archive | What spec-brief's archive would refuse or warn about, run with --dry-run. |
| Assertions | The brief's own assertions through spec-guard: a goal that fails, or a premise (under a heading such as The Defect, Measured) that still holds after the round meant to change it. |
| Rulings | Rulings whose signatures do not verify. |
| Dependencies | Every dependency the round added to package.json, Cargo.toml, go.mod, pyproject.toml, requirements*.txt, NuGet project files or a Gemfile, and every package it allowed to run install scripts in a package.json. |
Install scripts
A dependency's install script is code no reviewer read, run on every contributor's machine and in CI when the dependency is installed. npm 12 runs one only where allowScripts in the project's package.json allows the package, and npm install-scripts approve writes the approval there. So the audit reads that field in each package.json the round changed, as npm reads it: an entry whose value is true is an approval, one whose value is false a denial, and any other value neither.
| The round | The audit reports |
| --- | --- |
| added an entry that is true, or turned one from false to true | install-script-allowed, a warning, as a new dependency is: it fails the audit under --strict, and no ruling covers it. |
| added an entry that is false, or turned one from true to false | install-script-denied, a note. |
| removed an entry | install-script-entry-removed, a note, which says whether the entry allowed or denied. |
- A denial and a removed entry are no grant, so neither is a warning.
- An entry is named as the manifest names it. npm pins an approval to the version a person reviewed,
[email protected], so one moved to[email protected]is a new approval, and the old entry a removed one. --format jsonlists them asinstallScripts, besidedependencies: each with itsfile, itssection(allowScripts), itsname, andbeforeandafter, each"allowed","denied"ornull.
It reads no other grant (ADR-0005 has the reasons):
.npmrc: npm readsallow-scriptsthere whenpackage.jsonholds no entry, anddangerously-allow-all-scriptswhenever it is set..npmrcis not a manifest the audit reads.- pnpm: pnpm 11 and 12 keep their approvals as
allowBuildsinpnpm-workspace.yaml, YAML the audit has no reader for, and read nothing from thepnpmfield ofpackage.json. Bun'strustedDependenciesand Yarn'sdependenciesMetaare not read either. - The package's own
preinstall,install,postinstallandpreparescripts. They are the repository's own commands, on a line of the round's diff, where an approval turns on code that is in no diff; andprepareis where a package builds, so a round changes it for ordinary reasons. To have a round stop before it changes them, protectpackage.jsonin the brief. - Whether npm honours a key. npm passes over a semver range,
canvas@^3, and a dist-tag,canvas@latest, and reads the field in the project's root alone, warning about one in a workspace. The audit reports the entry as written, in whicheverpackage.jsonholds it.
What it could not measure
A part that could not be measured is a finding, never a silence. Each of these is a warning, which fails the audit only under --strict:
assertion-unreadable: an assertion in the brief spec-guard cannot read, on its line, with spec-guard's reason. Nothing it states was run.manifest-name-unread: a name independencies.manifeststhat spec-core's glob cannot read, malformed or too large to compile, with the reason. The audit reads the manifests the other names name.protection-rootedandruling-path-rooted: a pattern inprotectedFiles, or a path of a signed ruling, that a leading/roots, alone or on a brace alternative, such as/src/db/schema.ts. It protects no path, so the round could change the file it meant to protect, or allows nothing, so the file is still refused. Write the protection without the slash; a ruling's path is fixed by a new ruling.manifest-name-rooted: a name a leading/roots at the filesystem's root, alone or on a brace alternative, such as/package.jsonor{/Gemfile,Cargo.toml}. Every path the audit reads is repository-relative, so the rooted part names no manifest; write the name without the slash, since a name is matched at any depth.
What it measured
What was measured is said beside what was found, so an audit that found nothing can be told from one that checked nothing (spec-core's ADR-0005). It is the line above the counts, which stay the last line:
measured: goals: 2 held, 0 failed · premises: 1 retired, 0 holding · archive: asked · rulings: none · dependencies: 3 changed, 0 unread
0 error(s), 0 warning(s), 1 note(s)A brief that declares no assertion says goals: none declared, and draws no warning: a brief without assertions is a brief, and the archive still measures its round. A round that changed an install-script policy has its entries counted at the end of the line, · install scripts: 2 changed; a round that changed none says nothing of them there.
--format json has the same as measured, beside counts, and audit_round says it too:
| Field | Holds |
| --- | --- |
| changes | measured or unmeasured |
| archive | asked or unavailable |
| assertions | run or unavailable |
| goals | counts: held, failed |
| premises | counts: retired, holding |
| unreadableAssertions | a count |
| rulings | counts: verified, unverified |
| dependencies | counts: changed, unread |
| installScripts | a count: changed |
For a forge
--format gitlab, sarif or github prints the findings for a forge:
| Format | Prints | An error, a warning, a note |
| --- | --- | --- |
| gitlab | a GitLab Code Quality report | major, minor, info |
| sarif | SARIF 2.1.0 | levels error, warning, note |
| github | GitHub workflow commands | error, warning, notice |
- Each carries the finding's hint after its message.
- A finding with no file of its own is placed on the brief, and one with no line on line 1.
- The fingerprint is the SHA-256 of the rule, the file and the finding's
subject- the assertion, ruling, dependency or name it is about - never of its message or line, so a reworded message or a line added above does not read as one problem fixed and another found. - SARIF carries the fingerprint as
partialFingerprints.specHarnessFinding, with what was measured as a note.
In GitLab CI:
spec-harness:
image: node:22
script:
- npm ci
- npx --no-install @descent-vtt/spec-harness audit --format gitlab > gl-spec-harness.json
artifacts:
when: always
reports:
codequality: gl-spec-harness.jsonprobe [brief]
A defect is measured before it is filed. The brief carries its probe:
```probe
id: old-token-still-accepted
setup: npm ci
run: npm test -- tests/probes/rotate.test.ts
signature: expected 401, got 200
```
```probe-file tests/probes/rotate.test.ts
...
```probe --at baseruns it in a temporary worktree at the base commit: every run must fail for the declared reason - thesignaturein the output, or a JUnittestfailing - or the verdict isvacuous(no defect),flakyorinvalid.probe --at headmust be green:fixed.- That worktree is a fresh checkout, so a probe's commands can rely on the files the commit tracks, the probe's own files and what its
setupinstalled, and on nothing else: there is nonode_modulesuntilsetupputs one there. Start a tool through one of the project's scripts, asnpm test --does above, or throughnpx --no-install <tool>. A command is given no input: one that reads its input is told at once that there is none. - A probe's commands fetch nothing through
npxunless you say so. Given a tool's name alone wheresetupdid not install the tool,npxwould fetch the registry's package of that name and run it, unasked, since the command has no terminal to be asked on (Names).proberuns a probe's commands withnpm_config_yes=false, so npm stops there and names the package,npx canceled due to missing packages, and the probe isinvalid. To have npm fetch, say--yeson the probe's line, or setnpm_config_yesyourself, in lower case or upper:probeleaves the variable as it finds it. The variable outranks ayes=truein an.npmrc. It is npm's alone, sopnpm dlxfetches whatever it says, and it stops a download only: a copy an earlier fetch left in npm's cache still runs. - A run that proves nothing says what its command printed. Where a run failed for another reason than the probe declares, was stopped at its timeout or left no report - every run of an
invalidprobe, and any such run of aflakyone -probeshows the last 2,000 characters of its command's output, where the reason is. With the table that is on the standard error, for the first such run of each probe, under a line that names the probe and the run; with--format jsonit isoutputon each such run, and nothing is written beside the document. Colours are dropped and any other control character but the line feed and the tab is written as its escape,\u001b, so nothing in it reaches a terminal as a command. Asetupthat fails shows its output the same way. - The evidence table it prints names the hash of the probe it measured with (ADR-0007).
- A command, a
setupas well as arun, is its shell, as a step is in CI: it is answered when its shell ends, by the shell's exit code and what was printed until then. Arunis red, green or neither by those, and asetuphas passed when the code is 0. What the command started and did not wait for is no part of the answer, so a command's work must be done when its shell ends: have it wait for what it starts. - Where something the command started still holds its output once the shell has ended, as a server left in the background does,
probewaits three seconds for that output, then reads no more of it and goes on; GitHub's runner does the same with a step, after five. The run's evidence then ends, and something it started was left running, holding its output. Asetupthat passed says so in a line on the standard error,spec-harness: the probe setup "<line>" passed at base, and something it started was left running, holding its output, and one that failed says it in the error that stops the probe. A process that writes to that output afterwards has the write fail, as it would onceprobehad ended. Send a process's output to a file and nothing is held, nothing waited for and nothing said; on Windows a process the shell started withstart /bholds the command's output even so. - What a command left running runs on beside the commands after it, as what a step left does in CI, and is stopped when
probeis done at that commit, before the worktree is removed, as far asprobecan still reach it (ADR-0003):- On Linux and macOS that is every process still in the command's process group, forced: what the shell put in the background with
&, whether or not it holds the command's output. Not what moved to a session or a group of its own, as a daemon does,setsid, and a process Node startsdetached. - On Windows it is nothing: once a command's shell has ended, nothing the command left can be found again.
- What is out of reach runs on until it ends or you end it: through the runs after it, and through the head's when it was left at the base.
- On Linux and macOS that is every process still in the command's process group, forced: what the shell put in the background with
- A command whose shell still runs at its timeout -
probes.timeoutseconds, or for arunthe probe's owntimeout- is stopped, forced, with whatprobecan reach of what it started, and no more:- On Linux and macOS that is every process still in the command's process group: all the command started, except what moved to a session or a group of its own.
- On Windows it is the command's shell and the processes under it, each found through a parent that still runs. A process whose parent has ended is not found.
- The timeout ends the wait as well. Three seconds after it has stopped what it can, the run is answered whether or not something still holds the command's output: it is a run stopped at its timeout, with what was printed until then, and its evidence says the same of what was left running. A
setupanswered so says it in the error that stops the probe. - Interrupted - by Ctrl+C, SIGTERM or SIGHUP, or by Ctrl+Break on Windows -
probestops the commands still running, with the reach a timeout has, waits up to three seconds for their output, stops what earlier commands left, with the reach the end of a commit has, and removes the worktree before it exits. - A worktree's directory that cannot be deleted - on Windows, one that something a command started is still running in - costs the verdict nothing: git forgets the worktree,
probeprints and exits as it would have, and it names the directory it left on the standard error as it exits.
premises
Is every live brief still about something true? It runs spec-guard over the live briefs' assertions and reports each premise - an assertion under a section in assertions.premises - that no longer holds as stale-premise: the defect was fixed another way, or the code the brief describes is gone, and an agent sent to fix it would fix nothing.
- Goals are left to
audit, since a goal fails until its round is done. - For the same reason, the premise of the brief a round is working on - the one
--brief,SPEC_BRIEFor the branch names - is reported asauditreports it,premise-retired, a note that fails nothing: on the round's branch, a premise that no longer holds is the work being done. - A premise spec-guard cannot read is
assertion-unreadable, a warning, as in the audit: nothing checks it, so--strictfails the run on it, and the summary counts it apart from the premises that were checked. - A brief
--brieforSPEC_BRIEFnames that spec-brief does not know is refused, exit 2, as every command that acts on a brief refuses it: read as no round, it held the round's own premise against it. - A branch whose name reads as a brief's and names none is read as no round, as before, and so is a brief spec-brief has archived. CI runs
premiseson every branch, andfix/brief-list-order, which nameslistunder the defaultbranches, is no round's; an archived brief is a closed round, which retires no premise, and is what the branch of a round names once the round has archived it.
Run it in CI: exit 1 when a premise is stale, exit 2 when spec-guard is not there to ask, or --brief or SPEC_BRIEF names a brief that is not one. --format gitlab, sarif or github prints its findings for a forge, as audit does.
mcp
start_round, check_path, request_escalation, audit_round and list_rounds over MCP on stdio, with the four workflow prompts. Both protocol eras are served. The architecture rules themselves stay with spec-guard's server.
A tool answers an argument it cannot use with a tool error that names the argument, and writes nothing: one it does not take, one of the wrong type, and one that is given and names nothing, such as an empty brief.
init
Configures the family to agree. It prints the plan, merges into files that exist, keeping what is there, and changes nothing without --write. Each step names its file and what init does there: skip is a step it does not take and advise one it leaves to you, each with the reason.
- spec-brief:
spec-brief initwhen there is no configuration, then:- this package's spec-brief plugin in its
plugins,"@descent-vtt/spec-harness/spec-brief-plugin". spec-brief's archive asks the plugin whether a signed ruling allows a protected file, and refuses the file without it; - the base below as its
archiving.base, unless one is set. Without a base, the archive checks no protected file at all, warning only that the scope went unmeasured, so a plainspec-brief archiveis a gate only once one is named.
- this package's spec-brief plugin in its
- spec-graph, when it is installed: the archive in its
historyPatterns, so an archived brief is a record whatever its status says, and a live brief that depends on one is not a stale premise.- That holds only where spec-graph reads the briefs, and its default patterns (
docs/,doc/,adr/,rfcs/,specs/and Markdown at the root) do not reachbriefs/.initsays whether they do, and leaves adding the briefs topatternsto you, since that changes what spec-graph checks, and itspatternsreplace its defaults. - It merges into the configuration spec-graph reads, and leaves a
"spec-graph"key inpackage.jsonto you.
- That holds only where spec-graph reads the briefs, and its default patterns (
.spec-harness.json, naming the base rounds are measured from and the allowed signers are read on:- the remote's default branch where git recorded one, as a clone does and git 2.48 or later does on a fetch;
- otherwise the branch
initruns on when it ismainormaster, or the only branch, since a repository made withgit initand pushed to a remote often records no default; - when it cannot tell, it says so and writes none: set
"base"yourself.
- Claude Code: the guard hooks in
.claude/settings.jsonand the MCP server in.mcp.json, each run withnodefrom the project'snode_modules. Thenpxentries 0.1 wrote are replaced.- These are the ones the Claude Code plugin brings, so while that plugin is on -
"spec-harness@<marketplace>": trueunderenabledPluginsin your user, project or local Claude Code settings -initwrites neither and says why (skip). - To have its entries instead, which every clone reads, turn the Claude Code plugin off for the project,
claude plugin disable spec-harness@spec-tools --scope local, and runinitagain. - It removes nothing: where the Claude Code plugin is on and a file already holds its entry, it says so (
advise) and leaves the file alone. So where the hook stands in a settings file of your own, whichinitdoes not write. - A line of the hook that is there already is known as
doctorknows it: by its command and the wordshook claude, however a person changed it, with the event and the matcher its entry stands under. Where the file holds such a line that Claude Code starts for a write,initwrites no hook into it, since a second line would run beside the first, and says which line it left, indoctor's words for it (keep, oradvisewhere the line is no guard: it turns the guard off, holds every write, or is waited on before none). It rewrites no line but the one 0.1 wrote, and completes only its own line where that stands under one event alone. A line Claude Code starts for no write is left as it is, andinitwrites its own beside it.
- These are the ones the Claude Code plugin brings, so while that plugin is on -
- git's pre-commit hook, where git runs it from:
core.hooksPath, relative, absolute or under~, and the shared hooks of a linked worktree.- It refuses a commit that changes what the active brief protects, for any agent or none, a shell's writes included.
- With
--git-hookit is written; without, the plan advises it (advise). - It runs
nodeand the script in the project'snode_modules, as the Claude Code hooks do, and nonpx. git starts a pre-commit hook at the top of the work tree it commits in, so the script is read from there: a linked worktree is guarded by its own install, and so is each repository that shares a hooks directory. Where the install is not there, the hook says so and the commit waits. - It is a POSIX
shscript on every platform: Git for Windows runs a hook with theshit ships. - A hook that exists is never replaced:
initsays to add the line to it. One that runs spec-harness throughnpx, asinitwrote it through 0.9.1, keeps working, andinitnames the line that runs it withnode.
- The allowed-signers file
rulings.allowedSignersnames,.github/allowed_signersby default: one line per person, and a FIDO2 key,ssh-keygen -t ed25519-sk, for anyone whose agent runs as them (advise).- With it, how the forge keeps it, the ADRs, the tool configurations and the CI configuration from changing unread, as spec-core's ADR-0005 asks; spec-core's adopting guide has the settings:
- GitHub: CODEOWNERS and a protected branch;
- GitLab Premium: Code Owners;
- GitLab Free, which has no Code Owners approval: a protected branch no one pushes to, merged by Maintainers, with agents as Developers and pipelines that must succeed.
- With it, how the forge keeps it, the ADRs, the tool configurations and the CI configuration from changing unread, as spec-core's ADR-0005 asks; spec-core's adopting guide has the settings:
doctor
The first thing to run when a hook refuses something unexpectedly. It reports:
- The siblings: which are installed, at which versions, and how each is run.
- A sibling older than this release needs is
outdated, with the minimum and the command that installs a newer one, and exit 1. - A command named under
toolsis run as named, and its version is not checked.
- A sibling older than this release needs is
- The repository: its root, the branch, and the brief the flag,
SPEC_BRIEFor the branch names.- A brief no round can work under is said on that line, with why:
brief none: the flag names brief 999, and spec-brief knows no such brief. So is one spec-brief has archived, an empty--brief, and a spec-brief that could not list the briefs. The guard holds every write while it stands, which is often whydoctoris being run. - It fails nothing, as a base that names no commit fails nothing:
doctorexits as it would have.--format jsonhas the id asbriefand the reason asbriefProblem,nullwhere there is none.
- A brief no round can work under is said on that line, with why:
- What a signed ruling needs to count:
- the base, and whether
--base,.spec-harness.jsonor the remote named it; - whether the allowed-signers file is on that base;
- whether spec-brief loads this package's spec-brief plugin, by name or by a path to its file.
- the base, and whether
- The signers' keys: a note naming each signer in the allowed-signers file whose key is not a FIDO2 key (
[email protected]or[email protected]).- A note, never a failure: ADR-0006 also accepts a key the agent's account cannot read, and a PIV or PKCS#11 hardware key reads as a plain
ssh-rsaorecdsaline. - A
cert-authorityline is a certificate authority's key, not a person's, and is left out. - A line OpenSSH refuses is named as no signer, with why. Its quotes are read as OpenSSH reads them: in the principals, the first double quote, wherever it opens, is taken out with the next, which ends the field; an option's value is always in double quotes,
\"a quote inside it. And its options are OpenSSH's:cert-authority,namespaces,valid-afterandvalid-before, separated by commas.
- A note, never a failure: ADR-0006 also accepts a key the agent's account cannot read, and a PIV or PKCS#11 hardware key reads as a plain
- How Claude Code runs the guard: the Claude Code plugin, with the settings file that turns it on;
init's hooks and server; both, which guards every write twice and exits 1, with how to keep one; or neither.- A hook in Claude Code's settings is the guard by its command,
spec-harnessor its script, and the wordshook claude, whatever else is on its line.doctorreads the line as the hook reads it, through the same code, with the project in place of${CLAUDE_PROJECT_DIR}, and says what the hook does with it. - A line that holds more than
initwrites, such as--root ${CLAUDE_PROJECT_DIR}or--brief 012, is said as it is and fails nothing:"hook claude --root ${CLAUDE_PROJECT_DIR}" in .claude/settings.json is more than init writes, and guards: the hook reads the line, and its root, /work/app, is a directory. - A line the hook cannot read, and a
--rootthat is a file or is not there, are a guard that is off. Each is said in the words the hook itself says before a write, and exits 1. - A line that names a brief which is not one, as
--brief 404does where spec-brief knows no brief 404 or has archived it, holds every write: the hook exits 2 before each until the line is mended.doctorasks spec-brief about the name as the hook does, in the work tree the line's root is in, says it in the words the hook holds the write with, and exits 1:"hook claude --brief 404" in .claude/settings.json: the flag names brief 404, and spec-brief knows no such brief, so the hook holds every write until the line is mended: name a brief spec-brief lists, or take --brief off the line. - An entry is read with the event it stands under and its matcher, as Claude Code's hooks reference has them: Claude Code waits on a hook before a tool call under
PreToolUsealone, and starts an entry for the tools its matcher lets through - every tool where the matcher is left out, empty or*, the tools it names where it is a list of names, and the names a pattern matches otherwise. An entry that runs in the background (async,asyncRewake) is waited on for none.- A line Claude Code waits on before no write is not the guard, whatever the hook would do with it.
doctorsays so with what the line stands under,"hook claude" in .claude/settings.json: Claude Code waits on it before no write, so it is not the guard and refuses none (under PreToolUse it has the matcher "Bash"), and a file whose only line is started for no write, before one or after, is not counted amonginit's entries. It fails nothing: no guard is a choice. - A line waited on before some of
Edit,Write,MultiEditandNotebookEditguards those, anddoctornames the rest, whose writes go unchecked. A line started after no write warns of none, which is said too. Neither fails anything. - An
ifon the entry has Claude Code start it only for the tool calls its rule matches. The rule is not read, so which writes the line is asked on is cannot tell, never fine;--strictfails it. So is a matcher that is not text, or is no pattern, or is a pattern that takes more than a tenth of a second to test against the four names: one can be written that never finishes, anddoctorandinitcome back all the same.
- A line Claude Code waits on before no write is not the guard, whatever the hook would do with it.
- A line with no
argsis one Claude Code hands to a shell. Where it holds a quote, a variable or anything else a shell rewrites after its command, what the hook is handed is cannot tell, never fine;--strictfails it. Written withargs, each word reaches the hook as it stands, anddoctorreads it. "onFailure": "block"on the hook's entry is said in a note. Claude Code 2.1.295 or later then holds a write when the hook fails, where without the field the write goes ahead: by the hook's exit 1 for a question it cannot read, by a hook that does not start, or by one that runs past its timeout. With a line that turns the guard off, that is every write, the one that mends the line among them.--format jsonhas each line underclaudeCode.lines: itsfile,events, thelineas written, itsstate(guards,off,held,unstartedorunknown),onFailure, the writing tools Claude Code waits on itbeforea write by and starts itafterone by (nullwhere that cannot be told), and what is said of it asdetail,coverandnote,nullwhere there is nothing to say.- The plugin's own hooks are not read: they are a release's files, and every release wrote
hook claudeand no more on the line, under the matcherinitwrites. Nor is whether the plugin a settings file turns on is installed on this machine.
- A hook in Claude Code's settings is the guard by its command,
- Which Claude Code, when Claude Code runs the guard:
claude --version, found onPATHand run without a shell.- Older than 2.1.139 exits 1. An older release ignores a hook's
argsand runs a barenode, which reads the hook's input as a script and fails, and a PreToolUse hook that fails with anything but exit 2 blocks nothing: every write passes unguarded. - On Windows, npm installs Claude Code as a
claude.cmdshim, which cannot be run without a shell, so it is read instead: thenode_modules\@anthropic-ai\claude-codeit runs, as npm's shim or pnpm's names it, and that package.json'sversion, which doctor says it read. Nothing is run. - A
claudethat is not found, a Windows script that runs no@anthropic-ai/claude-codepackage whose package.json gives a version, and a version that cannot be read are cannot tell, never fine;--strictfails them. - The Claude Code in an editor or the desktop app may be another release than the one on
PATH: check it there withclaude --versionor/status.
- Older than 2.1.139 exits 1. An older release ignores a hook's
- git's pre-commit hook: whether it runs spec-harness, where git runs it from: installed, missing, a hook of the repository's own without the line, or, outside Windows, one git skips because it is not executable.
- A hook that runs it through
npxis one that runs it. A note names the line that runs it withnode:npxstarts npm to start node on every commit, and under npm 12 prints twonpm notice runlines each time. A note, never a failure;--format jsonhas it asgitHook.note.
- A hook that runs it through
An input that names nothing
An option, an argument or a value that is given and names nothing is refused: exit 2, one line on stderr that names it and says what to do, nothing read or written for it. None is read as if it had not been given. --root "$DIR" where the variable is not set would otherwise run in the current directory, and the answer would look like the one that was asked for (ADR-0005).
An option a command does not read. A command takes the options its answer depends on, and refuses any other by name, with the ones it takes:
doctor does not take --note; its options are --brief, --base, --root, --format and --strict.| Command | Reads | | --- | --- | |
context,rulings|--brief,--base,--root,--format| |guard,audit,doctor|--brief,--base,--root,--format,--strict| |probe|--brief,--base,--root,--format,--id,--at| |premises|--brief,--root,--format,--strict| |escalate|--brief,--root,--format,--path,--reason,--option,--recommend,--list,--show| |rule|--root,--format,--allow,--deny,--note| |init|--root,--format,--write,--git-hook| |hook|--brief,--base,--root| |mcp|--root|initchooses the base itself, and the server is told the brief and the base by each call, so neither takes--baseor--brief: taking one would say the base or the brief had been set.An option beside one that leaves it unread.
escalate --listandescalate --show <id>print what waits and raise nothing, and beside either only--rootand--formatare read:escalate --list does not take --path; beside --list its options are --root and --format. A request spelled out beside--listprinted the list, exit 0, and was never raised.A brief named twice.
context,rulings,auditandprobetake the brief as their argument or as--brief, and refuse the two together, whatever they hold:audit was given the brief twice, as its argument "012" and as --brief "013"; give one of them. The argument won with no word.SPEC_BRIEFand the branch beside either are the order as it stands.An argument a command does not take.
context,rulings,auditandprobetake one brief's id at most,ruleone escalation's id,guardits paths andhookthe wordclaudeorgit;escalate,premises,init,mcpanddoctortake none.init mydirset up the directory it stood in;init --root mydirsets up another.A value that names nothing. An option that takes a value refuses one that is empty or only space, by the option's name:
--brief is "", which names no brief. So does an argument, as inguard "". No option gives an empty value a meaning: leave the option out for what the command does without it.A
--rootthat is no directory, a file or a path that is not there:--root is "nowhere", which is not a directory.A
.spec-harness.jsonthat is a directory does not load, and is said so in a line, as one that is not JSON is.
--help and --version answer whatever else is on the line. Three things are read another way, each for its reason:
hook claudereads its line as every release has. The line is in.claude/settings.jsonand in the Claude Code plugin of every release since 0.1.0, which a later release cannot rewrite, and exit 2 from it holds every write of a session, the one that would mend the line among them. So what it does not read is passed over as before, an empty--briefwith the rest. The releases have handed the harness five command lines,hook claude,hook git,mcp, andmcp --rootwith the project or with., and each is read as the release that wrote it read it (ADR-0012).Two things on its line are said, and still refuse nothing. Each is one line on stderr and exit 1, said where there is a write the line leaves unchecked. Claude Code does not block a tool call on exit 1: it goes on with the write and shows the line as a hook error in the transcript. No release wrote more than
hook claudeon the hook's line, so either is a line a person changed.- A
--rootthat is a file, or is not there. It read as a session outside a git work tree, exit 0 and no word, and the guard was off. It is--root is "nowhere", which is not a directory, so the guard is off and this write is not checked, with what to do. An empty--rootis the directory the hook was started in, and guards there, as before. - A line the parser cannot read: an option no command has, as in
hook claude --nope; an option left without its value, as--root $DIRis in a shell where the variable is not set; a--formator an--atthat is none. Each was exit 2, as it is from every other command, and Claude Code blocks the tool call on a PreToolUse hook's exit 2: every write of the session waited on a line only a write can mend. It isthe hook's line cannot be read (Option '--root <value>' argument missing), so the guard is off and this write is not checked, with what to do. The parser's own words stand between the brackets, on one line.
Exit 2 from
hook claudeis kept for a write it could not check: a configuration that does not load, briefs that cannot be read, a brief that is named and is not one, an error nothing expected.hook gitis not read this way. It stops one commit, exit 2, on a line a person reads, who can then mend the hook. Nor is a line that does not sayhook claude, such ashookalone: the harness cannot tell whose it is. A hook entry that sets"onFailure": "block", which Claude Code 2.1.295 and later read, has Claude Code hold the action when the hook fails, and exit 1 is a failure to it; neitherinitnor the plugin writes that field.doctorsays each of these of a line it finds in Claude Code's settings, before any write meets it.- A
doctorsays an empty--briefor--basein its report, as it says a brief spec-brief does not know: a diagnosis is what a person runs when something is wrong, and it reports what is wrong with what it was given.SPEC_BRIEFset and empty is as unset, since what sets it sets it whether or not there is a round (Which brief).
In a tool call. The server's tools refuse the same in their arguments, with a tool error that names the argument, as in "paths[1]" is "", which names no file: an empty brief or base, an empty path, an empty recommendation, an option whose label is empty. An argument a tool does not take was refused already. A brief spec-brief does not know, or has archived, is said of the argument too, "brief" names brief 999, and spec-brief knows no such brief, where the line called it the flag.
As a Claude Code plugin
spec-harness ships two different things called a plugin. This section is the Claude Code plugin, installed into Claude Code. The spec-brief plugin, @descent-vtt/spec-harness/spec-brief-plugin, is a module spec-brief's archive loads to learn of signed rulings; init configures it (above).
The repository is a Claude Code plugin and a one-plugin marketplace: the four skills (draft-brief, split-goal, run-round, close-round), the hooks and the MCP server. The hooks and the server run the spec-harness installed in your project, node ${CLAUDE_PROJECT_DIR}/node_modules/@descent-vtt/spec-harness/bin/spec-harness.js, so install it there first; the server is told the project with --root, since Claude Code starts a plugin's server in the plugin's own directory. It needs Claude Code 2.1.139 or later.
/plugin marketplace add DescentVTT/spec-harness
/plugin install spec-harness@spec-toolsFrom a mirror
DescentVTT/spec-harness is GitHub shorthand, fetched from github.com. Where GitHub cannot be reached, as on a company network, mirror the repository and add the mirror by its full git URL; the plugin is spec-harness@spec-tools either way:
/plugin marketplace add https://gitlab.example.com/tools/spec-harness.git
/plugin install spec-harness@spec-toolsThe plugin or init, not both
The Claude Code plugin's hooks and server are the ones init writes into .claude/settings.json and .mcp.json: use one or the other. With both, every write is guarded twice and the server is registered twice.
initrun while the plugin is on writes neither.- The other order,
initfirst and the plugin after, nothing prevents: Claude Code runs a plugin's hooks as the plugin ships them. - A plugin hook that stood down because it believed
init's ran could leave no guard at all: Claude Code reads no hooks from.claude/settings.jsonin a session that spans several repositories, and managed settings can turn off the settings' hooks while those of a plugin they force on keep running (ADR-0012).
So after installing the plugin where init has run:
- Run
npx --no-install @descent-vtt/spec-harness doctor: a double install exits 1. - Take the
spec-harnessentries out of those two files, or turn the plugin off for the project.
In a repository that is not Node
The hooks, the server and git's hook run the spec-harness installed in the project, so a .NET, Java or Python repository installs the tools as a Node project does, and its build never sees them:
- A
package.jsonat the root holding{ "private": true }.privatekeeps npm from ever publishing it. npm install --save-dev @descent-vtt/spec-harness @descent-vtt/spec-brief, and@descent-vtt/spec-guardfor the rules and the assertions. npm writes the tools asdevDependencies, andpackage-lock.jsonpins them.node_modules/in.gitignore.npm ciafter a clone and in CI, which installs what the lock pins.
The hooks name ${CLAUDE_PROJECT_DIR}/node_modules/@descent-vtt/spec-harness/bin/spec-harness.js, not npx or a global install:
- Claude Code starts a hook wherever the session stands and a plugin's server in the plugin's directory, so neither can rely on
npxfinding the project's install. - On Windows
npxis a shim that cannot start without a shell. - The copy in the project is the version the project pinned, the one
doctorchecks the siblings against.
git's hook names the same script, node_modules/@descent-vtt/spec-harness/bin/spec-harness.js, from the top of the work tree, where git starts it. npx would start npm to start node on every commit.
A person who runs the tools by hand runs npx --no-install @descent-vtt/spec-harness, from the root, giving npx the package's full name.
Configuration
.spec-harness.json at the repository root. Every key is optional; an unknown key stops the run with exit 2.
| Key | Default | What it says |
| --- | --- | --- |
| branches | brief/{id}, brief-{id}, */brief/{id}, */brief-{id} | Branch names that carry the active brief's id. |
| outOfScope | "warn" | A write outside affectedFiles: warn, ask or deny. |
| base | the remote's default branch | What rounds are measured from, and where allowed signers are read. init names it, since a repository git did not clone may have no default branch recorded. |
| rulings.section | "Rulings" | The brief section holding the rulings table. |
| rulings.allowedSigners | ".github/allowed_signers" | The allowed-signers file, read from the base branch. |
| dependencies.manifests | the list under audit | Manifest names whose added dependencies the audit reports, and, in a package.json, the install scripts it allowed. A name that cannot be read is a warning in the audit. |
| context.budget | 60000 | Characters a context packet may hold; cited documents fill what the rest leaves, and those that do not fit are named by path. |
| assertions.premises | The Defect, Measured, Premises, Preconditions | Sections whose assertions state what was true before the round. |
| probes.runs, probes.timeout | 2, 600 | Runs per probe, and seconds per run. |
| tools | found in node_modules | A sibling's command by name: { "spec-brief": ["node", "path/to/spec-brief.js"] }. |
Exit codes:
| Code | Means |
| --- | --- |
| 0 | Clean. |
| 1 | Refused, found something, or waiting on a person. From hook claude, also what it says and holds no write for: a question it cannot read, and a line of its own it cannot read or whose --root is no directory. |
| 2 | The answer cannot be trusted, or did not arrive. An option, an argument or a value that names nothing is one. A git command that failed is one, and so is a stdout its reader closed before all of the output was written, as a pipeline into head does: each is one line on stderr. An error the harness did not expect is one too: spec-harness: unexpected error: and its stack, on stderr. |
| 130 | A probe interrupted by SIGINT (Ctrl+C) while it runs. |
| 143 | A probe interrupted by SIGTERM. |
| 129 | A probe interrupted by SIGHUP. |
| 149 | A probe interrupted by SIGBREAK, which is Ctrl+Break on Windows. |
An interrupted probe exits as a shell reports a process the signal ended.
Design
The decisions and what they cost are in docs/adr/; what the whole family shares is spec-core's ADR-0005. Working on spec-harness itself: CONTRIBUTING.md; reporting a vulnerability: SECURITY.md.
License
MIT
