@descent-vtt/spec-harness 0.15.0 → 0.17.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.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +198 -0
  4. package/README.md +18 -3
  5. package/dist/branch.js +12 -0
  6. package/dist/branch.js.map +1 -1
  7. package/dist/cli.d.ts +1 -1
  8. package/dist/cli.js +76 -56
  9. package/dist/cli.js.map +1 -1
  10. package/dist/configure.d.ts +200 -20
  11. package/dist/configure.js +514 -69
  12. package/dist/configure.js.map +1 -1
  13. package/dist/hooks.d.ts +17 -1
  14. package/dist/hooks.js +17 -1
  15. package/dist/hooks.js.map +1 -1
  16. package/dist/reader.js +5 -1
  17. package/dist/reader.js.map +1 -1
  18. package/dist/round.d.ts +35 -6
  19. package/dist/round.js +137 -17
  20. package/dist/round.js.map +1 -1
  21. package/dist/rulings.js +6 -0
  22. package/dist/rulings.js.map +1 -1
  23. package/dist/setup.d.ts +1 -1
  24. package/dist/setup.js +72 -17
  25. package/dist/setup.js.map +1 -1
  26. package/dist/signers.js +5 -1
  27. package/dist/signers.js.map +1 -1
  28. package/dist/usage.d.ts +48 -8
  29. package/dist/usage.js +35 -1
  30. package/dist/usage.js.map +1 -1
  31. package/dist/vendor/spec-core/jsonrpc/mcp.js.map +1 -1
  32. package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -1
  33. package/dist/vendor/spec-core/markdown/headings.js.map +1 -1
  34. package/dist/vendor/spec-core/markdown/links.js.map +1 -1
  35. package/dist/vendor/spec-core/markdown/scan.js.map +1 -1
  36. package/dist/vendor/spec-core/pattern/automaton.js.map +1 -1
  37. package/dist/vendor/spec-core/pattern/charset.js.map +1 -1
  38. package/dist/vendor/spec-core/pattern/regex.js.map +1 -1
  39. package/dist/vendor/spec-core/text/lines.js.map +1 -1
  40. package/dist/vendor/spec-core/text/width.js.map +1 -1
  41. package/dist/workspace.d.ts +12 -3
  42. package/dist/workspace.js +20 -3
  43. package/dist/workspace.js.map +1 -1
  44. package/package.json +1 -1
@@ -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.15.0"
13
+ "version": "0.17.0"
14
14
  }
15
15
  ]
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-harness",
3
- "version": "0.15.0",
3
+ "version": "0.17.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,204 @@ 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.17.0
13
+
14
+ `doctor` and `init` see four more ways a guard is set up and checks
15
+ nothing: a root on the hook's line that is in no work tree, hooks switched
16
+ off by `disableAllHooks`, an entry that is no command, and a plugin that
17
+ settings turn on and the machine does not have. And they name only a tool
18
+ Claude Code has as one whose writes go unchecked. `doctor`, `init` and the
19
+ hooks ask git at once for what waits on nothing else, about half a second
20
+ of the five a `doctor` takes on a Windows workstation, and a matcher that
21
+ is a pattern is read once in a run.
22
+
23
+ ### Changed
24
+
25
+ - **`doctor` fails for a hook's line whose root is a directory outside any
26
+ git work tree.** `hook claude --root <such a directory>` reads a session
27
+ outside a work tree: the hook checks no write and says nothing, exit 0,
28
+ as in every release, and `doctor` said of the line that it guards, by
29
+ its root. It now asks git about the root as the hook does and says the
30
+ line as a guard that is off: `"hook claude --root /elsewhere" in
31
+ .claude/settings.json: --root is "/elsewhere", a directory outside any git
32
+ work tree, which the hook reads as a session outside one, exit 0 and no
33
+ word, so the guard is off and no write is checked`. `init` says the same
34
+ of the file (`advise`) and writes nothing into it. The hook answers as it
35
+ did ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
36
+ Upgrading: `doctor` exits 1 for such a line, where it exited 0 while no
37
+ write was checked: give the line a directory in the work tree, or take
38
+ `--root` off it for the project Claude Code names.
39
+ - **`doctor` fails where `disableAllHooks` switches the guard off.** With
40
+ `"disableAllHooks": true` in a settings file, Claude Code's settings
41
+ reference has it run no hook of the user's, the project's or the local
42
+ settings and none of a plugin, and `doctor` reported `init`'s entries, or
43
+ the plugin, as the guard. It now reads the key from the three files as
44
+ Claude Code merges them, the last that sets it deciding, names the file,
45
+ and exits 1 where there are guard hooks for the key to switch off; where
46
+ nothing is wired it is a note. `init` says it of the file that holds the
47
+ key (`advise`) and writes what it would have written. Hooks of managed
48
+ settings, and of a plugin managed settings force on, keep running, and
49
+ neither `doctor` nor `init` reads managed settings or `--settings`.
50
+ `--format json` has the file as `claudeCode.hooksOff`, `null` where no
51
+ file switches the hooks off.
52
+ Upgrading: `doctor` exits 1 in such a tree, where it exited 0 while no
53
+ hook ran: take the key out, or set it to `false` in a file of higher
54
+ precedence.
55
+ - **Only an entry Claude Code runs as a command is read as the hook.** An
56
+ entry of another handler type, `"type": "prompt"`, `"agent"`, `"http"`
57
+ or `"mcp_tool"`, that held the hook's command was reported as the guard.
58
+ Such an entry runs no command, so it is no line of the hook: `doctor`
59
+ reports no guard for it, and `init --write` writes its hooks beside it.
60
+ An entry with no `"type"`, or with a word the hooks reference does not
61
+ list, is *cannot tell*: the reference requires the field and says nothing
62
+ of an entry without it.
63
+ Upgrading: `doctor --strict` exits 1 for a hook's entry with no
64
+ `"type"`: give it `"type": "command"`, as `init` and the plugin write it.
65
+ - **`doctor` says whether the plugin that settings turn on is on this
66
+ machine.** It reported the plugin as the guard, and `init` skipped, on the
67
+ word of `enabledPlugins` alone. Both now read what Claude Code's plugin
68
+ loading reference documents of the directories it keeps plugins in
69
+ (`plugins` in its configuration directory, or
70
+ `CLAUDE_CODE_PLUGIN_CACHE_DIR`, and each `CLAUDE_CODE_PLUGIN_SEED_DIR`).
71
+ A version of the plugin in a cache is the plugin on the machine, and
72
+ nothing more is said. Where the project's `.claude/settings.json` alone
73
+ turns it on, no version is cached and no marketplace of its name is
74
+ recorded as fetched or declared in settings, Claude Code fetches none, so
75
+ the plugin is not the guard: `doctor` says so with what would bring it,
76
+ and exits 1 where no hook of `init`'s guards instead. Everything else is
77
+ *cannot tell*, said, and a failure under `--strict` alone: a record that
78
+ is not there or cannot be read, a marketplace that is there or declared
79
+ with nothing cached, a `true` in a settings file of your own, an id that
80
+ names no marketplace. `init` writes no hook on that reading alone: it
81
+ says what `doctor` says (`advise`), and how to have the hooks in the file
82
+ instead. `--format json` has `claudeCode.presence`, `null` where no
83
+ plugin is on, with its `state` (`present`, `absent` or `unknown`) and the
84
+ reason as `detail`, and `claudeCode.notes`, what is said of the plugin
85
+ and of `disableAllHooks`
86
+ ([ADR-0012](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0012-one-way-into-claude-code.md)).
87
+ Upgrading: `doctor` exits 1 where the project's settings alone turn the
88
+ plugin on and the machine has neither a version of it nor its
89
+ marketplace: install the plugin there, or turn it off for the project and
90
+ run `init --write`. `claudeCode.state` is then `none` or `init`, where
91
+ it was `plugin` or `both`. `doctor --strict` exits 1 where it cannot
92
+ tell, as on a machine where Claude Code has recorded no marketplace.
93
+ - **`doctor` and `init` no longer name `MultiEdit` as a tool whose writes
94
+ go unchecked.** `doctor` says which of the tools that write a file a
95
+ hook's line is waited on before, and it counted four: `Edit`, `Write`,
96
+ `MultiEdit` and `NotebookEdit`. So a line under the matcher
97
+ `Edit|Write|NotebookEdit` was said to leave a write by `MultiEdit`
98
+ unchecked. No Claude Code the hooks run under has that tool: its tools
99
+ reference lists `Edit`, `Write` and `NotebookEdit`, and the tool inputs
100
+ its releases declare hold a `MultiEdit` up to 1.0.128 and none from 2.0.0
101
+ on, where the hooks need 2.1.139. A line is now read for those three, and
102
+ one waited on before all of them is said with nothing more, as a line
103
+ under the matcher `init` writes is. The hook reads the file a `MultiEdit`
104
+ names as it did, for whatever still sends one, and `init` writes the
105
+ matcher it wrote
106
+ ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
107
+ Upgrading: in `doctor --format json`, `before` and `after` of each of
108
+ `claudeCode.lines` hold `Edit`, `Write` and `NotebookEdit` at most, three
109
+ names for `init`'s own line where they held four, so a script that
110
+ compared them with the four names compares with three. A line whose
111
+ matcher lets `MultiEdit` through and none of the three is `unstarted`
112
+ where it was `guards`: it is no longer one of `init`'s entries, so beside
113
+ the plugin `doctor` exits 0 where it exited 1 for a double install, and
114
+ `init --write` writes its hooks beside such a line.
115
+
116
+ ### Fixed
117
+
118
+ - **`doctor`, `init` and the hooks ask git at once for what waits on nothing
119
+ else, and `doctor` asks Claude Code its release meanwhile.** Each started
120
+ its processes one after another, nine for a plain `doctor`, and on a
121
+ Windows workstation a process is what a command takes: 227 ms for one of
122
+ git's there, and 2.6 seconds for Claude Code's. Measured there with the
123
+ two builds taking turns, at the median of ten runs, `doctor` took 4.8
124
+ seconds where it took 5.3, and 4.3 where it took 5.4 on a brief's branch;
125
+ with other work holding every processor of the workstation, 20.0 where it
126
+ took 25.5. `init` and the hooks take a tenth of a second less than they
127
+ did, of one to two and a half. On Linux, where git answers in 2 ms, a
128
+ `doctor` takes 0.10 seconds either way. Nothing any of them prints changes
129
+ ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
130
+ - **A matcher that is a pattern is read once in a run.** `doctor` read it
131
+ again for every entry under it, and `init` once more for what to write.
132
+ Under a pattern that runs out its budget of 100 ms, fifty entries took a
133
+ `doctor` 5.2 seconds, and take 0.2. A file with a pattern of its own over
134
+ each entry is read as before, a budget each. In the programmatic API,
135
+ `mergeClaudeSettings` takes what reads a pattern as an optional third
136
+ argument; without it, it reads each pattern once for itself.
137
+
138
+ ## 0.16.0
139
+
140
+ `doctor` and `init` see the rest of Claude Code's hook. A line that names
141
+ a brief which is not one holds every write, and `doctor` says so, exit 1,
142
+ where it said the line guards. An entry is read with the event it stands
143
+ under and its matcher, so a line Claude Code never starts before a write
144
+ is no longer reported as the guard. And `init` writes no second line
145
+ beside a line of the hook a person changed, and says of each file what
146
+ `doctor` says of it.
147
+
148
+ ### Changed
149
+
150
+ - **`doctor` says a hook's line that names a brief which is not one, and
151
+ exits 1.** `hook claude --brief 404` on the line in Claude Code's settings
152
+ has the hook hold every write, exit 2, where spec-brief knows no brief 404
153
+ or has archived it, and `doctor` said of that line that it guards. It now
154
+ asks spec-brief about the name as the hook does, in the work tree the
155
+ line's root is in, and says the line in the words the hook holds the write
156
+ with: `"hook claude --brief 404" in .claude/settings.json: the flag names
157
+ brief 404, and spec-brief knows no such brief, so the hook holds every
158
+ write until the line is mended`. `--format json` has the line's `state` as
159
+ `held`. The hook answers as it did
160
+ ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
161
+ Upgrading: `doctor` exits 1 for such a line, where it exited 0 while every
162
+ write was held: name a brief spec-brief lists on the line, or take
163
+ `--brief` off it.
164
+ - **`doctor` reads the event and the matcher of the hook's entries.** It
165
+ read neither, so a line under `PostToolUse` alone, or under a matcher such
166
+ as `Bash`, which Claude Code never starts before a write, was reported as
167
+ the guard. An entry is now read as Claude Code's hooks reference has it:
168
+ waited on before a tool call under `PreToolUse` alone, for the tools its
169
+ matcher lets through, and for none where it runs in the background
170
+ (`async`, `asyncRewake`). A line waited on before no write is said as not
171
+ the guard, with what it stands under; one waited on before some of `Edit`,
172
+ `Write`, `MultiEdit` and `NotebookEdit` guards those, and the rest are
173
+ named as unchecked; an entry with an `if`, whose rule is not read, or with
174
+ a matcher that is not text, is no pattern, or is a pattern that takes more
175
+ than a tenth of a second to test against the four names, is *cannot
176
+ tell*. `--format
177
+ json` has `before`, `after` and `cover` on each of `claudeCode.lines`, and
178
+ the states `unstarted` and `held` beside `guards`, `off` and `unknown`.
179
+ Upgrading: a settings file whose only line of the hook is started for no
180
+ write, before one or after, is no longer one of `init`'s entries, so
181
+ beside the plugin it is no double install, exit 0 where it was 1, and
182
+ alone `doctor` reports no guard and does not ask Claude Code its release.
183
+ `doctor --strict` exits 1 for a line whose entry has an `if`, or a matcher
184
+ it cannot read. A script that reads `claudeCode.lines[].state` meets the
185
+ two new states.
186
+ - **`init` writes no hook beside a line of the hook that is there.** It knew
187
+ only the line it writes and the one 0.1 wrote, so beside a line a person
188
+ had changed, such as `hook claude --root ${CLAUDE_PROJECT_DIR}`, `init
189
+ --write` added its own, and both ran for every write; and with the Claude
190
+ Code plugin on beside such a line it said `skip` where `doctor` exits 1
191
+ for the double install. It now knows the hook as `doctor` knows it, by its
192
+ command and the words `hook claude`, with the event and the matcher its
193
+ entry stands under. Where the file holds such a line that Claude Code
194
+ starts for a write, `init` writes no hook into it, and says which line it
195
+ left in `doctor`'s words for it: `keep`, or `advise` where the line turns
196
+ the guard off, holds every write or is waited on before none. Beside the
197
+ plugin the file is `advise`, the double install, and so where the line
198
+ stands in a settings file of your own. It rewrites no line but the one 0.1
199
+ wrote
200
+ ([ADR-0012](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0012-one-way-into-claude-code.md)).
201
+ Upgrading: `init --write` leaves `.claude/settings.json` as it is where it
202
+ added a second line; a file that holds two since an earlier `init` keeps
203
+ both, and one of them is yours to take out. A line Claude Code starts for
204
+ no write, under a matcher that names no writing tool or in the background
205
+ before a write, no longer counts as installed: `init` writes its hooks
206
+ beside it. In the programmatic API, `mergeClaudeSettings` takes the
207
+ hook's reading as an optional second argument; without it, it knows
208
+ `init`'s own line alone, read with its event and matcher as above.
209
+
12
210
  ## 0.15.0
13
211
 
14
212
  `doctor` reads the line of Claude Code's hook as the hook itself reads it:
package/README.md CHANGED
@@ -342,7 +342,10 @@ Configures the family to agree. It prints the plan, merges into files that exist
342
342
  - **Claude Code**: the guard hooks in `.claude/settings.json` and the MCP server in `.mcp.json`, each run with `node` from the project's `node_modules`. The `npx` entries 0.1 wrote are replaced.
343
343
  - These are the ones the [Claude Code plugin](#as-a-claude-code-plugin) brings, so while that plugin is on - `"spec-harness@<marketplace>": true` under `enabledPlugins` in your user, project or local Claude Code settings - `init` writes neither and says why (`skip`).
344
344
  - 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 run `init` again.
345
- - 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.
345
+ - 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, which `init` does not write.
346
+ - Where the plugin that is turned on is not on this machine, as [`doctor`](#doctor) reads it, `init` still writes neither entry: a plugin that is there after all would bring them a second time. It says what `doctor` says (`advise`), and how to have the entries in the files instead. Where that cannot be told, it says so beside the `skip`.
347
+ - `"disableAllHooks": true` in a settings file is said of that file (`advise`): the hooks `init` writes do not run while it stands. `init` writes what it would have written.
348
+ - A line of the hook that is there already is known as [`doctor`](#doctor) knows it: by its command and the words `hook 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, `init` writes no hook into it, since a second line would run beside the first, and says which line it left, in `doctor`'s words for it (`keep`, or `advise` where 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, and `init` writes its own beside it.
346
349
  - **git's pre-commit hook**, where git runs it from: `core.hooksPath`, relative, absolute or under `~`, and the shared hooks of a linked worktree.
347
350
  - It refuses a commit that changes what the active brief protects, for any agent or none, a shell's writes included.
348
351
  - With `--git-hook` it is written; without, the plan advises it (`advise`).
@@ -374,13 +377,25 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
374
377
  - A `cert-authority` line is a certificate authority's key, not a person's, and is left out.
375
378
  - 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.
376
379
  - **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.
380
+ - `"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`.
377
381
  - 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.
382
+ - 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.
378
383
  - 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`.
379
384
  - 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.
385
+ - 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`.
386
+ - 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`.
387
+ - 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.
388
+ - 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.
389
+ - 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.
390
+ - Those three are the tools a Claude Code from 2.1.139 on writes a file with, by its [tools reference](https://code.claude.com/docs/en/tools-reference). The hook also reads the file a `MultiEdit` names, a tool no release from 2.0.0 on declares, and `init`'s matcher keeps the name; `doctor` says of no line that it leaves `MultiEdit` unchecked, and a line under `Edit|Write|NotebookEdit` is said with nothing more.
391
+ - An `if` on 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; `--strict` fails 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 three names: one can be written that never finishes, and `doctor` and `init` come back all the same.
380
392
  - 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.
381
393
  - `"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.
382
- - `--format json` has each line under `claudeCode.lines`: its `file`, `events`, the `line` as written, its `state` (`guards`, `off` or `unknown`), `onFailure`, and what is said of it as `detail` and `note`, `null` where there is nothing to say.
383
- - The plugin's own hooks are not read: they are a release's files, and no release wrote more than `hook claude` on the line.
394
+ - `--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.
395
+ - 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.
396
+ - *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.
397
+ - *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.
398
+ - *Cannot tell*, never fine, and a failure under `--strict` alone: a record that is not there or cannot be read; a marketplace that is on the machine, or declared, while nothing is cached, since a plugin its marketplace lists by a relative path loads from the marketplace, whose list is not read; marketplaces hosted on claude.ai; a `true` in a settings file of your own, for which Claude Code fetches; and an id that names no marketplace (`@inline`, `@skills-dir`, `@synced`).
384
399
  - **Which Claude Code**, when Claude Code runs the guard: `claude --version`, found on `PATH` and run without a shell.
385
400
  - Older than 2.1.139 exits 1. An older release ignores a hook's `args` and runs a bare `node`, 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.
386
401
  - On Windows, npm installs Claude Code as a `claude.cmd` shim, which cannot be run without a shell, so it is read instead: the `node_modules\@anthropic-ai\claude-code` it runs, as npm's shim or pnpm's names it, and that package.json's `version`, which doctor says it read. Nothing is run.
package/dist/branch.js CHANGED
@@ -25,6 +25,11 @@ function isIdChar(ch) {
25
25
  */
26
26
  function prefixEnds(prefix, name) {
27
27
  // reach[j]: the prefix read so far can end at name offset j.
28
+ // An offset nothing was written at reads as one that cannot be reached,
29
+ // and `forEach` passes over it. So a list made with no places, here and
30
+ // for `next`, is equivalent to its mutant, and so is one made two places
31
+ // short, for every name but the empty one, whose list would have fewer
32
+ // places than none: a test holds that name.
28
33
  let reach = new Array(name.length + 1).fill(false);
29
34
  reach[0] = true;
30
35
  for (const token of prefix) {
@@ -41,12 +46,17 @@ function prefixEnds(prefix, name) {
41
46
  }
42
47
  }
43
48
  else {
49
+ // One place past the end `charAt` gives the empty string, which is no
50
+ // token, so `<=` for `<` is equivalent.
44
51
  for (let j = 0; j < name.length; j += 1)
45
52
  if (reach[j] && name.charAt(j) === token)
46
53
  next[j + 1] = true;
47
54
  }
48
55
  reach = next;
49
56
  }
57
+ // `idFromBranch` reads an id from each offset and passes over one where
58
+ // none starts, which a text among them is: a list that starts with one is
59
+ // equivalent.
50
60
  const ends = [];
51
61
  reach.forEach((ok, j) => {
52
62
  if (ok)
@@ -70,6 +80,8 @@ export function idFromBranch(template, name) {
70
80
  const prefix = template.slice(0, -'{id}'.length);
71
81
  for (const start of prefixEnds(prefix, name)) {
72
82
  let end = start;
83
+ // Past the end `charAt` gives the empty string, which is no character of
84
+ // an id, so the bound is equivalent to its mutants.
73
85
  while (end < name.length && isIdChar(name.charAt(end)))
74
86
  end += 1;
75
87
  if (end === start)
@@ -1 +1 @@
1
- {"version":3,"file":"branch.js","sourceRoot":"","sources":["../src/branch.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Which brief a branch is working on, read from its name.\n *\n * A round runs on a branch, and naming the branch after the brief is the one\n * convention every repository that runs agents on briefs already has. Reading\n * it from there keeps the harness stateless: no file says which brief is\n * active, so no file can say it wrongly (ADR-0004).\n *\n * A template is a branch name with `{id}` where the id goes and `*` for any\n * run of characters other than `/`: `brief/{id}`, `*\\/brief-{id}`. The id is\n * the longest run of letters and digits at that point, and must be followed\n * by the end of the name or a separator - so `brief/012-rotate-tokens` names\n * 012, and `brief/012x` names 012x, never 012.\n */\n\nconst SEPARATORS = new Set(['-', '_', '/', '.']);\n\nfunction isIdChar(ch: string): boolean {\n // Asked of one character at a time, so the mutants that drop either anchor\n // are equivalent.\n return /^[A-Za-z0-9]$/.test(ch);\n}\n\n/**\n * The offsets in `name` at which a template prefix can end, by dynamic\n * programming over prefix and name, so a template of many stars costs their\n * product and never an exponent.\n */\nfunction prefixEnds(prefix: string, name: string): number[] {\n // reach[j]: the prefix read so far can end at name offset j.\n let reach: boolean[] = new Array<boolean>(name.length + 1).fill(false);\n reach[0] = true;\n for (const token of prefix) {\n const next = new Array<boolean>(name.length + 1).fill(false);\n if (token === '*') {\n let open = false;\n for (let j = 0; j <= name.length; j += 1) {\n if (reach[j]) open = true;\n if (open) next[j] = true;\n if (name.charAt(j) === '/') open = false;\n }\n } else {\n for (let j = 0; j < name.length; j += 1) if (reach[j] && name.charAt(j) === token) next[j + 1] = true;\n }\n reach = next;\n }\n const ends: number[] = [];\n reach.forEach((ok, j) => {\n if (ok) ends.push(j);\n });\n return ends;\n}\n\n/** Why a template cannot be used, or `null`. */\nexport function templateError(template: string): string | null {\n const count = template.split('{id}').length - 1;\n if (count !== 1) return `\"${template}\" must contain {id} exactly once`;\n if (!template.endsWith('{id}')) return `\"${template}\" must end with {id}; whatever follows the id is the branch's own`;\n return null;\n}\n\n/** The brief id a branch name carries under one template, or `null`. */\nexport function idFromBranch(template: string, name: string): string | null {\n if (templateError(template) !== null) return null;\n const prefix = template.slice(0, -'{id}'.length);\n for (const start of prefixEnds(prefix, name)) {\n let end = start;\n while (end < name.length && isIdChar(name.charAt(end))) end += 1;\n if (end === start) continue;\n if (end < name.length && !SEPARATORS.has(name.charAt(end))) continue;\n return name.slice(start, end);\n }\n return null;\n}\n\n/**\n * The branch a CI run is building, as the forge names it, for a checkout on\n * no branch: CI checks out a commit, and without the branch a round's own\n * run would name no brief. In order: a GitHub pull request's head branch; the\n * branch a GitHub push built; a GitLab merge request's source branch; the\n * branch a GitLab pipeline built. Each is set only where it names a branch -\n * GitHub's `GITHUB_REF_NAME` names a tag too, so only when `GITHUB_REF_TYPE`\n * says `branch` - and an empty one is not set. `null` outside CI.\n */\nexport function ciBranch(env: Readonly<Record<string, string | undefined>>): { readonly branch: string; readonly source: string } | null {\n const named = (name: string): { branch: string; source: string } | null => {\n const value = env[name]?.trim() ?? '';\n return value === '' ? null : { branch: value, source: name };\n };\n return (\n named('GITHUB_HEAD_REF') ??\n (env['GITHUB_REF_TYPE'] === 'branch' ? named('GITHUB_REF_NAME') : null) ??\n named('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') ??\n named('CI_COMMIT_BRANCH')\n );\n}\n\n/** The first template that yields an id, in order; `null` for a head on no branch, which names no brief. */\nexport function briefIdFromBranch(templates: readonly string[], name: string | null): string | null {\n if (name === null) return null;\n for (const template of templates) {\n const id = idFromBranch(template, name);\n if (id !== null) return id;\n }\n return null;\n}\n\n/**\n * Whether two ids name the same brief: equal as written, or equal as numbers\n * when both are all digits, since a branch says `brief/12` of a brief whose\n * file says `012`.\n */\nexport function sameId(a: string, b: string): boolean {\n if (a === b) return true;\n return /^\\d+$/.test(a) && /^\\d+$/.test(b) && BigInt(a) === BigInt(b);\n}\n"]}
1
+ {"version":3,"file":"branch.js","sourceRoot":"","sources":["../src/branch.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Which brief a branch is working on, read from its name.\n *\n * A round runs on a branch, and naming the branch after the brief is the one\n * convention every repository that runs agents on briefs already has. Reading\n * it from there keeps the harness stateless: no file says which brief is\n * active, so no file can say it wrongly (ADR-0004).\n *\n * A template is a branch name with `{id}` where the id goes and `*` for any\n * run of characters other than `/`: `brief/{id}`, `*\\/brief-{id}`. The id is\n * the longest run of letters and digits at that point, and must be followed\n * by the end of the name or a separator - so `brief/012-rotate-tokens` names\n * 012, and `brief/012x` names 012x, never 012.\n */\n\nconst SEPARATORS = new Set(['-', '_', '/', '.']);\n\nfunction isIdChar(ch: string): boolean {\n // Asked of one character at a time, so the mutants that drop either anchor\n // are equivalent.\n return /^[A-Za-z0-9]$/.test(ch);\n}\n\n/**\n * The offsets in `name` at which a template prefix can end, by dynamic\n * programming over prefix and name, so a template of many stars costs their\n * product and never an exponent.\n */\nfunction prefixEnds(prefix: string, name: string): number[] {\n // reach[j]: the prefix read so far can end at name offset j.\n // An offset nothing was written at reads as one that cannot be reached,\n // and `forEach` passes over it. So a list made with no places, here and\n // for `next`, is equivalent to its mutant, and so is one made two places\n // short, for every name but the empty one, whose list would have fewer\n // places than none: a test holds that name.\n let reach: boolean[] = new Array<boolean>(name.length + 1).fill(false);\n reach[0] = true;\n for (const token of prefix) {\n const next = new Array<boolean>(name.length + 1).fill(false);\n if (token === '*') {\n let open = false;\n for (let j = 0; j <= name.length; j += 1) {\n if (reach[j]) open = true;\n if (open) next[j] = true;\n if (name.charAt(j) === '/') open = false;\n }\n } else {\n // One place past the end `charAt` gives the empty string, which is no\n // token, so `<=` for `<` is equivalent.\n for (let j = 0; j < name.length; j += 1) if (reach[j] && name.charAt(j) === token) next[j + 1] = true;\n }\n reach = next;\n }\n // `idFromBranch` reads an id from each offset and passes over one where\n // none starts, which a text among them is: a list that starts with one is\n // equivalent.\n const ends: number[] = [];\n reach.forEach((ok, j) => {\n if (ok) ends.push(j);\n });\n return ends;\n}\n\n/** Why a template cannot be used, or `null`. */\nexport function templateError(template: string): string | null {\n const count = template.split('{id}').length - 1;\n if (count !== 1) return `\"${template}\" must contain {id} exactly once`;\n if (!template.endsWith('{id}')) return `\"${template}\" must end with {id}; whatever follows the id is the branch's own`;\n return null;\n}\n\n/** The brief id a branch name carries under one template, or `null`. */\nexport function idFromBranch(template: string, name: string): string | null {\n if (templateError(template) !== null) return null;\n const prefix = template.slice(0, -'{id}'.length);\n for (const start of prefixEnds(prefix, name)) {\n let end = start;\n // Past the end `charAt` gives the empty string, which is no character of\n // an id, so the bound is equivalent to its mutants.\n while (end < name.length && isIdChar(name.charAt(end))) end += 1;\n if (end === start) continue;\n if (end < name.length && !SEPARATORS.has(name.charAt(end))) continue;\n return name.slice(start, end);\n }\n return null;\n}\n\n/**\n * The branch a CI run is building, as the forge names it, for a checkout on\n * no branch: CI checks out a commit, and without the branch a round's own\n * run would name no brief. In order: a GitHub pull request's head branch; the\n * branch a GitHub push built; a GitLab merge request's source branch; the\n * branch a GitLab pipeline built. Each is set only where it names a branch -\n * GitHub's `GITHUB_REF_NAME` names a tag too, so only when `GITHUB_REF_TYPE`\n * says `branch` - and an empty one is not set. `null` outside CI.\n */\nexport function ciBranch(env: Readonly<Record<string, string | undefined>>): { readonly branch: string; readonly source: string } | null {\n const named = (name: string): { branch: string; source: string } | null => {\n const value = env[name]?.trim() ?? '';\n return value === '' ? null : { branch: value, source: name };\n };\n return (\n named('GITHUB_HEAD_REF') ??\n (env['GITHUB_REF_TYPE'] === 'branch' ? named('GITHUB_REF_NAME') : null) ??\n named('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') ??\n named('CI_COMMIT_BRANCH')\n );\n}\n\n/** The first template that yields an id, in order; `null` for a head on no branch, which names no brief. */\nexport function briefIdFromBranch(templates: readonly string[], name: string | null): string | null {\n if (name === null) return null;\n for (const template of templates) {\n const id = idFromBranch(template, name);\n if (id !== null) return id;\n }\n return null;\n}\n\n/**\n * Whether two ids name the same brief: equal as written, or equal as numbers\n * when both are all digits, since a branch says `brief/12` of a brief whose\n * file says `012`.\n */\nexport function sameId(a: string, b: string): boolean {\n if (a === b) return true;\n return /^\\d+$/.test(a) && /^\\d+$/.test(b) && BigInt(a) === BigInt(b);\n}\n"]}
package/dist/cli.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { type CliIO } from './workspace.js';
13
13
  export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version, type CliIO } from './workspace.js';
14
- 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 or PostToolUse hook (stdin).\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.\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 or its hook's line turns it off, or Claude Code\n is too old to run its hooks.\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 --help, --version\n\nAn option a command does not read, an argument it does not take, an option or an\nargument given an empty value, and a brief given both as the argument and as --brief\nare refused, exit 2, not read as if they were not there. doctor says an empty --brief\nor --base in its report. hook claude reads its line as every release has, and says a\nline it cannot read, and a --root that is not a directory, with exit 1: it refuses\nnothing for either.\n\nExit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.\n";
14
+ 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 or PostToolUse hook (stdin).\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.\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, disableAllHooks switches it off, the plugin that is\n turned on is not on this machine and nothing else guards, or\n Claude Code is too old to run its hooks.\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 --help, --version\n\nAn option a command does not read, an argument it does not take, an option or an\nargument given an empty value, and a brief given both as the argument and as --brief\nare refused, exit 2, not read as if they were not there. doctor says an empty --brief\nor --base in its report. hook claude reads its line as every release has, and says a\nline it cannot read, and a --root that is not a directory, with exit 1: it refuses\nnothing for either.\n\nExit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.\n";
15
15
  /**
16
16
  * Runs the command line and resolves to the exit code, for an error nothing
17
17
  * below expected too.
package/dist/cli.js CHANGED
@@ -12,21 +12,21 @@
12
12
  import { SiblingOutputError } from './briefs.js';
13
13
  import { auditCommand, contextCommand, escalateCommand, probeCommand, ruleCommand, rulingsCommand } from './commands.js';
14
14
  import { ConfigError, SIBLINGS } from './config.js';
15
- import { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describePlugin, describeSignerKeys, describeSignerProblem, describeSigners, wiringState, } from './configure.js';
15
+ import { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, wiringState, } from './configure.js';
16
16
  import { isDirectory } from './fs.js';
17
17
  import { GitError, show, stagedChanges } from './git.js';
18
18
  import { claudeResponse, gitResponse, parseClaudeHook } from './hooks.js';
19
19
  import { claudeVersion } from './host.js';
20
20
  import { premisesCommand } from './premises.js';
21
21
  import { createReader } from './reader.js';
22
- import { checkPaths, claudeCodeWiring, resolveBase, specBriefPlugin } from './round.js';
22
+ import { checkPaths, claudeCodeWiring, diagnoseBrief, resolveBase, specBriefPlugin } from './round.js';
23
23
  import { mcpCommand } from './server.js';
24
24
  import { gitHook, initCommand } from './setup.js';
25
25
  import { notFido2, readAllowedSigners } from './signers.js';
26
26
  import { SiblingError } from './siblings.js';
27
- import { emptyValue, isBlank, isClaudeHook, readHookLine, refusal, unreadHookLine } from './usage.js';
27
+ import { emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, unreadHookLine } from './usage.js';
28
28
  import { checkClaudeCode, CLAUDE_CODE_MINIMUM, MINIMUM_VERSIONS } from './versions.js';
29
- import { activeBrief, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, namedId, openWorkspace, parseOptions, UsageError, version, } from './workspace.js';
29
+ import { activeBrief, answerOf, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, openWorkspace, parseOptions, UsageError, version, } from './workspace.js';
30
30
  export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version } from './workspace.js';
31
31
  export const HELP = `spec-harness - context, guards, audits and rulings for rounds of agent work
32
32
 
@@ -60,8 +60,10 @@ Commands:
60
60
  doctor Which siblings are installed, at which versions, which brief is
61
61
  named, how Claude Code runs the guard, and git's hook. Exit 1
62
62
  when a sibling is older than this release needs, the guard is
63
- installed twice or its hook's line turns it off, or Claude Code
64
- is too old to run its hooks.
63
+ installed twice, its hook's line turns it off or holds every
64
+ write, disableAllHooks switches it off, the plugin that is
65
+ turned on is not on this machine and nothing else guards, or
66
+ Claude Code is too old to run its hooks.
65
67
 
66
68
  Options:
67
69
  --brief <id> The brief the round works on. Otherwise SPEC_BRIEF, then the branch.
@@ -204,8 +206,10 @@ async function hookCommand(options, io) {
204
206
  try {
205
207
  const decided = await decisionsFor(workspace, options, io, request.paths, cwd);
206
208
  if ('problem' in decided) {
207
- // Exit 2 blocks the tool call, and its stderr reaches the model.
208
- io.stderr.write(`spec-harness: ${decided.problem}. Fix the brief or the branch before writing.\n`);
209
+ // Exit 2 blocks the tool call, and its stderr reaches the model. Where
210
+ // the name stands on the hook's own line, what is wrong with it is
211
+ // `doctor`'s to say as well, in the same words (usage.ts).
212
+ io.stderr.write(`spec-harness: ${heldHookLine(decided.problem).wrong}. Fix the brief or the branch before writing.\n`);
209
213
  return EXIT_ERROR;
210
214
  }
211
215
  const response = claudeResponse(request.event, decided.decisions);
@@ -240,30 +244,29 @@ async function hookCommand(options, io) {
240
244
  }
241
245
  /* ------------------------------------------------------------------ doctor */
242
246
  /**
243
- * The brief a round here works on, as doctor reports it: its id, and what is
244
- * wrong with the name when no round can work under it. Every other command
245
- * stops there. A diagnosis says it, being what a person runs when the guard
246
- * holds a write they expected it to pass.
247
+ * The base, and the allowed-signers file as it is on it: the file waits on
248
+ * the base, and the two on nothing else doctor asks. A --base given nothing
249
+ * is no base, said as the brief's is, in the report.
247
250
  */
248
- async function diagnoseBrief(workspace, options, env) {
249
- if (options.brief !== undefined && isBlank(options.brief))
250
- return { id: null, problem: emptyValue('--brief', 'brief', options.brief) };
251
- const id = namedId(workspace, options, env);
252
- // A spec-brief that cannot be run is a row of the report, with the reason.
253
- if (workspace.siblings.locate('spec-brief').kind !== 'found')
254
- return { id, problem: null };
255
- try {
256
- // Where nothing names a brief, spec-brief is not asked (ADR-0004), and
257
- // there is no problem to state.
258
- return { id, problem: describeActive(await activeBrief(workspace, options, env)).problem };
259
- }
260
- catch (error) {
261
- // Asking spec-brief throws nothing but this, so the check is equivalent
262
- // to its mutant; it keeps any other error the stack it needs.
263
- if (!(error instanceof SiblingError))
264
- throw error;
265
- return { id, problem: `spec-brief could not say whether it knows brief ${id}: ${error.message}` };
266
- }
251
+ async function baseAndSigners(workspace, flag, signersFile) {
252
+ // Nothing in doctor asks what kind a base is but whether it is resolved, so
253
+ // the mutant that names this one otherwise is equivalent.
254
+ const base = flag !== undefined && isBlank(flag) ? { kind: 'unresolved', reason: emptyValue('--base', 'base', flag) } : await resolveBase(workspace, flag);
255
+ // An unresolved base has no commit: git shows nothing for one, so asking
256
+ // anyway reads as no file, and that mutant is equivalent.
257
+ const signersText = base.kind === 'resolved' ? await show(base.sha, signersFile, workspace.root) : null;
258
+ return { base, signersText };
259
+ }
260
+ /**
261
+ * How Claude Code is wired to the guard, and the release of the one on
262
+ * `PATH`, which waits on the wiring: only a Claude Code that runs the guard
263
+ * is asked its release, since one older than the hooks need lets every
264
+ * write through unguarded. `null` where it was not asked.
265
+ */
266
+ async function wiringAndRelease(root, io) {
267
+ const wiring = await claudeCodeWiring(root, io);
268
+ const release = wiringState(wiring) === 'none' ? null : checkClaudeCode(await claudeVersion(io.env));
269
+ return { wiring, release };
267
270
  }
268
271
  async function doctorCommand(options, io) {
269
272
  const workspace = await openWorkspace(options, io);
@@ -278,19 +281,25 @@ async function doctorCommand(options, io) {
278
281
  const detail = sibling.version === null ? `${command} (named in .spec-harness.json; its version is not checked)` : `${command} (${sibling.version})`;
279
282
  return { ...row, version: sibling.version, detail };
280
283
  });
281
- const named = await diagnoseBrief(workspace, options, io.env);
284
+ const signersFile = workspace.config.rulings.allowedSigners;
285
+ // Five things that wait on the work tree and on nothing of one another,
286
+ // each a process or a chain of them. Asked one after another, Claude
287
+ // Code's release, the longest wait of a doctor on Windows, was asked for
288
+ // only when every answer of git's before it was in. So they are asked at
289
+ // once, and read below in the order they were asked in before: where more
290
+ // than one fails, the first of that order is the one thrown, whichever
291
+ // failed first by the clock (ADR-0002).
292
+ const [briefAsked, baseAsked, pluginAsked, claudeAsked, hookAsked] = await Promise.allSettled([
293
+ diagnoseBrief(workspace, options, io.env),
294
+ baseAndSigners(workspace, options.base, signersFile),
295
+ specBriefPlugin(workspace.root),
296
+ wiringAndRelease(workspace.root, io),
297
+ gitHook(workspace.root),
298
+ ]);
299
+ const named = answerOf(briefAsked);
282
300
  // A ruling counts only by a signature checked against the allowed signers
283
301
  // on the base, and only once spec-brief's archive asks the plugin about it.
284
- // A --base given nothing is no base, said as the brief's is, in the report.
285
- // Nothing below asks what kind a base is but whether it is resolved, so the
286
- // mutant that names this one otherwise is equivalent.
287
- const base = options.base !== undefined && isBlank(options.base)
288
- ? { kind: 'unresolved', reason: emptyValue('--base', 'base', options.base) }
289
- : await resolveBase(workspace, options.base);
290
- const signersFile = workspace.config.rulings.allowedSigners;
291
- // An unresolved base has no commit: git shows nothing for one, so asking
292
- // anyway reads as no file, and that mutant is equivalent.
293
- const signersText = base.kind === 'resolved' ? await show(base.sha, signersFile, workspace.root) : null;
302
+ const { base, signersText } = answerOf(baseAsked);
294
303
  const onBase = base.kind === 'resolved' ? signersText !== null : null;
295
304
  const signers = describeSigners(signersFile, base.kind === 'resolved' ? base.ref : null, onBase === true);
296
305
  // Which keys sign, as notes: a key that is not FIDO2 can still be one the
@@ -298,21 +307,25 @@ async function doctorCommand(options, io) {
298
307
  const read = readAllowedSigners(signersText ?? '');
299
308
  const plain = notFido2(read.signers);
300
309
  const signerNotes = [describeSignerKeys(plain), ...read.problems.map(describeSignerProblem)].filter((note) => note !== null);
301
- const plugin = await specBriefPlugin(workspace.root);
310
+ const plugin = answerOf(pluginAsked);
302
311
  // The plugin and init's entries both on: Claude Code runs every guard twice
303
312
  // and registers the server twice (ADR-0012).
304
- const wiring = await claudeCodeWiring(workspace.root, io.env);
313
+ const { wiring, release } = answerOf(claudeAsked);
305
314
  const claude = wiringState(wiring);
306
315
  // What the hook does with each line the settings hold for it, where there
307
316
  // is something to say: more on it than init writes, a line that turns the
308
- // guard off, one only a shell can read, and what onFailure changes.
309
- const lineNotes = wiring.lines.flatMap((line) => [line.detail, line.note]).filter((note) => note !== null);
310
- // Only a Claude Code that runs the guard is asked its release: one older
311
- // than the hooks need lets every write through unguarded.
312
- const release = claude === 'none' ? null : checkClaudeCode(await claudeVersion(io.env));
317
+ // guard off or holds every write, one only a shell can read, the writes
318
+ // Claude Code starts it for where they are not all of them, and what
319
+ // onFailure changes.
320
+ const lineNotes = wiring.lines.flatMap((line) => [line.detail, line.cover, line.note]).filter((note) => note !== null);
321
+ // What stands over every line: a plugin that is turned on and is not on
322
+ // this machine, or cannot be told to be, and disableAllHooks, which
323
+ // switches off whatever hooks there are.
324
+ const switchedOff = guardSwitchedOff(wiring);
325
+ const wiringNotes = [describePresence(wiring), wiring.hooksOff === null ? null : describeHooksOff(wiring.hooksOff, switchedOff)].filter((note) => note !== null);
313
326
  // How the hook runs the harness is a note: one that goes through npx guards
314
327
  // as well as one that does not, and fails nothing, --strict or not.
315
- const { hook, note: hookNote } = await gitHook(workspace.root);
328
+ const { hook, note: hookNote } = answerOf(hookAsked);
316
329
  const branch = workspace.branch === null ? '(detached)' : `${workspace.branch}${workspace.branchSource === undefined ? '' : ` (detached; ${workspace.branchSource} names it)`}`;
317
330
  if (options.format === 'json') {
318
331
  io.stdout.write(json('doctor', {
@@ -334,6 +347,7 @@ async function doctorCommand(options, io) {
334
347
  state: claude,
335
348
  ...wiring,
336
349
  detail: describeClaudeCode(wiring),
350
+ notes: wiringNotes,
337
351
  release: release === null
338
352
  ? { state: 'unchecked', version: null, minimum: CLAUDE_CODE_MINIMUM, detail: 'not asked: nothing wires Claude Code to the guard here' }
339
353
  : { state: release.state, version: 'version' in release ? release.version : null, minimum: CLAUDE_CODE_MINIMUM, detail: describeClaudeRelease(release) },
@@ -353,6 +367,7 @@ async function doctorCommand(options, io) {
353
367
  ...more(signerNotes),
354
368
  `plugin ${describePlugin(plugin)}`,
355
369
  `claude ${describeClaudeCode(wiring)}`,
370
+ ...more(wiringNotes),
356
371
  ...more(lineNotes),
357
372
  ...more(release === null ? [] : [describeClaudeRelease(release)]),
358
373
  `git ${describeGitHook(hook)}`,
@@ -366,15 +381,20 @@ async function doctorCommand(options, io) {
366
381
  // An outdated sibling is a problem to fix, where a missing optional one is
367
382
  // a choice; with none installed, the harness can check nothing. A double
368
383
  // install is a problem too, where no Claude Code wiring at all is a choice,
369
- // and so are a hook whose own line turns the guard off and a Claude Code
370
- // too old to run the hooks. A release that cannot be told is not fine
371
- // either, nor a line only a shell can read: --strict fails both.
384
+ // and so are a hook whose own line turns the guard off or holds every
385
+ // write, guard hooks that disableAllHooks switches off, a plugin that is
386
+ // turned on and is not on this machine where no hook of init's stands in
387
+ // for it, and a Claude Code too old to run the hooks. A release that
388
+ // cannot be told is not fine either, nor a line whose reading cannot be,
389
+ // nor a plugin that cannot be told to be on this machine: --strict fails
390
+ // the three.
372
391
  const outdated = rows.some((row) => row.state === 'outdated');
373
392
  const usable = rows.some((row) => row.state === 'found');
374
- const off = wiring.lines.some((line) => line.state === 'off');
393
+ const off = wiring.lines.some((line) => line.state === 'off' || line.state === 'held');
394
+ const missing = wiring.presence?.state === 'absent' && wiring.hooks.length === 0;
375
395
  const tooOld = release?.state === 'outdated';
376
- const untold = options.strict && (release?.state === 'unknown' || wiring.lines.some((line) => line.state === 'unknown'));
377
- return outdated || !usable || claude === 'both' || off || tooOld || untold ? EXIT_FAILED : EXIT_OK;
396
+ const untold = options.strict && (release?.state === 'unknown' || wiring.lines.some((line) => line.state === 'unknown') || wiring.presence?.state === 'unknown');
397
+ return outdated || !usable || claude === 'both' || off || switchedOff || missing || tooOld || untold ? EXIT_FAILED : EXIT_OK;
378
398
  }
379
399
  /* ---------------------------------------------------------------- dispatch */
380
400
  /**