@descent-vtt/spec-harness 0.11.1 → 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.1"
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.1",
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,110 @@ 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
+
12
116
  ## 0.11.1
13
117
 
14
118
  A probe's timeout ends the wait as well as the command, where a process out
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,14 +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
- - A command, a `setup` as well as a `run`, is waited for until its output closes, or until its timeout: `probes.timeout` seconds, or for a `run` the probe's own `timeout`. One that ends and leaves a process holding its output, as a server started in the background does, is waited for until the timeout, and is then a command that was stopped. On Windows a process the shell started with `start /b` holds that output even when its own is sent to a file.
282
- - At its timeout a command is stopped, forced, with what `probe` can reach of what it started, and no more ([ADR-0003](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0003-state-outside-the-work-tree.md)):
283
- - 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, as a daemon does, and a process Node starts `detached`.
284
- - 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, and once the shell itself has ended, as it has when its line only started something with `start /b`, nothing the command left is stopped.
285
- - What is out of reach runs on until it ends or you end it.
286
- - 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 ends `, and something it started was left running, holding its output`. A `setup` answered so says the same in the error that stops the probe. `probe` reads no more of that output, so a process that writes to it afterwards has the write fail, as it would once `probe` had ended, and a later run starts while that process still runs.
287
- - Interrupted - by Ctrl+C, SIGTERM or SIGHUP, or by Ctrl+Break on Windows - `probe` stops the commands still running, with the same reach and no more, waits up to three seconds for their output, and removes the worktree before it exits.
288
- - Neither a timeout nor an interrupt stops what an earlier command left running once that command had ended and its output had closed: `probe` holds nothing of it by then.
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.
289
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.
290
302
 
291
303
  ### `premises`
@@ -302,6 +314,8 @@ Run it in CI: exit 1 when a premise is stale, exit 2 when spec-guard is not ther
302
314
 
303
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.
304
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
+
305
319
  ### `init`
306
320
 
307
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.
@@ -340,6 +354,8 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
340
354
  - A sibling older than this release needs is `outdated`, with the minimum and the command that installs a newer one, and exit 1.
341
355
  - A command named under `tools` is run as named, and its version is not checked.
342
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.
343
359
  - **What a signed ruling needs to count**:
344
360
  - the base, and whether `--base`, `.spec-harness.json` or the remote named it;
345
361
  - whether the allowed-signers file is on that base;
@@ -357,6 +373,38 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
357
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.
358
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`.
359
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
+
360
408
  ## As a Claude Code plugin
361
409
 
362
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)).
@@ -432,7 +480,7 @@ A person who runs the tools by hand runs `npx --no-install @descent-vtt/spec-har
432
480
  | --- | --- |
433
481
  | `0` | Clean. |
434
482
  | `1` | Refused, found something, or waiting on a person. |
435
- | `2` | The answer cannot be trusted, or did not arrive. 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. |
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. |
436
484
  | `130` | A `probe` interrupted by SIGINT (Ctrl+C) while it runs. |
437
485
  | `143` | A `probe` interrupted by SIGTERM. |
438
486
  | `129` | A `probe` interrupted by SIGHUP. |
package/dist/cli.d.ts CHANGED
@@ -2,15 +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, a git
7
- * command that failed, an output its reader closed, an error nothing here
8
- * expected. A run that could not look must never exit as though it looked and
9
- * 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).
10
11
  */
11
12
  import { type CliIO } from './workspace.js';
12
13
  export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version, type CliIO } from './workspace.js';
13
- 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";
14
15
  /**
15
16
  * Runs the command line and resolves to the exit code, for an error nothing
16
17
  * below expected too.
package/dist/cli.js CHANGED
@@ -2,16 +2,18 @@
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, a git
7
- * command that failed, an output its reader closed, an error nothing here
8
- * expected. A run that could not look must never exit as though it looked and
9
- * 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).
10
11
  */
11
12
  import { SiblingOutputError } from './briefs.js';
12
13
  import { auditCommand, contextCommand, escalateCommand, probeCommand, ruleCommand, rulingsCommand } from './commands.js';
13
14
  import { ConfigError, SIBLINGS } from './config.js';
14
15
  import { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describePlugin, describeSignerKeys, describeSignerProblem, describeSigners, wiringState, } from './configure.js';
16
+ import { isDirectory } from './fs.js';
15
17
  import { GitError, show, stagedChanges } from './git.js';
16
18
  import { claudeResponse, gitResponse, parseClaudeHook } from './hooks.js';
17
19
  import { claudeVersion } from './host.js';
@@ -22,6 +24,7 @@ import { mcpCommand } from './server.js';
22
24
  import { gitHook, initCommand } from './setup.js';
23
25
  import { notFido2, readAllowedSigners } from './signers.js';
24
26
  import { SiblingError } from './siblings.js';
27
+ import { emptyValue, isBlank, refusal } from './usage.js';
25
28
  import { checkClaudeCode, CLAUDE_CODE_MINIMUM, MINIMUM_VERSIONS } from './versions.js';
26
29
  import { activeBrief, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, namedId, openWorkspace, parseOptions, UsageError, version, } from './workspace.js';
27
30
  export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version } from './workspace.js';
@@ -40,13 +43,13 @@ Commands:
40
43
  [--option "<label>: <cost>"]... [--recommend <text>].
41
44
  --list shows what waits; --show <id> prints one memo.
42
45
  rule <id> Record a person's ruling in the brief: --allow or --deny, with
43
- --note. It counts once committed signed.
46
+ --note <text>. It counts once committed signed.
44
47
  rulings [brief] The brief's rulings, and whether each one's signature verifies.
45
48
  audit [brief] Did the round stay inside the lines: what the archive would say,
46
49
  the brief's assertions, unverified rulings, new dependencies
47
50
  and the install scripts it allowed.
48
51
  probe [brief] Run the brief's probes: red at the base, green at the head.
49
- --at base|head|both, --id <probe>.
52
+ --at <base|head|both>, --id <probe>.
50
53
  premises Do the live briefs' premises still hold? For CI: exit 1 when one
51
54
  is stale, or, with --strict, when spec-guard cannot read one.
52
55
  init Configure the spec-* tools to agree. Prints the plan; --write
@@ -60,13 +63,22 @@ Commands:
60
63
 
61
64
  Options:
62
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.
63
68
  --base <ref> What the round is measured from. Otherwise "base" in
64
- .spec-harness.json, then the remote's default branch.
65
- --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.
66
72
  --format <fmt> pretty or json; audit and premises also gitlab, sarif or github.
67
- --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.
68
75
  --help, --version
69
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
+
70
82
  Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.
71
83
  `;
72
84
  /* ------------------------------------------------------------------- guard */
@@ -185,6 +197,32 @@ async function hookCommand(options, io) {
185
197
  throw new UsageError('hook takes "claude" or "git"');
186
198
  }
187
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
+ }
188
226
  async function doctorCommand(options, io) {
189
227
  const workspace = await openWorkspace(options, io);
190
228
  const rows = SIBLINGS.map((name) => {
@@ -198,10 +236,15 @@ async function doctorCommand(options, io) {
198
236
  const detail = sibling.version === null ? `${command} (named in .spec-harness.json; its version is not checked)` : `${command} (${sibling.version})`;
199
237
  return { ...row, version: sibling.version, detail };
200
238
  });
201
- const named = namedId(workspace, options, io.env);
239
+ const named = await diagnoseBrief(workspace, options, io.env);
202
240
  // A ruling counts only by a signature checked against the allowed signers
203
241
  // on the base, and only once spec-brief's archive asks the plugin about it.
204
- 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);
205
248
  const signersFile = workspace.config.rulings.allowedSigners;
206
249
  // An unresolved base has no commit: git shows nothing for one, so asking
207
250
  // anyway reads as no file, and that mutant is equivalent.
@@ -230,7 +273,8 @@ async function doctorCommand(options, io) {
230
273
  root: workspace.root,
231
274
  branch: workspace.branch,
232
275
  branchSource: workspace.branchSource ?? null,
233
- brief: named,
276
+ brief: named.id,
277
+ briefProblem: named.problem,
234
278
  base: base.kind === 'resolved' ? { ref: base.ref, source: base.source, mergeBase: base.mergeBase } : { unresolved: base.reason },
235
279
  allowedSigners: {
236
280
  file: signersFile,
@@ -257,7 +301,7 @@ async function doctorCommand(options, io) {
257
301
  io.stdout.write([
258
302
  `root ${workspace.root}`,
259
303
  `branch ${branch}`,
260
- `brief ${named ?? '(none named)'}`,
304
+ `brief ${named.problem === null ? (named.id ?? '(none named)') : `none: ${named.problem}`}`,
261
305
  `base ${describeBase(base)}`,
262
306
  `signers ${signers}`,
263
307
  ...more(signerNotes),
@@ -290,20 +334,22 @@ async function doctorCommand(options, io) {
290
334
  * `Object` as a command and ended on a stack trace, where `spec-harness
291
335
  * construct` is an unknown command with exit 2.
292
336
  */
293
- const COMMANDS = new Map([
294
- ['guard', guardCommand],
295
- ['hook', hookCommand],
296
- ['context', contextCommand],
297
- ['audit', auditCommand],
298
- ['escalate', escalateCommand],
299
- ['rule', ruleCommand],
300
- ['rulings', rulingsCommand],
301
- ['probe', probeCommand],
302
- ['premises', premisesCommand],
303
- ['init', initCommand],
304
- ['mcp', mcpCommand],
305
- ['doctor', doctorCommand],
306
- ]);
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
+ }));
307
353
  async function dispatch(argv, io) {
308
354
  let options;
309
355
  try {
@@ -326,6 +372,12 @@ async function dispatch(argv, io) {
326
372
  io.stderr.write(`spec-harness: unknown command "${options.command}"; see spec-harness --help\n`);
327
373
  return EXIT_ERROR;
328
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
+ }
329
381
  return command(options, io);
330
382
  }
331
383
  /**