@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +104 -0
- package/README.md +57 -9
- package/dist/cli.d.ts +7 -6
- package/dist/cli.js +80 -28
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +26 -9
- package/dist/commands.js.map +1 -1
- package/dist/fs.d.ts +2 -0
- package/dist/fs.js +16 -1
- package/dist/fs.js.map +1 -1
- package/dist/rulings.js +3 -1
- package/dist/rulings.js.map +1 -1
- package/dist/sandbox.d.ts +24 -13
- package/dist/sandbox.js +88 -16
- package/dist/sandbox.js.map +1 -1
- package/dist/server.js +81 -22
- package/dist/server.js.map +1 -1
- package/dist/usage.d.ts +210 -0
- package/dist/usage.js +148 -0
- package/dist/usage.js.map +1 -0
- package/dist/workspace.d.ts +2 -0
- package/dist/workspace.js +1 -0
- package/dist/workspace.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spec-harness",
|
|
3
|
-
"version": "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,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
|
|
282
|
-
-
|
|
283
|
-
|
|
284
|
-
- On
|
|
285
|
-
-
|
|
286
|
-
-
|
|
287
|
-
-
|
|
288
|
-
-
|
|
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,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* found nothing
|
|
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
|
|
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,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* found nothing
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
/**
|