@descent-vtt/spec-harness 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +325 -0
- package/README.md +39 -18
- package/dist/briefs.d.ts +45 -0
- package/dist/briefs.js +73 -0
- package/dist/briefs.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +308 -142
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +7 -2
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +14 -3
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +94 -3
- package/dist/configure.js +204 -30
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +104 -1
- package/dist/fs.js +383 -11
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +88 -2
- package/dist/git.js +155 -8
- package/dist/git.js.map +1 -1
- package/dist/guard.d.ts +20 -1
- package/dist/guard.js +31 -0
- package/dist/guard.js.map +1 -1
- package/dist/hooks.d.ts +59 -0
- package/dist/hooks.js +76 -3
- package/dist/hooks.js.map +1 -1
- package/dist/round.d.ts +27 -1
- package/dist/round.js +75 -17
- package/dist/round.js.map +1 -1
- package/dist/sandbox.js +4 -3
- package/dist/sandbox.js.map +1 -1
- package/dist/server.d.ts +25 -1
- package/dist/server.js +230 -28
- package/dist/server.js.map +1 -1
- package/dist/setup.js +40 -10
- package/dist/setup.js.map +1 -1
- package/dist/siblings.d.ts +29 -6
- package/dist/siblings.js +152 -39
- package/dist/siblings.js.map +1 -1
- package/dist/usage.d.ts +42 -7
- package/dist/usage.js +65 -6
- package/dist/usage.js.map +1 -1
- package/dist/workspace.d.ts +45 -3
- package/dist/workspace.js +143 -13
- package/dist/workspace.js.map +1 -1
- package/package.json +3 -3
package/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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
|
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
|
|
687
|
-
//
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
//
|
|
691
|
-
|
|
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 [
|
|
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 [
|
|
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
|
|
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
|