@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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +325 -0
  4. package/README.md +39 -18
  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 +308 -142
  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 +13 -1
  14. package/dist/config.js +14 -3
  15. package/dist/config.js.map +1 -1
  16. package/dist/configure.d.ts +94 -3
  17. package/dist/configure.js +204 -30
  18. package/dist/configure.js.map +1 -1
  19. package/dist/fs.d.ts +104 -1
  20. package/dist/fs.js +383 -11
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git.d.ts +88 -2
  23. package/dist/git.js +155 -8
  24. package/dist/git.js.map +1 -1
  25. package/dist/guard.d.ts +20 -1
  26. package/dist/guard.js +31 -0
  27. package/dist/guard.js.map +1 -1
  28. package/dist/hooks.d.ts +59 -0
  29. package/dist/hooks.js +76 -3
  30. package/dist/hooks.js.map +1 -1
  31. package/dist/round.d.ts +27 -1
  32. package/dist/round.js +75 -17
  33. package/dist/round.js.map +1 -1
  34. package/dist/sandbox.js +4 -3
  35. package/dist/sandbox.js.map +1 -1
  36. package/dist/server.d.ts +25 -1
  37. package/dist/server.js +230 -28
  38. package/dist/server.js.map +1 -1
  39. package/dist/setup.js +40 -10
  40. package/dist/setup.js.map +1 -1
  41. package/dist/siblings.d.ts +29 -6
  42. package/dist/siblings.js +152 -39
  43. package/dist/siblings.js.map +1 -1
  44. package/dist/usage.d.ts +42 -7
  45. package/dist/usage.js +65 -6
  46. package/dist/usage.js.map +1 -1
  47. package/dist/workspace.d.ts +45 -3
  48. package/dist/workspace.js +143 -13
  49. package/dist/workspace.js.map +1 -1
  50. package/package.json +3 -3
package/dist/configure.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { isAbsolute, join } from 'node:path';
11
11
  import { runInNewContext } from 'node:vm';
12
- import { CALLABLE_TOOLS } from './hooks.js';
12
+ import { CALLABLE_TOOLS, HOOK_BUDGET_MS } from './hooks.js';
13
13
  import { literal } from './literal.js';
14
14
  import { namedTree } from './usage.js';
15
15
  import { parseGlobList } from './vendor/spec-core/pattern/index.js';
@@ -58,12 +58,28 @@ const GUARDED_EVENTS = [BEFORE, AFTER];
58
58
  * left out, it is every way a session starts.
59
59
  */
60
60
  const AT_START = 'SessionStart';
61
+ /**
62
+ * The event Claude Code runs as its main agent finishes a turn, under which
63
+ * the hook reports what the work tree holds where `turnReport` is on
64
+ * (ADR-0005). Its entry has no matcher: the event takes none.
65
+ */
66
+ const AT_TURN_END = 'Stop';
61
67
  const GUARD_ARGS = Object.freeze([`${PROJECT_DIR}/${BIN}`, 'hook', 'claude']);
62
68
  /**
63
69
  * The guard as a Claude Code hook: exec form, `args` given, so the project's
64
70
  * path is one argument whatever it holds, and no shell is involved.
65
71
  */
66
72
  export const GUARD_HOOK = Object.freeze({ type: 'command', command: 'node', args: GUARD_ARGS, timeout: 60 });
73
+ /**
74
+ * The hook's entry at the end of a turn: the guard's line, run in the
75
+ * background. Claude Code waits on no part of it, so the turn ends as it
76
+ * would, and it hands the hook's `systemMessage` to Claude as the next
77
+ * turn begins, where from an entry it waits on it shows the person the
78
+ * message instead (the hooks reference,
79
+ * https://code.claude.com/docs/en/hooks, *Run hooks in the background* and
80
+ * *JSON output*, read 2026-10-19).
81
+ */
82
+ export const TURN_HOOK = Object.freeze({ ...GUARD_HOOK, async: true });
67
83
  // bare-name: what 0.1 installed, an npx command found from wherever the
68
84
  // session stood. Read here so that init replaces it, and never run.
69
85
  const LEGACY_HOOK = 'npx --no-install spec-harness hook claude';
@@ -331,6 +347,34 @@ export function withSessionStart(settings, isHook) {
331
347
  const listed = hooks[AT_START];
332
348
  return { ...settings, hooks: { ...hooks, [AT_START]: [...(Array.isArray(listed) ? listed : []), { hooks: [{ ...GUARD_HOOK, args: [...GUARD_ARGS] }] }] } };
333
349
  }
350
+ /** The events init writes its own line under. */
351
+ const INIT_EVENTS = [BEFORE, AFTER, AT_START];
352
+ /**
353
+ * Settings with `onFailure: "block"` on each of init's own entries, under
354
+ * the three events it writes them under, or `null` where each has it
355
+ * already: `init --block-on-failure`. Claude Code's hooks reference
356
+ * (https://code.claude.com/docs/en/hooks, Block the action when a hook
357
+ * fails, read 2026-10-19) has the field read by Claude Code 2.1.295 and
358
+ * later, and a failure under it, a hook that cannot start or that reaches
359
+ * its timeout among them, do what exit 2 does on its event: before a write,
360
+ * hold it. What a release before 2.1.295 does with the key is on no page, so
361
+ * init writes it only where it is asked to (ADR-0012). An entry a person
362
+ * wrote is theirs, and is left as it is.
363
+ */
364
+ export function withOnFailure(settings) {
365
+ const hooks = isObject(settings['hooks']) ? settings['hooks'] : {};
366
+ const unblocked = (hook) => isGuard(hook) && hook['onFailure'] !== 'block';
367
+ const next = { ...hooks };
368
+ let changed = false;
369
+ for (const event of INIT_EVENTS) {
370
+ const groups = hooks[event];
371
+ if (!Array.isArray(groups) || !groups.some((group) => hooksOf(group).some(unblocked)))
372
+ continue;
373
+ next[event] = groups.map((group) => (hooksOf(group).some(unblocked) ? { ...group, hooks: hooksOf(group).map((hook) => (unblocked(hook) ? { ...hook, onFailure: 'block' } : hook)) } : group));
374
+ changed = true;
375
+ }
376
+ return changed ? { ...settings, hooks: next } : null;
377
+ }
334
378
  /**
335
379
  * Whether settings have Claude Code start the hook as a session starts: an
336
380
  * entry under `SessionStart` whose line is init's, or one `isHook` knows
@@ -343,6 +387,25 @@ export function startsSessions(settings, isHook) {
343
387
  return false;
344
388
  return groups.some((group) => hooksOf(group).some((hook) => isObject(hook) && (isGuard(hook) || knownAs(hook, isHook))));
345
389
  }
390
+ /**
391
+ * Settings with init's entry under `Stop` added, or `null` where a line
392
+ * of the hook stands there already, init's or one `isHook` knows, which
393
+ * would report the turn twice (ADR-0012).
394
+ */
395
+ export function withTurnReport(settings, isHook) {
396
+ if (reportsTurns(settings, isHook))
397
+ return null;
398
+ const hooks = isObject(settings['hooks']) ? settings['hooks'] : {};
399
+ const listed = hooks[AT_TURN_END];
400
+ return { ...settings, hooks: { ...hooks, [AT_TURN_END]: [...(Array.isArray(listed) ? listed : []), { hooks: [{ ...TURN_HOOK, args: [...GUARD_ARGS] }] }] } };
401
+ }
402
+ /** Whether settings have Claude Code start the hook as a turn ends: an entry under `Stop` whose line is init's, or one `isHook` knows as the hook's. */
403
+ export function reportsTurns(settings, isHook) {
404
+ const groups = field(field(settings, 'hooks'), AT_TURN_END);
405
+ if (!Array.isArray(groups))
406
+ return false;
407
+ return groups.some((group) => hooksOf(group).some((hook) => isObject(hook) && (isGuard(hook) || knownAs(hook, isHook))));
408
+ }
346
409
  /** Whether `isHook` knows an entry's line as the hook's. */
347
410
  function knownAs(hook, isHook) {
348
411
  const written = writtenHook(hook);
@@ -354,6 +417,30 @@ function knownAs(hook, isHook) {
354
417
  * which it holds, and not as the session starts.
355
418
  */
356
419
  export const NO_SESSION_START = 'note: no settings file has Claude Code start the hook as a session starts, so a configuration that does not load, or a brief named that spec-brief does not list, is said only at the first write, which it holds: spec-harness init --write adds the entry beside the two it wrote';
420
+ /**
421
+ * What doctor notes where `turnReport` is on and no settings file has Claude
422
+ * Code start the hook as a turn ends: the report the configuration asks for
423
+ * is made at no turn's end. The plugin brings no entry for it (ADR-0012).
424
+ */
425
+ export const NO_TURN_END = `note: "turnReport" is on, and no settings file has Claude Code start the hook as a turn ends, so no turn's end is reported: spec-harness init --write adds the entry beside init's, and says where it goes where the plugin brings the guard hooks`;
426
+ /**
427
+ * What doctor notes, run in a linked worktree, of the spec-harness the
428
+ * hook's line runs: the one installed where `${CLAUDE_PROJECT_DIR}` points,
429
+ * which is this work tree in a session started in it, and the main
430
+ * checkout in a worktree session of the desktop app's, where Claude Code
431
+ * reads the hooks from the main checkout's root and points the placeholder
432
+ * at it (its worktrees page, What worktrees share with the main checkout,
433
+ * read 2026-10-19). Said where the two are not one release, `null` for
434
+ * none, and `null` where they are. A note: from 0.19.0 on either decides a
435
+ * write by the work tree that holds it, so it says which release answers,
436
+ * not where a write is decided.
437
+ */
438
+ export function describeInstalls(main, there, here) {
439
+ if (there === here)
440
+ return null;
441
+ const release = (version) => (version === null ? 'none' : version);
442
+ return `note: this is a linked worktree of ${main}, and the hook's line runs the spec-harness installed where \${CLAUDE_PROJECT_DIR} points: this work tree's, ${release(here)}, in a session started here, and ${main}'s, ${release(there)}, in one started there, as a worktree session of the desktop app is`;
443
+ }
357
444
  /** The server's entry, reading the project at `projectDir`: {@link PROJECT_DIR} for the plugin, {@link PROJECT_DIR_OR_HERE} for a project. */
358
445
  export function mcpServer(projectDir) {
359
446
  return { command: 'node', args: [`${projectDir}/${BIN}`, 'mcp', '--root', projectDir] };
@@ -421,7 +508,7 @@ function writtenHook(hook) {
421
508
  return null;
422
509
  const words = line.slice(at + 1);
423
510
  const untyped = type === 'command' ? null : type === undefined ? 'no "type"' : `the "type" ${JSON.stringify(type)}`;
424
- return { words, shell: shell && words.some((word) => SHELL_READS.test(word)), blocks: hook['onFailure'] === 'block', untyped };
511
+ return { words, shell: shell && words.some((word) => SHELL_READS.test(word)), blocks: hook['onFailure'] === 'block', untyped, launcher: { word: line[at], shell } };
425
512
  }
426
513
  /**
427
514
  * Every line in Claude Code's settings that runs the harness, under the two
@@ -432,17 +519,37 @@ function writtenHook(hook) {
432
519
  */
433
520
  export function writtenHooks(settings) {
434
521
  const found = [];
435
- for (const { event, words, shell, blocks, untyped } of harnessEntries(settings)) {
436
- const same = found.find((other) => sameLine(other, { words, shell, blocks, untyped }));
522
+ for (const { event, words, shell, blocks, untyped, launcher } of harnessEntries(settings)) {
523
+ const same = found.find((other) => sameLine(other, { words, shell, blocks, untyped, launcher }));
437
524
  if (same === undefined)
438
- found.push({ events: [event], words, shell, blocks, untyped });
525
+ found.push({ events: [event], words, shell, blocks, untyped, launcher });
439
526
  else if (!same.events.includes(event))
440
527
  same.events.push(event);
441
528
  }
442
529
  return found;
443
530
  }
444
531
  function sameLine(one, other) {
445
- return one.shell === other.shell && one.blocks === other.blocks && one.untyped === other.untyped && sameStrings(one.words, other.words);
532
+ // `shell` is not compared: it follows from whether a shell reads the
533
+ // launcher and from the words after it, which are.
534
+ return (one.blocks === other.blocks &&
535
+ one.untyped === other.untyped &&
536
+ one.launcher.word === other.launcher.word &&
537
+ one.launcher.shell === other.launcher.shell &&
538
+ sameStrings(one.words, other.words));
539
+ }
540
+ /**
541
+ * What is said of the script a line runs where it is in none of the places
542
+ * it can name, `null` where it is in one, and `null` where that cannot be
543
+ * told here: a relative path, which is read from wherever the session
544
+ * stands as the hook starts, a name a program finds by itself (npx's
545
+ * package, a command on `PATH`), and a word a shell reads before it runs
546
+ * anything.
547
+ */
548
+ function absentScript(launcher, places) {
549
+ const paths = [...new Set(places.projects.map((project) => launcher.word.split(PROJECT_DIR).join(project)))];
550
+ if (paths.some((path) => (launcher.shell && SHELL_READS.test(path)) || !isAbsolute(path) || places.isFile(path)))
551
+ return null;
552
+ return paths.length === 1 ? `the script it runs, ${paths[0]}, is not there` : `the script it runs is in neither place \${CLAUDE_PROJECT_DIR} can name, ${paths.join(' or ')}`;
446
553
  }
447
554
  /**
448
555
  * The writing tools whose call an entry is handed and waited on for, or
@@ -477,17 +584,58 @@ export function isStarted(tools) {
477
584
  }
478
585
  /**
479
586
  * The release of Claude Code that reads `onFailure` on a hook's entry, by its
480
- * hooks reference (https://code.claude.com/docs/en/hooks, read 2026-10-12).
587
+ * hooks reference (https://code.claude.com/docs/en/hooks, Command hook
588
+ * fields, read 2026-10-19) and its changelog, which adds the field under it.
589
+ */
590
+ export const ON_FAILURE_SINCE = '2.1.295';
591
+ /** Whether the Claude Code doctor asked its release reads `onFailure`: one that was told, at {@link ON_FAILURE_SINCE} or later. */
592
+ export function readsOnFailure(release) {
593
+ // A release that was told is one whose version was read as one, and the
594
+ // constant is a version, so both parse.
595
+ return release !== null && release.state !== 'unknown' && meets(parseVersion(release.version), parseVersion(ON_FAILURE_SINCE));
596
+ }
597
+ /**
598
+ * What `onFailure: "block"` changes for a line, named by `where` it stands,
599
+ * or `null` where the entry does not set it. The hook fails for none of its
600
+ * own answers, a line of its own that turns the guard off among them, which
601
+ * it says with exit 0 (ADR-0005): what the field holds is a hook that does
602
+ * not run.
481
603
  */
482
- const ON_FAILURE_SINCE = '2.1.295';
483
- /** What `onFailure: "block"` changes for a line, named by `where` it stands, or `null` where the entry does not set it. */
484
- function describeOnFailure(where, blocks, off) {
604
+ function describeOnFailure(where, blocks) {
485
605
  if (!blocks)
486
606
  return null;
487
- const holds = `${where} sets onFailure to "block", so Claude Code ${ON_FAILURE_SINCE} or later holds a write when the hook fails`;
488
- if (off)
489
- return `${holds}, which with this line is every write, the one that mends the line among them: mend it by hand`;
490
- return `${holds}, where it lets the write go ahead without it: by its exit 1 for a question it cannot read, by not starting, or by running past its timeout`;
607
+ return `${where} sets onFailure to "block", so Claude Code ${ON_FAILURE_SINCE} or later holds a write when the hook fails, where it lets the write go ahead without it: by not starting, as where the script on its line is not there, or by running past its timeout`;
608
+ }
609
+ /**
610
+ * What is said of a line whose entries give the hook less time than a run
611
+ * of it is given ({@link HOOK_BUDGET_MS}), or `null`. Claude Code's hooks
612
+ * reference (Timeouts, read 2026-10-24) has a command hook cancelled at its
613
+ * entry's `timeout`, and a PreToolUse hook it cancels blocks no tool call:
614
+ * a run that needs its budget, which holds the write at the end of it, is
615
+ * cut short there, and the write goes ahead. A note, which fails nothing:
616
+ * the timeout is the person's. One that is no number is not read.
617
+ */
618
+ function describeTimeout(where, entries) {
619
+ const short = entries.flatMap((entry) => {
620
+ const seconds = entry.hook['timeout'];
621
+ return typeof seconds === 'number' && seconds * 1000 < HOOK_BUDGET_MS ? [`${seconds} seconds under ${entry.event}`] : [];
622
+ });
623
+ if (short.length === 0)
624
+ return null;
625
+ return `${where} gives the hook ${named(short)}, less than the ${HOOK_BUDGET_MS / 1000} a run of it is given, so Claude Code cancels a run that takes longer before it answers, and a write it would have held goes ahead: give the entry 60, as init writes it, or no timeout`;
626
+ }
627
+ /**
628
+ * What is said of a line that guards and whose entries set no `onFailure`,
629
+ * where the Claude Code here reads the field: a hook that cannot start lets
630
+ * the write go ahead, and the field would hold it. Advice, which fails
631
+ * nothing: the file is read by every clone's Claude Code, and what a release
632
+ * before {@link ON_FAILURE_SINCE} does with the key is on no page. `own` is
633
+ * whether the entries are init's, in the file init writes, which
634
+ * `init --block-on-failure` gives the field.
635
+ */
636
+ function adviseOnFailure(where, own) {
637
+ const how = own ? 'spec-harness init --write --block-on-failure writes it on these entries' : 'it is written on each of its entries';
638
+ return `${where} sets no onFailure, so Claude Code lets a write go ahead where the hook cannot start, as where the script on its line is not there, or runs past its timeout: where every Claude Code that reads the file is ${ON_FAILURE_SINCE} or later, as the one here is, "onFailure": "block" holds the write instead, and ${how}`;
491
639
  }
492
640
  /** Names as a sentence lists them: one, two with "and", more with commas before it. Never asked of none. */
493
641
  function named(names) {
@@ -637,8 +785,14 @@ export function handedLines(sources, project) {
637
785
  * `PermissionRequest` has none of its hooks loaded ({@link arrayMatcher}):
638
786
  * each line of the hook there is started for no write, and one that stood
639
787
  * to be started for a write is a guard that is off.
788
+ *
789
+ * `scripts` is where a caller that says whether the script a line runs is
790
+ * there looks for it; with none it is not asked. A line whose script is
791
+ * not there is a hook Claude Code cannot start, which it lets each write
792
+ * go ahead without, or holds every write for where the entry sets
793
+ * `onFailure` to `"block"` and the release here reads it.
640
794
  */
641
- export function reportHooks(sources, project, read, pattern = patternReader(), matching = TODAY) {
795
+ export function reportHooks(sources, project, read, pattern = patternReader(), matching = TODAY, onFailureHere = false, scripts = null) {
642
796
  const reading = { pattern, matching };
643
797
  return sources.flatMap(({ file, settings }) => {
644
798
  const entries = harnessEntries(settings);
@@ -661,15 +815,18 @@ export function reportHooks(sources, project, read, pattern = patternReader(), m
661
815
  // someone meant a matcher, is a guard set up that checks nothing;
662
816
  // one that stood to be started for none leaves no write unchecked
663
817
  // that it was ever asked about.
664
- const unloaded = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before: [], after: [], cover: null, note: null };
818
+ const unloaded = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before: [], after: [], cover: null, note: null, timeout: null };
665
819
  const said = `${where}: ${describeArrayMatcher(file, array)}`;
666
820
  if (isStarted(before) || isStarted(after) || mine.some((entry) => Array.isArray(entry.matcher))) {
667
821
  return [{ ...unloaded, state: 'off', detail: `${said}, so the guard is off and no write is checked; ${ONE_STRING}` }];
668
822
  }
669
823
  return [{ ...unloaded, state: 'unstarted', detail: `${said}: the line is not started, and stood to be started for no write as it is, so no guard is switched off` }];
670
824
  }
671
- const written = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before, after, cover: describeCover(where, before, after, mine, reading) };
672
- const note = describeOnFailure(where, hook.blocks, false);
825
+ const written = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before, after, cover: describeCover(where, before, after, mine, reading), timeout: describeTimeout(where, mine) };
826
+ const note = describeOnFailure(where, hook.blocks);
827
+ // Where the release here reads the field, a line that guards without it is advised it.
828
+ const own = file === PROJECT_SETTINGS && mine.every((entry) => isGuard(entry.hook));
829
+ const guards = (detail) => ({ ...written, state: 'guards', detail, note: note ?? (onFailureHere ? adviseOnFailure(where, own) : null) });
673
830
  if (hook.untyped !== null) {
674
831
  const detail = `${where} stands on an entry with ${hook.untyped}, where Claude Code's hooks reference has "type": "command" on an entry it runs as a command, so whether Claude Code runs it cannot be told here: give the entry "type": "command"`;
675
832
  return [{ ...written, state: 'unknown', detail, note }];
@@ -681,17 +838,30 @@ export function reportHooks(sources, project, read, pattern = patternReader(), m
681
838
  // What the hook would do with a line it is never asked on before a write is not what guards.
682
839
  const unstarted = { ...written, state: 'unstarted', detail: null, note };
683
840
  const waited = before === null || before.length > 0;
841
+ // Whatever the hook would do with the words, it never reads them where
842
+ // its script is not there. By the hooks reference
843
+ // (https://code.claude.com/docs/en/hooks, Block the action when a hook
844
+ // fails, read 2026-10-24) a command hook that cannot start, as where
845
+ // its script does not exist, has failed, and the action goes ahead
846
+ // unless the entry sets onFailure to "block", which holds it.
847
+ const absent = scripts === null ? null : absentScript(hook.launcher, scripts);
848
+ if (absent !== null) {
849
+ if (!waited && !isStarted(after))
850
+ return [unstarted];
851
+ const mend = 'install spec-harness in the project, npm install --save-dev @descent-vtt/spec-harness, or name its script where it is';
852
+ if (hook.blocks && onFailureHere && waited) {
853
+ return [{ ...written, state: 'held', detail: `${where}: ${absent}, so the hook cannot start, and Claude Code holds every write it is started for before, since the entry sets onFailure to "block"; ${mend}`, note }];
854
+ }
855
+ return [{ ...written, state: 'off', detail: `${where}: ${absent}, so the hook cannot start, the guard is off and Claude Code lets each write it is started for go ahead without it; ${mend}`, note }];
856
+ }
684
857
  if (handed.state === 'off') {
685
858
  const detail = `${where}: ${handed.fault.wrong}, so the guard is off and ${handed.unchecked ?? 'no write is checked'}; ${handed.fault.mend}`;
686
- // A fault the hook says, it says with exit 1 after every write it is
687
- // started for as well, so it is said wherever the line stands.
688
- if (handed.silent !== true)
689
- return [{ ...written, state: 'off', detail, note: describeOnFailure(where, hook.blocks, true) }];
690
- // A root outside any work tree, or in one that does not hold the
691
- // project, of which the hook says nothing: no run of it fails
692
- // there, so onFailure holds no write for it, and where the line is
693
- // waited on before no write there is none it leaves unchecked.
694
- return [waited ? { ...written, state: 'off', detail, note } : unstarted];
859
+ // A fault the hook says, it says after every write it is started for
860
+ // as well, so it is said wherever the line stands. A root outside
861
+ // any work tree, or in one that does not hold the project, it says
862
+ // nothing of, and where the line is waited on before no write there
863
+ // is none it leaves unchecked.
864
+ return [handed.silent !== true || waited ? { ...written, state: 'off', detail, note } : unstarted];
695
865
  }
696
866
  if (!waited)
697
867
  return [unstarted];
@@ -703,11 +873,11 @@ export function reportHooks(sources, project, read, pattern = patternReader(), m
703
873
  return [{ ...written, state: 'unknown', detail: null, note }];
704
874
  // What a release wrote: its hook, and nothing after it.
705
875
  if (sameStrings(hook.words, ['hook', 'claude']))
706
- return [{ ...written, state: 'guards', detail: null, note }];
876
+ return [guards(null)];
707
877
  const root = handed.root === undefined ? '' : `, and its root, ${handed.root}, is a directory`;
708
878
  // Whose briefs a write is checked by, where they are not the project's own.
709
879
  const holder = handed.holder === undefined ? '' : ` in ${namedTree(handed.holder)}, which holds this project: the hook checks a write here by that work tree's briefs and its branch, as a file under ${handed.holder.path}/ there`;
710
- return [{ ...written, state: 'guards', detail: `${where} is more than init writes, and guards: the hook reads the line${root}${holder}`, note }];
880
+ return [guards(`${where} is more than init writes, and guards: the hook reads the line${root}${holder}`)];
711
881
  });
712
882
  });
713
883
  }
@@ -936,7 +1106,11 @@ export function wiringState(wiring) {
936
1106
  * checks no write.
937
1107
  */
938
1108
  export function guardSwitchedOff(wiring) {
939
- return wiring.hooksOff !== null && (wiring.hooks.length > 0 || (wiring.plugin !== null && absence(wiring) === null));
1109
+ return wiring.hooksOff !== null && guardWired(wiring);
1110
+ }
1111
+ /** Whether something brings the guard hooks: a settings file that holds them, or a plugin settings turn on that may be on this machine. */
1112
+ export function guardWired(wiring) {
1113
+ return wiring.hooks.length > 0 || (wiring.plugin !== null && absence(wiring) === null);
940
1114
  }
941
1115
  /**
942
1116
  * What doctor and init say of `disableAllHooks` in `file`. `guarded` is