@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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +113 -0
- package/README.md +13 -7
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +119 -30
- package/dist/cli.js.map +1 -1
- package/dist/configure.d.ts +40 -3
- package/dist/configure.js +75 -9
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +30 -4
- package/dist/fs.js +104 -11
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +38 -0
- package/dist/git.js +59 -2
- 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 +14 -0
- package/dist/hooks.js +14 -0
- package/dist/hooks.js.map +1 -1
- package/dist/round.d.ts +19 -1
- package/dist/round.js +59 -16
- package/dist/round.js.map +1 -1
- package/dist/server.js +42 -7
- package/dist/server.js.map +1 -1
- package/dist/siblings.d.ts +5 -3
- package/dist/siblings.js +52 -31
- package/dist/siblings.js.map +1 -1
- package/dist/workspace.js +23 -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';
|
|
@@ -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
|
-
|
|
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
|