@descent-vtt/spec-harness 0.18.0 → 0.20.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.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +372 -0
  4. package/README.md +78 -11
  5. package/dist/briefs.d.ts +45 -0
  6. package/dist/briefs.js +73 -0
  7. package/dist/briefs.js.map +1 -1
  8. package/dist/cli.d.ts +1 -1
  9. package/dist/cli.js +384 -84
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands.js +7 -2
  12. package/dist/commands.js.map +1 -1
  13. package/dist/config.d.ts +15 -1
  14. package/dist/config.js +16 -3
  15. package/dist/config.js.map +1 -1
  16. package/dist/configure.d.ts +142 -32
  17. package/dist/configure.js +316 -89
  18. package/dist/configure.js.map +1 -1
  19. package/dist/fs.d.ts +150 -3
  20. package/dist/fs.js +459 -17
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git.d.ts +85 -9
  23. package/dist/git.js +140 -16
  24. package/dist/git.js.map +1 -1
  25. package/dist/hooks.d.ts +111 -0
  26. package/dist/hooks.js +172 -16
  27. package/dist/hooks.js.map +1 -1
  28. package/dist/round.d.ts +8 -0
  29. package/dist/round.js +32 -9
  30. package/dist/round.js.map +1 -1
  31. package/dist/sandbox.js +4 -3
  32. package/dist/sandbox.js.map +1 -1
  33. package/dist/server.d.ts +25 -1
  34. package/dist/server.js +194 -27
  35. package/dist/server.js.map +1 -1
  36. package/dist/setup.d.ts +6 -3
  37. package/dist/setup.js +74 -23
  38. package/dist/setup.js.map +1 -1
  39. package/dist/siblings.d.ts +42 -7
  40. package/dist/siblings.js +135 -30
  41. package/dist/siblings.js.map +1 -1
  42. package/dist/types.d.ts +5 -0
  43. package/dist/types.js.map +1 -1
  44. package/dist/usage.d.ts +66 -12
  45. package/dist/usage.js +134 -21
  46. package/dist/usage.js.map +1 -1
  47. package/dist/workspace.d.ts +80 -1
  48. package/dist/workspace.js +188 -8
  49. package/dist/workspace.js.map +1 -1
  50. package/hooks/hooks.json +16 -0
  51. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -18,14 +18,15 @@
18
18
  * line and exit 2, as every error does.
19
19
  */
20
20
  import { SiblingOutputError } from './briefs.js';
21
- import { ConfigError, SIBLINGS } from './config.js';
22
- import { isDirectory } from './fs.js';
23
- import { GitError, show, stagedChanges } from './git.js';
24
- import { claudeResponse, gitResponse, parseClaudeHook } from './hooks.js';
25
- import { SiblingError } from './siblings.js';
26
- import { emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, unreadHookLine } from './usage.js';
21
+ import { CONFIG_FILE, ConfigError, SIBLINGS, SPEC_BRIEF_CONFIGS } from './config.js';
22
+ import { dirname, join, resolve } from 'node:path';
23
+ import { gitDirectories, isDirectory, keepCall, loadConfig, mainWorkTree, outsideEvery, StateError, takeCall, writesOnly } from './fs.js';
24
+ import { changedPaths, GitError, show, stagedChanges } from './git.js';
25
+ import { afterResponse, callFile, callRecord, claudeResponse, gitResponse, keptCall, mendResponse, NONE, parseClaudeHook, SESSION_START, sessionOf, sessionResponse, TURN_END, turnResponse, warningResponse, warningsKept, warningsOf } from './hooks.js';
26
+ import { declaredVersion, SiblingError } from './siblings.js';
27
+ import { answerBeside, emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, twoValues, unreadHookLine } from './usage.js';
27
28
  import { CLAUDE_CODE_MINIMUM, MINIMUM_VERSIONS } from './versions.js';
28
- import { activeBrief, answerOf, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, openWorkspace, parseOptions, UsageError, version, } from './workspace.js';
29
+ import { activeBrief, answerOf, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, MAIN_INSTALL, mayBeElsewhere, openFound, openWorkspace, otherTree, parseOptions, UsageError, version, writesBy, } from './workspace.js';
29
30
  export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version } from './workspace.js';
30
31
  export const HELP = `spec-harness - context, guards, audits and rulings for rounds of agent work
31
32
 
@@ -36,7 +37,10 @@ Commands:
36
37
  context [brief] What an agent needs to start a round: the brief, its scope,
37
38
  rulings, dependencies, the rules in force, the cited documents.
38
39
  guard <path...> May the round write these paths? Exit 1 if one is refused.
39
- hook claude Answer a Claude Code PreToolUse or PostToolUse hook (stdin).
40
+ hook claude Answer a Claude Code PreToolUse, PostToolUse, SessionStart or Stop
41
+ hook (stdin). At session start it says what would hold every
42
+ write; at a turn's end, with "turnReport" on, what the work tree
43
+ holds that the brief protects or that is outside its scope.
40
44
  hook git Answer git's pre-commit hook for the staged files.
41
45
  escalate Ask a person to rule: --path <file> (repeatable) --reason <why>
42
46
  [--option "<label>: <cost>"]... [--recommend <text>].
@@ -53,17 +57,21 @@ Commands:
53
57
  premises Do the live briefs' premises still hold? For CI: exit 1 when one
54
58
  is stale, or, with --strict, when spec-guard cannot read one.
55
59
  init Configure the spec-* tools to agree. Prints the plan; --write
56
- applies it; --git-hook adds a pre-commit hook.
60
+ applies it; --git-hook adds a pre-commit hook; --block-on-failure
61
+ has Claude Code 2.1.295 or later hold a write when the hook cannot
62
+ start or runs past its timeout.
57
63
  mcp Serve start_round, check_path, request_escalation, audit_round,
58
64
  list_rounds and the workflow prompts over MCP on stdio.
59
65
  doctor Which siblings are installed, at which versions, which brief is
60
66
  named, how Claude Code runs the guard, and git's hook. Exit 1
61
67
  when a sibling is older than this release needs, the guard is
62
68
  installed twice, its hook's line turns it off or holds every
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.
69
+ write, the branch names a brief spec-brief does not list or
70
+ has archived, disableAllHooks or an array for a matcher
71
+ switches it off, the plugin that is turned on is not on this
72
+ machine and nothing else guards, Claude Code is too old to
73
+ run its hooks, or git stops every commit at what stands for
74
+ its hook.
67
75
 
68
76
  Options:
69
77
  --brief <id> The brief the round works on. Otherwise SPEC_BRIEF, then the branch.
@@ -76,14 +84,19 @@ Options:
76
84
  --format <fmt> pretty or json; audit and premises also gitlab, sarif or github.
77
85
  For every command but hook and mcp.
78
86
  --strict Warnings fail the run. For guard, audit, premises and doctor.
79
- --help, --version
87
+ --block-on-failure onFailure "block" on init's entries but the one as a turn ends. For init.
88
+ --help, --version One of the two: both on one line are refused.
80
89
 
81
90
  An option a command does not read, an argument it does not take, an option or an
82
- argument given an empty value, and a brief given both as the argument and as --brief
83
- are refused, exit 2, not read as if they were not there. doctor says an empty --brief
84
- or --base in its report. hook claude reads its line as every release has, and says a
85
- line it cannot read, and a --root that is not a directory, with exit 1: it refuses
86
- nothing for either.
91
+ argument given an empty value, a brief given both as the argument and as --brief,
92
+ and an option that takes one value given two that name two things are refused, exit 2,
93
+ not read as if they were not there, and so with --help or --version on the line. The
94
+ same value twice, as --root . --root ./, is read once, and on Windows a drive letter in
95
+ either case. doctor says an empty --brief or --base in its report. hook claude reads its
96
+ line as every release has, and says a line it cannot read, two values for one option or
97
+ --help beside a --root that is not a directory among them, a --root that is not a
98
+ directory, and a question it cannot read, as a warning to the person with exit 0: it
99
+ refuses nothing for any of them.
87
100
 
88
101
  Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.
89
102
  `;
@@ -93,13 +106,41 @@ Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be truste
93
106
  * to a file no brief protects reads none.
94
107
  */
95
108
  const reader = async () => (await import('./reader.js')).createReader();
96
- async function decisionsFor(workspace, options, io, given, cwd) {
97
- const active = await activeBrief(workspace, options, io.env);
98
- const { brief, note, problem } = describeActive(active);
109
+ /**
110
+ * The work tree with spec-brief's answers kept for a session of Claude
111
+ * Code's, and given again to it where all they were read from reads the
112
+ * same (ADR-0002); as it is where no session is named.
113
+ */
114
+ function keptFor(workspace, session) {
115
+ return session === null ? workspace : { ...workspace, siblings: workspace.siblings.keeping(workspace.commonDir, session) };
116
+ }
117
+ async function decisionsFor(workspace, options, io, given, cwd, session = null) {
118
+ // A path outside every work tree of the repository is decided as one
119
+ // outside it, whichever brief is named and whatever is wrong with the
120
+ // name: the problem held every write, a file outside the repository
121
+ // among them, where no brief decides one (ADR-0005). So no brief is asked
122
+ // about for such a write, and spec-brief is not run for it.
123
+ const outside = outsideEvery(given, workspace.root, workspace.commonDir, cwd);
124
+ const active = outside ? null : await activeBrief(keptFor(workspace, session), options, io.env);
125
+ const { brief, note, problem } = active === null ? { brief: null, note: undefined, problem: null } : describeActive(active);
99
126
  if (problem !== null)
100
127
  return { problem };
101
128
  const { checkPaths } = await import('./round.js');
102
- return { decisions: await checkPaths(workspace, brief, note, given, cwd, reader, options.base) };
129
+ return { decisions: await checkPaths(workspace, brief, note, given, cwd, reader, options.base), brief };
130
+ }
131
+ /**
132
+ * Whether a decision is of a file the answer after the write is made from:
133
+ * the configuration, spec-brief's, the brief, or a file beside it in the
134
+ * briefs' directory, which spec-brief reads too. A call that writes one is
135
+ * decided again after the write, so that what the write changed is read
136
+ * (ADR-0002).
137
+ */
138
+ function decidesAnswers(decision, brief) {
139
+ if (decision.path === CONFIG_FILE || SPEC_BRIEF_CONFIGS.includes(decision.path))
140
+ return true;
141
+ if (brief === null)
142
+ return false;
143
+ return decision.path === brief.file || decision.path.startsWith(`${dirname(brief.file)}/`);
103
144
  }
104
145
  const SYMBOL = { allow: 'ok ', warn: 'warning', ask: 'ask ', deny: 'refused' };
105
146
  async function guardCommand(options, io) {
@@ -139,10 +180,12 @@ async function readStdin(io) {
139
180
  return Buffer.concat(chunks).toString('utf8');
140
181
  }
141
182
  /**
142
- * The write Claude Code asks the hook about, or the exit code that answers
143
- * already: 1 for a question that cannot be read, 0 for a tool that writes
144
- * no file. Asked before anything on the hook's own line is, so that a line
145
- * that is wrong is said where there is a write it leaves unchecked.
183
+ * The write Claude Code asks the hook about, or the session that starts, or
184
+ * the exit code that answers already, 0: for a question that cannot be read,
185
+ * said to the person, and for a tool that writes no file. Asked before
186
+ * anything on the hook's own line is, so that a line that is wrong is said
187
+ * where there is a write it leaves unchecked, or a session whose every write
188
+ * it leaves so.
146
189
  */
147
190
  async function writeAsked(io) {
148
191
  let question;
@@ -152,27 +195,138 @@ async function writeAsked(io) {
152
195
  catch (error) {
153
196
  // A question that could not be read is one the hook cannot see, as one
154
197
  // it cannot parse is, below: it has no event to hold a write by.
155
- io.stderr.write(`spec-harness: the hook input could not be read: ${error.message}\n`);
156
- return EXIT_FAILED;
198
+ return answer(io, warningResponse(`the hook input could not be read: ${error.message}, so nothing it asks about is checked`));
157
199
  }
158
200
  const request = parseClaudeHook(question);
159
- if ('error' in request) {
160
- // A hook that cannot read its question refuses nothing it cannot see,
161
- // but says so where the person reads it: a non-blocking error.
162
- io.stderr.write(`spec-harness: ${request.error}\n`);
163
- return EXIT_FAILED;
164
- }
165
- return request.paths.length === 0 ? EXIT_OK : request;
201
+ // A hook that cannot read its question refuses nothing it cannot see, and
202
+ // says so where the person reads it.
203
+ if ('error' in request)
204
+ return answer(io, warningResponse(`${request.error}, so nothing it asks about is checked`));
205
+ return request.paths.length === 0 && request.event !== SESSION_START && request.event !== TURN_END ? EXIT_OK : request;
166
206
  }
167
207
  /**
168
208
  * What is wrong with the hook's own line, said and nothing refused for it:
169
- * one line, since Claude Code shows the first line of the stderr of a hook
170
- * that exits 1 and goes on with the write, where exit 2 would hold every
171
- * write of the session, the one that mends the line among them (ADR-0005).
209
+ * one line, shown to the person, and exit 0, where exit 2 would hold every
210
+ * write of the session, the one that mends the line among them. Exit 1 said
211
+ * it as well, and is what Claude Code counts as a hook that failed, which
212
+ * `onFailure: "block"` holds the write for (ADR-0005).
213
+ */
214
+ function guardOff(io, { wrong, mend }, event) {
215
+ if (event === SESSION_START)
216
+ return answer(io, sessionResponse(`${wrong}, so the guard is off and no write of this session is checked; ${mend}`));
217
+ if (event === TURN_END)
218
+ return answer(io, turnResponse(`spec-harness: ${wrong}, so the guard is off and no write of this session is checked; ${mend}`));
219
+ return answer(io, warningResponse(`${wrong}, so the guard is off and this write is not checked; ${mend}`));
220
+ }
221
+ function answer(io, response) {
222
+ io.stdout.write(response.stdout);
223
+ return response.exitCode;
224
+ }
225
+ /**
226
+ * The answer to a write a configuration that does not load holds no more,
227
+ * or `null` for every other write, which is held as before (ADR-0005). Every
228
+ * write was held. A write outside every work tree of the repository is
229
+ * decided as such a write is, after the write as before it: nothing of the
230
+ * repository's decides it. And before a write to the configuration file
231
+ * itself, and to nothing else, the line says why every other write is held,
232
+ * so that a session can mend a typo in it. `cwd` is what a relative path
233
+ * is read from.
234
+ */
235
+ function mending(error, request, cwd, env) {
236
+ // Every configuration the hook loads is read from a file, so `file` is
237
+ // never `null` here, and the test of it is the compiler's: its mutant is
238
+ // equivalent.
239
+ if (!(error instanceof ConfigError) || error.file === null)
240
+ return null;
241
+ // The work trees are named by the files git keeps, where git would name
242
+ // the same ones; a repository whose configuration or environment moves a
243
+ // work tree elsewhere is not known to leave the path outside. The file
244
+ // was loaded from the top git named, where a `.git` stands, so the files
245
+ // name a work tree, and the test of `null` is the compiler's.
246
+ const found = gitDirectories(dirname(error.file), env);
247
+ if (found !== null && found.exact && outsideEvery(request.paths, found.top, found.common, cwd))
248
+ return NONE;
249
+ if (request.event !== 'PreToolUse' || !writesOnly(request.paths, error.file, cwd))
250
+ return null;
251
+ return mendResponse(`${error.message}, so every other write is held until it loads; this write, to ${error.file} itself, is not checked`);
252
+ }
253
+ /**
254
+ * What would hold every write of the session that starts, said once as it
255
+ * starts and no write waits on it (ADR-0005): a configuration that does not
256
+ * load, a brief named that spec-brief does not list or has archived, and
257
+ * spec-brief itself, where a brief is named, missing, too old or unable to
258
+ * list the briefs. Each in the words a write would be held with, and
259
+ * `doctor` says, and with what mends it. `null` where nothing does. The
260
+ * check never ends the session's start: whatever it meets is said.
261
+ */
262
+ async function sessionProblem(options, io, root, cwd, session) {
263
+ const held = 'so every write of this session is held until it is mended';
264
+ try {
265
+ const named = await openWorkspace({ ...options, root }, io);
266
+ // A session in another work tree of the project has its writes decided
267
+ // there, and so is the check (ADR-0005).
268
+ const found = cwd === null ? null : await otherTree(named, cwd);
269
+ const workspace = found === null ? named : await openFound(found, io, () => [named.root]);
270
+ const { problem } = describeActive(await activeBrief(keptFor(workspace, session), options, io.env));
271
+ return problem === null ? null : `${heldHookLine(problem).wrong}, ${held}: fix the brief or the branch before writing; spec-harness doctor says more`;
272
+ }
273
+ catch (error) {
274
+ // A session in no work tree has nothing to guard, as a write there has
275
+ // not. Anything else that stops the check stops a write's as well, which
276
+ // is then held as one the hook cannot check: a configuration that does
277
+ // not load, a spec-brief that is missing, too old or cannot list the
278
+ // briefs, and whatever a defect throws.
279
+ if (error instanceof UsageError)
280
+ return null;
281
+ // But a write to the configuration file itself, which mends it, where
282
+ // the file is one a write may reach under its name alone (ADR-0005). The
283
+ // configuration is read from a file here as before a write, so the test
284
+ // of `file` is the compiler's, and its mutant is equivalent.
285
+ const mendable = error instanceof ConfigError && error.file !== null && writesOnly([error.file], error.file, root);
286
+ return `${error.message}, ${held}${mendable ? `, but for a write to ${error.file} itself, which is not checked` : ''}; spec-harness doctor says more`;
287
+ }
288
+ }
289
+ /** The most paths a report names one by one: past it, how many more there are. */
290
+ const REPORTED = 20;
291
+ /**
292
+ * What the work tree holds at the end of a turn that the brief protects or
293
+ * that is outside its scope, one line a path in the words a write of it is
294
+ * given; or `null` where there is nothing to say: `turnReport` is off, no
295
+ * brief is named, nothing changed is either, or the session is in no work
296
+ * tree (ADR-0005). The work tree is the one a write of the session is
297
+ * decided in. One start of git reads what changed, and nothing is asked of
298
+ * the base, so no ruling is read: a protected file a signed ruling allows
299
+ * is said as protected, and the audit reads the ruling. A configuration
300
+ * that does not load is said by every write and as the session starts, and
301
+ * here holds the report back without a word.
172
302
  */
173
- function guardOff(io, { wrong, mend }) {
174
- io.stderr.write(`spec-harness: ${wrong}, so the guard is off and this write is not checked; ${mend}\n`);
175
- return EXIT_FAILED;
303
+ async function turnReport(options, io, root, cwd) {
304
+ try {
305
+ const named = await openWorkspace({ ...options, root }, io);
306
+ const found = cwd === null ? null : await otherTree(named, cwd);
307
+ const workspace = found === null ? named : await openFound(found, io, () => [named.root]);
308
+ if (!workspace.config.turnReport)
309
+ return null;
310
+ const { brief, problem } = describeActive(await activeBrief(workspace, options, io.env));
311
+ if (problem !== null)
312
+ return `spec-harness: ${heldHookLine(problem).wrong}, so what the work tree holds is not reported, and every write is held; spec-harness doctor says more`;
313
+ if (brief === null)
314
+ return null;
315
+ const [changed, { decide }] = await Promise.all([changedPaths(workspace.root), import('./guard.js')]);
316
+ const outOfScope = workspace.config.outOfScope;
317
+ const said = changed.map((path) => decide({ path, given: path, brief, rulings: [], outOfScope })).filter((decision) => decision.verdict !== 'allow');
318
+ if (said.length === 0)
319
+ return null;
320
+ const lines = said.slice(0, REPORTED).map((decision) => `- ${decision.message}. Next: ${decision.hint}.`);
321
+ const more = said.length > REPORTED ? [`- and ${said.length - REPORTED} more: spec-harness guard <path> says each`] : [];
322
+ const paths = said.length === 1 ? `a path that brief ${brief.id} protects or that is outside its scope` : `${said.length} paths that brief ${brief.id} protects or that are outside its scope`;
323
+ return [`spec-harness: as this turn ended, the work tree held changes to ${paths}:`, ...lines, ...more].join('\n');
324
+ }
325
+ catch (error) {
326
+ if (error instanceof UsageError || error instanceof ConfigError)
327
+ return null;
328
+ return `spec-harness: ${error.message}, so what the work tree holds is not reported`;
329
+ }
176
330
  }
177
331
  /**
178
332
  * Claude Code's hook on a line the parser refused: an option no command has,
@@ -185,7 +339,53 @@ async function unreadLine(said, io) {
185
339
  const request = await writeAsked(io);
186
340
  if (typeof request === 'number')
187
341
  return request;
188
- return guardOff(io, unreadHookLine(said).fault);
342
+ return guardOff(io, unreadHookLine(said).fault, request.event);
343
+ }
344
+ /**
345
+ * The answer after a write, where it can be given with no process started,
346
+ * or `null` where it is to be decided as before (ADR-0002, ADR-0003).
347
+ *
348
+ * The git directory is the one the files at the hook's root name. What the
349
+ * run before the write kept for this call, if anything, is taken away as it
350
+ * is read, and answered from where it is this call's: the same release,
351
+ * session, call, tool, paths, directories, line and variables. And where
352
+ * nothing is kept, a run after a write says nothing but a warning outside
353
+ * the scope, which only `outOfScope: "warn"` decides: under `ask` or
354
+ * `deny` there is nothing to say, where the configuration read is the one
355
+ * git would have the hook read, of the work tree the session is in.
356
+ */
357
+ async function answeredAfter(root, record, cwd, paths, io) {
358
+ const found = gitDirectories(root, io.env);
359
+ if (found === null)
360
+ return null;
361
+ if (record !== null) {
362
+ const kept = await takeCall(found.common, callFile(record));
363
+ // warningsKept reads no text as no record as well: the test is there for
364
+ // the type, and its mutant is equivalent.
365
+ const warnings = kept === null ? null : warningsKept(kept, record);
366
+ if (warnings !== null)
367
+ return afterResponse(warnings);
368
+ }
369
+ // A path in another work tree than the one this configuration is read
370
+ // from may be decided by another's, which may warn.
371
+ if (!found.exact || (cwd !== null && mayBeElsewhere(cwd, found.top)) || paths.some((path) => mayBeElsewhere(resolve(cwd ?? io.cwd, path), found.top)))
372
+ return null;
373
+ try {
374
+ return (await loadConfig(found.top)).config.outOfScope === 'warn' ? null : afterResponse([]);
375
+ }
376
+ catch {
377
+ // A configuration that does not load is said as it was, by the run.
378
+ return null;
379
+ }
380
+ }
381
+ /**
382
+ * The answer before a write the hook could not check: one line, and exit 2,
383
+ * which holds the write. Claude Code shows the line to Claude as the
384
+ * reason.
385
+ */
386
+ function cannotCheck(io, error) {
387
+ io.stderr.write(`spec-harness: cannot check this write: ${error.message}\n`);
388
+ return EXIT_ERROR;
189
389
  }
190
390
  async function hookCommand(options, io) {
191
391
  const kind = options.positionals[0];
@@ -197,41 +397,90 @@ async function hookCommand(options, io) {
197
397
  // reads the line where it finds it in a settings file (usage.ts).
198
398
  const line = readHookLine(options.root, io.cwd, isDirectory);
199
399
  if (line.state === 'off')
200
- return guardOff(io, line.fault);
400
+ return guardOff(io, line.fault, request.event);
201
401
  const cwd = request.cwd ?? io.cwd;
402
+ const root = line.root ?? io.env['CLAUDE_PROJECT_DIR'] ?? cwd;
403
+ const session = sessionOf(request);
404
+ if (request.event === SESSION_START)
405
+ return answer(io, sessionResponse(await sessionProblem(options, io, root, request.cwd, session)));
406
+ if (request.event === TURN_END)
407
+ return answer(io, turnResponse(await turnReport(options, io, root, request.cwd)));
408
+ const record = callRecord(request, { root: line.root, brief: options.brief, base: options.base }, io.cwd, io.env, version());
409
+ if (request.event === 'PostToolUse') {
410
+ const answered = await answeredAfter(root, record, request.cwd, request.paths, io);
411
+ if (answered !== null) {
412
+ io.stdout.write(answered.stdout);
413
+ return answered.exitCode;
414
+ }
415
+ }
202
416
  let workspace;
203
417
  try {
204
- workspace = await openWorkspace({ ...options, root: line.root ?? io.env['CLAUDE_PROJECT_DIR'] ?? cwd }, io);
418
+ workspace = await openWorkspace({ ...options, root }, io);
205
419
  }
206
420
  catch (error) {
207
421
  if (error instanceof UsageError)
208
422
  return EXIT_OK;
423
+ const mended = mending(error, request, cwd, io.env);
424
+ if (mended !== null)
425
+ return answer(io, mended);
426
+ // git asked where the work tree is and did not answer within its time
427
+ // limit: before a write that is a write the hook could not check, said
428
+ // as one (ADR-0002).
429
+ if (error instanceof GitError && request.event === 'PreToolUse')
430
+ return cannotCheck(io, error);
209
431
  throw error;
210
432
  }
211
433
  try {
212
- const decided = await decisionsFor(workspace, options, io, request.paths, cwd);
213
- if ('problem' in decided) {
214
- // Exit 2 blocks the tool call, and its stderr reaches the model. Where
215
- // the name stands on the hook's own line, what is wrong with it is
216
- // `doctor`'s to say as well, in the same words (usage.ts).
217
- io.stderr.write(`spec-harness: ${heldHookLine(decided.problem).wrong}. Fix the brief or the branch before writing.\n`);
218
- return EXIT_ERROR;
434
+ // The answer reads a decision by its verdict, which a mutant's text
435
+ // put first on this list has none of: that mutant is equivalent.
436
+ const decisions = [];
437
+ // Whether the run after the write may answer from what this one keeps:
438
+ // not where a decision refuses, nor where the write is to what the
439
+ // answer is made from, in any work tree that decides it.
440
+ let keeps = true;
441
+ for (const part of await writesBy(workspace, request.cwd, request.paths, io)) {
442
+ const decided = await decisionsFor(part.workspace, options, io, part.paths, cwd, session);
443
+ if ('problem' in decided) {
444
+ // Exit 2 blocks the tool call, and its stderr reaches the model. Where
445
+ // the name stands on the hook's own line, what is wrong with it is
446
+ // `doctor`'s to say as well, in the same words (usage.ts).
447
+ io.stderr.write(`spec-harness: ${heldHookLine(decided.problem).wrong}. Fix the brief or the branch before writing.\n`);
448
+ return EXIT_ERROR;
449
+ }
450
+ // A line of the answer for each path, by the work tree that decided it.
451
+ decisions.push(...decided.decisions);
452
+ if (decided.decisions.some((decision) => decision.verdict === 'deny' || decidesAnswers(decision, decided.brief)))
453
+ keeps = false;
219
454
  }
220
- const response = claudeResponse(request.event, decided.decisions);
455
+ const response = claudeResponse(request.event, decisions);
221
456
  io.stdout.write(response.stdout);
457
+ // What the run after the write will say, kept for it where the call
458
+ // goes ahead: a refused call has none. It changes nothing of the
459
+ // answer, which is made before it is kept.
460
+ if (request.event === 'PreToolUse' && record !== null && keeps) {
461
+ await keepCall(workspace.commonDir, callFile(record), keptCall(record, warningsOf(decisions)));
462
+ }
222
463
  return response.exitCode;
223
464
  }
224
465
  catch (error) {
225
466
  if (request.event !== 'PreToolUse')
226
467
  throw error;
468
+ // The configuration of another work tree the write is decided in,
469
+ // which does not load, lets a write to itself through as the
470
+ // project's does.
471
+ const mended = mending(error, request, cwd, io.env);
472
+ if (mended !== null)
473
+ return answer(io, mended);
227
474
  // A round is named and its briefs cannot be read: the guard cannot
228
475
  // tell a protected file from any other, so the write waits.
229
- io.stderr.write(`spec-harness: cannot check this write: ${error.message}\n`);
230
- return EXIT_ERROR;
476
+ return cannotCheck(io, error);
231
477
  }
232
478
  }
233
479
  if (kind === 'git') {
234
- const workspace = await openWorkspace(options, io);
480
+ // git starts its hook at the top of the work tree it commits in, a
481
+ // linked worktree's own, whose install may be none: it is then checked
482
+ // with the main work tree's (ADR-0005).
483
+ const workspace = await openWorkspace(options, io, MAIN_INSTALL);
235
484
  const staged = await stagedChanges(workspace.root);
236
485
  const paths = staged.flatMap((change) => (change.from === undefined ? [change.path] : [change.from, change.path]));
237
486
  if (paths.length === 0)
@@ -262,6 +511,15 @@ async function baseAndSigners({ resolveBase }, workspace, flag, signersFile) {
262
511
  const signersText = base.kind === 'resolved' ? await show(base.sha, signersFile, workspace.root) : null;
263
512
  return { base, signersText };
264
513
  }
514
+ /**
515
+ * The release of spec-harness installed in a work tree, where the hook's
516
+ * line, init's and the plugin's, names its script under the project's
517
+ * `node_modules`; `null` where none is, or its manifest gives no version.
518
+ */
519
+ function harnessRelease(root) {
520
+ const version = declaredVersion(join(root, 'node_modules', '@descent-vtt', 'spec-harness', 'package.json'));
521
+ return typeof version === 'string' ? version : null;
522
+ }
265
523
  /**
266
524
  * How Claude Code is wired to the guard, and the release of the one on
267
525
  * `PATH`, which waits on the wiring: only a Claude Code that runs the guard
@@ -279,21 +537,25 @@ async function doctorCommand(options, io) {
279
537
  // What doctor alone runs of them, loaded before anything is asked: the
280
538
  // five things below are asked at once, and none waits on a module.
281
539
  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;
540
+ const { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describeInstalls, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, guardWired, NO_SESSION_START, wiringState } = configure;
283
541
  const { diagnoseBrief, specBriefPlugin } = round;
284
542
  const { gitHook } = setup;
285
543
  const { notFido2, readAllowedSigners } = signing;
286
- const workspace = await openWorkspace(options, io);
544
+ // The siblings as git's hook finds them in this work tree: its own
545
+ // install, and in a linked worktree with none the main work tree's.
546
+ const workspace = await openWorkspace(options, io, MAIN_INSTALL);
287
547
  const rows = SIBLINGS.map((name) => {
288
548
  const sibling = workspace.siblings.locate(name);
289
549
  const row = { tool: name, state: sibling.kind, minimum: MINIMUM_VERSIONS[name] };
290
550
  if (sibling.kind === 'absent')
291
- return { ...row, version: null, detail: sibling.reason };
551
+ return { ...row, version: null, elsewhere: null, detail: sibling.reason };
552
+ const elsewhere = sibling.elsewhere ?? null;
553
+ const whose = elsewhere === null ? '' : `, the install of ${elsewhere}: this work tree has none of its own`;
292
554
  if (sibling.kind === 'outdated')
293
- return { ...row, version: sibling.version, detail: sibling.reason };
555
+ return { ...row, version: sibling.version, elsewhere, detail: `${sibling.reason}${whose}` };
294
556
  const command = sibling.command.join(' ');
295
- const detail = sibling.version === null ? `${command} (named in .spec-harness.json; its version is not checked)` : `${command} (${sibling.version})`;
296
- return { ...row, version: sibling.version, detail };
557
+ const detail = sibling.version === null ? `${command} (named in .spec-harness.json; its version is not checked)` : `${command} (${sibling.version}${whose})`;
558
+ return { ...row, version: sibling.version, elsewhere, detail };
297
559
  });
298
560
  const signersFile = workspace.config.rulings.allowedSigners;
299
561
  // Five things that wait on the work tree and on nothing of one another,
@@ -336,7 +598,15 @@ async function doctorCommand(options, io) {
336
598
  // this machine, or cannot be told to be, and disableAllHooks, which
337
599
  // switches off whatever hooks there are.
338
600
  const switchedOff = guardSwitchedOff(wiring);
339
- const wiringNotes = [describePresence(wiring), wiring.hooksOff === null ? null : describeHooksOff(wiring.hooksOff, switchedOff)].filter((note) => note !== null);
601
+ // And whether the hook is started as a session starts, where settings hold
602
+ // the guard: a note, failing nothing, and `sessionStart` in the document.
603
+ const atStart = wiring.hooks.length > 0 && !wiring.sessionStart ? NO_SESSION_START : null;
604
+ // And, in a linked worktree where something wires the guard, the release
605
+ // the hook's line runs where it is not this work tree's own: a note. In
606
+ // the main work tree both are read from one install, and are one.
607
+ const main = mainWorkTree(workspace.commonDir);
608
+ const installs = guardWired(wiring) && main !== null ? describeInstalls(main, harnessRelease(main), harnessRelease(workspace.root)) : null;
609
+ const wiringNotes = [describePresence(wiring), wiring.hooksOff === null ? null : describeHooksOff(wiring.hooksOff, switchedOff), installs].filter((note) => note !== null);
340
610
  // How the hook runs the harness is a note: one that goes through npx guards
341
611
  // as well as one that does not, and fails nothing, --strict or not.
342
612
  const { hook, note: hookNote } = answerOf(hookAsked);
@@ -384,6 +654,7 @@ async function doctorCommand(options, io) {
384
654
  ...more(wiringNotes),
385
655
  ...more(lineNotes),
386
656
  ...more(release === null ? [] : [describeClaudeRelease(release)]),
657
+ ...more(atStart === null ? [] : [atStart]),
387
658
  `git ${describeGitHook(hook)}`,
388
659
  ...more(hookNote === null ? [] : [hookNote]),
389
660
  '',
@@ -396,21 +667,28 @@ async function doctorCommand(options, io) {
396
667
  // a choice; with none installed, the harness can check nothing. A double
397
668
  // install is a problem too, where no Claude Code wiring at all is a choice,
398
669
  // and so are a hook whose own line turns the guard off or holds every
399
- // write, guard hooks that disableAllHooks switches off, a plugin that is
400
- // turned on and is not on this machine where no hook of init's stands in
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.
670
+ // write, a branch that names a brief no round can work under, which holds
671
+ // every write as well (ADR-0004), guard hooks that disableAllHooks
672
+ // switches off, a plugin that is turned on and is not on this machine
673
+ // where no hook of init's stands in for it, a Claude Code too old to run
674
+ // the hooks, and something at the path of git's pre-commit hook that git
675
+ // cannot run and stops every commit at. A release that cannot be told is
676
+ // not fine either, nor a line whose reading cannot be, nor a plugin that
677
+ // cannot be told to be on this machine, nor a hook git runs that cannot be
678
+ // read, which stops every commit as a script and runs as a program, nor a
679
+ // place git runs its hooks from that git cannot name: --strict fails the
680
+ // five.
406
681
  const outdated = rows.some((row) => row.state === 'outdated');
407
682
  const usable = rows.some((row) => row.state === 'found');
408
683
  const off = wiring.lines.some((line) => line.state === 'off' || line.state === 'held');
409
684
  const missing = wiring.presence?.state === 'absent' && wiring.hooks.length === 0;
410
685
  const tooOld = release?.state === 'outdated';
411
686
  const stopped = hook.state === 'stops';
412
- const untold = options.strict && (release?.state === 'unknown' || wiring.lines.some((line) => line.state === 'unknown') || wiring.presence?.state === 'unknown');
413
- return outdated || !usable || claude === 'both' || off || switchedOff || missing || tooOld || stopped || untold ? EXIT_FAILED : EXIT_OK;
687
+ // Only an unreadable hook has `runs`, so the test of its state is the
688
+ // compiler's, and its mutant is equivalent.
689
+ const untold = options.strict &&
690
+ (release?.state === 'unknown' || wiring.lines.some((line) => line.state === 'unknown') || wiring.presence?.state === 'unknown' || (hook.state === 'unreadable' && hook.runs) || hook.state === 'unknown');
691
+ return outdated || !usable || claude === 'both' || off || named.branch || switchedOff || missing || tooOld || stopped || untold ? EXIT_FAILED : EXIT_OK;
414
692
  }
415
693
  /* ---------------------------------------------------------------- dispatch */
416
694
  /**
@@ -452,16 +730,20 @@ async function dispatch(argv, io) {
452
730
  io.stderr.write(`spec-harness: ${refused.message}\n`);
453
731
  return EXIT_ERROR;
454
732
  }
455
- if (options.version) {
456
- io.stdout.write(`${version()}\n`);
457
- return EXIT_OK;
458
- }
459
- if (options.help || options.command === undefined) {
460
- io.stdout.write(HELP);
461
- return options.help ? EXIT_OK : EXIT_ERROR;
733
+ // Two values for an option that takes one: the parser kept the last, and
734
+ // which was meant cannot be told from the line, so it is refused as a line
735
+ // the parser cannot read is, and Claude Code's hook says it of its own.
736
+ const twice = twoValues(options, io.cwd);
737
+ if (twice !== null) {
738
+ if (isClaudeHook(options.command, options.positionals[0]))
739
+ return unreadLine(twice, io);
740
+ io.stderr.write(`spec-harness: ${twice}; give it one\n`);
741
+ return EXIT_ERROR;
462
742
  }
463
- const command = COMMANDS.get(options.command);
464
- if (command === undefined) {
743
+ // A line that is wrong is refused before --help or --version answers: an
744
+ // answer beside it said nothing of what was wrong, exit 0, as an option no
745
+ // command has never was (spec-core ADR-0005).
746
+ if (options.command !== undefined && !COMMANDS.has(options.command)) {
465
747
  io.stderr.write(`spec-harness: unknown command "${options.command}"; see spec-harness --help\n`);
466
748
  return EXIT_ERROR;
467
749
  }
@@ -471,7 +753,22 @@ async function dispatch(argv, io) {
471
753
  io.stderr.write(`spec-harness: ${refused}\n`);
472
754
  return EXIT_ERROR;
473
755
  }
474
- return command(options, io);
756
+ // Claude Code's hook is exempt from every refusal above, and said a root
757
+ // that is not a directory only as it ran, which the help or the version
758
+ // answered in place of: printed beside one, either is a line it cannot
759
+ // read, said where there is a write it leaves unchecked (ADR-0005).
760
+ const beside = isClaudeHook(options.command, options.positionals[0]) ? answerBeside(options, io.cwd, isDirectory) : null;
761
+ if (beside !== null)
762
+ return unreadLine(beside, io);
763
+ if (options.version) {
764
+ io.stdout.write(`${version()}\n`);
765
+ return EXIT_OK;
766
+ }
767
+ if (options.help || options.command === undefined) {
768
+ io.stdout.write(HELP);
769
+ return options.help ? EXIT_OK : EXIT_ERROR;
770
+ }
771
+ return COMMANDS.get(options.command)(options, io);
475
772
  }
476
773
  /**
477
774
  * Runs the command line and resolves to the exit code, for an error nothing
@@ -506,12 +803,15 @@ export async function run(argv, io) {
506
803
  catch (error) {
507
804
  // A sibling's document of a shape the harness does not read is a sibling
508
805
  // that printed something it cannot read (answers.ts): exit 2. A git
509
- // command that failed is what git said, and no line of the harness's.
806
+ // command that failed is what git said, and no line of the harness's;
807
+ // and a path under git's directory where the harness keeps its state,
808
+ // which holds something else, is said in a line of its own (fs.ts).
510
809
  if (error instanceof UsageError ||
511
810
  error instanceof ConfigError ||
512
811
  error instanceof SiblingError ||
513
812
  error instanceof SiblingOutputError ||
514
- error instanceof GitError) {
813
+ error instanceof GitError ||
814
+ error instanceof StateError) {
515
815
  io.stderr.write(`spec-harness: ${error.message}\n`);
516
816
  return EXIT_ERROR;
517
817
  }