@descent-vtt/spec-harness 0.11.0 → 0.12.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.11.0"
13
+ "version": "0.12.0"
14
14
  }
15
15
  ]
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-harness",
3
- "version": "0.11.0",
3
+ "version": "0.12.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,149 @@ 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.12.0
13
+
14
+ A probe's command is answered when its shell ends, by the shell's exit code,
15
+ where one that left something holding its output was waited for until its
16
+ timeout and then called stopped. And an input that is set and names nothing
17
+ is refused by a line that names it, where it was read as if it had not been
18
+ given: an option another command reads, an argument a command does not
19
+ take, an empty `--root`, `--brief` or `--base`, an escalation with an empty
20
+ path or reason, from the command line or through the server. Every command
21
+ line a release's `init` wrote is read as it was.
22
+
23
+ ### Changed
24
+
25
+ - **A probe's command is answered when its shell ends, by the shell's exit
26
+ code.** A command that ended and left something holding its output, as a
27
+ server started in the background does, was waited for until its timeout,
28
+ ten minutes by default, and then counted as stopped: its `run` was a
29
+ `timeout` and the probe `invalid`, and its `setup` stopped the probe.
30
+ `probe` now waits three seconds for that output, as GitHub's runner waits
31
+ five for a step's, judges the command by its exit code and what it had
32
+ printed, and says that something it started was left running
33
+ ([ADR-0003](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0003-state-outside-the-work-tree.md)).
34
+ Upgrading: a command's work must be done when its shell ends, so have it
35
+ wait for what it starts. A probe that was `invalid` at its timeout may now
36
+ be `measured`, `vacuous`, `fixed` or `still-failing`, and a `setup` that
37
+ stopped the probe may pass.
38
+ - On Linux and macOS, what a probe's command left running in its process
39
+ group is stopped when `probe` is done at that commit, before the worktree
40
+ is removed. One that held the command's output was stopped at the
41
+ command's timeout, and one that held nothing ran on after `probe` had
42
+ ended. Upgrading: a process a `setup` starts in the background serves the
43
+ runs at that commit and does not outlive them. On Windows it still runs
44
+ on, since nothing a command left can be reached there.
45
+ - **An option a command does not read is refused by that command.** Every
46
+ command took every option, so `doctor --note x`, `init --base main`,
47
+ `mcp --brief 012` and `hook git --strict` ran as if the option were not
48
+ there. Each is now exit 2 and one line that names it, `doctor does not
49
+ take --note; its options are --brief, --base, --root, --format and
50
+ --strict`
51
+ ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md),
52
+ [An input that names nothing](README.md#an-input-that-names-nothing)).
53
+ Upgrading: drop the option. The README's table has what each command
54
+ reads; `--strict` is for `guard`, `audit`, `premises` and `doctor`, and
55
+ `init` and `mcp` take neither `--brief` nor `--base`.
56
+ - **An argument a command does not take is refused.** `doctor extra`,
57
+ `premises extra` and `context 012 extra` ran as if it were not there, and
58
+ `init mydir` set up the directory it stood in; each is exit 2 with the
59
+ argument named. Upgrading: drop the argument; `init --root mydir` sets up
60
+ another directory.
61
+ - **An option or an argument given an empty value is refused by name, not
62
+ read as left out.** `--root ""` ran in the current directory, `--brief ""`
63
+ was answered for the branch's brief, `audit --base ""` measured no change
64
+ and warned, and `guard src/a.ts ""` judged the repository itself. Each is
65
+ exit 2 and a line such as `--root is "", which names no directory`, for a
66
+ value of only space too; `probe --id ""` and `escalate --show ""`, exit 2
67
+ before, say the same line
68
+ ([ADR-0004](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0004-the-active-brief-is-named-not-guessed.md)).
69
+ Upgrading: where a script may hand an option nothing, leave the option
70
+ out, as `${ID:+--brief "$ID"}` does in a shell. `SPEC_BRIEF` set and
71
+ empty is still as unset.
72
+ - **A memo is not written with a part that says nothing.** `escalate` with
73
+ an empty `--path`, `--reason`, `--option` or `--recommend`, and
74
+ `request_escalation` with an empty path, recommendation or option label,
75
+ recorded a request a person could not rule on: a file with no name, an
76
+ empty *Why*, `1. **** - `. Each is refused, exit 2 or a tool error that
77
+ names the argument, and nothing is recorded
78
+ ([ADR-0006](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0006-a-ruling-is-a-signed-commit.md)).
79
+ Upgrading: give each of them something, or leave `--option` and
80
+ `--recommend` out; an option needs a label, `"<label>: <cost>"`, and its
81
+ cost may be left out.
82
+ - **The server's tools refuse an empty `brief`, `base` or path.**
83
+ `start_round`, `check_path`, `request_escalation` and `audit_round`
84
+ answered an empty `brief` for the branch's brief and an empty `base` with
85
+ no ruling verified, and `check_path` judged an empty path as the project
86
+ itself. Each is a tool error that names the argument, `"brief" is "",
87
+ which names no brief`. Upgrading: omit an argument there is nothing to
88
+ give.
89
+ - `hook claude` refuses none of the above: whatever else is on its line is
90
+ passed over as before, since exit 2 from it holds every write of a
91
+ session. Every command line a release's `init` wrote, or its plugin
92
+ ships, is read as it was, and a test holds each
93
+ ([ADR-0012](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0012-one-way-into-claude-code.md)).
94
+
95
+ ### Added
96
+
97
+ - `doctor` says when the brief a round would work under is no brief: one
98
+ spec-brief does not know or has archived, whichever of `--brief`,
99
+ `SPEC_BRIEF` or the branch names it, and an empty `--brief` or `--base`.
100
+ It printed the id and no more, `brief 999`, for a brief under which the
101
+ guard held every write. Its exit code is as it was, and `--format json`
102
+ has the reason as `briefProblem`.
103
+
104
+ ### Fixed
105
+
106
+ - A `--root` that is a file, or is not there, is refused by name, `--root
107
+ is "nowhere", which is not a directory`, where the line said the path was
108
+ outside a git work tree. Exit 2, as before.
109
+ - A `.spec-harness.json` that is a directory is said in the harness's
110
+ words, `.spec-harness.json is a directory, not a configuration file`,
111
+ where it was `is not valid JSON: EISDIR: illegal operation on a
112
+ directory, read`. Exit 2, as before.
113
+ - A memo gives an option that states no cost as the choice alone,
114
+ `2. **Refuse**`, where it ended in a dash that led nowhere.
115
+
116
+ ## 0.11.1
117
+
118
+ A probe's timeout ends the wait as well as the command, where a process out
119
+ of reach that held the command's output kept the probe waiting without
120
+ limit; and an exit 2 the harness could foresee says so in a line.
121
+
122
+ ### Fixed
123
+
124
+ - **A reader that closes the output is answered in one line, not a stack.**
125
+ `spec-harness context | head` could end with `spec-harness: unexpected
126
+ error: Error: EPIPE: broken pipe, write` and a stack once `head` had left;
127
+ it now prints `spec-harness: stdout was closed before all of the output
128
+ was written` on stderr, from every command and the server. The exit code
129
+ is 2, as it was
130
+ ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
131
+ - A git command that failed - the diff `audit` reads, the staged files
132
+ `hook git` reads, the worktree `probe` adds - is reported as what git
133
+ said, on one line: `spec-harness: git diff --cached failed: fatal: ...`.
134
+ It was `unexpected error: GitError:` and a stack. Exit 2, as before.
135
+ - A probe's timeout ends the wait for its command. Where a process the
136
+ command started still held its output and was out of the timeout's reach -
137
+ one in a session of its own, or on Windows anything left by a command whose
138
+ shell had ended - `probe` waited for that process to end, and never ended
139
+ while it ran. The command is now answered three seconds after its timeout,
140
+ as stopped at it, with what it had printed, and the run's evidence says
141
+ that something it started was left running
142
+ ([ADR-0003](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0003-state-outside-the-work-tree.md)).
143
+ - A probe's `setup` that was stopped at its timeout says so, and after how
144
+ many seconds. It said only that the setup failed, with whatever the setup
145
+ had printed.
146
+
147
+ ### Documentation
148
+
149
+ - The README said an interrupted `probe` stops every command "with
150
+ everything those started". It and ADR-0003 now say what a timeout and an
151
+ interrupt reach - the command's process group on Linux and macOS, the
152
+ processes under the command's shell on Windows while that shell runs - and
153
+ what they leave running.
154
+
12
155
  ## 0.11.0
13
156
 
14
157
  An error the harness did not expect is exit 2, where it was Node's exit 1; a
package/README.md CHANGED
@@ -70,6 +70,12 @@ CI checks out a commit on no branch, so on a detached HEAD the branch is the one
70
70
 
71
71
  A branch checked out always wins, and `doctor` says which variable named the branch. Without them, a round's own CI run would name no brief, and `premises` would report the premise the round retires as stale.
72
72
 
73
+ A name that names no brief is not passed over for the next ([ADR-0004](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0004-the-active-brief-is-named-not-guessed.md)):
74
+
75
+ - `--brief` given nothing, `--brief ""`, is refused, as `--brief "$ID"` is where the variable is not set: it was read as the option left out, and the branch's brief answered.
76
+ - `SPEC_BRIEF` set and empty, or to space alone, is as unset. A hook's settings and a pipeline's `env` block set it whether or not a round is under way, and the branch then names the brief, which `doctor` shows.
77
+ - An id spec-brief does not know, or one it has archived, is not passed over either, whichever of the three names it: a command that acts on the brief stops with exit 2, and `doctor` says so in its report.
78
+
73
79
  ## Commands
74
80
 
75
81
  In the order a round meets them; `init` and `doctor` set a repository up and check it.
@@ -124,6 +130,7 @@ Paths are compared as the filesystem spells them, links resolved and case correc
124
130
 
125
131
  - `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.
126
132
  - `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`.
133
+ - `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.
127
134
 
128
135
  A guard is a guardrail - an agent that writes through a shell passes it - so the audit and spec-brief's archive are the gates ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
129
136
 
@@ -142,6 +149,8 @@ npx --no-install @descent-vtt/spec-harness rule E-012-1 --allow --note "Add rota
142
149
  git commit -S -m "ruling R-012-1: allow" -- briefs/012_rotate-tokens.md
143
150
  ```
144
151
 
152
+ A memo is what a person rules on, so none is written with a part that says nothing: a path, the reason, a recommendation or an option that is empty is [refused](#an-input-that-names-nothing), by `escalate` and by the server's `request_escalation` alike. An option is `"<label>: <cost>"`: the label names the choice and may not be empty, and the cost may be left out, the memo then giving the choice alone.
153
+
145
154
  A ruling is a row in the brief's `## Rulings` table. It **counts** when the commit that last changed the row is signed by a key the **base branch's** `.github/allowed_signers` lists. The file is `rulings.allowedSigners` in `.spec-harness.json`, `.github/allowed_signers` by default.
146
155
 
147
156
  - An agent can write a row and compute any hash; it cannot produce the person's signature, and an edit to the row moves it to a commit that must be signed again ([ADR-0006](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0006-a-ruling-is-a-signed-commit.md)).
@@ -278,7 +287,17 @@ signature: expected 401, got 200
278
287
  - A probe's commands fetch nothing through `npx` unless you say so. Given a tool's name alone where `setup` did not install the tool, `npx` would fetch the registry's package of that name and run it, unasked, since the command has no terminal to be asked on ([Names](#names)). `probe` runs a probe's commands with `npm_config_yes=false`, so npm stops there and names the package, `npx canceled due to missing packages`, and the probe is `invalid`. To have npm fetch, say `--yes` on the probe's line, or set `npm_config_yes` yourself, in lower case or upper: `probe` leaves the variable as it finds it. The variable outranks a `yes=true` in an `.npmrc`. It is npm's alone, so `pnpm dlx` fetches whatever it says, and it stops a download only: a copy an earlier fetch left in npm's cache still runs.
279
288
  - A run that proves nothing says what its command printed. Where a run failed for another reason than the probe declares, was stopped at its timeout or left no report - every run of an `invalid` probe, and any such run of a `flaky` one - `probe` shows the last 2,000 characters of its command's output, where the reason is. With the table that is on the standard error, for the first such run of each probe, under a line that names the probe and the run; with `--format json` it is `output` on each such run, and nothing is written beside the document. Colours are dropped and any other control character but the line feed and the tab is written as its escape, `\u001b`, so nothing in it reaches a terminal as a command. A `setup` that fails shows its output the same way.
280
289
  - The evidence table it prints names the hash of the probe it measured with ([ADR-0007](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0007-probes-declare-their-failure.md)).
281
- - Interrupted, it stops every command it started, with everything those started, before it removes the worktree ([ADR-0003](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0003-state-outside-the-work-tree.md)).
290
+ - A command, a `setup` as well as a `run`, is its shell, as a step is in CI: it is answered when its shell ends, by the shell's exit code and what was printed until then. A `run` is red, green or neither by those, and a `setup` has passed when the code is 0. What the command started and did not wait for is no part of the answer, so a command's work must be done when its shell ends: have it wait for what it starts.
291
+ - Where something the command started still holds its output once the shell has ended, as a server left in the background does, `probe` waits three seconds for that output, then reads no more of it and goes on; GitHub's runner does the same with a step, after five. The run's evidence then ends `, and something it started was left running, holding its output`. A `setup` that passed says so in a line on the standard error, `spec-harness: the probe setup "<line>" passed at base, and something it started was left running, holding its output`, and one that failed says it in the error that stops the probe. A process that writes to that output afterwards has the write fail, as it would once `probe` had ended. Send a process's output to a file and nothing is held, nothing waited for and nothing said; on Windows a process the shell started with `start /b` holds the command's output even so.
292
+ - What a command left running runs on beside the commands after it, as what a step left does in CI, and is stopped when `probe` is done at that commit, before the worktree is removed, as far as `probe` can still reach it ([ADR-0003](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0003-state-outside-the-work-tree.md)):
293
+ - On Linux and macOS that is every process still in the command's process group, forced: what the shell put in the background with `&`, whether or not it holds the command's output. Not what moved to a session or a group of its own, as a daemon does, `setsid`, and a process Node starts `detached`.
294
+ - On Windows it is nothing: once a command's shell has ended, nothing the command left can be found again.
295
+ - What is out of reach runs on until it ends or you end it: through the runs after it, and through the head's when it was left at the base.
296
+ - A command whose shell still runs at its timeout - `probes.timeout` seconds, or for a `run` the probe's own `timeout` - is stopped, forced, with what `probe` can reach of what it started, and no more:
297
+ - On Linux and macOS that is every process still in the command's process group: all the command started, except what moved to a session or a group of its own.
298
+ - On Windows it is the command's shell and the processes under it, each found through a parent that still runs. A process whose parent has ended is not found.
299
+ - The timeout ends the wait as well. Three seconds after it has stopped what it can, the run is answered whether or not something still holds the command's output: it is a run stopped at its timeout, with what was printed until then, and its evidence says the same of what was left running. A `setup` answered so says it in the error that stops the probe.
300
+ - Interrupted - by Ctrl+C, SIGTERM or SIGHUP, or by Ctrl+Break on Windows - `probe` stops the commands still running, with the reach a timeout has, waits up to three seconds for their output, stops what earlier commands left, with the reach the end of a commit has, and removes the worktree before it exits.
282
301
  - A worktree's directory that cannot be deleted - on Windows, one that something a command started is still running in - costs the verdict nothing: git forgets the worktree, `probe` prints and exits as it would have, and it names the directory it left on the standard error as it exits.
283
302
 
284
303
  ### `premises`
@@ -295,6 +314,8 @@ Run it in CI: exit 1 when a premise is stale, exit 2 when spec-guard is not ther
295
314
 
296
315
  `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.
297
316
 
317
+ 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`.
318
+
298
319
  ### `init`
299
320
 
300
321
  Configures the family to agree. It prints the plan, merges into files that exist, keeping what is there, and changes nothing without `--write`. Each step names its file and what `init` does there: `skip` is a step it does not take and `advise` one it leaves to you, each with the reason.
@@ -333,6 +354,8 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
333
354
  - A sibling older than this release needs is `outdated`, with the minimum and the command that installs a newer one, and exit 1.
334
355
  - A command named under `tools` is run as named, and its version is not checked.
335
356
  - **The repository**: its root, the branch, and the brief the flag, `SPEC_BRIEF` or the branch names.
357
+ - 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.
358
+ - It fails nothing, as a base that names no commit fails nothing: `doctor` exits as it would have. `--format json` has the id as `brief` and the reason as `briefProblem`, `null` where there is none.
336
359
  - **What a signed ruling needs to count**:
337
360
  - the base, and whether `--base`, `.spec-harness.json` or the remote named it;
338
361
  - whether the allowed-signers file is on that base;
@@ -350,6 +373,38 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
350
373
  - **git's pre-commit hook**: whether it runs spec-harness, where git runs it from: installed, missing, a hook of the repository's own without the line, or, outside Windows, one git skips because it is not executable.
351
374
  - A hook that runs it through `npx` is one that runs it. A note names the line that runs it with `node`: `npx` starts npm to start node on every commit, and under npm 12 prints two `npm notice run` lines each time. A note, never a failure; `--format json` has it as `gitHook.note`.
352
375
 
376
+ ### An input that names nothing
377
+
378
+ An option, an argument or a value that is given and names nothing is refused: exit 2, one line on stderr that names it and says what to do, nothing read or written for it. None is read as if it had not been given. `--root "$DIR"` where the variable is not set would otherwise run in the current directory, and the answer would look like the one that was asked for ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
379
+
380
+ - **An option a command does not read.** A command takes the options its answer depends on, and refuses any other by name, with the ones it takes: `doctor does not take --note; its options are --brief, --base, --root, --format and --strict`.
381
+
382
+ | Command | Reads |
383
+ | --- | --- |
384
+ | `context`, `rulings` | `--brief`, `--base`, `--root`, `--format` |
385
+ | `guard`, `audit`, `doctor` | `--brief`, `--base`, `--root`, `--format`, `--strict` |
386
+ | `probe` | `--brief`, `--base`, `--root`, `--format`, `--id`, `--at` |
387
+ | `premises` | `--brief`, `--root`, `--format`, `--strict` |
388
+ | `escalate` | `--brief`, `--root`, `--format`, `--path`, `--reason`, `--option`, `--recommend`, `--list`, `--show` |
389
+ | `rule` | `--root`, `--format`, `--allow`, `--deny`, `--note` |
390
+ | `init` | `--root`, `--format`, `--write`, `--git-hook` |
391
+ | `hook` | `--brief`, `--base`, `--root` |
392
+ | `mcp` | `--root` |
393
+
394
+ `init` chooses the base itself, and the server is told the brief and the base by each call, so neither takes `--base` or `--brief`: taking one would say the base or the brief had been set.
395
+ - **An argument a command does not take.** `context`, `rulings`, `audit` and `probe` take one brief's id at most, `rule` one escalation's id, `guard` its paths and `hook` the word `claude` or `git`; `escalate`, `premises`, `init`, `mcp` and `doctor` take none. `init mydir` set up the directory it stood in; `init --root mydir` sets up another.
396
+ - **A value that names nothing.** An option that takes a value refuses one that is empty or only space, by the option's name: `--brief is "", which names no brief`. So does an argument, as in `guard ""`. No option gives an empty value a meaning: leave the option out for what the command does without it.
397
+ - **A `--root` that is no directory**, a file or a path that is not there: `--root is "nowhere", which is not a directory`.
398
+ - **A `.spec-harness.json` that is a directory** does not load, and is said so in a line, as one that is not JSON is.
399
+
400
+ `--help` and `--version` answer whatever else is on the line. Three things are read another way, each for its reason:
401
+
402
+ - **`hook claude` reads its line as every release has.** The line is in `.claude/settings.json` and in the Claude Code plugin of every release since 0.1.0, which a later release cannot rewrite, and exit 2 from it holds every write of a session, the one that would mend the line among them. So what it does not read is passed over as before, an empty `--brief` with the rest; an option no command has is exit 2 there, as it always was. The releases have handed the harness five command lines, `hook claude`, `hook git`, `mcp`, and `mcp --root` with the project or with `.`, and each is read as the release that wrote it read it ([ADR-0012](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0012-one-way-into-claude-code.md)).
403
+ - **`doctor` says an empty `--brief` or `--base` in its report**, as it says a brief spec-brief does not know: a diagnosis is what a person runs when something is wrong, and it reports what is wrong with what it was given.
404
+ - **`SPEC_BRIEF` set and empty is as unset**, since what sets it sets it whether or not there is a round ([Which brief](#which-brief)).
405
+
406
+ **In a tool call.** The server's tools refuse the same in their arguments, with a tool error that names the argument, as in `"paths[1]" is "", which names no file`: an empty `brief` or `base`, an empty path, an empty `recommendation`, an option whose `label` is empty. An argument a tool does not take was refused already.
407
+
353
408
  ## As a Claude Code plugin
354
409
 
355
410
  spec-harness ships two different things called a plugin. This section is the **Claude Code plugin**, installed into Claude Code. The **spec-brief plugin**, `@descent-vtt/spec-harness/spec-brief-plugin`, is a module spec-brief's archive loads to learn of signed rulings; `init` configures it ([above](#escalate-rule-rulings)).
@@ -425,7 +480,7 @@ A person who runs the tools by hand runs `npx --no-install @descent-vtt/spec-har
425
480
  | --- | --- |
426
481
  | `0` | Clean. |
427
482
  | `1` | Refused, found something, or waiting on a person. |
428
- | `2` | The answer cannot be trusted. An error the harness did not expect is one: `spec-harness: unexpected error:` and its stack, on stderr. |
483
+ | `2` | The answer cannot be trusted, or did not arrive. An option, an argument or a value that [names nothing](#an-input-that-names-nothing) is one. A git command that failed is one, and so is a stdout its reader closed before all of the output was written, as a pipeline into `head` does: each is one line on stderr. An error the harness did not expect is one too: `spec-harness: unexpected error:` and its stack, on stderr. |
429
484
  | `130` | A `probe` interrupted by SIGINT (Ctrl+C) while it runs. |
430
485
  | `143` | A `probe` interrupted by SIGTERM. |
431
486
  | `129` | A `probe` interrupted by SIGHUP. |
@@ -7,8 +7,15 @@
7
7
  // main answers the errors it awaits with exit 2. What nothing awaits - a
8
8
  // stream's error, a timer's, a promise nobody holds - Node ends with exit 1,
9
9
  // which a script reads as a refusal or a finding: the same answer for those.
10
+ //
11
+ // A reader that closed stdout, as `| head` does, is no defect, and it arrives
12
+ // here, as the stream's error: one line, with no stack to send a person
13
+ // looking for a bug. The code alone names it, since stdout and stderr are the
14
+ // only pipes the harness writes to, and a line about a closed stderr reaches
15
+ // nobody.
10
16
  process.on('uncaughtException', (error) => {
11
- process.stderr.write(`spec-harness: unexpected error: ${error?.stack ?? error}\n`);
17
+ const said = error?.code === 'EPIPE' ? 'stdout was closed before all of the output was written' : `unexpected error: ${error?.stack ?? error}`;
18
+ process.stderr.write(`spec-harness: ${said}\n`);
12
19
  process.exit(2);
13
20
  });
14
21
 
package/dist/cli.d.ts CHANGED
@@ -2,14 +2,16 @@
2
2
  * The command line: arguments, dispatch, output and exit codes.
3
3
  *
4
4
  * Exit 0: done, nothing refused. Exit 1: something refused or found. Exit 2:
5
- * the answer cannot be trusted - a bad flag, a configuration that does not
6
- * load, a sibling that is missing or printed something unreadable, an error
7
- * nothing here expected. A run that could not look must never exit as though
8
- * it looked and found nothing (spec-core ADR-0005).
5
+ * the answer cannot be trusted - a bad flag, an option, an argument or a
6
+ * value that names nothing, a configuration that does not load, a sibling
7
+ * that is missing or printed something unreadable, a git command that
8
+ * failed, an output its reader closed, an error nothing here expected. A run
9
+ * that could not look must never exit as though it looked and found nothing
10
+ * (spec-core ADR-0005).
9
11
  */
10
12
  import { type CliIO } from './workspace.js';
11
13
  export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version, type CliIO } from './workspace.js';
12
- 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.\n rule <id> Record a person's ruling in the brief: --allow or --deny, with\n --note. 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 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 --base <ref> What the round is measured from. Otherwise \"base\" in\n .spec-harness.json, then the remote's default branch.\n --root <dir> Run from another directory.\n --format <fmt> pretty or json; audit and premises also gitlab, sarif or github.\n --strict Warnings fail the run.\n --help, --version\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.\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 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, and an option or an\nargument given an empty value are refused, exit 2, not read as if they were not there.\ndoctor says an empty --brief or --base in its report, and hook claude reads its line\nas every release has.\n\nExit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.\n";
13
15
  /**
14
16
  * Runs the command line and resolves to the exit code, for an error nothing
15
17
  * below expected too.
@@ -21,6 +23,14 @@ export declare const HELP = "spec-harness - context, guards, audits and rulings
21
23
  * write the guard could not check went ahead. The stack is what makes a
22
24
  * report of it something to act on, and it goes to stderr alone: a script
23
25
  * that reads a document from stdout is handed no part of one.
26
+ *
27
+ * A write its reader had closed the pipe for is not such an error: the answer
28
+ * was not delivered, which is still 2, and nothing in the harness is at
29
+ * fault, so it is said in a line and no stack sends a person looking for a
30
+ * defect. The process's own streams report it as an event, which the
31
+ * launcher answers in the same words; here it is a caller's stream that
32
+ * throws it. It is read by its code: stdout and stderr are the only pipes
33
+ * the harness writes to.
24
34
  */
25
35
  export declare function run(argv: readonly string[], io: CliIO): Promise<number>;
26
36
  export declare function main(argv?: readonly string[]): Promise<number>;
package/dist/cli.js CHANGED
@@ -2,16 +2,19 @@
2
2
  * The command line: arguments, dispatch, output and exit codes.
3
3
  *
4
4
  * Exit 0: done, nothing refused. Exit 1: something refused or found. Exit 2:
5
- * the answer cannot be trusted - a bad flag, a configuration that does not
6
- * load, a sibling that is missing or printed something unreadable, an error
7
- * nothing here expected. A run that could not look must never exit as though
8
- * it looked and found nothing (spec-core ADR-0005).
5
+ * the answer cannot be trusted - a bad flag, an option, an argument or a
6
+ * value that names nothing, a configuration that does not load, a sibling
7
+ * that is missing or printed something unreadable, a git command that
8
+ * failed, an output its reader closed, an error nothing here expected. A run
9
+ * that could not look must never exit as though it looked and found nothing
10
+ * (spec-core ADR-0005).
9
11
  */
10
12
  import { SiblingOutputError } from './briefs.js';
11
13
  import { auditCommand, contextCommand, escalateCommand, probeCommand, ruleCommand, rulingsCommand } from './commands.js';
12
14
  import { ConfigError, SIBLINGS } from './config.js';
13
15
  import { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describePlugin, describeSignerKeys, describeSignerProblem, describeSigners, wiringState, } from './configure.js';
14
- import { show, stagedChanges } from './git.js';
16
+ import { isDirectory } from './fs.js';
17
+ import { GitError, show, stagedChanges } from './git.js';
15
18
  import { claudeResponse, gitResponse, parseClaudeHook } from './hooks.js';
16
19
  import { claudeVersion } from './host.js';
17
20
  import { premisesCommand } from './premises.js';
@@ -21,6 +24,7 @@ import { mcpCommand } from './server.js';
21
24
  import { gitHook, initCommand } from './setup.js';
22
25
  import { notFido2, readAllowedSigners } from './signers.js';
23
26
  import { SiblingError } from './siblings.js';
27
+ import { emptyValue, isBlank, refusal } from './usage.js';
24
28
  import { checkClaudeCode, CLAUDE_CODE_MINIMUM, MINIMUM_VERSIONS } from './versions.js';
25
29
  import { activeBrief, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, namedId, openWorkspace, parseOptions, UsageError, version, } from './workspace.js';
26
30
  export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version } from './workspace.js';
@@ -39,13 +43,13 @@ Commands:
39
43
  [--option "<label>: <cost>"]... [--recommend <text>].
40
44
  --list shows what waits; --show <id> prints one memo.
41
45
  rule <id> Record a person's ruling in the brief: --allow or --deny, with
42
- --note. It counts once committed signed.
46
+ --note <text>. It counts once committed signed.
43
47
  rulings [brief] The brief's rulings, and whether each one's signature verifies.
44
48
  audit [brief] Did the round stay inside the lines: what the archive would say,
45
49
  the brief's assertions, unverified rulings, new dependencies
46
50
  and the install scripts it allowed.
47
51
  probe [brief] Run the brief's probes: red at the base, green at the head.
48
- --at base|head|both, --id <probe>.
52
+ --at <base|head|both>, --id <probe>.
49
53
  premises Do the live briefs' premises still hold? For CI: exit 1 when one
50
54
  is stale, or, with --strict, when spec-guard cannot read one.
51
55
  init Configure the spec-* tools to agree. Prints the plan; --write
@@ -59,13 +63,22 @@ Commands:
59
63
 
60
64
  Options:
61
65
  --brief <id> The brief the round works on. Otherwise SPEC_BRIEF, then the branch.
66
+ For context, guard, hook, escalate, rulings, audit, probe, premises
67
+ and doctor.
62
68
  --base <ref> What the round is measured from. Otherwise "base" in
63
- .spec-harness.json, then the remote's default branch.
64
- --root <dir> Run from another directory.
69
+ .spec-harness.json, then the remote's default branch. For context,
70
+ guard, hook, rulings, audit, probe and doctor.
71
+ --root <dir> Run from another directory, which must exist. For every command.
65
72
  --format <fmt> pretty or json; audit and premises also gitlab, sarif or github.
66
- --strict Warnings fail the run.
73
+ For every command but hook and mcp.
74
+ --strict Warnings fail the run. For guard, audit, premises and doctor.
67
75
  --help, --version
68
76
 
77
+ An option a command does not read, an argument it does not take, and an option or an
78
+ argument given an empty value are refused, exit 2, not read as if they were not there.
79
+ doctor says an empty --brief or --base in its report, and hook claude reads its line
80
+ as every release has.
81
+
69
82
  Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.
70
83
  `;
71
84
  /* ------------------------------------------------------------------- guard */
@@ -184,6 +197,32 @@ async function hookCommand(options, io) {
184
197
  throw new UsageError('hook takes "claude" or "git"');
185
198
  }
186
199
  /* ------------------------------------------------------------------ doctor */
200
+ /**
201
+ * The brief a round here works on, as doctor reports it: its id, and what is
202
+ * wrong with the name when no round can work under it. Every other command
203
+ * stops there. A diagnosis says it, being what a person runs when the guard
204
+ * holds a write they expected it to pass.
205
+ */
206
+ async function diagnoseBrief(workspace, options, env) {
207
+ if (options.brief !== undefined && isBlank(options.brief))
208
+ return { id: null, problem: emptyValue('--brief', 'brief', options.brief) };
209
+ const id = namedId(workspace, options, env);
210
+ // A spec-brief that cannot be run is a row of the report, with the reason.
211
+ if (workspace.siblings.locate('spec-brief').kind !== 'found')
212
+ return { id, problem: null };
213
+ try {
214
+ // Where nothing names a brief, spec-brief is not asked (ADR-0004), and
215
+ // there is no problem to state.
216
+ return { id, problem: describeActive(await activeBrief(workspace, options, env)).problem };
217
+ }
218
+ catch (error) {
219
+ // Asking spec-brief throws nothing but this, so the check is equivalent
220
+ // to its mutant; it keeps any other error the stack it needs.
221
+ if (!(error instanceof SiblingError))
222
+ throw error;
223
+ return { id, problem: `spec-brief could not say whether it knows brief ${id}: ${error.message}` };
224
+ }
225
+ }
187
226
  async function doctorCommand(options, io) {
188
227
  const workspace = await openWorkspace(options, io);
189
228
  const rows = SIBLINGS.map((name) => {
@@ -197,10 +236,15 @@ async function doctorCommand(options, io) {
197
236
  const detail = sibling.version === null ? `${command} (named in .spec-harness.json; its version is not checked)` : `${command} (${sibling.version})`;
198
237
  return { ...row, version: sibling.version, detail };
199
238
  });
200
- const named = namedId(workspace, options, io.env);
239
+ const named = await diagnoseBrief(workspace, options, io.env);
201
240
  // A ruling counts only by a signature checked against the allowed signers
202
241
  // on the base, and only once spec-brief's archive asks the plugin about it.
203
- const base = await resolveBase(workspace, options.base);
242
+ // A --base given nothing is no base, said as the brief's is, in the report.
243
+ // Nothing below asks what kind a base is but whether it is resolved, so the
244
+ // mutant that names this one otherwise is equivalent.
245
+ const base = options.base !== undefined && isBlank(options.base)
246
+ ? { kind: 'unresolved', reason: emptyValue('--base', 'base', options.base) }
247
+ : await resolveBase(workspace, options.base);
204
248
  const signersFile = workspace.config.rulings.allowedSigners;
205
249
  // An unresolved base has no commit: git shows nothing for one, so asking
206
250
  // anyway reads as no file, and that mutant is equivalent.
@@ -229,7 +273,8 @@ async function doctorCommand(options, io) {
229
273
  root: workspace.root,
230
274
  branch: workspace.branch,
231
275
  branchSource: workspace.branchSource ?? null,
232
- brief: named,
276
+ brief: named.id,
277
+ briefProblem: named.problem,
233
278
  base: base.kind === 'resolved' ? { ref: base.ref, source: base.source, mergeBase: base.mergeBase } : { unresolved: base.reason },
234
279
  allowedSigners: {
235
280
  file: signersFile,
@@ -256,7 +301,7 @@ async function doctorCommand(options, io) {
256
301
  io.stdout.write([
257
302
  `root ${workspace.root}`,
258
303
  `branch ${branch}`,
259
- `brief ${named ?? '(none named)'}`,
304
+ `brief ${named.problem === null ? (named.id ?? '(none named)') : `none: ${named.problem}`}`,
260
305
  `base ${describeBase(base)}`,
261
306
  `signers ${signers}`,
262
307
  ...more(signerNotes),
@@ -289,20 +334,22 @@ async function doctorCommand(options, io) {
289
334
  * `Object` as a command and ended on a stack trace, where `spec-harness
290
335
  * construct` is an unknown command with exit 2.
291
336
  */
292
- const COMMANDS = new Map([
293
- ['guard', guardCommand],
294
- ['hook', hookCommand],
295
- ['context', contextCommand],
296
- ['audit', auditCommand],
297
- ['escalate', escalateCommand],
298
- ['rule', ruleCommand],
299
- ['rulings', rulingsCommand],
300
- ['probe', probeCommand],
301
- ['premises', premisesCommand],
302
- ['init', initCommand],
303
- ['mcp', mcpCommand],
304
- ['doctor', doctorCommand],
305
- ]);
337
+ const COMMANDS = new Map(
338
+ // One for every command that has a shape (usage.ts), which the compiler holds.
339
+ Object.entries({
340
+ guard: guardCommand,
341
+ hook: hookCommand,
342
+ context: contextCommand,
343
+ audit: auditCommand,
344
+ escalate: escalateCommand,
345
+ rule: ruleCommand,
346
+ rulings: rulingsCommand,
347
+ probe: probeCommand,
348
+ premises: premisesCommand,
349
+ init: initCommand,
350
+ mcp: mcpCommand,
351
+ doctor: doctorCommand,
352
+ }));
306
353
  async function dispatch(argv, io) {
307
354
  let options;
308
355
  try {
@@ -325,6 +372,12 @@ async function dispatch(argv, io) {
325
372
  io.stderr.write(`spec-harness: unknown command "${options.command}"; see spec-harness --help\n`);
326
373
  return EXIT_ERROR;
327
374
  }
375
+ // What parsed and names nothing, before anything is read or written for it.
376
+ const refused = refusal(options, isDirectory);
377
+ if (refused !== null) {
378
+ io.stderr.write(`spec-harness: ${refused}\n`);
379
+ return EXIT_ERROR;
380
+ }
328
381
  return command(options, io);
329
382
  }
330
383
  /**
@@ -338,6 +391,14 @@ async function dispatch(argv, io) {
338
391
  * write the guard could not check went ahead. The stack is what makes a
339
392
  * report of it something to act on, and it goes to stderr alone: a script
340
393
  * that reads a document from stdout is handed no part of one.
394
+ *
395
+ * A write its reader had closed the pipe for is not such an error: the answer
396
+ * was not delivered, which is still 2, and nothing in the harness is at
397
+ * fault, so it is said in a line and no stack sends a person looking for a
398
+ * defect. The process's own streams report it as an event, which the
399
+ * launcher answers in the same words; here it is a caller's stream that
400
+ * throws it. It is read by its code: stdout and stderr are the only pipes
401
+ * the harness writes to.
341
402
  */
342
403
  export async function run(argv, io) {
343
404
  try {
@@ -345,12 +406,22 @@ export async function run(argv, io) {
345
406
  }
346
407
  catch (error) {
347
408
  // A sibling's document of a shape the harness does not read is a sibling
348
- // that printed something it cannot read (answers.ts): exit 2.
349
- if (error instanceof UsageError || error instanceof ConfigError || error instanceof SiblingError || error instanceof SiblingOutputError) {
409
+ // that printed something it cannot read (answers.ts): exit 2. A git
410
+ // command that failed is what git said, and no line of the harness's.
411
+ if (error instanceof UsageError ||
412
+ error instanceof ConfigError ||
413
+ error instanceof SiblingError ||
414
+ error instanceof SiblingOutputError ||
415
+ error instanceof GitError) {
350
416
  io.stderr.write(`spec-harness: ${error.message}\n`);
351
417
  return EXIT_ERROR;
352
418
  }
353
- io.stderr.write(`spec-harness: unexpected error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
419
+ if (!(error instanceof Error))
420
+ io.stderr.write(`spec-harness: unexpected error: ${String(error)}\n`);
421
+ else if (error.code === 'EPIPE')
422
+ io.stderr.write('spec-harness: stdout was closed before all of the output was written\n');
423
+ else
424
+ io.stderr.write(`spec-harness: unexpected error: ${error.stack ?? error.message}\n`);
354
425
  return EXIT_ERROR;
355
426
  }
356
427
  }