@descent-vtt/spec-harness 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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';
@@ -417,6 +417,12 @@ function knownAs(hook, isHook) {
417
417
  * which it holds, and not as the session starts.
418
418
  */
419
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`;
420
426
  /**
421
427
  * What doctor notes, run in a linked worktree, of the spec-harness the
422
428
  * hook's line runs: the one installed where `${CLAUDE_PROJECT_DIR}` points,
@@ -502,7 +508,7 @@ function writtenHook(hook) {
502
508
  return null;
503
509
  const words = line.slice(at + 1);
504
510
  const untyped = type === 'command' ? null : type === undefined ? 'no "type"' : `the "type" ${JSON.stringify(type)}`;
505
- 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 } };
506
512
  }
507
513
  /**
508
514
  * Every line in Claude Code's settings that runs the harness, under the two
@@ -513,17 +519,37 @@ function writtenHook(hook) {
513
519
  */
514
520
  export function writtenHooks(settings) {
515
521
  const found = [];
516
- for (const { event, words, shell, blocks, untyped } of harnessEntries(settings)) {
517
- 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 }));
518
524
  if (same === undefined)
519
- found.push({ events: [event], words, shell, blocks, untyped });
525
+ found.push({ events: [event], words, shell, blocks, untyped, launcher });
520
526
  else if (!same.events.includes(event))
521
527
  same.events.push(event);
522
528
  }
523
529
  return found;
524
530
  }
525
531
  function sameLine(one, other) {
526
- 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 ')}`;
527
553
  }
528
554
  /**
529
555
  * The writing tools whose call an entry is handed and waited on for, or
@@ -580,6 +606,24 @@ function describeOnFailure(where, blocks) {
580
606
  return null;
581
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`;
582
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
+ }
583
627
  /**
584
628
  * What is said of a line that guards and whose entries set no `onFailure`,
585
629
  * where the Claude Code here reads the field: a hook that cannot start lets
@@ -741,8 +785,14 @@ export function handedLines(sources, project) {
741
785
  * `PermissionRequest` has none of its hooks loaded ({@link arrayMatcher}):
742
786
  * each line of the hook there is started for no write, and one that stood
743
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.
744
794
  */
745
- export function reportHooks(sources, project, read, pattern = patternReader(), matching = TODAY, onFailureHere = false) {
795
+ export function reportHooks(sources, project, read, pattern = patternReader(), matching = TODAY, onFailureHere = false, scripts = null) {
746
796
  const reading = { pattern, matching };
747
797
  return sources.flatMap(({ file, settings }) => {
748
798
  const entries = harnessEntries(settings);
@@ -765,14 +815,14 @@ export function reportHooks(sources, project, read, pattern = patternReader(), m
765
815
  // someone meant a matcher, is a guard set up that checks nothing;
766
816
  // one that stood to be started for none leaves no write unchecked
767
817
  // that it was ever asked about.
768
- 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 };
769
819
  const said = `${where}: ${describeArrayMatcher(file, array)}`;
770
820
  if (isStarted(before) || isStarted(after) || mine.some((entry) => Array.isArray(entry.matcher))) {
771
821
  return [{ ...unloaded, state: 'off', detail: `${said}, so the guard is off and no write is checked; ${ONE_STRING}` }];
772
822
  }
773
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` }];
774
824
  }
775
- const written = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before, after, cover: describeCover(where, before, after, mine, reading) };
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) };
776
826
  const note = describeOnFailure(where, hook.blocks);
777
827
  // Where the release here reads the field, a line that guards without it is advised it.
778
828
  const own = file === PROJECT_SETTINGS && mine.every((entry) => isGuard(entry.hook));
@@ -788,6 +838,22 @@ export function reportHooks(sources, project, read, pattern = patternReader(), m
788
838
  // What the hook would do with a line it is never asked on before a write is not what guards.
789
839
  const unstarted = { ...written, state: 'unstarted', detail: null, note };
790
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
+ }
791
857
  if (handed.state === 'off') {
792
858
  const detail = `${where}: ${handed.fault.wrong}, so the guard is off and ${handed.unchecked ?? 'no write is checked'}; ${handed.fault.mend}`;
793
859
  // A fault the hook says, it says after every write it is started for