@descent-vtt/spec-harness 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "name": "spec-harness",
11
11
  "source": "./",
12
12
  "description": "Rounds of agent work under a brief.",
13
- "version": "0.20.0"
13
+ "version": "0.21.0"
14
14
  }
15
15
  ]
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-harness",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Rounds of agent work under a brief: context, a guard on every write, escalations a person signs, an audit at the end. Needs Claude Code 2.1.139 or later, and @descent-vtt/spec-harness and @descent-vtt/spec-brief 0.2.0 or later installed in the project.",
5
5
  "author": {
6
6
  "name": "DescentVTT",
package/CHANGELOG.md CHANGED
@@ -9,6 +9,119 @@ only fixes, reports less or documents, so `^0.7.0` takes only releases that
9
9
  cannot turn a run red. The family's policy is
10
10
  [spec-core's ADR-0009](https://github.com/DescentVTT/spec-core/blob/main/docs/adr/0009-versions-before-1-0.md).
11
11
 
12
+ ## 0.21.0
13
+
14
+ A path through a link is decided as it reads and as the system writes it,
15
+ and the stricter answer stands; a run of the hook has one budget, 45
16
+ seconds, for every process it starts; and the server's `check_path` answers
17
+ each path as the hook decides a write of it. `doctor` passes the branch that
18
+ archived its brief, a round's last commit, where git says the branch
19
+ archived it, so a CI job that runs it passes a round's last pull request;
20
+ and it fails for a hook's line whose script is not there and for a brief the
21
+ branch names that spec-brief cannot be asked about
22
+ ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md),
23
+ [ADR-0004](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0004-the-active-brief-is-named-not-guessed.md)).
24
+
25
+ ### Changed
26
+
27
+ - **A path through a link is decided as it reads and as the system writes
28
+ it, and the stricter answer stands.** On Linux and macOS a `..` after a
29
+ link is written after the link: `deep/../schema.ts`, `deep` a link to
30
+ `lib/x`, reads as `schema.ts` and is written to `lib/schema.ts`, and the
31
+ hook, `guard` and the server's `check_path` decided it as `schema.ts`.
32
+ A link to nothing was decided as the link, where a write makes the file
33
+ it names, and a path outside the repository whose `..` after a link
34
+ leads into it as outside. Each such path is now decided both ways, each
35
+ file by the work tree that holds it, and a refusal says where the link
36
+ leads. On Windows, which reads the `..` first, no answer changes
37
+ ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
38
+ Upgrading: nothing to change. A write through a link to a file the brief
39
+ protects, which passed, is refused, and `guard` exits 1 for it.
40
+ - **A run of the hook has one budget, 45 seconds, for every process it
41
+ starts, and holds the write at it.** Each process the hook asked a
42
+ question of had 20 seconds, and several that answered slowly, none of
43
+ them at that, passed the 60 seconds `init` and the plugin give the
44
+ hook's entry, where Claude Code cancels the hook and lets the write go
45
+ ahead. A process is now given its 20 seconds or what is left of the
46
+ run's 45, whichever is less, and one stopped at the budget says so:
47
+ `cannot check this write: spec-brief did not answer within the 45
48
+ seconds the hook's run is given, and was stopped`, exit 2. `doctor`
49
+ notes an entry of the hook whose `timeout` is below the 45 seconds, and
50
+ `--format json` has the note as each line's `timeout`
51
+ ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
52
+ Upgrading: nothing to change where the hook's entry has the 60 seconds
53
+ `init` writes, or no timeout; an entry below 45 is noted, and fails
54
+ nothing.
55
+ - **The server's `check_path` answers each path as the hook decides a
56
+ write of it.** A file outside every work tree of the repository is
57
+ outside the repository whatever names the brief, where a brief that
58
+ spec-brief does not list or has archived failed the call; a path inside
59
+ that such a brief holds is `deny`, with the reason `held` and the
60
+ hook's words; and with no brief named each path is answered as no
61
+ brief's, where the call was a tool error. spec-brief is asked only where
62
+ a brief is named and a path is inside. The report at the end of a turn
63
+ says that every write is held but one to a file outside every work tree
64
+ ([ADR-0008](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0008-one-server-for-rounds.md),
65
+ [ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
66
+ Upgrading: a client that read a tool error from `check_path` for a
67
+ brief's problem or for no brief named reads an answer for each path, and
68
+ `held` is a new `reason` of a decision.
69
+ - **`doctor` fails nothing for the branch that archived its brief, a
70
+ round's last commit.** A round archives its brief on its own branch
71
+ before the branch merges, and since 0.20.0 `doctor` exited 1 there, so a
72
+ CI job that runs it failed every round's last pull request. Where git
73
+ says the brief's archived file is in HEAD and not at the merge base of
74
+ the base and HEAD, `doctor` says so in a note and exits 0 for it; where
75
+ the file is at the merge base, a closed round's branch used again, it
76
+ exits 1 as before, and so where git cannot say. `--format json` has the
77
+ answer as `closedRound`. The hook holds every write on such a branch as
78
+ before, and says the round is archived and ends when the branch merges
79
+ ([ADR-0004](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0004-the-active-brief-is-named-not-guessed.md)).
80
+ Upgrading: a CI job that runs `doctor` fetches the base under the name it
81
+ is configured by, with its history (`git fetch origin main:main`, in a
82
+ clone that is not shallow); without it, a base that names no commit or a
83
+ shallow history, a round's last pull request still fails `doctor`. Run
84
+ `doctor` in CI without `--strict`, which fails where the release of
85
+ Claude Code cannot be told.
86
+ - **`doctor` fails for a line of Claude Code's hook whose script is not
87
+ there**, which Claude Code cannot start and lets every write past: a
88
+ guard that is off, or, under `"onFailure": "block"` that the Claude
89
+ Code on `PATH` reads, one that holds every write. The script is looked
90
+ for where `${CLAUDE_PROJECT_DIR}` can point, a linked worktree's main
91
+ checkout among them; a relative path, npx's package and a word a shell
92
+ reads are not
93
+ ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
94
+ Upgrading: a project whose hook's line names an install that is not
95
+ there has `doctor` exit 1 where it exited 0: install
96
+ `@descent-vtt/spec-harness`, or mend the line. `claudeCode.lines` has a
97
+ line for each way an entry names the harness.
98
+ - **`doctor` fails where the branch names a brief spec-brief cannot be
99
+ asked about**, not installed, too old, or failing as it lists the
100
+ briefs, since the hook holds every write there, and says why on its
101
+ `brief` line; a hook's line that names a brief where spec-brief cannot
102
+ be run is said as one that holds every write
103
+ ([ADR-0004](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0004-the-active-brief-is-named-not-guessed.md)).
104
+ Upgrading: exit 1 where it was 0; the flag and `SPEC_BRIEF` still fail
105
+ nothing.
106
+
107
+ ### Added
108
+
109
+ - **`doctor` notes a report at the end of a turn that nothing runs**:
110
+ `"turnReport"` on, and no settings file starting the hook as a turn
111
+ ends. It fails nothing; `--format json` has `claudeCode.turnEnd`
112
+ ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
113
+
114
+ ### Fixed
115
+
116
+ - **A `tools` command that cannot be started is said in one way on every
117
+ host**: `cannot start "<command>": name the tool's script, such as
118
+ ["node", ...]`, the words a name no program has was given. On Windows a
119
+ script with no `node` before it, or a `.cmd`, ended a command on
120
+ `unexpected error: Error: spawn EFTYPE` (or `EINVAL`) and a stack trace,
121
+ and on Linux a file that may not be run was `exited -1` and nothing more;
122
+ the hook holds the write as it did
123
+ ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
124
+
12
125
  ## 0.20.0
13
126
 
14
127
  The hook decides each path by the work tree that holds it, a third
package/README.md CHANGED
@@ -174,15 +174,17 @@ May the round write this?
174
174
 
175
175
  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.
176
176
 
177
+ A path through a link is decided two ways: as it reads, its `..` taken first, and as the system writes it, which on Linux and macOS reads a `..` from where the link before it leads and follows a link to nothing to the file it names. Each file is decided by the work tree that holds it, and the stricter answer stands, so `deep/../schema.ts`, `deep` a link to `lib/x`, is refused where `lib/**` is protected, in words that say where the link leads. Which of the two Claude Code's tools write, its hooks reference and its permissions page do not say (read 2026-10-24). On Windows, which reads the `..` first, the two are one ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
178
+
177
179
  - `spec-harness hook claude` answers 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 answers `allow`, 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.
178
180
  - While `.spec-harness.json` does not load, a write to that file itself is let through, so that a session can mend it: no decision, so the person's own permission prompt stands, and the line that says why every other write is held, to the person as `systemMessage` and to Claude as `additionalContext`, as `.spec-harness.json: unknown key "outofscope"; ..., so every other write is held until it loads; this write, to /work/app/.spec-harness.json itself, is not checked`. The write is to the file and nothing else as the hook reads a path and as the disk does: a file that stands under that name alone, not a link to one and with no second name, named by every path of the call. A file of the same name elsewhere, a link to it, and the configuration of another work tree than the one that does not load are held as every other write is. After the write the hook loads the file again, and says, exit 2, where it still does not load. While it does not load nothing of it is read, so a brief that protects the file, or an `outOfScope` it sets, does not hold this write; the audit and spec-brief's archive still measure the round's change to it ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
179
- - A write outside every work tree of the repository is decided as one outside the repository is, whatever holds the writes inside: a brief that is archived, or that spec-brief does not list, whichever names it, and a configuration that does not load. It passes with no word, before the write and after it, and spec-brief is not asked about it; `guard` says it is outside the repository. The work trees are the main one and every linked worktree, as the files git keeps under its common directory name them, and a file of a linked worktree is decided as any other inside, by the worktree that holds it. Where they cannot all be named, a git directory kept apart, an entry git keeps that cannot be read, or a `core.worktree` where the configuration does not load, the write is held as before, and so is a path with a `..` in it, which the hook reads before its links where Linux and macOS write it after ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
181
+ - A write outside every work tree of the repository is decided as one outside the repository is, whatever holds the writes inside: a brief that is archived, or that spec-brief does not list, whichever names it, and a configuration that does not load. It passes with no word, before the write and after it, and spec-brief is not asked about it; `guard` says it is outside the repository. The work trees are the main one and every linked worktree, as the files git keeps under its common directory name them, and a file of a linked worktree is decided as any other inside, by the worktree that holds it. Where they cannot all be named, a git directory kept apart, an entry git keeps that cannot be read, or a `core.worktree` where the configuration does not load, the write is held as before, and so is a path with a `..` in it, and one that a link outside them, a link to nothing among them, has written into a work tree ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
180
182
  - In a worktree, a write is decided by the worktree's brief. Claude Code names the project a session started in, and hands the hook the directory Claude works in as the question's `cwd`: in a worktree session started from the desktop app, and after Claude enters a worktree, the first is the main checkout and the second the worktree. Where `cwd` is in another work tree of the same repository, that work tree's branch names the brief, its briefs and configuration are read, and its siblings are looked for in its own `node_modules` and then the project's, so a worktree with no install is checked with the project's. A path it does not hold, and every path where `cwd` is in another repository or in none, is decided by the project as before ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
181
183
  - After a write the hook answers from what it decided before it, for the same call: the run before keeps the warnings it would give under the git common directory, in `spec-harness/calls/`, named by the question's `session_id` and `tool_use_id`, and the run after takes them away and answers with no process started. A refused call keeps nothing, nor one that writes `.spec-harness.json`, spec-brief's configuration, the brief or a file beside it, which are decided again after the write; what no run after a write reads is removed after a day. Under `outOfScope: "ask"` or `"deny"` no write is warned of, and the run after one says nothing with no process started ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
182
184
  - On a brief's branch the hook asks spec-brief for the briefs, which on Windows is most of what a write waits on. Within a session it keeps spec-brief's answer, where spec-brief's `list` names every input it was read from (`inputs`), under the git common directory in `spec-harness/answers/`, by the work tree, the session, spec-brief's script and version, the command line, and the variables git and Node read; and it gives the answer again while each file on the list has the digest listed, or is still not there, each directory holds the files listed, or is still not there where it was listed as not there, and the git index and ignore files read as listed, with no process started. Anything else starts spec-brief as before: an input that is missing, unreadable or reads otherwise, `keep` not `true`, an answer with no `inputs`, a command `tools` names, and, where the list names what git read, `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE` or `GIT_COMMON_DIR` set. What is kept is removed after a day; every other command starts spec-brief every time ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
183
185
  - As a session starts, resumes, is cleared, compacted or forked, `hook claude` answers Claude Code's SessionStart with what would hold every write of the session, in one line, to the person as `systemMessage` and to Claude as `additionalContext`: a configuration that does not load, a brief the line, `SPEC_BRIEF` or the branch names that spec-brief does not list, spec-brief missing or too old where a brief is named, and what is wrong with the hook's own line. Nothing is said where all is well, and it exits 0 whatever it finds: the event cannot block, and the session's start is not to be stopped by it ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
184
186
  - With `"turnReport": true` in `.spec-harness.json`, `hook claude` answers Claude Code's `Stop`, which it runs as its main agent finishes a turn: with one `git --no-optional-locks status`, every path the work tree holds changed, a shell's writes among them, that the brief protects or that is outside its scope under the `outOfScope` in force, in the words a write of each is given, twenty by name and how many more, as one `systemMessage`; or nothing. It exits 0 and never has Claude go on. `init` writes the entry with `"async": true`, so the turn waits on none of it and Claude Code hands the report to Claude as the next turn begins; without `async` the person sees it as the turn ends, Claude does not, and the end of the turn waits for it. No ruling is read: a protected file a signed ruling allows is reported, and the audit reads the ruling. The plugin brings no such entry; with it on, `init` says the entry to add ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md), [ADR-0012](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0012-one-way-into-claude-code.md)).
185
- - A process the hook asks a question of, git or spec-brief's list of the briefs, is stopped after 20 seconds: Claude Code cancels the hook at the 60 seconds `init` and the plugin give its entry, and before a write lets the write go ahead. At the limit the hook says `cannot check this write: spec-brief did not answer within 20 seconds, and was stopped` and exits 2, which holds the write. Every command gives a question the same time, and a round's work, a diff, a probe's worktree, a sibling's check, ten minutes ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
187
+ - A run of the hook has 45 seconds for every process it starts, together, and a process it asks a question of, git or spec-brief's list of the briefs, is stopped after 20 seconds, or after what is left of the 45 where that is less: Claude Code cancels the hook at the 60 seconds `init` and the plugin give its entry, and before a write lets the write go ahead, and several processes that each answered inside their 20 seconds could pass that minute together. At either limit the hook says `cannot check this write: spec-brief did not answer within 20 seconds, and was stopped`, or `within the 45 seconds the hook's run is given`, and exits 2, which holds the write; a process the run reaches with nothing left is not started, and said so. Every command gives a question the same time, and a round's work, a diff, a probe's worktree, a sibling's check, ten minutes ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
186
188
  - `spec-harness hook git` is a pre-commit hook for any agent or none. `init --git-hook` writes it; in a hook of your own it is the line `node node_modules/@descent-vtt/spec-harness/bin/spec-harness.js hook git`.
187
189
  - `hook claude` passes over whatever else is on its line that it does not read, as every release has, where every other command [refuses it](#an-input-that-names-nothing): its line is in settings a later release cannot rewrite, and exit 2 from it holds every write.
188
190
  - A `--root` on the hook's line that is a file, or is not there, is a guard that was set up and is off: `hook claude` says so in one line to the person, as `systemMessage`, and exits 0, and the write goes on. It refuses nothing for it. No release wrote a `--root` there; 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.
@@ -376,7 +378,7 @@ Run it in CI: exit 1 when a premise is stale, exit 2 when spec-guard is not ther
376
378
 
377
379
  `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.
378
380
 
379
- - The server is started for the project Claude Code names. `check_path` decides an absolute path by the work tree of the same repository that holds it, as the hook decides a write, and `start_round`, `check_path`, `request_escalation` and `audit_round` read the round, its branch and its brief, in one work tree: the one a `directory` argument names; else the one the client's roots name, where they name exactly one work tree of the repository, asked with `roots/list` where the client declared roots; else the project's. Each says which as `workTree`, and in its text where it is not the project's ([ADR-0008](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0008-one-server-for-rounds.md)).
381
+ - The server is started for the project Claude Code names. `check_path` decides an absolute path by the work tree of the same repository that holds it, and answers each path as the hook decides a write of it alone: a file outside every work tree of the repository as outside it, whatever names the brief; a path the hook holds for a brief spec-brief does not list or has archived as `deny`, with the reason `held` and the hook's words; and every path, with no brief named, as no brief's, without asking spec-brief. `start_round`, `check_path`, `request_escalation` and `audit_round` read the round, its branch and its brief, in one work tree: the one a `directory` argument names; else the one the client's roots name, where they name exactly one work tree of the repository, asked with `roots/list` where the client declared roots; else the project's. Each says which as `workTree`, and in its text where it is not the project's ([ADR-0008](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0008-one-server-for-rounds.md)).
380
382
 
381
383
  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](#an-input-that-names-nothing), such as an empty `brief`.
382
384
 
@@ -423,11 +425,12 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
423
425
 
424
426
  - **The siblings**: which are installed, at which versions, and how each is run.
425
427
  - A sibling older than this release needs is `outdated`, with the minimum and the command that installs a newer one, and exit 1.
426
- - A command named under `tools` is run as named, and its version is not checked.
428
+ - A command named under `tools` is run as named, with no shell, and its version is not checked. One that cannot be started is said in one way on every host, `cannot start "<command>": name the tool's script, such as ["node", ...]`: a name no program has, a directory, a `.cmd` such as `npx.cmd`, and a script with no `node` before it, which Windows does not run and Linux runs only where it has the mode to run and a `#!` line ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
427
429
  - In a linked worktree with no install of its own, the siblings are the main work tree's, as git's hook runs them there, and each such row says whose install it is: `(0.8.0, the install of /work/app: this work tree has none of its own)`. `--format json` has it as `elsewhere`, `null` for a sibling of the work tree's own.
428
430
  - **The repository**: its root, the branch, and the brief the flag, `SPEC_BRIEF` or the branch names.
429
431
  - 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 why `doctor` is being run.
430
- - Where the branch names it, a brief spec-brief does not know or has archived, the hook holds every write on that branch, and `doctor` exits 1, as for a hook's line that holds every write: `brief none: the branch names brief 999, and spec-brief knows no such brief`. One the flag or `SPEC_BRIEF` names fails nothing: the flag is the question `doctor` was asked, and the variable `doctor` reads is its own, which need not be the one Claude Code's hook is started with. Nor does a spec-brief that could not list the briefs, which is a sibling's row to judge. A base that names no commit fails nothing either. `--format json` has the id as `brief` and the reason as `briefProblem`, `null` where there is none ([ADR-0004](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0004-the-active-brief-is-named-not-guessed.md)).
432
+ - Where the branch names it, a brief spec-brief does not know or has archived, the hook holds every write on that branch, and `doctor` exits 1, as for a hook's line that holds every write: `brief none: the branch names brief 999, and spec-brief knows no such brief`. One the flag or `SPEC_BRIEF` names fails nothing: the flag is the question `doctor` was asked, and the variable `doctor` reads is its own, which need not be the one Claude Code's hook is started with. Where the branch names a brief and spec-brief cannot be asked about it, not installed, too old, or failing as it lists the briefs, the hook holds every write on that branch as well, and `doctor` says why on that line and exits 1: `brief none: spec-brief could not say whether it knows brief 001: spec-brief is not installed here: ...`. Under the flag or `SPEC_BRIEF` a spec-brief that could not list the briefs fails nothing, and one that cannot be run is its row's to say. A base that names no commit fails nothing either. `--format json` has the id as `brief` and the reason as `briefProblem`, `null` where there is none ([ADR-0004](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0004-the-active-brief-is-named-not-guessed.md)).
433
+ - But for the branch that archived its brief, which is a round's last commit: a round archives its brief on its own branch before the branch merges. Where git says the brief's archived file, as spec-brief lists it, is in HEAD and not at the merge base of the base and HEAD, `doctor` says so in a note and fails nothing for it, so a round's last pull request passes a CI job that runs `doctor`: `brief 020 was archived on this branch, which waits to merge: briefs/archive/020_compute.md is in HEAD and not at the merge base with main, 8835800a03b3, so this fails nothing; the hook holds every write on the branch all the same`. Where the file is at the merge base, the brief was archived before the branch, a closed round's branch used again, and `doctor` exits 1 as before. Where git cannot say, it cannot tell, and exits 1: a base that names no commit, a history that is shallow, a git that cannot read a tree, an archive not committed at HEAD. So a CI job that runs `doctor` fetches the base under the name it is configured by, with its history (`git fetch origin main:main`, in a clone that is not shallow); without that, a round's last pull request fails `doctor` as it did. It runs `doctor` without `--strict`, which fails where the release of Claude Code cannot be told, as on a runner with no `claude` and `init`'s hooks in the settings. A brief spec-brief does not list, which no round's last commit names, fails as before. The hook holds every write on such a branch in every case, and says the round is archived and ends when the branch merges. `--format json` has the answer as `closedRound`, with its `state` (`waiting-to-merge`, `archived-before` or `unknown`), the `file`, the `mergeBase` and the note as `detail`, and `null` where the branch names no brief spec-brief has archived.
431
434
  - **What a signed ruling needs to count**:
432
435
  - the base, and whether `--base`, `.spec-harness.json` or the remote named it;
433
436
  - whether the allowed-signers file is on that base;
@@ -438,16 +441,18 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
438
441
  - 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-after` and `valid-before`, separated by commas.
439
442
  - **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.
440
443
  - Where a settings file guards writes and none starts the hook as a session starts, a note says so and how to add it, `spec-harness init --write`, and fails nothing. `--format json` has `claudeCode.sessionStart`.
444
+ - Where `"turnReport"` is on and no settings file starts the hook as a turn ends, a note says that no turn's end is reported, and how to add the entry, and fails nothing; the plugin brings none. `--format json` has `claudeCode.turnEnd`.
441
445
  - Run in a linked worktree where something wires the guard, a note says which release of spec-harness the hook's line runs, where the main checkout's install and this work tree's are not one: the line names its script under `${CLAUDE_PROJECT_DIR}`, which is this work tree in a session started in it, and the main checkout in a worktree session of the desktop app, where by [Claude Code's worktrees page](https://code.claude.com/docs/en/worktrees) Claude Code reads the hooks from the main checkout's root and points the placeholder at it. Either decides a write by the work tree that holds it, from 0.19.0 on, so the note says which release answers, and fails nothing.
442
446
  - `"disableAllHooks": true` in a settings file switches the guard off: by [Claude Code's settings reference](https://code.claude.com/docs/en/settings-reference) it turns off the hooks of the user's, the project's and the local settings and of plugins. `doctor` reads the key from the three files as Claude Code merges them, the last that sets it deciding, names the file that holds it, and exits 1 where there are guard hooks for it to switch off. Where nothing is wired it is a note. Hooks of managed settings, and of a plugin managed settings force on, keep running; `doctor` reads no managed settings and no `--settings`.
443
447
  - A hook in Claude Code's settings is the guard by its command, `spec-harness` or its script, and the words `hook claude`, whatever else is on its line. `doctor` reads 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.
444
448
  - Only an entry Claude Code runs as a command is a line of the hook, `"type": "command"`. An entry of another handler type (`http`, `mcp_tool`, `prompt`, `agent`) runs no command, whatever it holds, and is not reported. One with no `type`, or with a word the hooks reference does not list, is *cannot tell*, never fine: the reference requires the field and says nothing of an entry without it; `--strict` fails it.
445
449
  - A line that holds more than `init` writes, 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`.
446
450
  - A line the hook cannot read, and a `--root` that 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.
451
+ - A line whose script is not there is a guard that is off as well, said with the script and how to install it, and exits 1: [Claude Code's hooks reference](https://code.claude.com/docs/en/hooks) counts a hook that cannot start as one that failed, and lets the write go ahead. Where its entry sets `"onFailure": "block"` and the Claude Code on `PATH` is 2.1.295 or later, it is said as a line that holds every write, since Claude Code holds each write before which the hook fails, and exits 1 too. The script is looked for where `${CLAUDE_PROJECT_DIR}` can name the project as a session starts: the work tree `doctor` runs in, and in a linked worktree the main checkout as well, where a worktree session of the desktop app finds it; it is not there only where it is in neither. A relative path, which is read from wherever the session stands, a name npx or `PATH` finds, and in a line a shell reads a word with anything a shell rewrites, are not looked for. `init` does not look: the line it writes names the project's install, which may come after it.
447
452
  - A line that asks for the help or the version, with `--help`, `--version`, `-h` or `-v`, is a guard that is off as well. The hook prints the help or the version, exit 0, in place of reading its question, and [Claude Code's hooks reference](https://code.claude.com/docs/en/hooks) has what a hook that exits 0 prints go to the debug log: no write is checked, and nothing says so. `doctor` says it and exits 1, whatever else is on the line: `"hook claude --help" in .claude/settings.json: the line asks for the help, which the hook prints, exit 0, in place of checking the write, so the guard is off and no write is checked`. Beside a `--root` that is a file or is not there, it is a line the hook cannot read, which the hook says before a write as it says the root without either.
448
453
  - A `--root` that is a directory outside any git work tree is a guard that is off as well, and the one fault the hook says nothing of: it reads a session outside a work tree there, checks no write, and exits 0. `doctor` asks git about the root as the hook does, says so, and exits 1: `"hook claude --root /elsewhere" in .claude/settings.json: --root is "/elsewhere", a directory outside any git work tree, which the hook reads as a session outside one, exit 0 and no word, so the guard is off and no write is checked`.
449
454
  - A `--root` in another git work tree than the project's is read by where that work tree is. The hook opens the work tree its root is in and decides by that work tree's briefs, and a file outside it is none of its repository's, which the hook passes with no word. So where that work tree does not hold the project - another repository beside it, or one inside it: a repository of its own, a submodule, a linked worktree - no write to a file of the project is checked. The guard is off, and `doctor` exits 1: `"hook claude --root ../other" in .claude/settings.json: --root is "../other", a directory in /work/other, the work tree of another repository, which does not hold this project: the hook checks a write to a file of that work tree, by its briefs, and passes any other with no word, exit 0, so the guard is off and no write to a file of this project is checked`. Where the work tree holds the project, as a repository the project is nested in does, or the main work tree around a linked one, a write here is checked, by that work tree's briefs and its branch and under the path the file has there: `doctor` says whose briefs they are and fails nothing. A work tree of the project's own repository, linked or main, is named `another work tree of this repository`, and is read by the same rule: a linked worktree beside the project holds none of its files.
450
- - A line that names a brief which is not one, as `--brief 404` does 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. `doctor` asks 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`.
455
+ - A line that names a brief which is not one, as `--brief 404` does 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. `doctor` asks 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`. So does a line that names a brief where spec-brief cannot be run, not installed or too old, which the hook asks before each write.
451
456
  - An entry is read with the event it stands under and its matcher, as [Claude Code's hooks reference](https://code.claude.com/docs/en/hooks) has them: Claude Code waits on a hook before a tool call under `PreToolUse` alone, 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.
452
457
  - A line Claude Code waits on before no write is not the guard, whatever the hook would do with it. `doctor` says 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 among `init`'s entries. It fails nothing: no guard is a choice.
453
458
  - A line waited on before some of `Edit`, `Write` and `NotebookEdit` guards those, and `doctor` names the rest, whose writes go unchecked. A line started after no write warns of none, which is said too. Neither fails anything.
@@ -458,7 +463,8 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
458
463
  - A matcher that is an array, `["Edit", "Write"]`, is by the same page an entry Claude Code lists as an invalid setting, and no hook under it is started. Where one stands under `PreToolUse` or `PermissionRequest`, in an entry of the hook's or of any other program's, Claude Code loads none of that settings file's other hooks either. `doctor` says so of every line of the hook the file holds, and a line that stood to be started for a write is a guard that is off, exit 1: `"hook claude" in .claude/settings.json: .claude/settings.json has an array for a matcher under PreToolUse, ["Bash"], which Claude Code lists as an invalid setting, and beside one under PreToolUse or PermissionRequest it loads none of the file's other hooks, so the guard is off and no write is checked`. The hooks of another settings file are read as that file has them. Where such an array stands in the project's `.claude/settings.json`, the file `init` writes its hooks into, `doctor`'s advice is the array's first, since `init` writes no hook there while it stands, and then `init --write`. `--format json` has the array as `claudeCode.unloaded`, `null` where there is none.
459
464
  - A line with no `args` is 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; `--strict` fails it. Written with `args`, each word reaches the hook as it stands, and `doctor` reads it.
460
465
  - `"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 a hook that does not start, or by one that runs past its timeout. The hook says each of its own answers with exit 0 or 2, a line that turns the guard off among them, so the field holds no write for those. Where the Claude Code on `PATH` is 2.1.295 or later and a line that guards sets no `onFailure`, a note advises it, with `init --write --block-on-failure` for `init`'s own entries, and fails nothing: every Claude Code that reads the file has to be one that reads the field.
461
- - `--format json` has each line under `claudeCode.lines`: its `file`, `events`, the `line` as written, its `state` (`guards`, `off`, `held`, `unstarted` or `unknown`), `onFailure`, the writing tools Claude Code waits on it `before` a write by and starts it `after` one by, of `Edit`, `Write` and `NotebookEdit` (`null` where that cannot be told), and what is said of it as `detail`, `cover` and `note`, `null` where there is nothing to say. Beside the lines, `claudeCode.presence` is whether the plugin is on this machine, `null` where no plugin is on and otherwise its `state` (`present`, `absent` or `unknown`) with the reason as `detail`; `claudeCode.hooksOff` is the settings file whose `disableAllHooks` is `true`, or `null`; and `claudeCode.notes` holds what is said of the two.
466
+ - An entry whose `timeout` is below the 45 seconds a run of the hook is given is said in a note, with the event it stands under: Claude Code cancels a hook at its entry's timeout, and before a write a cancelled hook lets the write go ahead, so a run that needs its budget is cut short. It fails nothing; `init` writes 60, and an entry with none has the 600 seconds Claude Code's hooks reference, read 2026-10-24, gives a command hook by default ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
467
+ - `--format json` has each line under `claudeCode.lines`: its `file`, `events`, the `line` as written, its `state` (`guards`, `off`, `held`, `unstarted` or `unknown`), `onFailure`, the writing tools Claude Code waits on it `before` a write by and starts it `after` one by, of `Edit`, `Write` and `NotebookEdit` (`null` where that cannot be told), and what is said of it as `detail`, `cover`, `note` and `timeout`, `null` where there is nothing to say. Beside the lines, `claudeCode.presence` is whether the plugin is on this machine, `null` where no plugin is on and otherwise its `state` (`present`, `absent` or `unknown`) with the reason as `detail`; `claudeCode.hooksOff` is the settings file whose `disableAllHooks` is `true`, or `null`; and `claudeCode.notes` holds what is said of the two. Entries that name the harness in two ways, by two scripts or with a shell and without one, are two lines.
462
468
  - The plugin's own hooks are not read: they are a release's files, and every release wrote `hook claude` and no more on the line, under the matcher `init` writes. What is read is whether the plugin that settings turn on is on this machine, by [Claude Code's plugin loading reference](https://code.claude.com/docs/en/plugins/loading), from the directories Claude Code keeps plugins in: `plugins` in its configuration directory, or what `CLAUDE_CODE_PLUGIN_CACHE_DIR` names, and each directory `CLAUDE_CODE_PLUGIN_SEED_DIR` names.
463
469
  - *On the machine*: a version of it is in a cache, `cache/<marketplace>/spec-harness/<version>/`, which no `.orphaned_at` marks as left behind. Nothing more is said.
464
470
  - *Not on the machine*: the project's `.claude/settings.json` alone turns it on, no version of it is cached, and no marketplace of its name is recorded as fetched (`known_marketplaces.json`) or declared in settings (`extraKnownMarketplaces`). Claude Code fetches no plugin that a project's settings alone turn on, so the plugin brings no hook and is not counted. With `init`'s hooks in a settings file, those are the guard, and the plugin is a note. Without them `doctor` exits 1 and says what would bring it.
package/dist/cli.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { type CliIO } from './workspace.js';
21
21
  export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version, type CliIO } from './workspace.js';
22
- export declare const HELP = "spec-harness - context, guards, audits and rulings for rounds of agent work\n\nUsage:\n spec-harness <command> [options]\n\nCommands:\n context [brief] What an agent needs to start a round: the brief, its scope,\n rulings, dependencies, the rules in force, the cited documents.\n guard <path...> May the round write these paths? Exit 1 if one is refused.\n hook claude Answer a Claude Code PreToolUse, PostToolUse, SessionStart or Stop\n hook (stdin). At session start it says what would hold every\n write; at a turn's end, with \"turnReport\" on, what the work tree\n holds that the brief protects or that is outside its scope.\n hook git Answer git's pre-commit hook for the staged files.\n escalate Ask a person to rule: --path <file> (repeatable) --reason <why>\n [--option \"<label>: <cost>\"]... [--recommend <text>].\n --list shows what waits; --show <id> prints one memo. Neither\n raises a request: beside one, only --root and --format are read.\n rule <id> Record a person's ruling in the brief: --allow or --deny, with\n --note <text>. It counts once committed signed.\n rulings [brief] The brief's rulings, and whether each one's signature verifies.\n audit [brief] Did the round stay inside the lines: what the archive would say,\n the brief's assertions, unverified rulings, new dependencies\n and the install scripts it allowed.\n probe [brief] Run the brief's probes: red at the base, green at the head.\n --at <base|head|both>, --id <probe>.\n premises Do the live briefs' premises still hold? For CI: exit 1 when one\n is stale, or, with --strict, when spec-guard cannot read one.\n init Configure the spec-* tools to agree. Prints the plan; --write\n applies it; --git-hook adds a pre-commit hook; --block-on-failure\n has Claude Code 2.1.295 or later hold a write when the hook cannot\n start or runs past its timeout.\n mcp Serve start_round, check_path, request_escalation, audit_round,\n list_rounds and the workflow prompts over MCP on stdio.\n doctor Which siblings are installed, at which versions, which brief is\n named, how Claude Code runs the guard, and git's hook. Exit 1\n when a sibling is older than this release needs, the guard is\n installed twice, its hook's line turns it off or holds every\n write, the branch names a brief spec-brief does not list or\n has archived, disableAllHooks or an array for a matcher\n switches it off, the plugin that is turned on is not on this\n machine and nothing else guards, Claude Code is too old to\n run its hooks, or git stops every commit at what stands for\n its hook.\n\nOptions:\n --brief <id> The brief the round works on. Otherwise SPEC_BRIEF, then the branch.\n For context, guard, hook, escalate, rulings, audit, probe, premises\n and doctor.\n --base <ref> What the round is measured from. Otherwise \"base\" in\n .spec-harness.json, then the remote's default branch. For context,\n guard, hook, rulings, audit, probe and doctor.\n --root <dir> Run from another directory, which must exist. For every command.\n --format <fmt> pretty or json; audit and premises also gitlab, sarif or github.\n For every command but hook and mcp.\n --strict Warnings fail the run. For guard, audit, premises and doctor.\n --block-on-failure onFailure \"block\" on init's entries but the one as a turn ends. For init.\n --help, --version One of the two: both on one line are refused.\n\nAn option a command does not read, an argument it does not take, an option or an\nargument given an empty value, a brief given both as the argument and as --brief,\nand an option that takes one value given two that name two things are refused, exit 2,\nnot read as if they were not there, and so with --help or --version on the line. The\nsame value twice, as --root . --root ./, is read once, and on Windows a drive letter in\neither case. doctor says an empty --brief or --base in its report. hook claude reads its\nline as every release has, and says a line it cannot read, two values for one option or\n--help beside a --root that is not a directory among them, a --root that is not a\ndirectory, and a question it cannot read, as a warning to the person with exit 0: it\nrefuses nothing for any of them.\n\nExit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.\n";
22
+ export declare const HELP = "spec-harness - context, guards, audits and rulings for rounds of agent work\n\nUsage:\n spec-harness <command> [options]\n\nCommands:\n context [brief] What an agent needs to start a round: the brief, its scope,\n rulings, dependencies, the rules in force, the cited documents.\n guard <path...> May the round write these paths? Exit 1 if one is refused.\n hook claude Answer a Claude Code PreToolUse, PostToolUse, SessionStart or Stop\n hook (stdin). At session start it says what would hold every\n write; at a turn's end, with \"turnReport\" on, what the work tree\n holds that the brief protects or that is outside its scope.\n hook git Answer git's pre-commit hook for the staged files.\n escalate Ask a person to rule: --path <file> (repeatable) --reason <why>\n [--option \"<label>: <cost>\"]... [--recommend <text>].\n --list shows what waits; --show <id> prints one memo. Neither\n raises a request: beside one, only --root and --format are read.\n rule <id> Record a person's ruling in the brief: --allow or --deny, with\n --note <text>. It counts once committed signed.\n rulings [brief] The brief's rulings, and whether each one's signature verifies.\n audit [brief] Did the round stay inside the lines: what the archive would say,\n the brief's assertions, unverified rulings, new dependencies\n and the install scripts it allowed.\n probe [brief] Run the brief's probes: red at the base, green at the head.\n --at <base|head|both>, --id <probe>.\n premises Do the live briefs' premises still hold? For CI: exit 1 when one\n is stale, or, with --strict, when spec-guard cannot read one.\n init Configure the spec-* tools to agree. Prints the plan; --write\n applies it; --git-hook adds a pre-commit hook; --block-on-failure\n has Claude Code 2.1.295 or later hold a write when the hook cannot\n start or runs past its timeout.\n mcp Serve start_round, check_path, request_escalation, audit_round,\n list_rounds and the workflow prompts over MCP on stdio.\n doctor Which siblings are installed, at which versions, which brief is\n named, how Claude Code runs the guard, and git's hook. Exit 1\n when a sibling is older than this release needs, the guard is\n installed twice, its hook's line turns it off or holds every\n write or runs a script that is not there, the branch names a\n brief spec-brief does not list, cannot be asked about, or has\n archived, but for the branch git says archived it, a round's\n last commit, which waits to merge (CI fetches the base with its\n history for git to say), disableAllHooks or an array for a\n matcher switches it off, the plugin that is turned on is not on\n this machine and nothing else guards, Claude Code is too old to\n run its hooks, or git stops every commit at what stands for its\n hook.\n\nOptions:\n --brief <id> The brief the round works on. Otherwise SPEC_BRIEF, then the branch.\n For context, guard, hook, escalate, rulings, audit, probe, premises\n and doctor.\n --base <ref> What the round is measured from. Otherwise \"base\" in\n .spec-harness.json, then the remote's default branch. For context,\n guard, hook, rulings, audit, probe and doctor.\n --root <dir> Run from another directory, which must exist. For every command.\n --format <fmt> pretty or json; audit and premises also gitlab, sarif or github.\n For every command but hook and mcp.\n --strict Warnings fail the run. For guard, audit, premises and doctor.\n --block-on-failure onFailure \"block\" on init's entries but the one as a turn ends. For init.\n --help, --version One of the two: both on one line are refused.\n\nAn option a command does not read, an argument it does not take, an option or an\nargument given an empty value, a brief given both as the argument and as --brief,\nand an option that takes one value given two that name two things are refused, exit 2,\nnot read as if they were not there, and so with --help or --version on the line. The\nsame value twice, as --root . --root ./, is read once, and on Windows a drive letter in\neither case. doctor says an empty --brief or --base in its report. hook claude reads its\nline as every release has, and says a line it cannot read, two values for one option or\n--help beside a --root that is not a directory among them, a --root that is not a\ndirectory, and a question it cannot read, as a warning to the person with exit 0: it\nrefuses nothing for any of them.\n\nExit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.\n";
23
23
  /**
24
24
  * Runs the command line and resolves to the exit code, for an error nothing
25
25
  * below expected too.