@descent-vtt/spec-harness 0.19.0 → 0.21.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 +325 -0
- package/README.md +39 -18
- package/dist/briefs.d.ts +45 -0
- package/dist/briefs.js +73 -0
- package/dist/briefs.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +308 -142
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +7 -2
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +14 -3
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +94 -3
- package/dist/configure.js +204 -30
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +104 -1
- package/dist/fs.js +383 -11
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +88 -2
- package/dist/git.js +155 -8
- package/dist/git.js.map +1 -1
- package/dist/guard.d.ts +20 -1
- package/dist/guard.js +31 -0
- package/dist/guard.js.map +1 -1
- package/dist/hooks.d.ts +59 -0
- package/dist/hooks.js +76 -3
- package/dist/hooks.js.map +1 -1
- package/dist/round.d.ts +27 -1
- package/dist/round.js +75 -17
- package/dist/round.js.map +1 -1
- package/dist/sandbox.js +4 -3
- package/dist/sandbox.js.map +1 -1
- package/dist/server.d.ts +25 -1
- package/dist/server.js +230 -28
- package/dist/server.js.map +1 -1
- package/dist/setup.js +40 -10
- package/dist/setup.js.map +1 -1
- package/dist/siblings.d.ts +29 -6
- package/dist/siblings.js +152 -39
- package/dist/siblings.js.map +1 -1
- package/dist/usage.d.ts +42 -7
- package/dist/usage.js +65 -6
- package/dist/usage.js.map +1 -1
- package/dist/workspace.d.ts +45 -3
- package/dist/workspace.js +143 -13
- package/dist/workspace.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -91,8 +91,11 @@ An app that makes a branch for a session puts a name of its own before the brief
|
|
|
91
91
|
- A branch that names an id spec-brief does not list, as `claude/brief-999-x`, is not passed over for the next: every write of the session is held until the brief or the branch is mended. Where Claude Code starts the hook as a session starts, the entry `init` and the plugin write under `SessionStart`, the session is told so before its first write.
|
|
92
92
|
- A tag of the same name as the branch does not hide it: the branch is read as HEAD names it.
|
|
93
93
|
- In a worktree session started from the desktop app, and after Claude enters a worktree mid-session, Claude Code starts the hook with `${CLAUDE_PROJECT_DIR}` at the main checkout and hands it the worktree as the question's `cwd` (the worktrees page, *What worktrees share with the main checkout*; [the hooks reference](https://code.claude.com/docs/en/hooks), *Reference scripts by path*). The worktree's branch names the brief, and a worktree with no install of its own is checked with the project's: an Edit of a file brief 042 protects, in a worktree on `claude/brief-042-x` of a main checkout on `main`, is refused.
|
|
94
|
-
-
|
|
95
|
-
-
|
|
94
|
+
- A path is decided by the work tree that holds it, where that is a work tree of the same repository, whichever `cwd` is in: an Edit of a file brief 042 protects, in a worktree on `claude/brief-042-x`, is refused from a session at the main checkout and from one in another worktree, inside the project or beside it. A path in no work tree of the repository is decided as before ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
|
|
95
|
+
- A nested repository, a submodule or a clone inside the tree, is another repository, and a path in it is decided as before, by the work tree of the project's repository that holds it, under the path it has there. What that lets through: a file the nested repository's own brief protects is outside the project's scope, warned of after the write and not refused, and one the project's brief does not reach passes with no word. That repository is guarded by its own hook, in its own sessions.
|
|
96
|
+
- Where `${CLAUDE_PROJECT_DIR}` points in a session started with `claude --worktree` is on no page of Claude Code's: the hooks reference has it the project root where the session started, the worktrees page has the flag start Claude in the worktree, and a worktree a fresh checkout to install dependencies into, and says where it points only for a session started from the desktop app. Where it is the worktree, the next point holds.
|
|
97
|
+
- git's pre-commit hook in such a worktree runs the main work tree's install where the worktree has none, and refuses the same file at the commit. So does every other command, `guard`, `context`, `audit`, `probe` and the rest, where each said that spec-brief is not installed here. A hook an earlier release wrote reads the worktree's own install alone: take it away and run `init --git-hook --write`.
|
|
98
|
+
- Where `${CLAUDE_PROJECT_DIR}` is the worktree itself, the project root a session started in, the hook's script is looked for in the worktree's own `node_modules`. A worktree is a fresh checkout (the worktrees page, *Set up the worktree environment*), and where it has no install, node cannot find the script and exits 1: Claude Code reads that as a hook's non-blocking error, and the write goes ahead (the hooks reference, *Exit code output*). Install in the worktree before such a session writes. From Claude Code 2.1.295, `"onFailure": "block"` on the hook's entry holds the write instead: `init --block-on-failure` writes it on init's PreToolUse, PostToolUse and SessionStart entries, and the plugin does not carry it ([ADR-0012](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0012-one-way-into-claude-code.md)).
|
|
96
99
|
|
|
97
100
|
### Sessions that write through a shell, with no rulings
|
|
98
101
|
|
|
@@ -103,6 +106,7 @@ Claude Code starts the guard for `Edit`, `Write`, `MultiEdit` and `NotebookEdit`
|
|
|
103
106
|
| The guard, before an Edit | an Edit of `src/db/schema.ts`: refused, with the next step | an Edit of `notes.md` under `outOfScope: "warn"`; every shell command |
|
|
104
107
|
| The guard, after an Edit | an Edit of `notes.md`: a warning to Claude | every shell command |
|
|
105
108
|
| The hook as a session starts | a configuration that does not load, an id spec-brief does not list, spec-brief missing or too old where a brief is named: one line, to the person and to Claude | everything: it holds nothing, and the event cannot block |
|
|
109
|
+
| The report at the end of a turn, `"turnReport": true` | every path the work tree holds changed, a shell's writes among them: `src/db/schema.ts` reported as protected and `notes.md` as outside the scope, to Claude as the next turn begins | a change made and undone, or committed, within the turn; an ignored file; a turn an interrupt or an API error ends; and it refuses nothing, the write having happened |
|
|
106
110
|
| git's pre-commit hook, `init --git-hook` | every staged path, a shell's writes among them: `src/db/schema.ts` refused; `notes.md` warned of under `warn`, refused under `ask` and `deny` | `git commit --no-verify`, which skips the hook ([githooks](https://git-scm.com/docs/githooks), *pre-commit*) |
|
|
107
111
|
| `spec-harness audit` | the round's commits from the base: `src/db/schema.ts` changed, an error, and `notes.md`, a warning; exit 1 | what the brief, as it stands at the end, allows |
|
|
108
112
|
| `spec-brief archive --strict --base <branch>` | the same, both errors: the brief is not archived | the same |
|
|
@@ -110,11 +114,11 @@ Claude Code starts the guard for `Edit`, `Write`, `MultiEdit` and `NotebookEdit`
|
|
|
110
114
|
What every layer lets through:
|
|
111
115
|
|
|
112
116
|
- **An edit of the brief.** Its file is always writable, and the audit and the archive read the brief as it stands at the end: a round that adds `notes.md` to `affectedFiles` and takes `src/db/schema.ts` out of `protectedFiles` is archived with `--strict`. The change is in the brief's diff, for the person who approved it to read.
|
|
113
|
-
- **A shell's write**, until the commit.
|
|
117
|
+
- **A shell's write**, until the commit; with `turnReport` on, it is reported at the end of the turn, and not refused.
|
|
114
118
|
- **`git commit --no-verify`**, until the audit.
|
|
115
119
|
- **A Claude Code mod** that handles `tool.check` can approve a call a `PreToolUse` hook blocked, unless the hook is in managed settings ([Claude Code's permissions page](https://code.claude.com/docs/en/permissions), *Precedence among rules, hooks, and mods*).
|
|
116
120
|
|
|
117
|
-
Two more layers were weighed and not built. A `PostToolUse` hook for the shell tools: every shell command would pay for it, it is easy to get around, and the audit stays the gate ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md), amended 2026-09-30). And `"protected": "ask"`: a protected file passes the guard only by a ruling whose commit a person signed, which the audit and the archive check again ([ADR-0006](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0006-a-ruling-is-a-signed-commit.md)), and an approval given in the session is kept nowhere they read.
|
|
121
|
+
Two more layers were weighed and not built. A `PostToolUse` hook for the shell tools: every shell command would pay for it, it is easy to get around, and the audit stays the gate; the report at the end of a turn is paid once a turn, in the background, and reads the work tree rather than a command ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md), amended 2026-09-30). And `"protected": "ask"`: a protected file passes the guard only by a ruling whose commit a person signed, which the audit and the archive check again ([ADR-0006](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0006-a-ruling-is-a-signed-commit.md)), and an approval given in the session is kept nowhere they read.
|
|
118
122
|
|
|
119
123
|
## Commands
|
|
120
124
|
|
|
@@ -170,14 +174,21 @@ May the round write this?
|
|
|
170
174
|
|
|
171
175
|
Paths are compared as the filesystem spells them, links resolved and case corrected, so `SRC/db/schema.ts` does not walk past a protection on `src/db/schema.ts` on Windows.
|
|
172
176
|
|
|
177
|
+
A path through a link is decided two ways: as it reads, its `..` taken first, and as the system writes it, which on Linux and macOS reads a `..` from where the link before it leads and follows a link to nothing to the file it names. Each file is decided by the work tree that holds it, and the stricter answer stands, so `deep/../schema.ts`, `deep` a link to `lib/x`, is refused where `lib/**` is protected, in words that say where the link leads. Which of the two Claude Code's tools write, its hooks reference and its permissions page do not say (read 2026-10-24). On Windows, which reads the `..` first, the two are one ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
|
|
178
|
+
|
|
173
179
|
- `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.
|
|
180
|
+
- While `.spec-harness.json` does not load, a write to that file itself is let through, so that a session can mend it: no decision, so the person's own permission prompt stands, and the line that says why every other write is held, to the person as `systemMessage` and to Claude as `additionalContext`, as `.spec-harness.json: unknown key "outofscope"; ..., so every other write is held until it loads; this write, to /work/app/.spec-harness.json itself, is not checked`. The write is to the file and nothing else as the hook reads a path and as the disk does: a file that stands under that name alone, not a link to one and with no second name, named by every path of the call. A file of the same name elsewhere, a link to it, and the configuration of another work tree than the one that does not load are held as every other write is. After the write the hook loads the file again, and says, exit 2, where it still does not load. While it does not load nothing of it is read, so a brief that protects the file, or an `outOfScope` it sets, does not hold this write; the audit and spec-brief's archive still measure the round's change to it ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
|
|
181
|
+
- A write outside every work tree of the repository is decided as one outside the repository is, whatever holds the writes inside: a brief that is archived, or that spec-brief does not list, whichever names it, and a configuration that does not load. It passes with no word, before the write and after it, and spec-brief is not asked about it; `guard` says it is outside the repository. The work trees are the main one and every linked worktree, as the files git keeps under its common directory name them, and a file of a linked worktree is decided as any other inside, by the worktree that holds it. Where they cannot all be named, a git directory kept apart, an entry git keeps that cannot be read, or a `core.worktree` where the configuration does not load, the write is held as before, and so is a path with a `..` in it, and one that a link outside them, a link to nothing among them, has written into a work tree ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
|
|
174
182
|
- In a worktree, a write is decided by the worktree's brief. Claude Code names the project a session started in, and hands the hook the directory Claude works in as the question's `cwd`: in a worktree session started from the desktop app, and after Claude enters a worktree, the first is the main checkout and the second the worktree. Where `cwd` is in another work tree of the same repository, that work tree's branch names the brief, its briefs and configuration are read, and its siblings are looked for in its own `node_modules` and then the project's, so a worktree with no install is checked with the project's. A path it does not hold, and every path where `cwd` is in another repository or in none, is decided by the project as before ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
|
|
175
183
|
- After a write the hook answers from what it decided before it, for the same call: the run before keeps the warnings it would give under the git common directory, in `spec-harness/calls/`, named by the question's `session_id` and `tool_use_id`, and the run after takes them away and answers with no process started. A refused call keeps nothing, nor one that writes `.spec-harness.json`, spec-brief's configuration, the brief or a file beside it, which are decided again after the write; what no run after a write reads is removed after a day. Under `outOfScope: "ask"` or `"deny"` no write is warned of, and the run after one says nothing with no process started ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
|
|
184
|
+
- On a brief's branch the hook asks spec-brief for the briefs, which on Windows is most of what a write waits on. Within a session it keeps spec-brief's answer, where spec-brief's `list` names every input it was read from (`inputs`), under the git common directory in `spec-harness/answers/`, by the work tree, the session, spec-brief's script and version, the command line, and the variables git and Node read; and it gives the answer again while each file on the list has the digest listed, or is still not there, each directory holds the files listed, or is still not there where it was listed as not there, and the git index and ignore files read as listed, with no process started. Anything else starts spec-brief as before: an input that is missing, unreadable or reads otherwise, `keep` not `true`, an answer with no `inputs`, a command `tools` names, and, where the list names what git read, `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE` or `GIT_COMMON_DIR` set. What is kept is removed after a day; every other command starts spec-brief every time ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
|
|
176
185
|
- As a session starts, resumes, is cleared, compacted or forked, `hook claude` answers Claude Code's SessionStart with what would hold every write of the session, in one line, to the person as `systemMessage` and to Claude as `additionalContext`: a configuration that does not load, a brief the line, `SPEC_BRIEF` or the branch names that spec-brief does not list, spec-brief missing or too old where a brief is named, and what is wrong with the hook's own line. Nothing is said where all is well, and it exits 0 whatever it finds: the event cannot block, and the session's start is not to be stopped by it ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)).
|
|
186
|
+
- With `"turnReport": true` in `.spec-harness.json`, `hook claude` answers Claude Code's `Stop`, which it runs as its main agent finishes a turn: with one `git --no-optional-locks status`, every path the work tree holds changed, a shell's writes among them, that the brief protects or that is outside its scope under the `outOfScope` in force, in the words a write of each is given, twenty by name and how many more, as one `systemMessage`; or nothing. It exits 0 and never has Claude go on. `init` writes the entry with `"async": true`, so the turn waits on none of it and Claude Code hands the report to Claude as the next turn begins; without `async` the person sees it as the turn ends, Claude does not, and the end of the turn waits for it. No ruling is read: a protected file a signed ruling allows is reported, and the audit reads the ruling. The plugin brings no such entry; with it on, `init` says the entry to add ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md), [ADR-0012](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0012-one-way-into-claude-code.md)).
|
|
187
|
+
- A run of the hook has 45 seconds for every process it starts, together, and a process it asks a question of, git or spec-brief's list of the briefs, is stopped after 20 seconds, or after what is left of the 45 where that is less: Claude Code cancels the hook at the 60 seconds `init` and the plugin give its entry, and before a write lets the write go ahead, and several processes that each answered inside their 20 seconds could pass that minute together. At either limit the hook says `cannot check this write: spec-brief did not answer within 20 seconds, and was stopped`, or `within the 45 seconds the hook's run is given`, and exits 2, which holds the write; a process the run reaches with nothing left is not started, and said so. Every command gives a question the same time, and a round's work, a diff, a probe's worktree, a sibling's check, ten minutes ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
|
|
177
188
|
- `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`.
|
|
178
189
|
- `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.
|
|
179
|
-
- A `--root` on the hook's line that is a file, or is not there, is a guard that was set up and is off: `hook claude` says so
|
|
180
|
-
- A line the hook cannot read - an option no command has, an option left without its value, a `--format` or an `--at` that is none, two values for an option that takes one, `--help` beside `--version` - is said the same way: one line
|
|
190
|
+
- A `--root` on the hook's line that is a file, or is not there, is a guard that was set up and is off: `hook claude` says so in one line to the person, as `systemMessage`, and exits 0, and the write goes on. It refuses nothing for it. No release wrote a `--root` there; without one the hook reads the project Claude Code names, then the session's directory, and outside a git work tree it has nothing to guard and says nothing.
|
|
191
|
+
- A line the hook cannot read - an option no command has, an option left without its value, a `--format` or an `--at` that is none, two values for an option that takes one, `--help` beside `--version` - is said the same way: one line to the person, exit 0, and no write held for it. So is a question the hook cannot read, one that is not JSON or names no event. Exit 2 from `hook claude` is for a write it could not check, never for what is wrong with its own line; and no answer of the hook's is exit 1, which Claude Code counts as a hook that failed, so that `"onFailure": "block"` holds a write only for a hook that did not run ([ADR-0005](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0005-a-guard-is-a-guardrail.md)). `hook git` stops the commit on such a line, exit 2, where a person reads why.
|
|
181
192
|
|
|
182
193
|
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)).
|
|
183
194
|
|
|
@@ -367,6 +378,8 @@ Run it in CI: exit 1 when a premise is stale, exit 2 when spec-guard is not ther
|
|
|
367
378
|
|
|
368
379
|
`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.
|
|
369
380
|
|
|
381
|
+
- The server is started for the project Claude Code names. `check_path` decides an absolute path by the work tree of the same repository that holds it, and answers each path as the hook decides a write of it alone: a file outside every work tree of the repository as outside it, whatever names the brief; a path the hook holds for a brief spec-brief does not list or has archived as `deny`, with the reason `held` and the hook's words; and every path, with no brief named, as no brief's, without asking spec-brief. `start_round`, `check_path`, `request_escalation` and `audit_round` read the round, its branch and its brief, in one work tree: the one a `directory` argument names; else the one the client's roots name, where they name exactly one work tree of the repository, asked with `roots/list` where the client declared roots; else the project's. Each says which as `workTree`, and in its text where it is not the project's ([ADR-0008](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0008-one-server-for-rounds.md)).
|
|
382
|
+
|
|
370
383
|
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`.
|
|
371
384
|
|
|
372
385
|
### `init`
|
|
@@ -385,6 +398,7 @@ Configures the family to agree. It prints the plan, merges into files that exist
|
|
|
385
398
|
- when it cannot tell, it says so and writes none: set `"base"` yourself.
|
|
386
399
|
- **Claude Code**: the guard hooks in `.claude/settings.json` and the MCP server in `.mcp.json`, each run with `node` from the project's `node_modules`. The `npx` entries 0.1 wrote are replaced.
|
|
387
400
|
- Beside the hook's entries under `PreToolUse` and `PostToolUse`, one under `SessionStart`, with no matcher: every way a session starts. In a file whose two an earlier release wrote, `init --write` adds it by itself; beside a line of the hook a person wrote or changed it adds none.
|
|
401
|
+
- With `--block-on-failure`, each of its entries before and after a write and as a session starts carries `"onFailure": "block"`, and so do those an earlier run wrote (`update`); the entry at the end of a turn, which runs in the background, is left without it, since the hooks reference gives the field no effect on a `Stop` hook or on one in the background. Claude Code 2.1.295 or later then holds a write where the hook cannot start, as where the project's install is not there, or runs past its timeout, where without the field it lets the write go ahead with a notice. Claude Code's documentation does not say what a release before 2.1.295 does with a key it does not know, and the file is read by every clone's Claude Code, so the field is written only where asked for: ask for it where every Claude Code that reads the file is 2.1.295 or later. The plugin's hooks do not carry it, and a line a person wrote is theirs to give it ([ADR-0012](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0012-one-way-into-claude-code.md)).
|
|
388
402
|
- These are the ones the [Claude Code plugin](#as-a-claude-code-plugin) brings, so while that plugin is on - `"spec-harness@<marketplace>": true` under `enabledPlugins` in your user, project or local Claude Code settings - `init` writes neither and says why (`skip`).
|
|
389
403
|
- To have its entries instead, which every clone reads, turn the Claude Code plugin off for the project, `claude plugin disable spec-harness@spec-tools --scope local`, and run `init` again.
|
|
390
404
|
- It removes nothing: where the Claude Code plugin is on and a file already holds its entry, it says so (`advise`) and leaves the file alone. So where the hook stands in a settings file of your own, which `init` does not write.
|
|
@@ -411,11 +425,12 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
|
|
|
411
425
|
|
|
412
426
|
- **The siblings**: which are installed, at which versions, and how each is run.
|
|
413
427
|
- A sibling older than this release needs is `outdated`, with the minimum and the command that installs a newer one, and exit 1.
|
|
414
|
-
- A command named under `tools` is run as named, and its version is not checked.
|
|
428
|
+
- A command named under `tools` is run as named, with no shell, and its version is not checked. One that cannot be started is said in one way on every host, `cannot start "<command>": name the tool's script, such as ["node", ...]`: a name no program has, a directory, a `.cmd` such as `npx.cmd`, and a script with no `node` before it, which Windows does not run and Linux runs only where it has the mode to run and a `#!` line ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
|
|
415
429
|
- In a linked worktree with no install of its own, the siblings are the main work tree's, as git's hook runs them there, and each such row says whose install it is: `(0.8.0, the install of /work/app: this work tree has none of its own)`. `--format json` has it as `elsewhere`, `null` for a sibling of the work tree's own.
|
|
416
430
|
- **The repository**: its root, the branch, and the brief the flag, `SPEC_BRIEF` or the branch names.
|
|
417
431
|
- 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.
|
|
418
|
-
-
|
|
432
|
+
- Where the branch names it, a brief spec-brief does not know or has archived, the hook holds every write on that branch, and `doctor` exits 1, as for a hook's line that holds every write: `brief none: the branch names brief 999, and spec-brief knows no such brief`. One the flag or `SPEC_BRIEF` names fails nothing: the flag is the question `doctor` was asked, and the variable `doctor` reads is its own, which need not be the one Claude Code's hook is started with. Where the branch names a brief and spec-brief cannot be asked about it, not installed, too old, or failing as it lists the briefs, the hook holds every write on that branch as well, and `doctor` says why on that line and exits 1: `brief none: spec-brief could not say whether it knows brief 001: spec-brief is not installed here: ...`. Under the flag or `SPEC_BRIEF` a spec-brief that could not list the briefs fails nothing, and one that cannot be run is its row's to say. A base that names no commit fails nothing either. `--format json` has the id as `brief` and the reason as `briefProblem`, `null` where there is none ([ADR-0004](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0004-the-active-brief-is-named-not-guessed.md)).
|
|
433
|
+
- But for the branch that archived its brief, which is a round's last commit: a round archives its brief on its own branch before the branch merges. Where git says the brief's archived file, as spec-brief lists it, is in HEAD and not at the merge base of the base and HEAD, `doctor` says so in a note and fails nothing for it, so a round's last pull request passes a CI job that runs `doctor`: `brief 020 was archived on this branch, which waits to merge: briefs/archive/020_compute.md is in HEAD and not at the merge base with main, 8835800a03b3, so this fails nothing; the hook holds every write on the branch all the same`. Where the file is at the merge base, the brief was archived before the branch, a closed round's branch used again, and `doctor` exits 1 as before. Where git cannot say, it cannot tell, and exits 1: a base that names no commit, a history that is shallow, a git that cannot read a tree, an archive not committed at HEAD. So a CI job that runs `doctor` fetches the base under the name it is configured by, with its history (`git fetch origin main:main`, in a clone that is not shallow); without that, a round's last pull request fails `doctor` as it did. It runs `doctor` without `--strict`, which fails where the release of Claude Code cannot be told, as on a runner with no `claude` and `init`'s hooks in the settings. A brief spec-brief does not list, which no round's last commit names, fails as before. The hook holds every write on such a branch in every case, and says the round is archived and ends when the branch merges. `--format json` has the answer as `closedRound`, with its `state` (`waiting-to-merge`, `archived-before` or `unknown`), the `file`, the `mergeBase` and the note as `detail`, and `null` where the branch names no brief spec-brief has archived.
|
|
419
434
|
- **What a signed ruling needs to count**:
|
|
420
435
|
- the base, and whether `--base`, `.spec-harness.json` or the remote named it;
|
|
421
436
|
- whether the allowed-signers file is on that base;
|
|
@@ -426,14 +441,18 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
|
|
|
426
441
|
- A line OpenSSH refuses is named as no signer, with why. Its quotes are read as OpenSSH reads them: in the principals, the first double quote, wherever it opens, is taken out with the next, which ends the field; an option's value is always in double quotes, `\"` a quote inside it. And its options are OpenSSH's: `cert-authority`, `namespaces`, `valid-after` and `valid-before`, separated by commas.
|
|
427
442
|
- **How Claude Code runs the guard**: the Claude Code plugin, with the settings file that turns it on; `init`'s hooks and server; both, which guards every write twice and exits 1, with how to keep one; or neither.
|
|
428
443
|
- Where a settings file guards writes and none starts the hook as a session starts, a note says so and how to add it, `spec-harness init --write`, and fails nothing. `--format json` has `claudeCode.sessionStart`.
|
|
444
|
+
- Where `"turnReport"` is on and no settings file starts the hook as a turn ends, a note says that no turn's end is reported, and how to add the entry, and fails nothing; the plugin brings none. `--format json` has `claudeCode.turnEnd`.
|
|
445
|
+
- Run in a linked worktree where something wires the guard, a note says which release of spec-harness the hook's line runs, where the main checkout's install and this work tree's are not one: the line names its script under `${CLAUDE_PROJECT_DIR}`, which is this work tree in a session started in it, and the main checkout in a worktree session of the desktop app, where by [Claude Code's worktrees page](https://code.claude.com/docs/en/worktrees) Claude Code reads the hooks from the main checkout's root and points the placeholder at it. Either decides a write by the work tree that holds it, from 0.19.0 on, so the note says which release answers, and fails nothing.
|
|
429
446
|
- `"disableAllHooks": true` in a settings file switches the guard off: by [Claude Code's settings reference](https://code.claude.com/docs/en/settings-reference) it turns off the hooks of the user's, the project's and the local settings and of plugins. `doctor` reads the key from the three files as Claude Code merges them, the last that sets it deciding, names the file that holds it, and exits 1 where there are guard hooks for it to switch off. Where nothing is wired it is a note. Hooks of managed settings, and of a plugin managed settings force on, keep running; `doctor` reads no managed settings and no `--settings`.
|
|
430
447
|
- A hook in Claude Code's settings is the guard by its command, `spec-harness` or its script, and the words `hook claude`, whatever else is on its line. `doctor` reads the line as the hook reads it, through the same code, with the project in place of `${CLAUDE_PROJECT_DIR}`, and says what the hook does with it.
|
|
431
448
|
- Only an entry Claude Code runs as a command is a line of the hook, `"type": "command"`. An entry of another handler type (`http`, `mcp_tool`, `prompt`, `agent`) runs no command, whatever it holds, and is not reported. One with no `type`, or with a word the hooks reference does not list, is *cannot tell*, never fine: the reference requires the field and says nothing of an entry without it; `--strict` fails it.
|
|
432
449
|
- 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`.
|
|
433
450
|
- 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.
|
|
451
|
+
- A line whose script is not there is a guard that is off as well, said with the script and how to install it, and exits 1: [Claude Code's hooks reference](https://code.claude.com/docs/en/hooks) counts a hook that cannot start as one that failed, and lets the write go ahead. Where its entry sets `"onFailure": "block"` and the Claude Code on `PATH` is 2.1.295 or later, it is said as a line that holds every write, since Claude Code holds each write before which the hook fails, and exits 1 too. The script is looked for where `${CLAUDE_PROJECT_DIR}` can name the project as a session starts: the work tree `doctor` runs in, and in a linked worktree the main checkout as well, where a worktree session of the desktop app finds it; it is not there only where it is in neither. A relative path, which is read from wherever the session stands, a name npx or `PATH` finds, and in a line a shell reads a word with anything a shell rewrites, are not looked for. `init` does not look: the line it writes names the project's install, which may come after it.
|
|
452
|
+
- A line that asks for the help or the version, with `--help`, `--version`, `-h` or `-v`, is a guard that is off as well. The hook prints the help or the version, exit 0, in place of reading its question, and [Claude Code's hooks reference](https://code.claude.com/docs/en/hooks) has what a hook that exits 0 prints go to the debug log: no write is checked, and nothing says so. `doctor` says it and exits 1, whatever else is on the line: `"hook claude --help" in .claude/settings.json: the line asks for the help, which the hook prints, exit 0, in place of checking the write, so the guard is off and no write is checked`. Beside a `--root` that is a file or is not there, it is a line the hook cannot read, which the hook says before a write as it says the root without either.
|
|
434
453
|
- 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`.
|
|
435
454
|
- 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.
|
|
436
|
-
- 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`.
|
|
455
|
+
- 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`. So does a line that names a brief where spec-brief cannot be run, not installed or too old, which the hook asks before each write.
|
|
437
456
|
- 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.
|
|
438
457
|
- 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.
|
|
439
458
|
- 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.
|
|
@@ -443,8 +462,9 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
|
|
|
443
462
|
- A matcher of names with a hyphen in one, `Edit|Write|my-tool`, is read as the Claude Code on `PATH` reads it too. By [Claude Code's changelog](https://code.claude.com/docs/en/changelog), 2.1.195, such a matcher matched as a substring before that release and exactly from it on; the changelog does not say how the rest of it was read, the names it lists exactly, the matcher as a pattern, under which `Edit` is in `NotebookEdit` too, or its whole text, which is in no tool's name. So under 2.1.139 to 2.1.194, and where the release cannot be told, which writes the line is started for is *cannot tell* wherever those readings differ, never fine; `--strict` fails it: `(under PreToolUse it has the matcher "Edit|Write|my-tool", which Claude Code 2.1.194 may read otherwise: before 2.1.195 a matcher with a hyphenated name in it matched as a substring, and Claude Code's changelog does not say how the rest of it was read; from 2.1.195 on it is Edit and Write)`. A matcher whose readings all come to no tool that writes, `Bash|my-tool`, is no guard under any release, and nothing is said of the release.
|
|
444
463
|
- 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. Where such an array stands in the project's `.claude/settings.json`, the file `init` writes its hooks into, `doctor`'s advice is the array's first, since `init` writes no hook there while it stands, and then `init --write`. `--format json` has the array as `claudeCode.unloaded`, `null` where there is none.
|
|
445
464
|
- 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.
|
|
446
|
-
- `"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
|
|
447
|
-
-
|
|
465
|
+
- `"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 a hook that does not start, or by one that runs past its timeout. The hook says each of its own answers with exit 0 or 2, a line that turns the guard off among them, so the field holds no write for those. Where the Claude Code on `PATH` is 2.1.295 or later and a line that guards sets no `onFailure`, a note advises it, with `init --write --block-on-failure` for `init`'s own entries, and fails nothing: every Claude Code that reads the file has to be one that reads the field.
|
|
466
|
+
- An entry whose `timeout` is below the 45 seconds a run of the hook is given is said in a note, with the event it stands under: Claude Code cancels a hook at its entry's timeout, and before a write a cancelled hook lets the write go ahead, so a run that needs its budget is cut short. It fails nothing; `init` writes 60, and an entry with none has the 600 seconds Claude Code's hooks reference, read 2026-10-24, gives a command hook by default ([ADR-0002](https://github.com/DescentVTT/spec-harness/blob/main/docs/adr/0002-siblings-through-their-command-lines.md)).
|
|
467
|
+
- `--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`, `note` and `timeout`, `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. Entries that name the harness in two ways, by two scripts or with a shell and without one, are two lines.
|
|
448
468
|
- The plugin's own hooks are not read: they are a release's files, and every release wrote `hook claude` and no more on the line, under the matcher `init` writes. What is read is whether the plugin that settings turn on is on this machine, by [Claude Code's plugin loading reference](https://code.claude.com/docs/en/plugins/loading), from the directories Claude Code keeps plugins in: `plugins` in its configuration directory, or what `CLAUDE_CODE_PLUGIN_CACHE_DIR` names, and each directory `CLAUDE_CODE_PLUGIN_SEED_DIR` names.
|
|
449
469
|
- *On the machine*: a version of it is in a cache, `cache/<marketplace>/spec-harness/<version>/`, which no `.orphaned_at` marks as left behind. Nothing more is said.
|
|
450
470
|
- *Not on the machine*: the project's `.claude/settings.json` alone turns it on, no version of it is cached, and no marketplace of its name is recorded as fetched (`known_marketplaces.json`) or declared in settings (`extraKnownMarketplaces`). Claude Code fetches no plugin that a project's settings alone turn on, so the plugin brings no hook and is not counted. With `init`'s hooks in a settings file, those are the guard, and the plugin is a note. Without them `doctor` exits 1 and says what would bring it.
|
|
@@ -458,8 +478,8 @@ The first thing to run when a hook refuses something unexpectedly. It reports:
|
|
|
458
478
|
- 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`.
|
|
459
479
|
- 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.
|
|
460
480
|
- A hook file the account may not read is said with what git does with it, where `doctor` and `init` ended on a stack. Where git may not execute it either, it skips it with a hint and commits: said, and nothing fails. Where it may, git runs it, and a script the shell cannot read stops every commit, where a program runs unread: whether it runs spec-harness is *cannot tell*, never fine; `--strict` fails it. `--format json` has the `gitHook.state` `unreadable`.
|
|
461
|
-
- A `core.hooksPath` that names a file is one git names no hook under: it says `Invalid path ...: Not a directory`, and commits with no hook. `doctor` says that where git runs its hooks cannot be told, in git's own words, where it gave the reason it has for a git older than 2.31.
|
|
462
|
-
- **What stands where a file is read.** A settings file, `.mcp.json` or a record of Claude Code's plugins that is neither a file nor a directory, such as a named pipe, is read as one that cannot be read as JSON, as a directory is: a pipe nothing writes to was read without end, and neither `doctor` nor `init` came back. `.spec-harness.json` that is neither stops every command, the hook among them, with `.spec-harness.json is neither a file nor a directory, not a configuration file`, exit 2, as one that is a directory does.
|
|
481
|
+
- A `core.hooksPath` that names a file is one git names no hook under: it says `Invalid path ...: Not a directory`, and commits with no hook. `doctor` says that where git runs its hooks cannot be told, in git's own words, where it gave the reason it has for a git older than 2.31. That is *cannot tell*, never fine; `--strict` fails it, where it failed nothing.
|
|
482
|
+
- **What stands where a file is read.** A settings file, `.mcp.json` or a record of Claude Code's plugins that is neither a file nor a directory, such as a named pipe, is read as one that cannot be read as JSON, as a directory is: a pipe nothing writes to was read without end, and neither `doctor` nor `init` came back. `.spec-harness.json` that is neither stops every command, the hook among them, with `.spec-harness.json is neither a file nor a directory, not a configuration file`, exit 2, as one that is a directory does. One that cannot be read, which the account may not read or another process holds open on Windows, is said as that, with the reason: `.spec-harness.json cannot be read: EACCES: permission denied, open ...`, exit 2, where it was said to be JSON that is not valid. Every other file the harness reads is read only where it is a file, and what else stands there as a file that is not there: a document a brief cites, a sibling's `package.json`, what the run before a write kept, git's configuration and a worktree's `commondir` as the hook reads them, a request waiting for a ruling, and a probe's report, said as `neither a file nor a directory`.
|
|
463
483
|
|
|
464
484
|
### An input that names nothing
|
|
465
485
|
|
|
@@ -475,7 +495,7 @@ An option, an argument or a value that is given and names nothing is refused: ex
|
|
|
475
495
|
| `premises` | `--brief`, `--root`, `--format`, `--strict` |
|
|
476
496
|
| `escalate` | `--brief`, `--root`, `--format`, `--path`, `--reason`, `--option`, `--recommend`, `--list`, `--show` |
|
|
477
497
|
| `rule` | `--root`, `--format`, `--allow`, `--deny`, `--note` |
|
|
478
|
-
| `init` | `--root`, `--format`, `--write`, `--git-hook` |
|
|
498
|
+
| `init` | `--root`, `--format`, `--write`, `--git-hook`, `--block-on-failure` |
|
|
479
499
|
| `hook` | `--brief`, `--base`, `--root` |
|
|
480
500
|
| `mcp` | `--root` |
|
|
481
501
|
|
|
@@ -485,19 +505,19 @@ An option, an argument or a value that is given and names nothing is refused: ex
|
|
|
485
505
|
- **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.
|
|
486
506
|
- **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.
|
|
487
507
|
- **A `--root` that is no directory**, a file or a path that is not there: `--root is "nowhere", which is not a directory`.
|
|
488
|
-
- **An option that takes one value, given two.** `doctor --root nowhere --root .` ran in `.`, and `--brief 404 --brief 001` was brief 001: the parser kept the last, and the earlier went unread with no word. It is `--root is given twice, as "nowhere" and as ".", and one of the two would go unread; give it one`. Two values are compared as the option reads them, so the same thing given twice is the option once: `--root .` beside `--root ./`, a directory however it is spelt from where the command is started, and `--brief 12` beside `--brief 012`, a brief by its id as spec-brief's names are matched. A base, a format, an id and text are compared as written. `--path` and `--option` are lists, and each use adds to the list.
|
|
508
|
+
- **An option that takes one value, given two.** `doctor --root nowhere --root .` ran in `.`, and `--brief 404 --brief 001` was brief 001: the parser kept the last, and the earlier went unread with no word. It is `--root is given twice, as "nowhere" and as ".", and one of the two would go unread; give it one`. Two values are compared as the option reads them, so the same thing given twice is the option once: `--root .` beside `--root ./`, a directory however it is spelt from where the command is started, a drive letter on Windows in either case (`--root C:\work` beside `--root c:\work`, which [spec-core's ADR-0005](https://github.com/DescentVTT/spec-core/blob/main/docs/adr/0005-the-family-contract.md) has every tool read as one: Windows names a volume by its letter in either case), and `--brief 12` beside `--brief 012`, a brief by its id as spec-brief's names are matched. A base, a format, an id and text are compared as written. `--path` and `--option` are lists, and each use adds to the list.
|
|
489
509
|
- **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.
|
|
490
510
|
|
|
491
511
|
A line that is wrong is refused with `--help` or `--version` on it, each of the above and a command that is none, as an option no command has always was: `doctor --help --root a --root b` printed the help, exit 0. `--help` beside `--version` asks for two answers, of which one would go unread, and is refused: `--help is given beside --version, and one of the two answers would go unread; give one of them`. On a line that is right, either answers, a command that needs more than the line gives it among them, as `guard --help` does. Three things are read another way, each for its reason:
|
|
492
512
|
|
|
493
513
|
- **`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. 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)).
|
|
494
514
|
|
|
495
|
-
Two things on its line are said, and still refuse nothing. Each is one line
|
|
515
|
+
Two things on its line are said, and still refuse nothing. Each is one line to the person, as `systemMessage`, and exit 0, said where there is a write the line leaves unchecked: Claude Code goes on with the write. It was exit 1, which Claude Code counts as a hook that failed, and under `"onFailure": "block"` that held every write of the session. No release wrote more than `hook claude` on the hook's line, so either is a line a person changed.
|
|
496
516
|
|
|
497
517
|
- **A `--root` that is a file, or is not there.** It read as a session outside a git work tree, exit 0 and no word, and the guard was off. It is `--root is "nowhere", which is not a directory, so the guard is off and this write is not checked`, with what to do. An empty `--root` is the directory the hook was started in, and guards there, as before.
|
|
498
|
-
- **A line the parser cannot read**: an option no command has, as in `hook claude --nope`; an option left without its value, as `--root $DIR` is in a shell where the variable is not set; a `--format` or an `--at` that is none; two values for an option that takes one, whichever option it is, as in `hook claude --brief 001 --brief 404`, with `--help` beside them or not; `--help` beside `--version
|
|
518
|
+
- **A line the parser cannot read**: an option no command has, as in `hook claude --nope`; an option left without its value, as `--root $DIR` is in a shell where the variable is not set; a `--format` or an `--at` that is none; two values for an option that takes one, whichever option it is, as in `hook claude --brief 001 --brief 404`, with `--help` beside them or not; `--help` beside `--version`; and `--help` or `--version` beside a `--root` that is a file or is not there, which the hook said only as it ran, and printed the help over, exit 0. Each was exit 2, as it is from every other command, and Claude Code blocks the tool call on a PreToolUse hook's exit 2: every write of the session waited on a line only a write can mend. It is `the hook's line cannot be read (Option '--root <value>' argument missing), so the guard is off and this write is not checked`, with what to do. The parser's own words stand between the brackets, on one line.
|
|
499
519
|
|
|
500
|
-
Exit 2 from `hook claude` is kept for a write it could not check: a configuration that does not load, briefs that cannot be read, a brief that is named and is not one, an error nothing expected. `hook git` is not read this way. It stops one commit, exit 2, on a line a person reads, who can then mend the hook. Nor is a line that does not say `hook claude`, such as `hook` alone: the harness cannot tell whose it is. A hook entry that sets `"onFailure": "block"`, which Claude Code 2.1.295 and later read, has Claude Code hold the action when the hook fails,
|
|
520
|
+
Exit 2 from `hook claude` is kept for a write it could not check: a configuration that does not load, briefs that cannot be read, a brief that is named and is not one, an error nothing expected. `hook git` is not read this way. It stops one commit, exit 2, on a line a person reads, who can then mend the hook. Nor is a line that does not say `hook claude`, such as `hook` alone: the harness cannot tell whose it is. A hook entry that sets `"onFailure": "block"`, which Claude Code 2.1.295 and later read, has Claude Code hold the action when the hook fails: when it cannot start, runs past its timeout, or exits with a code other than 0 and 2, which no answer of the hook's is. `init --block-on-failure` writes the field; the plugin does not. [`doctor`](#doctor) says each of these of a line it finds in Claude Code's settings, before any write meets it.
|
|
501
521
|
- **`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.
|
|
502
522
|
- **`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)).
|
|
503
523
|
|
|
@@ -571,6 +591,7 @@ A person who runs the tools by hand runs `npx --no-install @descent-vtt/spec-har
|
|
|
571
591
|
| `assertions.premises` | `The Defect, Measured`, `Premises`, `Preconditions` | Sections whose assertions state what was true before the round. |
|
|
572
592
|
| `probes.runs`, `probes.timeout` | `2`, `600` | Runs per probe, and seconds per run. |
|
|
573
593
|
| `tools` | found in `node_modules` | A sibling's command by name: `{ "spec-brief": ["node", "path/to/spec-brief.js"] }`. |
|
|
594
|
+
| `turnReport` | `false` | `true`: as each of Claude Code's turns ends, the hook reports every changed path the brief protects or that is outside its scope, to Claude as the next turn begins ([`guard` and the hooks](#guard-path-and-the-hooks)). `init` writes the entry that runs it where it is on. |
|
|
574
595
|
|
|
575
596
|
**Exit codes:**
|
|
576
597
|
|
package/dist/briefs.d.ts
CHANGED
|
@@ -19,6 +19,51 @@ export declare class SiblingOutputError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
/** Reads the document `spec-brief list --format json` prints. */
|
|
21
21
|
export declare function parseBriefList(document: unknown): BriefRow[];
|
|
22
|
+
/**
|
|
23
|
+
* A file a `list` answer was read from, by the SHA-256 of its bytes in hex;
|
|
24
|
+
* or `null` where spec-brief looked for it and found none, which is then to
|
|
25
|
+
* be found none again.
|
|
26
|
+
*/
|
|
27
|
+
export interface InputFile {
|
|
28
|
+
readonly path: string;
|
|
29
|
+
readonly sha256: string | null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A directory whose files a `list` answer was read from, by their names:
|
|
33
|
+
* entries that are files, as spec-brief lists them; or `null` where
|
|
34
|
+
* spec-brief looked for the directory and found none, which is then to be
|
|
35
|
+
* found none again.
|
|
36
|
+
*/
|
|
37
|
+
export interface InputDirectory {
|
|
38
|
+
readonly path: string;
|
|
39
|
+
readonly entries: readonly string[] | null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Everything a `list` answer was read from, as spec-brief lists it: its
|
|
43
|
+
* version, the files and directories it read, and what git read for it, the
|
|
44
|
+
* index and the ignore files, or `null` where it asked git nothing. A path
|
|
45
|
+
* is relative to the work tree and POSIX, or absolute where git names a
|
|
46
|
+
* file outside it (spec-core ADR-0005, amended 2026-10-19).
|
|
47
|
+
*/
|
|
48
|
+
export interface BriefInputs {
|
|
49
|
+
readonly tool: string;
|
|
50
|
+
readonly files: readonly InputFile[];
|
|
51
|
+
readonly directories: readonly InputDirectory[];
|
|
52
|
+
readonly git: {
|
|
53
|
+
readonly index: string | null;
|
|
54
|
+
readonly ignores: readonly InputFile[];
|
|
55
|
+
} | null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* What a `list` document says its answer was read from, where it may be
|
|
59
|
+
* given again; `null` where it lists nothing, where `keep` is anything but
|
|
60
|
+
* `true`, where it names nothing, and where anything on it is in a form not
|
|
61
|
+
* read here. Each is a doubt, and a doubt starts spec-brief as before
|
|
62
|
+
* (ADR-0002). The rows are `parseBriefList`'s to read, whatever this
|
|
63
|
+
* answers: a list this cannot read keeps an answer from being given again,
|
|
64
|
+
* and changes nothing of it.
|
|
65
|
+
*/
|
|
66
|
+
export declare function inputsOf(document: unknown): BriefInputs | null;
|
|
22
67
|
/**
|
|
23
68
|
* How the active brief was named, for messages. An argument is the id a
|
|
24
69
|
* command takes after its name, or a tool call's `brief`: said to be the
|
package/dist/briefs.js
CHANGED
|
@@ -83,6 +83,79 @@ export function parseBriefList(document) {
|
|
|
83
83
|
};
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
|
+
const DIGEST = /^[0-9a-f]{64}$/;
|
|
87
|
+
function inputPath(value) {
|
|
88
|
+
return typeof value === 'string' && value !== '';
|
|
89
|
+
}
|
|
90
|
+
function digestOf(value) {
|
|
91
|
+
return value === null || (typeof value === 'string' && DIGEST.test(value)) ? value : undefined;
|
|
92
|
+
}
|
|
93
|
+
function inputFiles(value) {
|
|
94
|
+
if (!Array.isArray(value))
|
|
95
|
+
return null;
|
|
96
|
+
const files = [];
|
|
97
|
+
for (const item of value) {
|
|
98
|
+
if (!isObject(item) || !inputPath(item['path']))
|
|
99
|
+
return null;
|
|
100
|
+
const sha256 = digestOf(item['sha256']);
|
|
101
|
+
if (sha256 === undefined)
|
|
102
|
+
return null;
|
|
103
|
+
files.push({ path: item['path'], sha256 });
|
|
104
|
+
}
|
|
105
|
+
return files;
|
|
106
|
+
}
|
|
107
|
+
function inputDirectories(value) {
|
|
108
|
+
if (!Array.isArray(value))
|
|
109
|
+
return null;
|
|
110
|
+
const directories = [];
|
|
111
|
+
for (const item of value) {
|
|
112
|
+
if (!isObject(item) || !inputPath(item['path']))
|
|
113
|
+
return null;
|
|
114
|
+
const entries = item['entries'];
|
|
115
|
+
// A name listed twice is a list that is not of one directory's files.
|
|
116
|
+
if (entries !== null && (!Array.isArray(entries) || !entries.every((entry) => typeof entry === 'string') || new Set(entries).size !== entries.length))
|
|
117
|
+
return null;
|
|
118
|
+
directories.push({ path: item['path'], entries });
|
|
119
|
+
}
|
|
120
|
+
return directories;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* What a `list` document says its answer was read from, where it may be
|
|
124
|
+
* given again; `null` where it lists nothing, where `keep` is anything but
|
|
125
|
+
* `true`, where it names nothing, and where anything on it is in a form not
|
|
126
|
+
* read here. Each is a doubt, and a doubt starts spec-brief as before
|
|
127
|
+
* (ADR-0002). The rows are `parseBriefList`'s to read, whatever this
|
|
128
|
+
* answers: a list this cannot read keeps an answer from being given again,
|
|
129
|
+
* and changes nothing of it.
|
|
130
|
+
*/
|
|
131
|
+
export function inputsOf(document) {
|
|
132
|
+
if (!isObject(document))
|
|
133
|
+
return null;
|
|
134
|
+
const inputs = document['inputs'];
|
|
135
|
+
if (!isObject(inputs) || inputs['keep'] !== true || typeof inputs['tool'] !== 'string')
|
|
136
|
+
return null;
|
|
137
|
+
const files = inputFiles(inputs['files']);
|
|
138
|
+
const directories = inputDirectories(inputs['directories']);
|
|
139
|
+
const git = inputs['git'];
|
|
140
|
+
let asked;
|
|
141
|
+
if (git === null) {
|
|
142
|
+
asked = null;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
if (!isObject(git))
|
|
146
|
+
return null;
|
|
147
|
+
const index = digestOf(git['index']);
|
|
148
|
+
const ignores = inputFiles(git['ignores']);
|
|
149
|
+
if (index === undefined || ignores === null)
|
|
150
|
+
return null;
|
|
151
|
+
asked = { index, ignores };
|
|
152
|
+
}
|
|
153
|
+
// A list that names nothing is an answer that cannot be told from one that
|
|
154
|
+
// was read from nowhere.
|
|
155
|
+
if (files === null || directories === null || files.length + directories.length === 0)
|
|
156
|
+
return null;
|
|
157
|
+
return { tool: inputs['tool'], files, directories, git: asked };
|
|
158
|
+
}
|
|
86
159
|
/**
|
|
87
160
|
* The brief a round is working on: named by an argument, by a flag, by
|
|
88
161
|
* `SPEC_BRIEF`, or by the branch - never guessed from which brief happens to
|
package/dist/briefs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"briefs.js","sourceRoot":"","sources":["../src/briefs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The briefs, as spec-brief reports them, and which one a round is working on.\n *\n * spec-brief owns the brief format and its configuration (its ADR-0003), so\n * the harness reads briefs the way spec-brief does by asking it:\n * `spec-brief list --archived --format json`. This module reads that\n * document and nothing else; a shape it does not recognise is an error, never\n * a best-effort reading of half the fields (ADR-0002).\n */\n\nimport { sameId } from './branch.js';\nimport type { BriefRow } from './types.js';\n\n/**\n * The spec-brief JSON document versions this harness reads. Version 1 is the\n * one spec-brief 0.1.0 prints; version 2 changed only `matrix`, which the\n * harness never reads, so the `list` and `archive` documents are the same.\n */\nexport const SUPPORTED_SCHEMA_VERSIONS: readonly number[] = [1, 2, 3];\n\nexport class SiblingOutputError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'SiblingOutputError';\n }\n}\n\ntype Json = Record<string, unknown>;\n\nfunction isObject(value: unknown): value is Json {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction strings(value: unknown, where: string): string[] {\n if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {\n throw new SiblingOutputError(`${where} is not a list of strings`);\n }\n return value;\n}\n\nfunction nullableString(value: unknown, where: string): string | null {\n if (value === null || value === undefined) return null;\n if (typeof value !== 'string') throw new SiblingOutputError(`${where} is not a string`);\n return value;\n}\n\n/** Reads the document `spec-brief list --format json` prints. */\nexport function parseBriefList(document: unknown): BriefRow[] {\n if (!isObject(document) || document['tool'] !== 'spec-brief' || document['command'] !== 'list') {\n throw new SiblingOutputError('spec-brief did not print a list document');\n }\n const version = document['schemaVersion'];\n // The list holds numbers alone, so `includes` refuses whatever is not one:\n // the `typeof` is the compiler's, and the mutant without it is equivalent.\n if (typeof version !== 'number' || !SUPPORTED_SCHEMA_VERSIONS.includes(version)) {\n throw new SiblingOutputError(\n `spec-brief printed schemaVersion ${String(version)}; this harness reads ${SUPPORTED_SCHEMA_VERSIONS.join(', ')}`,\n );\n }\n const briefs = document['briefs'];\n if (!Array.isArray(briefs)) throw new SiblingOutputError('spec-brief list has no \"briefs\"');\n return briefs.map((row, index) => {\n const where = `briefs[${index}]`;\n if (!isObject(row)) throw new SiblingOutputError(`${where} is not an object`);\n const id = row['id'];\n const file = row['file'];\n if (typeof id !== 'string' || typeof file !== 'string') throw new SiblingOutputError(`${where} has no id or file`);\n const phase = row['phase'];\n if (phase !== 'live' && phase !== 'archived') throw new SiblingOutputError(`${where}.phase is \"${String(phase)}\"`);\n const tasks = row['tasks'];\n const wave = row['wave'];\n return {\n id,\n file,\n title: nullableString(row['title'], `${where}.title`),\n phase,\n status: nullableString(row['status'], `${where}.status`),\n type: nullableString(row['type'], `${where}.type`),\n wave: typeof wave === 'number' ? wave : null,\n dependsOn: strings(row['dependsOn'] ?? [], `${where}.dependsOn`),\n affectedFiles: strings(row['affectedFiles'] ?? [], `${where}.affectedFiles`),\n protectedFiles: strings(row['protectedFiles'] ?? [], `${where}.protectedFiles`),\n tasks: {\n total: isObject(tasks) && typeof tasks['total'] === 'number' ? tasks['total'] : 0,\n checked: isObject(tasks) && typeof tasks['checked'] === 'number' ? tasks['checked'] : 0,\n },\n ready: row['ready'] === true,\n waitingOn: strings(row['waitingOn'] ?? [], `${where}.waitingOn`),\n };\n });\n}\n\n/**\n * How the active brief was named, for messages. An argument is the id a\n * command takes after its name, or a tool call's `brief`: said to be the\n * flag's, it sent a person looking for a `--brief` nobody had typed.\n */\nexport type ActiveSource = 'argument' | 'flag' | 'environment' | 'branch';\n\nexport type ActiveBrief =\n | { readonly kind: 'found'; readonly brief: BriefRow; readonly source: ActiveSource }\n | { readonly kind: 'none'; readonly reason: string }\n | { readonly kind: 'unknown'; readonly id: string; readonly source: ActiveSource }\n | { readonly kind: 'archived'; readonly brief: BriefRow; readonly source: ActiveSource };\n\n/**\n * The brief a round is working on: named by an argument, by a flag, by\n * `SPEC_BRIEF`, or by the branch - never guessed from which brief happens to\n * be the only active one (ADR-0004).\n */\nexport function findActive(\n briefs: readonly BriefRow[],\n named: {\n readonly argument?: string | undefined;\n readonly flag?: string | undefined;\n readonly environment?: string | undefined;\n readonly branch?: string | null | undefined;\n },\n): ActiveBrief {\n const pick = (id: string, source: ActiveSource): ActiveBrief => {\n const matches = briefs.filter((brief) => sameId(brief.id, id));\n const live = matches.find((brief) => brief.phase === 'live');\n if (live !== undefined) return { kind: 'found', brief: live, source };\n const archived = matches[0];\n if (archived !== undefined) return { kind: 'archived', brief: archived, source };\n return { kind: 'unknown', id, source };\n };\n if (named.argument !== undefined && named.argument.trim() !== '') return pick(named.argument.trim(), 'argument');\n if (named.flag !== undefined && named.flag.trim() !== '') return pick(named.flag.trim(), 'flag');\n if (named.environment !== undefined && named.environment.trim() !== '') return pick(named.environment.trim(), 'environment');\n if (named.branch !== undefined && named.branch !== null) return pick(named.branch, 'branch');\n return {\n kind: 'none',\n reason: 'no brief is named: pass --brief <id>, set SPEC_BRIEF, or work on a branch named like brief/<id>-<topic>',\n };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"briefs.js","sourceRoot":"","sources":["../src/briefs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The briefs, as spec-brief reports them, and which one a round is working on.\n *\n * spec-brief owns the brief format and its configuration (its ADR-0003), so\n * the harness reads briefs the way spec-brief does by asking it:\n * `spec-brief list --archived --format json`. This module reads that\n * document and nothing else; a shape it does not recognise is an error, never\n * a best-effort reading of half the fields (ADR-0002).\n */\n\nimport { sameId } from './branch.js';\nimport type { BriefRow } from './types.js';\n\n/**\n * The spec-brief JSON document versions this harness reads. Version 1 is the\n * one spec-brief 0.1.0 prints; version 2 changed only `matrix`, which the\n * harness never reads, so the `list` and `archive` documents are the same.\n */\nexport const SUPPORTED_SCHEMA_VERSIONS: readonly number[] = [1, 2, 3];\n\nexport class SiblingOutputError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'SiblingOutputError';\n }\n}\n\ntype Json = Record<string, unknown>;\n\nfunction isObject(value: unknown): value is Json {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction strings(value: unknown, where: string): string[] {\n if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {\n throw new SiblingOutputError(`${where} is not a list of strings`);\n }\n return value;\n}\n\nfunction nullableString(value: unknown, where: string): string | null {\n if (value === null || value === undefined) return null;\n if (typeof value !== 'string') throw new SiblingOutputError(`${where} is not a string`);\n return value;\n}\n\n/** Reads the document `spec-brief list --format json` prints. */\nexport function parseBriefList(document: unknown): BriefRow[] {\n if (!isObject(document) || document['tool'] !== 'spec-brief' || document['command'] !== 'list') {\n throw new SiblingOutputError('spec-brief did not print a list document');\n }\n const version = document['schemaVersion'];\n // The list holds numbers alone, so `includes` refuses whatever is not one:\n // the `typeof` is the compiler's, and the mutant without it is equivalent.\n if (typeof version !== 'number' || !SUPPORTED_SCHEMA_VERSIONS.includes(version)) {\n throw new SiblingOutputError(\n `spec-brief printed schemaVersion ${String(version)}; this harness reads ${SUPPORTED_SCHEMA_VERSIONS.join(', ')}`,\n );\n }\n const briefs = document['briefs'];\n if (!Array.isArray(briefs)) throw new SiblingOutputError('spec-brief list has no \"briefs\"');\n return briefs.map((row, index) => {\n const where = `briefs[${index}]`;\n if (!isObject(row)) throw new SiblingOutputError(`${where} is not an object`);\n const id = row['id'];\n const file = row['file'];\n if (typeof id !== 'string' || typeof file !== 'string') throw new SiblingOutputError(`${where} has no id or file`);\n const phase = row['phase'];\n if (phase !== 'live' && phase !== 'archived') throw new SiblingOutputError(`${where}.phase is \"${String(phase)}\"`);\n const tasks = row['tasks'];\n const wave = row['wave'];\n return {\n id,\n file,\n title: nullableString(row['title'], `${where}.title`),\n phase,\n status: nullableString(row['status'], `${where}.status`),\n type: nullableString(row['type'], `${where}.type`),\n wave: typeof wave === 'number' ? wave : null,\n dependsOn: strings(row['dependsOn'] ?? [], `${where}.dependsOn`),\n affectedFiles: strings(row['affectedFiles'] ?? [], `${where}.affectedFiles`),\n protectedFiles: strings(row['protectedFiles'] ?? [], `${where}.protectedFiles`),\n tasks: {\n total: isObject(tasks) && typeof tasks['total'] === 'number' ? tasks['total'] : 0,\n checked: isObject(tasks) && typeof tasks['checked'] === 'number' ? tasks['checked'] : 0,\n },\n ready: row['ready'] === true,\n waitingOn: strings(row['waitingOn'] ?? [], `${where}.waitingOn`),\n };\n });\n}\n\n/**\n * A file a `list` answer was read from, by the SHA-256 of its bytes in hex;\n * or `null` where spec-brief looked for it and found none, which is then to\n * be found none again.\n */\nexport interface InputFile {\n readonly path: string;\n readonly sha256: string | null;\n}\n\n/**\n * A directory whose files a `list` answer was read from, by their names:\n * entries that are files, as spec-brief lists them; or `null` where\n * spec-brief looked for the directory and found none, which is then to be\n * found none again.\n */\nexport interface InputDirectory {\n readonly path: string;\n readonly entries: readonly string[] | null;\n}\n\n/**\n * Everything a `list` answer was read from, as spec-brief lists it: its\n * version, the files and directories it read, and what git read for it, the\n * index and the ignore files, or `null` where it asked git nothing. A path\n * is relative to the work tree and POSIX, or absolute where git names a\n * file outside it (spec-core ADR-0005, amended 2026-10-19).\n */\nexport interface BriefInputs {\n readonly tool: string;\n readonly files: readonly InputFile[];\n readonly directories: readonly InputDirectory[];\n readonly git: { readonly index: string | null; readonly ignores: readonly InputFile[] } | null;\n}\n\nconst DIGEST = /^[0-9a-f]{64}$/;\n\nfunction inputPath(value: unknown): value is string {\n return typeof value === 'string' && value !== '';\n}\n\nfunction digestOf(value: unknown): string | null | undefined {\n return value === null || (typeof value === 'string' && DIGEST.test(value)) ? value : undefined;\n}\n\nfunction inputFiles(value: unknown): InputFile[] | null {\n if (!Array.isArray(value)) return null;\n const files: InputFile[] = [];\n for (const item of value) {\n if (!isObject(item) || !inputPath(item['path'])) return null;\n const sha256 = digestOf(item['sha256']);\n if (sha256 === undefined) return null;\n files.push({ path: item['path'], sha256 });\n }\n return files;\n}\n\nfunction inputDirectories(value: unknown): InputDirectory[] | null {\n if (!Array.isArray(value)) return null;\n const directories: InputDirectory[] = [];\n for (const item of value) {\n if (!isObject(item) || !inputPath(item['path'])) return null;\n const entries = item['entries'];\n // A name listed twice is a list that is not of one directory's files.\n if (entries !== null && (!Array.isArray(entries) || !entries.every((entry) => typeof entry === 'string') || new Set(entries).size !== entries.length)) return null;\n directories.push({ path: item['path'], entries });\n }\n return directories;\n}\n\n/**\n * What a `list` document says its answer was read from, where it may be\n * given again; `null` where it lists nothing, where `keep` is anything but\n * `true`, where it names nothing, and where anything on it is in a form not\n * read here. Each is a doubt, and a doubt starts spec-brief as before\n * (ADR-0002). The rows are `parseBriefList`'s to read, whatever this\n * answers: a list this cannot read keeps an answer from being given again,\n * and changes nothing of it.\n */\nexport function inputsOf(document: unknown): BriefInputs | null {\n if (!isObject(document)) return null;\n const inputs = document['inputs'];\n if (!isObject(inputs) || inputs['keep'] !== true || typeof inputs['tool'] !== 'string') return null;\n const files = inputFiles(inputs['files']);\n const directories = inputDirectories(inputs['directories']);\n const git = inputs['git'];\n let asked: BriefInputs['git'];\n if (git === null) {\n asked = null;\n } else {\n if (!isObject(git)) return null;\n const index = digestOf(git['index']);\n const ignores = inputFiles(git['ignores']);\n if (index === undefined || ignores === null) return null;\n asked = { index, ignores };\n }\n // A list that names nothing is an answer that cannot be told from one that\n // was read from nowhere.\n if (files === null || directories === null || files.length + directories.length === 0) return null;\n return { tool: inputs['tool'], files, directories, git: asked };\n}\n\n/**\n * How the active brief was named, for messages. An argument is the id a\n * command takes after its name, or a tool call's `brief`: said to be the\n * flag's, it sent a person looking for a `--brief` nobody had typed.\n */\nexport type ActiveSource = 'argument' | 'flag' | 'environment' | 'branch';\n\nexport type ActiveBrief =\n | { readonly kind: 'found'; readonly brief: BriefRow; readonly source: ActiveSource }\n | { readonly kind: 'none'; readonly reason: string }\n | { readonly kind: 'unknown'; readonly id: string; readonly source: ActiveSource }\n | { readonly kind: 'archived'; readonly brief: BriefRow; readonly source: ActiveSource };\n\n/**\n * The brief a round is working on: named by an argument, by a flag, by\n * `SPEC_BRIEF`, or by the branch - never guessed from which brief happens to\n * be the only active one (ADR-0004).\n */\nexport function findActive(\n briefs: readonly BriefRow[],\n named: {\n readonly argument?: string | undefined;\n readonly flag?: string | undefined;\n readonly environment?: string | undefined;\n readonly branch?: string | null | undefined;\n },\n): ActiveBrief {\n const pick = (id: string, source: ActiveSource): ActiveBrief => {\n const matches = briefs.filter((brief) => sameId(brief.id, id));\n const live = matches.find((brief) => brief.phase === 'live');\n if (live !== undefined) return { kind: 'found', brief: live, source };\n const archived = matches[0];\n if (archived !== undefined) return { kind: 'archived', brief: archived, source };\n return { kind: 'unknown', id, source };\n };\n if (named.argument !== undefined && named.argument.trim() !== '') return pick(named.argument.trim(), 'argument');\n if (named.flag !== undefined && named.flag.trim() !== '') return pick(named.flag.trim(), 'flag');\n if (named.environment !== undefined && named.environment.trim() !== '') return pick(named.environment.trim(), 'environment');\n if (named.branch !== undefined && named.branch !== null) return pick(named.branch, 'branch');\n return {\n kind: 'none',\n reason: 'no brief is named: pass --brief <id>, set SPEC_BRIEF, or work on a branch named like brief/<id>-<topic>',\n };\n}\n"]}
|
package/dist/cli.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { type CliIO } from './workspace.js';
|
|
21
21
|
export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version, type CliIO } from './workspace.js';
|
|
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, PostToolUse or
|
|
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, PostToolUse, SessionStart or Stop\n hook (stdin). At session start it says what would hold every\n write; at a turn's end, with \"turnReport\" on, what the work tree\n holds that the brief protects or that is outside its scope.\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; --block-on-failure\n has Claude Code 2.1.295 or later hold a write when the hook cannot\n start or runs past its timeout.\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 or runs a script that is not there, the branch names a\n brief spec-brief does not list, cannot be asked about, or has\n archived, but for the branch git says archived it, a round's\n last commit, which waits to merge (CI fetches the base with its\n history for git to say), disableAllHooks or an array for a\n matcher switches it off, the plugin that is turned on is not on\n this machine and nothing else guards, Claude Code is too old to\n run its hooks, or git stops every commit at what stands for its\n 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 --block-on-failure onFailure \"block\" on init's entries but the one as a turn ends. For init.\n --help, --version One of the two: both on one line are refused.\n\nAn option a command does not read, an argument it does not take, an option or an\nargument given an empty value, a brief given both as the argument and as --brief,\nand an option that takes one value given two that name two things are refused, exit 2,\nnot read as if they were not there, and so with --help or --version on the line. The\nsame value twice, as --root . --root ./, is read once, and on Windows a drive letter in\neither case. doctor says an empty --brief or --base in its report. hook claude reads its\nline as every release has, and says a line it cannot read, two values for one option or\n--help beside a --root that is not a directory among them, a --root that is not a\ndirectory, and a question it cannot read, as a warning to the person with exit 0: it\nrefuses nothing for any of them.\n\nExit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.\n";
|
|
23
23
|
/**
|
|
24
24
|
* Runs the command line and resolves to the exit code, for an error nothing
|
|
25
25
|
* below expected too.
|