@descent-vtt/spec-harness 0.18.0 → 0.19.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/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';
21
+ import { CONFIG_FILE, ConfigError, SIBLINGS, SPEC_BRIEF_CONFIGS } from './config.js';
22
+ import { dirname, join } from 'node:path';
23
+ import { gitDirectories, holdsGit, isDirectory, keepCall, loadConfig, realSpelling, repositoryPath, StateError, takeCall } from './fs.js';
23
24
  import { GitError, show, stagedChanges } from './git.js';
24
- import { claudeResponse, gitResponse, parseClaudeHook } from './hooks.js';
25
+ import { afterResponse, callFile, callRecord, claudeResponse, gitResponse, keptCall, parseClaudeHook, SESSION_START, sessionResponse, warningsKept, warningsOf } from './hooks.js';
25
26
  import { SiblingError } from './siblings.js';
26
- import { emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, unreadHookLine } from './usage.js';
27
+ import { 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, findTree, foundCommonDir, json, MAIN_INSTALL, openFound, openWorkspace, parseOptions, UsageError, version, } 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,8 @@ 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 or SessionStart hook
41
+ (stdin). At session start it says what would hold every write.
40
42
  hook git Answer git's pre-commit hook for the staged files.
41
43
  escalate Ask a person to rule: --path <file> (repeatable) --reason <why>
42
44
  [--option "<label>: <cost>"]... [--recommend <text>].
@@ -76,14 +78,16 @@ Options:
76
78
  --format <fmt> pretty or json; audit and premises also gitlab, sarif or github.
77
79
  For every command but hook and mcp.
78
80
  --strict Warnings fail the run. For guard, audit, premises and doctor.
79
- --help, --version
81
+ --help, --version One of the two: both on one line are refused.
80
82
 
81
83
  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.
84
+ argument given an empty value, a brief given both as the argument and as --brief,
85
+ and an option that takes one value given two that name two things are refused, exit 2,
86
+ not read as if they were not there, and so with --help or --version on the line. The
87
+ same value twice, as --root . --root ./, is read once. doctor says an empty --brief or
88
+ --base in its report. hook claude reads its line as every release has, and says a line
89
+ it cannot read, two values for one option among them, and a --root that is not a
90
+ directory, with exit 1: it refuses nothing for either.
87
91
 
88
92
  Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.
89
93
  `;
@@ -99,7 +103,21 @@ async function decisionsFor(workspace, options, io, given, cwd) {
99
103
  if (problem !== null)
100
104
  return { problem };
101
105
  const { checkPaths } = await import('./round.js');
102
- return { decisions: await checkPaths(workspace, brief, note, given, cwd, reader, options.base) };
106
+ return { decisions: await checkPaths(workspace, brief, note, given, cwd, reader, options.base), brief };
107
+ }
108
+ /**
109
+ * Whether a decision is of a file the answer after the write is made from:
110
+ * the configuration, spec-brief's, the brief, or a file beside it in the
111
+ * briefs' directory, which spec-brief reads too. A call that writes one is
112
+ * decided again after the write, so that what the write changed is read
113
+ * (ADR-0002).
114
+ */
115
+ function decidesAnswers(decision, brief) {
116
+ if (decision.path === CONFIG_FILE || SPEC_BRIEF_CONFIGS.includes(decision.path))
117
+ return true;
118
+ if (brief === null)
119
+ return false;
120
+ return decision.path === brief.file || decision.path.startsWith(`${dirname(brief.file)}/`);
103
121
  }
104
122
  const SYMBOL = { allow: 'ok ', warn: 'warning', ask: 'ask ', deny: 'refused' };
105
123
  async function guardCommand(options, io) {
@@ -139,10 +157,11 @@ async function readStdin(io) {
139
157
  return Buffer.concat(chunks).toString('utf8');
140
158
  }
141
159
  /**
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.
160
+ * The write Claude Code asks the hook about, or the session that starts, or
161
+ * the exit code that answers already: 1 for a question that cannot be read,
162
+ * 0 for a tool that writes no file. Asked before anything on the hook's own
163
+ * line is, so that a line that is wrong is said where there is a write it
164
+ * leaves unchecked, or a session whose every write it leaves so.
146
165
  */
147
166
  async function writeAsked(io) {
148
167
  let question;
@@ -162,7 +181,7 @@ async function writeAsked(io) {
162
181
  io.stderr.write(`spec-harness: ${request.error}\n`);
163
182
  return EXIT_FAILED;
164
183
  }
165
- return request.paths.length === 0 ? EXIT_OK : request;
184
+ return request.paths.length === 0 && request.event !== SESSION_START ? EXIT_OK : request;
166
185
  }
167
186
  /**
168
187
  * What is wrong with the hook's own line, said and nothing refused for it:
@@ -170,10 +189,47 @@ async function writeAsked(io) {
170
189
  * that exits 1 and goes on with the write, where exit 2 would hold every
171
190
  * write of the session, the one that mends the line among them (ADR-0005).
172
191
  */
173
- function guardOff(io, { wrong, mend }) {
192
+ function guardOff(io, { wrong, mend }, event) {
193
+ if (event === SESSION_START)
194
+ return answer(io, sessionResponse(`${wrong}, so the guard is off and no write of this session is checked; ${mend}`));
174
195
  io.stderr.write(`spec-harness: ${wrong}, so the guard is off and this write is not checked; ${mend}\n`);
175
196
  return EXIT_FAILED;
176
197
  }
198
+ function answer(io, response) {
199
+ io.stdout.write(response.stdout);
200
+ return response.exitCode;
201
+ }
202
+ /**
203
+ * What would hold every write of the session that starts, said once as it
204
+ * starts and no write waits on it (ADR-0005): a configuration that does not
205
+ * load, a brief named that spec-brief does not list or has archived, and
206
+ * spec-brief itself, where a brief is named, missing, too old or unable to
207
+ * list the briefs. Each in the words a write would be held with, and
208
+ * `doctor` says, and with what mends it. `null` where nothing does. The
209
+ * check never ends the session's start: whatever it meets is said.
210
+ */
211
+ async function sessionProblem(options, io, root, cwd) {
212
+ const held = 'so every write of this session is held until it is mended';
213
+ try {
214
+ const named = await openWorkspace({ ...options, root }, io);
215
+ // A session in another work tree of the project has its writes decided
216
+ // there, and so is the check (ADR-0005).
217
+ const found = cwd === null ? null : await otherTree(named, cwd);
218
+ const workspace = found === null ? named : await openFound(found, io, () => [named.root]);
219
+ const { problem } = describeActive(await activeBrief(workspace, options, io.env));
220
+ return problem === null ? null : `${heldHookLine(problem).wrong}, ${held}: fix the brief or the branch before writing; spec-harness doctor says more`;
221
+ }
222
+ catch (error) {
223
+ // A session in no work tree has nothing to guard, as a write there has
224
+ // not. Anything else that stops the check stops a write's as well, which
225
+ // is then held as one the hook cannot check: a configuration that does
226
+ // not load, a spec-brief that is missing, too old or cannot list the
227
+ // briefs, and whatever a defect throws.
228
+ if (error instanceof UsageError)
229
+ return null;
230
+ return `${error.message}, ${held}; spec-harness doctor says more`;
231
+ }
232
+ }
177
233
  /**
178
234
  * Claude Code's hook on a line the parser refused: an option no command has,
179
235
  * an option left without its value, a --format or an --at that is none. It
@@ -185,7 +241,115 @@ async function unreadLine(said, io) {
185
241
  const request = await writeAsked(io);
186
242
  if (typeof request === 'number')
187
243
  return request;
188
- return guardOff(io, unreadHookLine(said).fault);
244
+ return guardOff(io, unreadHookLine(said).fault, request.event);
245
+ }
246
+ /**
247
+ * Whether a work tree other than the one whose top is `top` may hold `cwd`:
248
+ * `cwd` is outside it, or a `.git` stands in a directory between the two,
249
+ * where a linked worktree, a submodule or a repository of its own starts.
250
+ * Where neither is so, `cwd` is in that work tree, and git is asked nothing
251
+ * more: a session works at its project's top, or below it, for nearly every
252
+ * write. Answering that one may is what git is then asked, so the mutant
253
+ * that always answers so is equivalent, at the cost of a process, and so
254
+ * is each that only answers so more often: a slice one short reaches the
255
+ * top itself, which holds a `.git`, and a part named in place of none is
256
+ * a directory no top holds.
257
+ */
258
+ function mayBeElsewhere(cwd, top) {
259
+ const path = repositoryPath(cwd, top, cwd);
260
+ if (path === null)
261
+ return true;
262
+ const parts = path === '' ? [] : path.split('/');
263
+ return parts.some((_, index) => holdsGit(join(top, ...parts.slice(0, index + 1))));
264
+ }
265
+ /**
266
+ * The work tree that holds `cwd`, where it is another work tree of the
267
+ * repository the hook's root names, by the git directory the two share;
268
+ * `null` where `cwd` is in the root's own, in another repository, or in
269
+ * none (ADR-0005).
270
+ */
271
+ async function otherTree(named, cwd) {
272
+ if (!mayBeElsewhere(cwd, named.root))
273
+ return null;
274
+ const found = await findTree(cwd);
275
+ // The project's own work tree, found again, would decide every path the
276
+ // same way once more: the check saves reading its configuration twice, and
277
+ // its mutant is equivalent.
278
+ if (found === null || found.root === named.root)
279
+ return null;
280
+ const common = await foundCommonDir(found);
281
+ // git names a common directory wherever it names a top, so `null` is
282
+ // there for the type.
283
+ if (common === null || realSpelling(common) !== realSpelling(named.commonDir))
284
+ return null;
285
+ return found;
286
+ }
287
+ /**
288
+ * The work trees a write is decided in, each with the paths it decides
289
+ * (ADR-0005). The repository is the one the hook's root names, as before,
290
+ * and so is the work tree, but where the question's `cwd` is in another
291
+ * work tree of that repository, linked or main, by the git directory the
292
+ * two share: there a session works in a worktree while Claude Code names
293
+ * the project it started in, and the hook decided by the project's branch,
294
+ * which named no brief, so a protected write passed with no word. That work
295
+ * tree decides the paths it holds, by its branch, its briefs and its
296
+ * configuration, with its siblings looked for in its install and then the
297
+ * project's. A path it does not hold is decided as before, by the work tree
298
+ * the root names: a write to the project's own file from a worktree is
299
+ * refused where it was. Where both hold a path, one is inside the other,
300
+ * and the inner one holds it as git reads it. A `cwd` in another
301
+ * repository, or in none, leaves every path where it was.
302
+ */
303
+ async function writesBy(named, cwd, paths, io) {
304
+ const asBefore = [{ workspace: named, paths }];
305
+ if (cwd === null)
306
+ return asBefore;
307
+ const found = await otherTree(named, cwd);
308
+ if (found === null)
309
+ return asBefore;
310
+ const inner = repositoryPath(found.root, named.root, cwd) !== null;
311
+ const holds = (path) => repositoryPath(path, found.root, cwd) !== null && (inner || repositoryPath(path, named.root, cwd) === null);
312
+ const own = paths.filter(holds);
313
+ if (own.length === 0)
314
+ return asBefore;
315
+ const workspace = await openFound(found, io, () => [named.root]);
316
+ const rest = paths.filter((path) => !holds(path));
317
+ return [{ workspace, paths: own }, ...(rest.length === 0 ? [] : [{ workspace: named, paths: rest }])];
318
+ }
319
+ /**
320
+ * The answer after a write, where it can be given with no process started,
321
+ * or `null` where it is to be decided as before (ADR-0002, ADR-0003).
322
+ *
323
+ * The git directory is the one the files at the hook's root name. What the
324
+ * run before the write kept for this call, if anything, is taken away as it
325
+ * is read, and answered from where it is this call's: the same release,
326
+ * session, call, tool, paths, directories, line and variables. And where
327
+ * nothing is kept, a run after a write says nothing but a warning outside
328
+ * the scope, which only `outOfScope: "warn"` decides: under `ask` or
329
+ * `deny` there is nothing to say, where the configuration read is the one
330
+ * git would have the hook read, of the work tree the session is in.
331
+ */
332
+ async function answeredAfter(root, record, cwd, io) {
333
+ const found = gitDirectories(root, io.env);
334
+ if (found === null)
335
+ return null;
336
+ if (record !== null) {
337
+ const kept = await takeCall(found.common, callFile(record));
338
+ // warningsKept reads no text as no record as well: the test is there for
339
+ // the type, and its mutant is equivalent.
340
+ const warnings = kept === null ? null : warningsKept(kept, record);
341
+ if (warnings !== null)
342
+ return afterResponse(warnings);
343
+ }
344
+ if (!found.exact || (cwd !== null && mayBeElsewhere(cwd, found.top)))
345
+ return null;
346
+ try {
347
+ return (await loadConfig(found.top)).config.outOfScope === 'warn' ? null : afterResponse([]);
348
+ }
349
+ catch {
350
+ // A configuration that does not load is said as it was, by the run.
351
+ return null;
352
+ }
189
353
  }
190
354
  async function hookCommand(options, io) {
191
355
  const kind = options.positionals[0];
@@ -197,11 +361,22 @@ async function hookCommand(options, io) {
197
361
  // reads the line where it finds it in a settings file (usage.ts).
198
362
  const line = readHookLine(options.root, io.cwd, isDirectory);
199
363
  if (line.state === 'off')
200
- return guardOff(io, line.fault);
364
+ return guardOff(io, line.fault, request.event);
201
365
  const cwd = request.cwd ?? io.cwd;
366
+ const root = line.root ?? io.env['CLAUDE_PROJECT_DIR'] ?? cwd;
367
+ if (request.event === SESSION_START)
368
+ return answer(io, sessionResponse(await sessionProblem(options, io, root, request.cwd)));
369
+ const record = callRecord(request, { root: line.root, brief: options.brief, base: options.base }, io.cwd, io.env, version());
370
+ if (request.event === 'PostToolUse') {
371
+ const answered = await answeredAfter(root, record, request.cwd, io);
372
+ if (answered !== null) {
373
+ io.stdout.write(answered.stdout);
374
+ return answered.exitCode;
375
+ }
376
+ }
202
377
  let workspace;
203
378
  try {
204
- workspace = await openWorkspace({ ...options, root: line.root ?? io.env['CLAUDE_PROJECT_DIR'] ?? cwd }, io);
379
+ workspace = await openWorkspace({ ...options, root }, io);
205
380
  }
206
381
  catch (error) {
207
382
  if (error instanceof UsageError)
@@ -209,16 +384,35 @@ async function hookCommand(options, io) {
209
384
  throw error;
210
385
  }
211
386
  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;
387
+ // The answer reads a decision by its verdict, which a mutant's text
388
+ // put first on this list has none of: that mutant is equivalent.
389
+ const decisions = [];
390
+ // Whether the run after the write may answer from what this one keeps:
391
+ // not where a decision refuses, nor where the write is to what the
392
+ // answer is made from, in any work tree that decides it.
393
+ let keeps = true;
394
+ for (const part of await writesBy(workspace, request.cwd, request.paths, io)) {
395
+ const decided = await decisionsFor(part.workspace, options, io, part.paths, cwd);
396
+ if ('problem' in decided) {
397
+ // Exit 2 blocks the tool call, and its stderr reaches the model. Where
398
+ // the name stands on the hook's own line, what is wrong with it is
399
+ // `doctor`'s to say as well, in the same words (usage.ts).
400
+ io.stderr.write(`spec-harness: ${heldHookLine(decided.problem).wrong}. Fix the brief or the branch before writing.\n`);
401
+ return EXIT_ERROR;
402
+ }
403
+ // A line of the answer for each path, by the work tree that decided it.
404
+ decisions.push(...decided.decisions);
405
+ if (decided.decisions.some((decision) => decision.verdict === 'deny' || decidesAnswers(decision, decided.brief)))
406
+ keeps = false;
219
407
  }
220
- const response = claudeResponse(request.event, decided.decisions);
408
+ const response = claudeResponse(request.event, decisions);
221
409
  io.stdout.write(response.stdout);
410
+ // What the run after the write will say, kept for it where the call
411
+ // goes ahead: a refused call has none. It changes nothing of the
412
+ // answer, which is made before it is kept.
413
+ if (request.event === 'PreToolUse' && record !== null && keeps) {
414
+ await keepCall(workspace.commonDir, callFile(record), keptCall(record, warningsOf(decisions)));
415
+ }
222
416
  return response.exitCode;
223
417
  }
224
418
  catch (error) {
@@ -231,7 +425,10 @@ async function hookCommand(options, io) {
231
425
  }
232
426
  }
233
427
  if (kind === 'git') {
234
- const workspace = await openWorkspace(options, io);
428
+ // git starts its hook at the top of the work tree it commits in, a
429
+ // linked worktree's own, whose install may be none: it is then checked
430
+ // with the main work tree's (ADR-0005).
431
+ const workspace = await openWorkspace(options, io, MAIN_INSTALL);
235
432
  const staged = await stagedChanges(workspace.root);
236
433
  const paths = staged.flatMap((change) => (change.from === undefined ? [change.path] : [change.from, change.path]));
237
434
  if (paths.length === 0)
@@ -279,21 +476,25 @@ async function doctorCommand(options, io) {
279
476
  // What doctor alone runs of them, loaded before anything is asked: the
280
477
  // five things below are asked at once, and none waits on a module.
281
478
  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;
479
+ const { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, NO_SESSION_START, wiringState } = configure;
283
480
  const { diagnoseBrief, specBriefPlugin } = round;
284
481
  const { gitHook } = setup;
285
482
  const { notFido2, readAllowedSigners } = signing;
286
- const workspace = await openWorkspace(options, io);
483
+ // The siblings as git's hook finds them in this work tree: its own
484
+ // install, and in a linked worktree with none the main work tree's.
485
+ const workspace = await openWorkspace(options, io, MAIN_INSTALL);
287
486
  const rows = SIBLINGS.map((name) => {
288
487
  const sibling = workspace.siblings.locate(name);
289
488
  const row = { tool: name, state: sibling.kind, minimum: MINIMUM_VERSIONS[name] };
290
489
  if (sibling.kind === 'absent')
291
- return { ...row, version: null, detail: sibling.reason };
490
+ return { ...row, version: null, elsewhere: null, detail: sibling.reason };
491
+ const elsewhere = sibling.elsewhere ?? null;
492
+ const whose = elsewhere === null ? '' : `, the install of ${elsewhere}: this work tree has none of its own`;
292
493
  if (sibling.kind === 'outdated')
293
- return { ...row, version: sibling.version, detail: sibling.reason };
494
+ return { ...row, version: sibling.version, elsewhere, detail: `${sibling.reason}${whose}` };
294
495
  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 };
496
+ const detail = sibling.version === null ? `${command} (named in .spec-harness.json; its version is not checked)` : `${command} (${sibling.version}${whose})`;
497
+ return { ...row, version: sibling.version, elsewhere, detail };
297
498
  });
298
499
  const signersFile = workspace.config.rulings.allowedSigners;
299
500
  // Five things that wait on the work tree and on nothing of one another,
@@ -336,6 +537,9 @@ async function doctorCommand(options, io) {
336
537
  // this machine, or cannot be told to be, and disableAllHooks, which
337
538
  // switches off whatever hooks there are.
338
539
  const switchedOff = guardSwitchedOff(wiring);
540
+ // And whether the hook is started as a session starts, where settings hold
541
+ // the guard: a note, failing nothing, and `sessionStart` in the document.
542
+ const atStart = wiring.hooks.length > 0 && !wiring.sessionStart ? NO_SESSION_START : null;
339
543
  const wiringNotes = [describePresence(wiring), wiring.hooksOff === null ? null : describeHooksOff(wiring.hooksOff, switchedOff)].filter((note) => note !== null);
340
544
  // How the hook runs the harness is a note: one that goes through npx guards
341
545
  // as well as one that does not, and fails nothing, --strict or not.
@@ -384,6 +588,7 @@ async function doctorCommand(options, io) {
384
588
  ...more(wiringNotes),
385
589
  ...more(lineNotes),
386
590
  ...more(release === null ? [] : [describeClaudeRelease(release)]),
591
+ ...more(atStart === null ? [] : [atStart]),
387
592
  `git ${describeGitHook(hook)}`,
388
593
  ...more(hookNote === null ? [] : [hookNote]),
389
594
  '',
@@ -402,14 +607,17 @@ async function doctorCommand(options, io) {
402
607
  // path of git's pre-commit hook that git cannot run and stops every
403
608
  // commit at. A release that cannot be told is not fine either, nor a
404
609
  // line whose reading cannot be, nor a plugin that cannot be told to be on
405
- // this machine: --strict fails the three.
610
+ // this machine, nor a hook git runs that cannot be read, which stops every
611
+ // commit as a script and runs as a program: --strict fails the four.
406
612
  const outdated = rows.some((row) => row.state === 'outdated');
407
613
  const usable = rows.some((row) => row.state === 'found');
408
614
  const off = wiring.lines.some((line) => line.state === 'off' || line.state === 'held');
409
615
  const missing = wiring.presence?.state === 'absent' && wiring.hooks.length === 0;
410
616
  const tooOld = release?.state === 'outdated';
411
617
  const stopped = hook.state === 'stops';
412
- const untold = options.strict && (release?.state === 'unknown' || wiring.lines.some((line) => line.state === 'unknown') || wiring.presence?.state === 'unknown');
618
+ // Only an unreadable hook has `runs`, so the test of its state is the
619
+ // compiler's, and its mutant is equivalent.
620
+ const untold = options.strict && (release?.state === 'unknown' || wiring.lines.some((line) => line.state === 'unknown') || wiring.presence?.state === 'unknown' || (hook.state === 'unreadable' && hook.runs));
413
621
  return outdated || !usable || claude === 'both' || off || switchedOff || missing || tooOld || stopped || untold ? EXIT_FAILED : EXIT_OK;
414
622
  }
415
623
  /* ---------------------------------------------------------------- dispatch */
@@ -452,16 +660,20 @@ async function dispatch(argv, io) {
452
660
  io.stderr.write(`spec-harness: ${refused.message}\n`);
453
661
  return EXIT_ERROR;
454
662
  }
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;
663
+ // Two values for an option that takes one: the parser kept the last, and
664
+ // which was meant cannot be told from the line, so it is refused as a line
665
+ // the parser cannot read is, and Claude Code's hook says it of its own.
666
+ const twice = twoValues(options, io.cwd);
667
+ if (twice !== null) {
668
+ if (isClaudeHook(options.command, options.positionals[0]))
669
+ return unreadLine(twice, io);
670
+ io.stderr.write(`spec-harness: ${twice}; give it one\n`);
671
+ return EXIT_ERROR;
462
672
  }
463
- const command = COMMANDS.get(options.command);
464
- if (command === undefined) {
673
+ // A line that is wrong is refused before --help or --version answers: an
674
+ // answer beside it said nothing of what was wrong, exit 0, as an option no
675
+ // command has never was (spec-core ADR-0005).
676
+ if (options.command !== undefined && !COMMANDS.has(options.command)) {
465
677
  io.stderr.write(`spec-harness: unknown command "${options.command}"; see spec-harness --help\n`);
466
678
  return EXIT_ERROR;
467
679
  }
@@ -471,7 +683,15 @@ async function dispatch(argv, io) {
471
683
  io.stderr.write(`spec-harness: ${refused}\n`);
472
684
  return EXIT_ERROR;
473
685
  }
474
- return command(options, io);
686
+ if (options.version) {
687
+ io.stdout.write(`${version()}\n`);
688
+ return EXIT_OK;
689
+ }
690
+ if (options.help || options.command === undefined) {
691
+ io.stdout.write(HELP);
692
+ return options.help ? EXIT_OK : EXIT_ERROR;
693
+ }
694
+ return COMMANDS.get(options.command)(options, io);
475
695
  }
476
696
  /**
477
697
  * Runs the command line and resolves to the exit code, for an error nothing
@@ -506,12 +726,15 @@ export async function run(argv, io) {
506
726
  catch (error) {
507
727
  // A sibling's document of a shape the harness does not read is a sibling
508
728
  // 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.
729
+ // command that failed is what git said, and no line of the harness's;
730
+ // and a path under git's directory where the harness keeps its state,
731
+ // which holds something else, is said in a line of its own (fs.ts).
510
732
  if (error instanceof UsageError ||
511
733
  error instanceof ConfigError ||
512
734
  error instanceof SiblingError ||
513
735
  error instanceof SiblingOutputError ||
514
- error instanceof GitError) {
736
+ error instanceof GitError ||
737
+ error instanceof StateError) {
515
738
  io.stderr.write(`spec-harness: ${error.message}\n`);
516
739
  return EXIT_ERROR;
517
740
  }