@descent-vtt/spec-harness 0.17.0 → 0.18.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 +103 -0
- package/README.md +7 -1
- package/dist/cli.d.ts +15 -1
- package/dist/cli.js +62 -36
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +12 -2
- package/dist/commands.js.map +1 -1
- package/dist/configure.d.ts +90 -6
- package/dist/configure.js +183 -39
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +10 -0
- package/dist/fs.js +14 -0
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +31 -0
- package/dist/git.js +38 -0
- package/dist/git.js.map +1 -1
- package/dist/round.d.ts +25 -4
- package/dist/round.js +92 -33
- package/dist/round.js.map +1 -1
- package/dist/setup.d.ts +7 -0
- package/dist/setup.js +57 -12
- package/dist/setup.js.map +1 -1
- package/dist/usage.d.ts +46 -2
- package/dist/usage.js +35 -0
- package/dist/usage.js.map +1 -1
- package/dist/workspace.js +25 -10
- 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.18.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,109 @@ 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.18.0
|
|
13
|
+
|
|
14
|
+
`doctor` and `init` say more of what looks like a guard and checks
|
|
15
|
+
nothing: a matcher the Claude Code on this machine reads otherwise, and a
|
|
16
|
+
root on the hook's line that is in another work tree. And a directory where
|
|
17
|
+
git looks for its hook, or a probe for its report, ends in a sentence where
|
|
18
|
+
it ended on a stack
|
|
19
|
+
([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
|
|
20
|
+
The hook loads only what it runs and opens its work tree in one start of
|
|
21
|
+
git, so it answers a write sooner: on a Windows workstation 1.0 seconds
|
|
22
|
+
where it took 1.4 with no brief named, and 1.9 where it took 3.6 before a
|
|
23
|
+
write to a protected file. Nothing any command prints changes
|
|
24
|
+
([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **A matcher that lists names with a comma is read as the Claude Code on
|
|
29
|
+
`PATH` reads it.** A comma separates names from Claude Code 2.1.191 on;
|
|
30
|
+
before it, by Claude Code's page on debugging a configuration, the matcher
|
|
31
|
+
matched no tool, and `doctor` said a line under `Edit, Write` guards
|
|
32
|
+
where Claude Code never started it. Under an older release the line is
|
|
33
|
+
now said as no guard, with the release and what the matcher is from
|
|
34
|
+
2.1.191 on, and `init --write` writes its own hooks beside it; where the
|
|
35
|
+
release cannot be told, or the matcher holds a `|` as well, it is
|
|
36
|
+
*cannot tell*.
|
|
37
|
+
Upgrading: under a Claude Code older than 2.1.191 `doctor` reports no
|
|
38
|
+
guard for such a line, exits 0 where it exited 1 for the plugin beside it,
|
|
39
|
+
and `--strict` fails a matcher with a comma and a `|`. Write the matcher
|
|
40
|
+
with `|` alone, as `Edit|Write`, or update Claude Code.
|
|
41
|
+
- **`doctor` fails where an array for a matcher keeps Claude Code from
|
|
42
|
+
loading a settings file's hooks.** By the same page, a `matcher` that is
|
|
43
|
+
an array is an invalid setting that starts no hook, and under
|
|
44
|
+
`PreToolUse` or `PermissionRequest` none of that file's other hooks
|
|
45
|
+
load either. Every line of the hook in such a file is said as one Claude
|
|
46
|
+
Code does not start, a guard that is off where it stood to be started for
|
|
47
|
+
a write, and `init` writes no hook into the file (`advise`).
|
|
48
|
+
Upgrading: `doctor` exits 1 for such a file, where it exited 0, or 1
|
|
49
|
+
under `--strict` alone: write the matcher as one string, its names
|
|
50
|
+
between `|`.
|
|
51
|
+
- **`doctor` fails for a hook's line whose root is in a work tree that does
|
|
52
|
+
not hold the project.** The hook opens the work tree its root is in and
|
|
53
|
+
passes a write to any file outside it with no word, so under a root in a
|
|
54
|
+
repository beside the project, or in one inside it, a submodule or a
|
|
55
|
+
linked worktree among them, no write to the project is checked: the line
|
|
56
|
+
is said as a guard that is off, and `init` advises. Under a root in a
|
|
57
|
+
work tree that holds the project, `doctor` says whose briefs a write
|
|
58
|
+
here is checked by, and fails nothing.
|
|
59
|
+
Upgrading: `doctor` exits 1 for such a line, where it exited 0: give the
|
|
60
|
+
line a directory in the project's work tree, or take `--root` off it.
|
|
61
|
+
- **`doctor` and `init` say a directory at the path of git's pre-commit
|
|
62
|
+
hook, where they ended on a stack.** git tries to run whatever it may
|
|
63
|
+
execute there, cannot run a directory, and stops every commit, so
|
|
64
|
+
`doctor` says the path and what git does with it and exits 1; `init`
|
|
65
|
+
advises and writes nothing over it. Where git may not execute it and
|
|
66
|
+
skips it, that is said and fails nothing. `--format json` has the two as
|
|
67
|
+
the `gitHook.state` `stops` and `ignored`.
|
|
68
|
+
Upgrading: `doctor` exits 1 there, and `init` 0, where each exited 2
|
|
69
|
+
with `unexpected error: Error: EISDIR`. Take the directory away, then run
|
|
70
|
+
`spec-harness init --git-hook --write`.
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- **`probe` says a report that is a directory, where it ended on a
|
|
75
|
+
stack.** Where a probe's `junit` names a directory the repository holds,
|
|
76
|
+
the run is `no-report`, `cannot be read: it is a directory, not a
|
|
77
|
+
report`, and the probe invalid, exit 1, where it was `unexpected error`
|
|
78
|
+
and exit 2.
|
|
79
|
+
- **A command loads what it runs, and the hook no other command.** `hook
|
|
80
|
+
claude` is started before and after every write an agent makes, and the
|
|
81
|
+
command line loaded every command before it read which one was typed: 52
|
|
82
|
+
modules, 42 ms on Linux, close to half of a hook that names no brief. A
|
|
83
|
+
module that only some commands run is now loaded where one of them is
|
|
84
|
+
the command, and such a hook loads 26. Measured on Linux with the build
|
|
85
|
+
before this change and this one taking turns, at the median of ten runs,
|
|
86
|
+
the hook took 64 ms where it took 91, and 155 where it took 176 on a
|
|
87
|
+
brief's branch; `doctor` 81 where it took 99, and `--version` 40 where it
|
|
88
|
+
took 71. Nothing any command prints changes, nor an exit code, but in a
|
|
89
|
+
build with a file of `dist/` missing: a command that does not run that
|
|
90
|
+
module now answers, where every command ended on `build output is
|
|
91
|
+
missing`, and one that runs it ends on that line still, exit 2. In the
|
|
92
|
+
programmatic API, `checkPaths` takes as its reader a reader or a function
|
|
93
|
+
that makes one
|
|
94
|
+
([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
|
|
95
|
+
- **A work tree is opened in two starts of git, asked at once, where it
|
|
96
|
+
took three in two waves.** Git is asked for the top of the work tree and
|
|
97
|
+
its common directory in one start, and where the command runs at the top
|
|
98
|
+
of its work tree, for the branch beside them. A process is what a command
|
|
99
|
+
takes on Windows. Measured on a workstation at rest, where starting node
|
|
100
|
+
to do nothing took 0.3 seconds, with the two builds taking turns, at the
|
|
101
|
+
median of ten runs: a hook that names no brief took 1.0 second where it
|
|
102
|
+
took 1.4, with this and the change above, and with every processor held
|
|
103
|
+
by other work, 13.7 seconds where it took 22.7. On Linux, where git
|
|
104
|
+
answers in milliseconds, it is three of the 26 milliseconds the hook is
|
|
105
|
+
quicker by. Below the top of a work tree, and in a directory that is in
|
|
106
|
+
none, as many processes are started as before.
|
|
107
|
+
- **A write the brief protects is refused without asking git for the
|
|
108
|
+
base.** The base is read for the allowed signers a ruling is checked
|
|
109
|
+
against, and it was resolved, three of git's processes, before the
|
|
110
|
+
brief's rulings were read. Where no ruling of the brief allows anything
|
|
111
|
+
the refusal now starts three processes where it started seven, and on
|
|
112
|
+
that workstation at rest took 1.9 seconds where it took 3.6; where one
|
|
113
|
+
does, the base is asked for as before.
|
|
114
|
+
|
|
12
115
|
## 0.17.0
|
|
13
116
|
|
|
14
117
|
`doctor` and `init` see four more ways a guard is set up and checks
|
package/README.md
CHANGED
|
@@ -346,12 +346,14 @@ Configures the family to agree. It prints the plan, merges into files that exist
|
|
|
346
346
|
- Where the plugin that is turned on is not on this machine, as [`doctor`](#doctor) reads it, `init` still writes neither entry: a plugin that is there after all would bring them a second time. It says what `doctor` says (`advise`), and how to have the entries in the files instead. Where that cannot be told, it says so beside the `skip`.
|
|
347
347
|
- `"disableAllHooks": true` in a settings file is said of that file (`advise`): the hooks `init` writes do not run while it stands. `init` writes what it would have written.
|
|
348
348
|
- A line of the hook that is there already is known as [`doctor`](#doctor) knows it: by its command and the words `hook claude`, however a person changed it, with the event and the matcher its entry stands under. Where the file holds such a line that Claude Code starts for a write, `init` writes no hook into it, since a second line would run beside the first, and says which line it left, in `doctor`'s words for it (`keep`, or `advise` where the line is no guard: it turns the guard off, holds every write, or is waited on before none). It rewrites no line but the one 0.1 wrote, and completes only its own line where that stands under one event alone. A line Claude Code starts for no write is left as it is, and `init` writes its own beside it.
|
|
349
|
+
- A matcher is read as [`doctor`](#doctor) reads it, for the Claude Code on `PATH`. A line under names with a comma, `Edit, Write`, is one a Claude Code older than 2.1.191 starts for no write, so there `init` writes its own hooks beside it; it asks Claude Code its release only where what it reads of a line turns on it. And into a file that holds an array for a matcher under `PreToolUse` or `PermissionRequest` it writes no hook (`advise`): Claude Code loads none of that file's hooks, so write the matcher as one string, its names between `|`, and run `init` again.
|
|
349
350
|
- **git's pre-commit hook**, where git runs it from: `core.hooksPath`, relative, absolute or under `~`, and the shared hooks of a linked worktree.
|
|
350
351
|
- It refuses a commit that changes what the active brief protects, for any agent or none, a shell's writes included.
|
|
351
352
|
- With `--git-hook` it is written; without, the plan advises it (`advise`).
|
|
352
353
|
- It runs `node` and the script in the project's `node_modules`, as the Claude Code hooks do, and no `npx`. git starts a pre-commit hook at the top of the work tree it commits in, so the script is read from there: a linked worktree is guarded by its own install, and so is each repository that shares a hooks directory. Where the install is not there, the hook says so and the commit waits.
|
|
353
354
|
- It is a POSIX `sh` script on every platform: Git for Windows runs a hook with the `sh` it ships.
|
|
354
355
|
- A hook that exists is never replaced: `init` says to add the line to it. One that runs spec-harness through `npx`, as `init` wrote it through 0.9.1, keeps working, and `init` names the line that runs it with `node`.
|
|
356
|
+
- Nor is anything else that stands at the hook's path. A directory there is said as [`doctor`](#doctor) says it (`advise`), with `--git-hook` or without, and nothing is written over it.
|
|
355
357
|
- **The allowed-signers file** `rulings.allowedSigners` names, `.github/allowed_signers` by default: one line per person, and a FIDO2 key, `ssh-keygen -t ed25519-sk`, for anyone whose agent runs as them (`advise`).
|
|
356
358
|
- With it, how the forge keeps it, the ADRs, the tool configurations and the CI configuration from changing unread, as [spec-core's ADR-0005](https://github.com/DescentVTT/spec-core/blob/main/docs/adr/0005-the-family-contract.md) asks; [spec-core's adopting guide](https://github.com/DescentVTT/spec-core/blob/main/docs/adopting.md) has the settings:
|
|
357
359
|
- GitHub: CODEOWNERS and a protected branch;
|
|
@@ -383,12 +385,15 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
|
|
|
383
385
|
- A line that holds more than `init` writes, such as `--root ${CLAUDE_PROJECT_DIR}` or `--brief 012`, is said as it is and fails nothing: `"hook claude --root ${CLAUDE_PROJECT_DIR}" in .claude/settings.json is more than init writes, and guards: the hook reads the line, and its root, /work/app, is a directory`.
|
|
384
386
|
- A line the hook cannot read, and a `--root` that is a file or is not there, are a guard that is off. Each is said in the words the hook itself says before a write, and exits 1.
|
|
385
387
|
- A `--root` that is a directory outside any git work tree is a guard that is off as well, and the one fault the hook says nothing of: it reads a session outside a work tree there, checks no write, and exits 0. `doctor` asks git about the root as the hook does, says so, and exits 1: `"hook claude --root /elsewhere" in .claude/settings.json: --root is "/elsewhere", a directory outside any git work tree, which the hook reads as a session outside one, exit 0 and no word, so the guard is off and no write is checked`.
|
|
388
|
+
- A `--root` in another git work tree than the project's is read by where that work tree is. The hook opens the work tree its root is in and decides by that work tree's briefs, and a file outside it is none of its repository's, which the hook passes with no word. So where that work tree does not hold the project - another repository beside it, or one inside it: a repository of its own, a submodule, a linked worktree - no write to a file of the project is checked. The guard is off, and `doctor` exits 1: `"hook claude --root ../other" in .claude/settings.json: --root is "../other", a directory in /work/other, the work tree of another repository, which does not hold this project: the hook checks a write to a file of that work tree, by its briefs, and passes any other with no word, exit 0, so the guard is off and no write to a file of this project is checked`. Where the work tree holds the project, as a repository the project is nested in does, or the main work tree around a linked one, a write here is checked, by that work tree's briefs and its branch and under the path the file has there: `doctor` says whose briefs they are and fails nothing. A work tree of the project's own repository, linked or main, is named `another work tree of this repository`, and is read by the same rule: a linked worktree beside the project holds none of its files.
|
|
386
389
|
- A line that names a brief which is not one, as `--brief 404` does where spec-brief knows no brief 404 or has archived it, holds every write: the hook exits 2 before each until the line is mended. `doctor` asks spec-brief about the name as the hook does, in the work tree the line's root is in, says it in the words the hook holds the write with, and exits 1: `"hook claude --brief 404" in .claude/settings.json: the flag names brief 404, and spec-brief knows no such brief, so the hook holds every write until the line is mended: name a brief spec-brief lists, or take --brief off the line`.
|
|
387
390
|
- An entry is read with the event it stands under and its matcher, as [Claude Code's hooks reference](https://code.claude.com/docs/en/hooks) has them: Claude Code waits on a hook before a tool call under `PreToolUse` alone, and starts an entry for the tools its matcher lets through - every tool where the matcher is left out, empty or `*`, the tools it names where it is a list of names, and the names a pattern matches otherwise. An entry that runs in the background (`async`, `asyncRewake`) is waited on for none.
|
|
388
391
|
- A line Claude Code waits on before no write is not the guard, whatever the hook would do with it. `doctor` says so with what the line stands under, `"hook claude" in .claude/settings.json: Claude Code waits on it before no write, so it is not the guard and refuses none (under PreToolUse it has the matcher "Bash")`, and a file whose only line is started for no write, before one or after, is not counted among `init`'s entries. It fails nothing: no guard is a choice.
|
|
389
392
|
- A line waited on before some of `Edit`, `Write` and `NotebookEdit` guards those, and `doctor` names the rest, whose writes go unchecked. A line started after no write warns of none, which is said too. Neither fails anything.
|
|
390
393
|
- Those three are the tools a Claude Code from 2.1.139 on writes a file with, by its [tools reference](https://code.claude.com/docs/en/tools-reference). The hook also reads the file a `MultiEdit` names, a tool no release from 2.0.0 on declares, and `init`'s matcher keeps the name; `doctor` says of no line that it leaves `MultiEdit` unchecked, and a line under `Edit|Write|NotebookEdit` is said with nothing more.
|
|
391
|
-
- An `if` on the entry has Claude Code start it only for the tool calls its rule matches. The rule is not read, so which writes the line is asked on is *cannot tell*, never fine; `--strict` fails it. So is a matcher that is
|
|
394
|
+
- An `if` on the entry has Claude Code start it only for the tool calls its rule matches. The rule is not read, so which writes the line is asked on is *cannot tell*, never fine; `--strict` fails it. So is a matcher that is neither text nor an array, or is no pattern, or is a pattern that takes more than a tenth of a second to test against the three names: one can be written that never finishes, and `doctor` and `init` come back all the same.
|
|
395
|
+
- A matcher that lists names with a `,` is read as the Claude Code on `PATH` reads it. By [Claude Code's page on debugging a configuration](https://code.claude.com/docs/en/debug-your-config), a comma separates names as a `|` does from 2.1.191 on; before it, a comma was read as part of a pattern, and the matcher matched no tool. So under 2.1.139 to 2.1.190 a line under `Edit, Write` is waited on before no write and is not the guard: `doctor` says it with the release and what the matcher is from 2.1.191 on, `(under PreToolUse it has the matcher "Edit, Write", which Claude Code 2.1.190 reads as a pattern that matches no tool: a comma separates names from 2.1.191 on, where the matcher is Edit and Write)`, and fails nothing for it, as for any line that is no guard. Where the release cannot be told, which writes the line is started for is *cannot tell*, never fine; `--strict` fails it. So is a matcher with a `,` and a `|` under an older release, where read as a pattern it would match a tool, as `Edit|Write, NotebookEdit` would `Edit`: the page has such a matcher never match, and has the comma read as part of a pattern, and for that matcher the two disagree. A matcher with no comma, the one `init` writes among them, is read the same under every release.
|
|
396
|
+
- A matcher that is an array, `["Edit", "Write"]`, is by the same page an entry Claude Code lists as an invalid setting, and no hook under it is started. Where one stands under `PreToolUse` or `PermissionRequest`, in an entry of the hook's or of any other program's, Claude Code loads none of that settings file's other hooks either. `doctor` says so of every line of the hook the file holds, and a line that stood to be started for a write is a guard that is off, exit 1: `"hook claude" in .claude/settings.json: .claude/settings.json has an array for a matcher under PreToolUse, ["Bash"], which Claude Code lists as an invalid setting, and beside one under PreToolUse or PermissionRequest it loads none of the file's other hooks, so the guard is off and no write is checked`. The hooks of another settings file are read as that file has them.
|
|
392
397
|
- A line with no `args` is one Claude Code hands to a shell. Where it holds a quote, a variable or anything else a shell rewrites after its command, what the hook is handed is *cannot tell*, never fine; `--strict` fails it. Written with `args`, each word reaches the hook as it stands, and `doctor` reads it.
|
|
393
398
|
- `"onFailure": "block"` on the hook's entry is said in a note. Claude Code 2.1.295 or later then holds a write when the hook fails, where without the field the write goes ahead: by the hook's exit 1 for a question it cannot read, by a hook that does not start, or by one that runs past its timeout. With a line that turns the guard off, that is every write, the one that mends the line among them.
|
|
394
399
|
- `--format json` has each line under `claudeCode.lines`: its `file`, `events`, the `line` as written, its `state` (`guards`, `off`, `held`, `unstarted` or `unknown`), `onFailure`, the writing tools Claude Code waits on it `before` a write by and starts it `after` one by, of `Edit`, `Write` and `NotebookEdit` (`null` where that cannot be told), and what is said of it as `detail`, `cover` and `note`, `null` where there is nothing to say. Beside the lines, `claudeCode.presence` is whether the plugin is on this machine, `null` where no plugin is on and otherwise its `state` (`present`, `absent` or `unknown`) with the reason as `detail`; `claudeCode.hooksOff` is the settings file whose `disableAllHooks` is `true`, or `null`; and `claudeCode.notes` holds what is said of the two.
|
|
@@ -403,6 +408,7 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
|
|
|
403
408
|
- The Claude Code in an editor or the desktop app may be another release than the one on `PATH`: check it there with `claude --version` or `/status`.
|
|
404
409
|
- **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.
|
|
405
410
|
- 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`.
|
|
411
|
+
- What stands at the hook's path and is no file is no hook, and git takes it for one all the same. A directory there, which a repository whose `core.hooksPath` is in its work tree can hold, is one git tries to run where it may execute it, which on Windows is always: it cannot, and stops every commit, with `cannot spawn` on Windows and `cannot exec` elsewhere. `doctor` says the path, what it is and what git does with it, and exits 1, since nothing is checked there and nothing is committed: `.git/hooks/pre-commit is a directory, not a hook, and git stops every commit at it`. Where git may not execute it, it skips it as it skips a hook that is not executable: that is said, and fails nothing. `--format json` has the two as the `gitHook.state` `stops` and `ignored`. Only a file is read, so `doctor` and `init` come back from a named pipe there as well.
|
|
406
412
|
|
|
407
413
|
### An input that names nothing
|
|
408
414
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -8,10 +8,18 @@
|
|
|
8
8
|
* failed, an output its reader closed, an error nothing here expected. A run
|
|
9
9
|
* that could not look must never exit as though it looked and found nothing
|
|
10
10
|
* (spec-core ADR-0005).
|
|
11
|
+
*
|
|
12
|
+
* A command loads what it runs. This module imports what every run needs,
|
|
13
|
+
* the hook's before a write among them, and a module that only some
|
|
14
|
+
* commands run is imported where one of them is the command: the hook
|
|
15
|
+
* runs before and after every write an agent makes, and what it loaded of
|
|
16
|
+
* the other commands was a wait before each (ADR-0002). A module that
|
|
17
|
+
* cannot be loaded is thrown where it was asked for, and ends the run in a
|
|
18
|
+
* line and exit 2, as every error does.
|
|
11
19
|
*/
|
|
12
20
|
import { type CliIO } from './workspace.js';
|
|
13
21
|
export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version, type CliIO } from './workspace.js';
|
|
14
|
-
export declare const HELP = "spec-harness - context, guards, audits and rulings for rounds of agent work\n\nUsage:\n spec-harness <command> [options]\n\nCommands:\n context [brief] What an agent needs to start a round: the brief, its scope,\n rulings, dependencies, the rules in force, the cited documents.\n guard <path...> May the round write these paths? Exit 1 if one is refused.\n hook claude Answer a Claude Code PreToolUse or PostToolUse hook (stdin).\n hook git Answer git's pre-commit hook for the staged files.\n escalate Ask a person to rule: --path <file> (repeatable) --reason <why>\n [--option \"<label>: <cost>\"]... [--recommend <text>].\n --list shows what waits; --show <id> prints one memo. Neither\n raises a request: beside one, only --root and --format are read.\n rule <id> Record a person's ruling in the brief: --allow or --deny, with\n --note <text>. It counts once committed signed.\n rulings [brief] The brief's rulings, and whether each one's signature verifies.\n audit [brief] Did the round stay inside the lines: what the archive would say,\n the brief's assertions, unverified rulings, new dependencies\n and the install scripts it allowed.\n probe [brief] Run the brief's probes: red at the base, green at the head.\n --at <base|head|both>, --id <probe>.\n premises Do the live briefs' premises still hold? For CI: exit 1 when one\n is stale, or, with --strict, when spec-guard cannot read one.\n init Configure the spec-* tools to agree. Prints the plan; --write\n applies it; --git-hook adds a pre-commit hook.\n mcp Serve start_round, check_path, request_escalation, audit_round,\n list_rounds and the workflow prompts over MCP on stdio.\n doctor Which siblings are installed, at which versions, which brief is\n named, how Claude Code runs the guard, and git's hook. Exit 1\n when a sibling is older than this release needs, the guard is\n installed twice, its hook's line turns it off or holds every\n write, disableAllHooks switches it
|
|
22
|
+
export declare const HELP = "spec-harness - context, guards, audits and rulings for rounds of agent work\n\nUsage:\n spec-harness <command> [options]\n\nCommands:\n context [brief] What an agent needs to start a round: the brief, its scope,\n rulings, dependencies, the rules in force, the cited documents.\n guard <path...> May the round write these paths? Exit 1 if one is refused.\n hook claude Answer a Claude Code PreToolUse or PostToolUse hook (stdin).\n hook git Answer git's pre-commit hook for the staged files.\n escalate Ask a person to rule: --path <file> (repeatable) --reason <why>\n [--option \"<label>: <cost>\"]... [--recommend <text>].\n --list shows what waits; --show <id> prints one memo. Neither\n raises a request: beside one, only --root and --format are read.\n rule <id> Record a person's ruling in the brief: --allow or --deny, with\n --note <text>. It counts once committed signed.\n rulings [brief] The brief's rulings, and whether each one's signature verifies.\n audit [brief] Did the round stay inside the lines: what the archive would say,\n the brief's assertions, unverified rulings, new dependencies\n and the install scripts it allowed.\n probe [brief] Run the brief's probes: red at the base, green at the head.\n --at <base|head|both>, --id <probe>.\n premises Do the live briefs' premises still hold? For CI: exit 1 when one\n is stale, or, with --strict, when spec-guard cannot read one.\n init Configure the spec-* tools to agree. Prints the plan; --write\n applies it; --git-hook adds a pre-commit hook.\n mcp Serve start_round, check_path, request_escalation, audit_round,\n list_rounds and the workflow prompts over MCP on stdio.\n doctor Which siblings are installed, at which versions, which brief is\n named, how Claude Code runs the guard, and git's hook. Exit 1\n when a sibling is older than this release needs, the guard is\n installed twice, its hook's line turns it off or holds every\n write, disableAllHooks or an array for a matcher switches it\n off, the plugin that is turned on is not on this machine and\n nothing else guards, Claude Code is too old to run its hooks,\n or git stops every commit at what stands for its hook.\n\nOptions:\n --brief <id> The brief the round works on. Otherwise SPEC_BRIEF, then the branch.\n For context, guard, hook, escalate, rulings, audit, probe, premises\n and doctor.\n --base <ref> What the round is measured from. Otherwise \"base\" in\n .spec-harness.json, then the remote's default branch. For context,\n guard, hook, rulings, audit, probe and doctor.\n --root <dir> Run from another directory, which must exist. For every command.\n --format <fmt> pretty or json; audit and premises also gitlab, sarif or github.\n For every command but hook and mcp.\n --strict Warnings fail the run. For guard, audit, premises and doctor.\n --help, --version\n\nAn option a command does not read, an argument it does not take, an option or an\nargument given an empty value, and a brief given both as the argument and as --brief\nare refused, exit 2, not read as if they were not there. doctor says an empty --brief\nor --base in its report. hook claude reads its line as every release has, and says a\nline it cannot read, and a --root that is not a directory, with exit 1: it refuses\nnothing for either.\n\nExit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.\n";
|
|
15
23
|
/**
|
|
16
24
|
* Runs the command line and resolves to the exit code, for an error nothing
|
|
17
25
|
* below expected too.
|
|
@@ -31,6 +39,12 @@ export declare const HELP = "spec-harness - context, guards, audits and rulings
|
|
|
31
39
|
* launcher answers in the same words; here it is a caller's stream that
|
|
32
40
|
* throws it. It is read by its code: stdout and stderr are the only pipes
|
|
33
41
|
* the harness writes to.
|
|
42
|
+
*
|
|
43
|
+
* Nor is a module of the build that is not there. The launcher says a build
|
|
44
|
+
* without its command line in a line, and a module a command asks for where
|
|
45
|
+
* it runs is said in the same line: a build with a file missing is not a
|
|
46
|
+
* defect a stack leads to. It is read by its code too: the harness imports
|
|
47
|
+
* nothing but its own modules and Node's.
|
|
34
48
|
*/
|
|
35
49
|
export declare function run(argv: readonly string[], io: CliIO): Promise<number>;
|
|
36
50
|
export declare function main(argv?: readonly string[]): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -8,24 +8,23 @@
|
|
|
8
8
|
* failed, an output its reader closed, an error nothing here expected. A run
|
|
9
9
|
* that could not look must never exit as though it looked and found nothing
|
|
10
10
|
* (spec-core ADR-0005).
|
|
11
|
+
*
|
|
12
|
+
* A command loads what it runs. This module imports what every run needs,
|
|
13
|
+
* the hook's before a write among them, and a module that only some
|
|
14
|
+
* commands run is imported where one of them is the command: the hook
|
|
15
|
+
* runs before and after every write an agent makes, and what it loaded of
|
|
16
|
+
* the other commands was a wait before each (ADR-0002). A module that
|
|
17
|
+
* cannot be loaded is thrown where it was asked for, and ends the run in a
|
|
18
|
+
* line and exit 2, as every error does.
|
|
11
19
|
*/
|
|
12
20
|
import { SiblingOutputError } from './briefs.js';
|
|
13
|
-
import { auditCommand, contextCommand, escalateCommand, probeCommand, ruleCommand, rulingsCommand } from './commands.js';
|
|
14
21
|
import { ConfigError, SIBLINGS } from './config.js';
|
|
15
|
-
import { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, wiringState, } from './configure.js';
|
|
16
22
|
import { isDirectory } from './fs.js';
|
|
17
23
|
import { GitError, show, stagedChanges } from './git.js';
|
|
18
24
|
import { claudeResponse, gitResponse, parseClaudeHook } from './hooks.js';
|
|
19
|
-
import { claudeVersion } from './host.js';
|
|
20
|
-
import { premisesCommand } from './premises.js';
|
|
21
|
-
import { createReader } from './reader.js';
|
|
22
|
-
import { checkPaths, claudeCodeWiring, diagnoseBrief, resolveBase, specBriefPlugin } from './round.js';
|
|
23
|
-
import { mcpCommand } from './server.js';
|
|
24
|
-
import { gitHook, initCommand } from './setup.js';
|
|
25
|
-
import { notFido2, readAllowedSigners } from './signers.js';
|
|
26
25
|
import { SiblingError } from './siblings.js';
|
|
27
26
|
import { emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, unreadHookLine } from './usage.js';
|
|
28
|
-
import {
|
|
27
|
+
import { CLAUDE_CODE_MINIMUM, MINIMUM_VERSIONS } from './versions.js';
|
|
29
28
|
import { activeBrief, answerOf, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, openWorkspace, parseOptions, UsageError, version, } from './workspace.js';
|
|
30
29
|
export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version } from './workspace.js';
|
|
31
30
|
export const HELP = `spec-harness - context, guards, audits and rulings for rounds of agent work
|
|
@@ -61,9 +60,10 @@ Commands:
|
|
|
61
60
|
named, how Claude Code runs the guard, and git's hook. Exit 1
|
|
62
61
|
when a sibling is older than this release needs, the guard is
|
|
63
62
|
installed twice, its hook's line turns it off or holds every
|
|
64
|
-
write, disableAllHooks
|
|
65
|
-
turned on is not on this machine and
|
|
66
|
-
Claude Code is too old to run its hooks
|
|
63
|
+
write, disableAllHooks or an array for a matcher switches it
|
|
64
|
+
off, the plugin that is turned on is not on this machine and
|
|
65
|
+
nothing else guards, Claude Code is too old to run its hooks,
|
|
66
|
+
or git stops every commit at what stands for its hook.
|
|
67
67
|
|
|
68
68
|
Options:
|
|
69
69
|
--brief <id> The brief the round works on. Otherwise SPEC_BRIEF, then the branch.
|
|
@@ -88,12 +88,17 @@ nothing for either.
|
|
|
88
88
|
Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.
|
|
89
89
|
`;
|
|
90
90
|
/* ------------------------------------------------------------------- guard */
|
|
91
|
-
|
|
91
|
+
/**
|
|
92
|
+
* The reader of a brief's rulings, loaded where a ruling is read: a write
|
|
93
|
+
* to a file no brief protects reads none.
|
|
94
|
+
*/
|
|
95
|
+
const reader = async () => (await import('./reader.js')).createReader();
|
|
92
96
|
async function decisionsFor(workspace, options, io, given, cwd) {
|
|
93
97
|
const active = await activeBrief(workspace, options, io.env);
|
|
94
98
|
const { brief, note, problem } = describeActive(active);
|
|
95
99
|
if (problem !== null)
|
|
96
100
|
return { problem };
|
|
101
|
+
const { checkPaths } = await import('./round.js');
|
|
97
102
|
return { decisions: await checkPaths(workspace, brief, note, given, cwd, reader, options.base) };
|
|
98
103
|
}
|
|
99
104
|
const SYMBOL = { allow: 'ok ', warn: 'warning', ask: 'ask ', deny: 'refused' };
|
|
@@ -248,7 +253,7 @@ async function hookCommand(options, io) {
|
|
|
248
253
|
* the base, and the two on nothing else doctor asks. A --base given nothing
|
|
249
254
|
* is no base, said as the brief's is, in the report.
|
|
250
255
|
*/
|
|
251
|
-
async function baseAndSigners(workspace, flag, signersFile) {
|
|
256
|
+
async function baseAndSigners({ resolveBase }, workspace, flag, signersFile) {
|
|
252
257
|
// Nothing in doctor asks what kind a base is but whether it is resolved, so
|
|
253
258
|
// the mutant that names this one otherwise is equivalent.
|
|
254
259
|
const base = flag !== undefined && isBlank(flag) ? { kind: 'unresolved', reason: emptyValue('--base', 'base', flag) } : await resolveBase(workspace, flag);
|
|
@@ -261,14 +266,23 @@ async function baseAndSigners(workspace, flag, signersFile) {
|
|
|
261
266
|
* How Claude Code is wired to the guard, and the release of the one on
|
|
262
267
|
* `PATH`, which waits on the wiring: only a Claude Code that runs the guard
|
|
263
268
|
* is asked its release, since one older than the hooks need lets every
|
|
264
|
-
* write through unguarded. `null` where it was not asked.
|
|
269
|
+
* write through unguarded. `null` where it was not asked. The wiring asks
|
|
270
|
+
* it itself, since which writes a line is started for can turn on the
|
|
271
|
+
* release as well (round.ts); the two are told apart here for the report,
|
|
272
|
+
* which has a place of its own for the release.
|
|
265
273
|
*/
|
|
266
|
-
async function wiringAndRelease(root, io) {
|
|
267
|
-
const wiring = await claudeCodeWiring(root, io);
|
|
268
|
-
const release = wiringState(wiring) === 'none' ? null : checkClaudeCode(await claudeVersion(io.env));
|
|
274
|
+
async function wiringAndRelease({ claudeCodeWiring }, root, io) {
|
|
275
|
+
const { release, ...wiring } = await claudeCodeWiring(root, io);
|
|
269
276
|
return { wiring, release };
|
|
270
277
|
}
|
|
271
278
|
async function doctorCommand(options, io) {
|
|
279
|
+
// What doctor alone runs of them, loaded before anything is asked: the
|
|
280
|
+
// five things below are asked at once, and none waits on a module.
|
|
281
|
+
const [configure, round, setup, signing] = await Promise.all([import('./configure.js'), import('./round.js'), import('./setup.js'), import('./signers.js')]);
|
|
282
|
+
const { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, wiringState } = configure;
|
|
283
|
+
const { diagnoseBrief, specBriefPlugin } = round;
|
|
284
|
+
const { gitHook } = setup;
|
|
285
|
+
const { notFido2, readAllowedSigners } = signing;
|
|
272
286
|
const workspace = await openWorkspace(options, io);
|
|
273
287
|
const rows = SIBLINGS.map((name) => {
|
|
274
288
|
const sibling = workspace.siblings.locate(name);
|
|
@@ -291,9 +305,9 @@ async function doctorCommand(options, io) {
|
|
|
291
305
|
// failed first by the clock (ADR-0002).
|
|
292
306
|
const [briefAsked, baseAsked, pluginAsked, claudeAsked, hookAsked] = await Promise.allSettled([
|
|
293
307
|
diagnoseBrief(workspace, options, io.env),
|
|
294
|
-
baseAndSigners(workspace, options.base, signersFile),
|
|
308
|
+
baseAndSigners(round, workspace, options.base, signersFile),
|
|
295
309
|
specBriefPlugin(workspace.root),
|
|
296
|
-
wiringAndRelease(workspace.root, io),
|
|
310
|
+
wiringAndRelease(round, workspace.root, io),
|
|
297
311
|
gitHook(workspace.root),
|
|
298
312
|
]);
|
|
299
313
|
const named = answerOf(briefAsked);
|
|
@@ -384,17 +398,19 @@ async function doctorCommand(options, io) {
|
|
|
384
398
|
// and so are a hook whose own line turns the guard off or holds every
|
|
385
399
|
// write, guard hooks that disableAllHooks switches off, a plugin that is
|
|
386
400
|
// turned on and is not on this machine where no hook of init's stands in
|
|
387
|
-
// for it,
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
401
|
+
// for it, a Claude Code too old to run the hooks, and something at the
|
|
402
|
+
// path of git's pre-commit hook that git cannot run and stops every
|
|
403
|
+
// commit at. A release that cannot be told is not fine either, nor a
|
|
404
|
+
// line whose reading cannot be, nor a plugin that cannot be told to be on
|
|
405
|
+
// this machine: --strict fails the three.
|
|
391
406
|
const outdated = rows.some((row) => row.state === 'outdated');
|
|
392
407
|
const usable = rows.some((row) => row.state === 'found');
|
|
393
408
|
const off = wiring.lines.some((line) => line.state === 'off' || line.state === 'held');
|
|
394
409
|
const missing = wiring.presence?.state === 'absent' && wiring.hooks.length === 0;
|
|
395
410
|
const tooOld = release?.state === 'outdated';
|
|
411
|
+
const stopped = hook.state === 'stops';
|
|
396
412
|
const untold = options.strict && (release?.state === 'unknown' || wiring.lines.some((line) => line.state === 'unknown') || wiring.presence?.state === 'unknown');
|
|
397
|
-
return outdated || !usable || claude === 'both' || off || switchedOff || missing || tooOld || untold ? EXIT_FAILED : EXIT_OK;
|
|
413
|
+
return outdated || !usable || claude === 'both' || off || switchedOff || missing || tooOld || stopped || untold ? EXIT_FAILED : EXIT_OK;
|
|
398
414
|
}
|
|
399
415
|
/* ---------------------------------------------------------------- dispatch */
|
|
400
416
|
/**
|
|
@@ -404,19 +420,21 @@ async function doctorCommand(options, io) {
|
|
|
404
420
|
* construct` is an unknown command with exit 2.
|
|
405
421
|
*/
|
|
406
422
|
const COMMANDS = new Map(
|
|
407
|
-
// One for every command that has a shape (usage.ts), which the compiler
|
|
423
|
+
// One for every command that has a shape (usage.ts), which the compiler
|
|
424
|
+
// holds. A command of another module loads its module when it is the one
|
|
425
|
+
// typed, and no other command's.
|
|
408
426
|
Object.entries({
|
|
409
427
|
guard: guardCommand,
|
|
410
428
|
hook: hookCommand,
|
|
411
|
-
context: contextCommand,
|
|
412
|
-
audit: auditCommand,
|
|
413
|
-
escalate: escalateCommand,
|
|
414
|
-
rule: ruleCommand,
|
|
415
|
-
rulings: rulingsCommand,
|
|
416
|
-
probe: probeCommand,
|
|
417
|
-
premises: premisesCommand,
|
|
418
|
-
init: initCommand,
|
|
419
|
-
mcp: mcpCommand,
|
|
429
|
+
context: async (options, io) => (await import('./commands.js')).contextCommand(options, io),
|
|
430
|
+
audit: async (options, io) => (await import('./commands.js')).auditCommand(options, io),
|
|
431
|
+
escalate: async (options, io) => (await import('./commands.js')).escalateCommand(options, io),
|
|
432
|
+
rule: async (options, io) => (await import('./commands.js')).ruleCommand(options, io),
|
|
433
|
+
rulings: async (options, io) => (await import('./commands.js')).rulingsCommand(options, io),
|
|
434
|
+
probe: async (options, io) => (await import('./commands.js')).probeCommand(options, io),
|
|
435
|
+
premises: async (options, io) => (await import('./premises.js')).premisesCommand(options, io),
|
|
436
|
+
init: async (options, io) => (await import('./setup.js')).initCommand(options, io),
|
|
437
|
+
mcp: async (options, io) => (await import('./server.js')).mcpCommand(options, io),
|
|
420
438
|
doctor: doctorCommand,
|
|
421
439
|
}));
|
|
422
440
|
async function dispatch(argv, io) {
|
|
@@ -474,6 +492,12 @@ async function dispatch(argv, io) {
|
|
|
474
492
|
* launcher answers in the same words; here it is a caller's stream that
|
|
475
493
|
* throws it. It is read by its code: stdout and stderr are the only pipes
|
|
476
494
|
* the harness writes to.
|
|
495
|
+
*
|
|
496
|
+
* Nor is a module of the build that is not there. The launcher says a build
|
|
497
|
+
* without its command line in a line, and a module a command asks for where
|
|
498
|
+
* it runs is said in the same line: a build with a file missing is not a
|
|
499
|
+
* defect a stack leads to. It is read by its code too: the harness imports
|
|
500
|
+
* nothing but its own modules and Node's.
|
|
477
501
|
*/
|
|
478
502
|
export async function run(argv, io) {
|
|
479
503
|
try {
|
|
@@ -495,6 +519,8 @@ export async function run(argv, io) {
|
|
|
495
519
|
io.stderr.write(`spec-harness: unexpected error: ${String(error)}\n`);
|
|
496
520
|
else if (error.code === 'EPIPE')
|
|
497
521
|
io.stderr.write('spec-harness: stdout was closed before all of the output was written\n');
|
|
522
|
+
else if (error.code === 'ERR_MODULE_NOT_FOUND')
|
|
523
|
+
io.stderr.write('spec-harness: build output is missing; run "npm run build" first, or install the published package.\n');
|
|
498
524
|
else
|
|
499
525
|
io.stderr.write(`spec-harness: unexpected error: ${error.stack ?? error.message}\n`);
|
|
500
526
|
return EXIT_ERROR;
|