@descent-vtt/spec-harness 0.17.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +263 -0
- package/README.md +63 -5
- package/dist/cli.d.ts +15 -1
- package/dist/cli.js +334 -85
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +12 -2
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +158 -18
- package/dist/configure.js +318 -55
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +83 -3
- package/dist/fs.js +190 -13
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +66 -7
- package/dist/git.js +80 -8
- package/dist/git.js.map +1 -1
- package/dist/hooks.d.ts +66 -0
- package/dist/hooks.js +112 -15
- package/dist/hooks.js.map +1 -1
- package/dist/round.d.ts +25 -4
- package/dist/round.js +100 -33
- package/dist/round.js.map +1 -1
- package/dist/setup.d.ts +12 -2
- package/dist/setup.js +92 -26
- package/dist/setup.js.map +1 -1
- package/dist/siblings.d.ts +18 -4
- package/dist/siblings.js +30 -17
- package/dist/siblings.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +71 -8
- package/dist/usage.js +108 -19
- package/dist/usage.js.map +1 -1
- package/dist/workspace.d.ts +38 -1
- package/dist/workspace.js +90 -15
- package/dist/workspace.js.map +1 -1
- package/hooks/hooks.json +16 -0
- package/package.json +2 -2
package/dist/configure.js
CHANGED
|
@@ -11,8 +11,9 @@ import { isAbsolute, join } from 'node:path';
|
|
|
11
11
|
import { runInNewContext } from 'node:vm';
|
|
12
12
|
import { CALLABLE_TOOLS } from './hooks.js';
|
|
13
13
|
import { literal } from './literal.js';
|
|
14
|
+
import { namedTree } from './usage.js';
|
|
14
15
|
import { parseGlobList } from './vendor/spec-core/pattern/index.js';
|
|
15
|
-
import { CLAUDE_CODE_MINIMUM } from './versions.js';
|
|
16
|
+
import { CLAUDE_CODE_MINIMUM, meets, parseVersion } from './versions.js';
|
|
16
17
|
function isObject(value) {
|
|
17
18
|
return typeof value === 'object' && value !== null;
|
|
18
19
|
}
|
|
@@ -51,6 +52,12 @@ const BEFORE = 'PreToolUse';
|
|
|
51
52
|
/** The event after a call that succeeded, where a hook can only tell the model what it wrote. */
|
|
52
53
|
const AFTER = 'PostToolUse';
|
|
53
54
|
const GUARDED_EVENTS = [BEFORE, AFTER];
|
|
55
|
+
/**
|
|
56
|
+
* The event Claude Code runs as a session starts, under which the hook says
|
|
57
|
+
* what would hold every write of it (ADR-0005). Its entry has no matcher:
|
|
58
|
+
* left out, it is every way a session starts.
|
|
59
|
+
*/
|
|
60
|
+
const AT_START = 'SessionStart';
|
|
54
61
|
const GUARD_ARGS = Object.freeze([`${PROJECT_DIR}/${BIN}`, 'hook', 'claude']);
|
|
55
62
|
/**
|
|
56
63
|
* The guard as a Claude Code hook: exec form, `args` given, so the project's
|
|
@@ -86,19 +93,109 @@ function upgraded(group) {
|
|
|
86
93
|
* `_`, `-`, spaces, `,` and `|` alone, the tools it names, one between each
|
|
87
94
|
* `|` or `,`; with any other character in it, a JavaScript regular
|
|
88
95
|
* expression that may match anywhere in a tool's name. `null` where the
|
|
89
|
-
* reference does not say, or it cannot be told: a matcher that is
|
|
90
|
-
* is no pattern, or is a pattern whose reading runs out
|
|
96
|
+
* reference does not say, or it cannot be told: a matcher that is neither
|
|
97
|
+
* text nor an array, is no pattern, or is a pattern whose reading runs out
|
|
98
|
+
* its budget.
|
|
99
|
+
*
|
|
100
|
+
* Two things its page on debugging a configuration adds
|
|
101
|
+
* (https://code.claude.com/docs/en/debug-your-config, Check hooks and Check
|
|
102
|
+
* common causes, read 2026-10-14). A matcher that is an array is an entry
|
|
103
|
+
* Claude Code lists as an invalid setting, among the reasons a hook is not
|
|
104
|
+
* loaded and never fires: no tool. And a comma separates names only from
|
|
105
|
+
* {@link COMMA_SINCE} on; its changelog adds that a hyphenated name is
|
|
106
|
+
* matched exactly only from {@link HYPHEN_SINCE} on. So a matcher of names
|
|
107
|
+
* with either in it is read as `matching` has the Claude Code here read it
|
|
108
|
+
* ({@link namesReadings}), and where the readings it may have differ, it
|
|
109
|
+
* cannot be told.
|
|
91
110
|
*/
|
|
92
|
-
function matchedTools(matcher, pattern) {
|
|
111
|
+
function matchedTools(matcher, pattern, matching) {
|
|
93
112
|
if (matcher === undefined || matcher === '' || matcher === '*')
|
|
94
113
|
return CALLABLE_TOOLS;
|
|
114
|
+
if (Array.isArray(matcher))
|
|
115
|
+
return [];
|
|
95
116
|
if (typeof matcher !== 'string')
|
|
96
117
|
return null;
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
118
|
+
if (!NAMES.test(matcher))
|
|
119
|
+
return pattern(matcher);
|
|
120
|
+
// A reading that cannot be told, or two that differ, leave the matcher one
|
|
121
|
+
// whose tools cannot be told; where every reading comes to the same tools,
|
|
122
|
+
// nothing turns on which the release has.
|
|
123
|
+
const readings = namesReadings(matcher, pattern, matching);
|
|
124
|
+
return readings.every((tools) => sameStrings(tools, readings[0])) ? readings[0] : null;
|
|
125
|
+
}
|
|
126
|
+
/** A matcher that is a name, or names between `|` and `,`: what the reference compares with a tool's name exactly. */
|
|
127
|
+
const NAMES = /^[A-Za-z0-9_\- ,|]*$/;
|
|
128
|
+
/** The writing tools a matcher of names lists, one between each `|` or `,`. */
|
|
129
|
+
function listedTools(matcher) {
|
|
130
|
+
const names = matcher.split(/[|,]/).map((name) => name.trim());
|
|
131
|
+
return CALLABLE_TOOLS.filter((tool) => names.includes(tool));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The release of Claude Code from which a comma in a matcher separates
|
|
135
|
+
* names as a `|` does. Its page on debugging a configuration (Check hooks,
|
|
136
|
+
* read 2026-10-14) has a comma before it fall through to the reading of a
|
|
137
|
+
* pattern, under which the matcher never matched, and its changelog has the
|
|
138
|
+
* fix under this release: hooks with comma-separated matchers no longer
|
|
139
|
+
* silently never firing.
|
|
140
|
+
*/
|
|
141
|
+
export const COMMA_SINCE = '2.1.191';
|
|
142
|
+
/**
|
|
143
|
+
* The release of Claude Code from which a hyphenated name in a matcher of
|
|
144
|
+
* names is matched exactly. Its changelog
|
|
145
|
+
* (https://code.claude.com/docs/en/changelog, 2.1.195, read 2026-10-18) has,
|
|
146
|
+
* among its fixes, hook matchers with hyphenated identifiers that matched as
|
|
147
|
+
* substrings, and match exactly from this release on; it does not say how
|
|
148
|
+
* the rest of such a matcher was read.
|
|
149
|
+
*/
|
|
150
|
+
export const HYPHEN_SINCE = '2.1.195';
|
|
151
|
+
/** The reading every release from {@link HYPHEN_SINCE} on has, and what a caller that asks no release is given. */
|
|
152
|
+
const TODAY = { as: 'today' };
|
|
153
|
+
/**
|
|
154
|
+
* How the Claude Code doctor asked its release reads a matcher of names. A
|
|
155
|
+
* release that was not asked, or could not be told, leaves the reading
|
|
156
|
+
* untold: asked or not, nothing says which it is.
|
|
157
|
+
*/
|
|
158
|
+
export function matchingUnder(release) {
|
|
159
|
+
if (release === null || release.state === 'unknown')
|
|
160
|
+
return { as: 'untold' };
|
|
161
|
+
// A release that was told is one whose version was read as one, and the
|
|
162
|
+
// constant is a version, so both parse.
|
|
163
|
+
return meets(parseVersion(release.version), parseVersion(HYPHEN_SINCE)) ? TODAY : { as: 'older', release: release.version };
|
|
164
|
+
}
|
|
165
|
+
/** Whether `release` reads a comma in `matcher` as part of a pattern: one before {@link COMMA_SINCE}, where the matcher holds one. */
|
|
166
|
+
function commaAsPattern(matcher, release) {
|
|
167
|
+
return matcher.includes(',') && !meets(parseVersion(release), parseVersion(COMMA_SINCE));
|
|
168
|
+
}
|
|
169
|
+
/** A matcher of names that holds a comma, as a release before {@link COMMA_SINCE} reads it. */
|
|
170
|
+
function commaEarlier(matcher, pattern) {
|
|
171
|
+
// No tool, where the matcher read as a pattern matches none of the writing
|
|
172
|
+
// tools, which is what the page has such a matcher do; and where it matches
|
|
173
|
+
// one, as `Edit|Write,Bash` matches `Edit`, the page's two sentences, that
|
|
174
|
+
// a comma is read as part of a pattern and that the matcher never matched,
|
|
175
|
+
// disagree, and it cannot be told.
|
|
176
|
+
const read = pattern(matcher);
|
|
177
|
+
return read !== null && read.length === 0 ? read : null;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Each reading the Claude Code here may have of a matcher of names: the
|
|
181
|
+
* writing tools it lists, or `null` where a reading cannot be told. From
|
|
182
|
+
* {@link HYPHEN_SINCE} on there is one, the names matched exactly. Before
|
|
183
|
+
* {@link COMMA_SINCE} a comma in it makes it a pattern ({@link commaEarlier}).
|
|
184
|
+
* Before {@link HYPHEN_SINCE} a hyphen in it made it match as a substring,
|
|
185
|
+
* and the changelog does not say which way: each name matched exactly but
|
|
186
|
+
* the one with the hyphen, the matcher read as a pattern, which matches
|
|
187
|
+
* anywhere in a tool's name, or its whole text found in a tool's name, which
|
|
188
|
+
* no writing tool's holds, since none has a hyphen. A release that cannot be
|
|
189
|
+
* told may read it any way a release does.
|
|
190
|
+
*/
|
|
191
|
+
function namesReadings(matcher, pattern, matching) {
|
|
192
|
+
const listed = listedTools(matcher);
|
|
193
|
+
if (matching.as === 'today')
|
|
194
|
+
return [listed];
|
|
195
|
+
const substring = matcher.includes('-') ? [pattern(matcher), []] : [];
|
|
196
|
+
if (matching.as === 'older')
|
|
197
|
+
return commaAsPattern(matcher, matching.release) ? [commaEarlier(matcher, pattern)] : [listed, ...substring];
|
|
198
|
+
return [listed, ...(matcher.includes(',') ? [commaEarlier(matcher, pattern)] : []), ...substring];
|
|
102
199
|
}
|
|
103
200
|
/**
|
|
104
201
|
* How long the reading of one pattern may take, in milliseconds of the
|
|
@@ -169,12 +266,12 @@ function inBackground(hook) {
|
|
|
169
266
|
* entry started only for the calls its rule matches, in the syntax of a
|
|
170
267
|
* permission rule, which is not read here.
|
|
171
268
|
*/
|
|
172
|
-
function answered(event, matcher, hook, pattern) {
|
|
269
|
+
function answered(event, matcher, hook, pattern, matching) {
|
|
173
270
|
if (event === BEFORE && inBackground(hook))
|
|
174
271
|
return [];
|
|
175
272
|
if (hook['if'] !== undefined)
|
|
176
273
|
return null;
|
|
177
|
-
return matchedTools(matcher, pattern);
|
|
274
|
+
return matchedTools(matcher, pattern, matching);
|
|
178
275
|
}
|
|
179
276
|
/**
|
|
180
277
|
* `.claude/settings.json`, or the plugin's hooks, with the guard hooks merged
|
|
@@ -191,10 +288,14 @@ function answered(event, matcher, hook, pattern) {
|
|
|
191
288
|
*
|
|
192
289
|
* `pattern` reads a matcher that is a pattern: the run's own reader, where
|
|
193
290
|
* the run has read the file's matchers already ({@link patternReader}).
|
|
291
|
+
* `matching` is how the Claude Code here reads a matcher of names, where
|
|
292
|
+
* the run asked its release ({@link matchingUnder}): a line that
|
|
293
|
+
* release starts for no write is no guard, and init's own is written beside
|
|
294
|
+
* it.
|
|
194
295
|
*/
|
|
195
|
-
export function mergeClaudeSettings(current, isHook = () => false, pattern = patternReader()) {
|
|
296
|
+
export function mergeClaudeSettings(current, isHook = () => false, pattern = patternReader(), matching = TODAY) {
|
|
196
297
|
const hooks = isObject(current['hooks']) ? current['hooks'] : {};
|
|
197
|
-
const theirs = harnessEntries(current).some((entry) => !isGuard(entry.hook) && !isLegacyGuard(entry.hook) && isHook(entry.words) && isStarted(toolsOf(entry, pattern)));
|
|
298
|
+
const theirs = harnessEntries(current).some((entry) => !isGuard(entry.hook) && !isLegacyGuard(entry.hook) && isHook(entry.words) && isStarted(toolsOf(entry, pattern, matching)));
|
|
198
299
|
if (theirs)
|
|
199
300
|
return null;
|
|
200
301
|
let changed = false;
|
|
@@ -204,7 +305,7 @@ export function mergeClaudeSettings(current, isHook = () => false, pattern = pat
|
|
|
204
305
|
const before = Array.isArray(listed) ? listed : [];
|
|
205
306
|
const groups = before.map(upgraded);
|
|
206
307
|
const replaced = groups.some((group, index) => group !== before[index]);
|
|
207
|
-
const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry, pattern)));
|
|
308
|
+
const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry, pattern, matching)));
|
|
208
309
|
if (!present)
|
|
209
310
|
groups.push({ matcher: HOOK_MATCHER, hooks: [{ ...GUARD_HOOK, args: [...GUARD_ARGS] }] });
|
|
210
311
|
if (replaced || !present) {
|
|
@@ -214,6 +315,45 @@ export function mergeClaudeSettings(current, isHook = () => false, pattern = pat
|
|
|
214
315
|
}
|
|
215
316
|
return changed ? { ...current, hooks: next } : null;
|
|
216
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Settings with init's entry under `SessionStart` added beside its two, or
|
|
320
|
+
* `null` where it is not to be: where init's own line does not stand
|
|
321
|
+
* under both `PreToolUse` and `PostToolUse`, a file init writes no hook
|
|
322
|
+
* into or one it has yet to complete; and where a line of the hook stands
|
|
323
|
+
* under `SessionStart` already, init's or one `isHook` knows, which would
|
|
324
|
+
* say the same twice (ADR-0012).
|
|
325
|
+
*/
|
|
326
|
+
export function withSessionStart(settings, isHook) {
|
|
327
|
+
const hooks = isObject(settings['hooks']) ? settings['hooks'] : {};
|
|
328
|
+
const own = GUARDED_EVENTS.every((event) => harnessEntries({ hooks: { [event]: hooks[event] } }).some((entry) => isGuard(entry.hook)));
|
|
329
|
+
if (!own || startsSessions(settings, isHook))
|
|
330
|
+
return null;
|
|
331
|
+
const listed = hooks[AT_START];
|
|
332
|
+
return { ...settings, hooks: { ...hooks, [AT_START]: [...(Array.isArray(listed) ? listed : []), { hooks: [{ ...GUARD_HOOK, args: [...GUARD_ARGS] }] }] } };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Whether settings have Claude Code start the hook as a session starts: an
|
|
336
|
+
* entry under `SessionStart` whose line is init's, or one `isHook` knows
|
|
337
|
+
* as the hook's. Its matcher is not read: one that names some of the ways a
|
|
338
|
+
* session starts has the check made for those.
|
|
339
|
+
*/
|
|
340
|
+
export function startsSessions(settings, isHook) {
|
|
341
|
+
const groups = field(field(settings, 'hooks'), AT_START);
|
|
342
|
+
if (!Array.isArray(groups))
|
|
343
|
+
return false;
|
|
344
|
+
return groups.some((group) => hooksOf(group).some((hook) => isObject(hook) && (isGuard(hook) || knownAs(hook, isHook))));
|
|
345
|
+
}
|
|
346
|
+
/** Whether `isHook` knows an entry's line as the hook's. */
|
|
347
|
+
function knownAs(hook, isHook) {
|
|
348
|
+
const written = writtenHook(hook);
|
|
349
|
+
return written !== null && isHook(written.words);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* What doctor notes where settings hold the guard hooks and none under
|
|
353
|
+
* `SessionStart`: what would hold every write is said at the first write,
|
|
354
|
+
* which it holds, and not as the session starts.
|
|
355
|
+
*/
|
|
356
|
+
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';
|
|
217
357
|
/** The server's entry, reading the project at `projectDir`: {@link PROJECT_DIR} for the plugin, {@link PROJECT_DIR_OR_HERE} for a project. */
|
|
218
358
|
export function mcpServer(projectDir) {
|
|
219
359
|
return { command: 'node', args: [`${projectDir}/${BIN}`, 'mcp', '--root', projectDir] };
|
|
@@ -309,8 +449,8 @@ function sameLine(one, other) {
|
|
|
309
449
|
* `null` where that cannot be told. Read where it is asked for and no
|
|
310
450
|
* sooner: a matcher that is a pattern can cost its whole budget.
|
|
311
451
|
*/
|
|
312
|
-
function toolsOf(entry, pattern) {
|
|
313
|
-
return answered(entry.event, entry.matcher, entry.hook, pattern);
|
|
452
|
+
function toolsOf(entry, pattern, matching) {
|
|
453
|
+
return answered(entry.event, entry.matcher, entry.hook, pattern, matching);
|
|
314
454
|
}
|
|
315
455
|
/** Every entry that runs the harness under the two events the guard answers, in the order the settings hold them. */
|
|
316
456
|
function harnessEntries(settings) {
|
|
@@ -355,18 +495,47 @@ function named(names) {
|
|
|
355
495
|
return names.length === 1 ? last : `${names.slice(0, -1).join(', ')} and ${last}`;
|
|
356
496
|
}
|
|
357
497
|
/** The writing tools a line's entries under `event` are waited on for between them, or `null` where an entry that cannot be told might add one. */
|
|
358
|
-
function toolsUnder(event, entries,
|
|
359
|
-
const read = entries.filter((entry) => entry.event === event).map((entry) => toolsOf(entry, pattern));
|
|
498
|
+
function toolsUnder(event, entries, reading) {
|
|
499
|
+
const read = entries.filter((entry) => entry.event === event).map((entry) => toolsOf(entry, reading.pattern, reading.matching));
|
|
360
500
|
const sure = CALLABLE_TOOLS.filter((tool) => read.some((tools) => tools !== null && tools.includes(tool)));
|
|
361
501
|
return sure.length < CALLABLE_TOOLS.length && read.some((tools) => tools === null) ? null : sure;
|
|
362
502
|
}
|
|
503
|
+
/**
|
|
504
|
+
* What is said after a matcher that Claude Code does not read as its text
|
|
505
|
+
* suggests: an array, which it loads no hook under, and names with a comma or
|
|
506
|
+
* a hyphen where the release here reads them another way than the newest
|
|
507
|
+
* does, or cannot be told. Nothing for any other matcher, and nothing where
|
|
508
|
+
* every reading the release may have is the names as listed.
|
|
509
|
+
*/
|
|
510
|
+
function saidOfMatcher(matcher, { pattern, matching }) {
|
|
511
|
+
if (Array.isArray(matcher))
|
|
512
|
+
return ', an array, which Claude Code lists as an invalid setting and loads no hook under';
|
|
513
|
+
if (typeof matcher !== 'string' || !NAMES.test(matcher))
|
|
514
|
+
return '';
|
|
515
|
+
const tools = listedTools(matcher);
|
|
516
|
+
if (namesReadings(matcher, pattern, matching).every((read) => sameStrings(read, tools)))
|
|
517
|
+
return '';
|
|
518
|
+
const listed = tools.length === 0 ? 'no tool that writes' : named(tools);
|
|
519
|
+
const earlier = commaEarlier(matcher, pattern) === null ? 'a pattern whose matches its documentation does not give' : 'a pattern that matches no tool';
|
|
520
|
+
const substring = "a matcher with a hyphenated name in it matched as a substring, and Claude Code's changelog does not say how the rest of it was read";
|
|
521
|
+
if (matching.as === 'older') {
|
|
522
|
+
if (commaAsPattern(matcher, matching.release))
|
|
523
|
+
return `, which Claude Code ${matching.release} reads as ${earlier}: a comma separates names from ${COMMA_SINCE} on, where the matcher is ${listed}`;
|
|
524
|
+
return `, which Claude Code ${matching.release} may read otherwise: before ${HYPHEN_SINCE} ${substring}; from ${HYPHEN_SINCE} on it is ${listed}`;
|
|
525
|
+
}
|
|
526
|
+
// The release cannot be told: a comma alone turns on 2.1.191, and a hyphen on 2.1.195.
|
|
527
|
+
if (!matcher.includes('-'))
|
|
528
|
+
return `, which is ${listed} from Claude Code ${COMMA_SINCE} on, where a comma separates names, and ${earlier} before it: the release here cannot be told`;
|
|
529
|
+
const comma = matcher.includes(',') ? `, and before ${COMMA_SINCE} ${earlier}, where a comma separated no names` : '';
|
|
530
|
+
return `, which is ${listed} from Claude Code ${HYPHEN_SINCE} on, and may be read otherwise before it, where ${substring}${comma}: the release here cannot be told`;
|
|
531
|
+
}
|
|
363
532
|
/** What a line's entries under `event` hold that decides when Claude Code starts them, as written. */
|
|
364
|
-
function entriesUnder(event, entries) {
|
|
533
|
+
function entriesUnder(event, entries, reading) {
|
|
365
534
|
const under = entries.filter((entry) => entry.event === event);
|
|
366
535
|
if (under.length === 0)
|
|
367
536
|
return `it stands under no ${event}`;
|
|
368
537
|
const said = under.map(({ matcher, hook }) => {
|
|
369
|
-
const pattern = matcher === undefined ? 'no matcher' : `the matcher ${JSON.stringify(matcher)}`;
|
|
538
|
+
const pattern = matcher === undefined ? 'no matcher' : `the matcher ${JSON.stringify(matcher)}${saidOfMatcher(matcher, reading)}`;
|
|
370
539
|
const background = inBackground(hook) ? ' in the background' : '';
|
|
371
540
|
const rule = hook['if'] === undefined ? '' : ` with "if" ${JSON.stringify(hook['if'])}`;
|
|
372
541
|
return `${pattern}${background}${rule}`;
|
|
@@ -381,24 +550,58 @@ function entriesUnder(event, entries) {
|
|
|
381
550
|
* hook still reads a path from, is named as unchecked for no line, since no
|
|
382
551
|
* such Claude Code has it to call (hooks.ts).
|
|
383
552
|
*/
|
|
384
|
-
function describeCover(where, before, after, entries) {
|
|
553
|
+
function describeCover(where, before, after, entries, reading) {
|
|
385
554
|
const parts = [];
|
|
386
555
|
const left = (tools) => CALLABLE_TOOLS.filter((tool) => !tools.includes(tool));
|
|
387
556
|
if (before === null)
|
|
388
|
-
parts.push(`which writes Claude Code waits on it before cannot be told here (${entriesUnder(BEFORE, entries)})`);
|
|
557
|
+
parts.push(`which writes Claude Code waits on it before cannot be told here (${entriesUnder(BEFORE, entries, reading)})`);
|
|
389
558
|
else if (before.length === 0)
|
|
390
|
-
parts.push(`Claude Code waits on it before no write, so it is not the guard and refuses none (${entriesUnder(BEFORE, entries)})`);
|
|
559
|
+
parts.push(`Claude Code waits on it before no write, so it is not the guard and refuses none (${entriesUnder(BEFORE, entries, reading)})`);
|
|
391
560
|
else if (left(before).length > 0) {
|
|
392
|
-
parts.push(`Claude Code waits on it before a write by ${named(before)}, and before none by ${named(left(before))}, which goes unchecked (${entriesUnder(BEFORE, entries)})`);
|
|
561
|
+
parts.push(`Claude Code waits on it before a write by ${named(before)}, and before none by ${named(left(before))}, which goes unchecked (${entriesUnder(BEFORE, entries, reading)})`);
|
|
393
562
|
}
|
|
394
563
|
if (after === null)
|
|
395
|
-
parts.push(`which writes it is started after cannot be told here (${entriesUnder(AFTER, entries)})`);
|
|
564
|
+
parts.push(`which writes it is started after cannot be told here (${entriesUnder(AFTER, entries, reading)})`);
|
|
396
565
|
else if (after.length === 0)
|
|
397
|
-
parts.push(`it is started after no write, so it warns of none (${entriesUnder(AFTER, entries)})`);
|
|
566
|
+
parts.push(`it is started after no write, so it warns of none (${entriesUnder(AFTER, entries, reading)})`);
|
|
398
567
|
else if (left(after).length > 0)
|
|
399
|
-
parts.push(`it is started after a write by ${named(after)} alone (${entriesUnder(AFTER, entries)})`);
|
|
568
|
+
parts.push(`it is started after a write by ${named(after)} alone (${entriesUnder(AFTER, entries, reading)})`);
|
|
400
569
|
return parts.length === 0 ? null : `${where}: ${parts.join('; ')}`;
|
|
401
570
|
}
|
|
571
|
+
/** The events under which an array for a matcher keeps Claude Code from loading the rest of a file's hooks. */
|
|
572
|
+
const UNLOADING = [BEFORE, 'PermissionRequest'];
|
|
573
|
+
/**
|
|
574
|
+
* The first array that stands for a matcher under `PreToolUse` or
|
|
575
|
+
* `PermissionRequest` in a settings file, or `null`.
|
|
576
|
+
*
|
|
577
|
+
* Claude Code's page on debugging a configuration
|
|
578
|
+
* (https://code.claude.com/docs/en/debug-your-config, Check hooks, read
|
|
579
|
+
* 2026-10-14) has a matcher that is an array, where it must be one string,
|
|
580
|
+
* as an entry Claude Code lists as an invalid setting, at the start of an
|
|
581
|
+
* interactive session and in `claude doctor`; and where the array is under
|
|
582
|
+
* one of these two events, none of that file's other hooks load either. It
|
|
583
|
+
* names no release. So a file that holds one runs no hook at all, whoever
|
|
584
|
+
* wrote the entry and whatever it runs: the array's own is not loaded, and
|
|
585
|
+
* neither is any other.
|
|
586
|
+
*/
|
|
587
|
+
export function arrayMatcher(settings) {
|
|
588
|
+
const hooks = field(settings, 'hooks');
|
|
589
|
+
for (const event of UNLOADING) {
|
|
590
|
+
const groups = field(hooks, event);
|
|
591
|
+
if (!Array.isArray(groups))
|
|
592
|
+
continue;
|
|
593
|
+
const group = groups.find((candidate) => Array.isArray(field(candidate, 'matcher')));
|
|
594
|
+
if (group !== undefined)
|
|
595
|
+
return { event, matcher: JSON.stringify(field(group, 'matcher')) };
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
/** What is said of a settings file that an array for a matcher keeps Claude Code from loading the hooks of. */
|
|
600
|
+
export function describeArrayMatcher(file, { event, matcher }) {
|
|
601
|
+
return `${file} has an array for a matcher under ${event}, ${matcher}, which Claude Code lists as an invalid setting, and beside one under PreToolUse or PermissionRequest it loads none of the file's other hooks`;
|
|
602
|
+
}
|
|
603
|
+
/** How a matcher that is an array is mended. */
|
|
604
|
+
export const ONE_STRING = 'write the matcher as one string, its names between "|"';
|
|
402
605
|
/** The words Claude Code hands the hook for a line: what follows the harness's command, each `${CLAUDE_PROJECT_DIR}` filled with `project`. */
|
|
403
606
|
function handedWords(words, project) {
|
|
404
607
|
return words.map((word) => word.split(PROJECT_DIR).join(project));
|
|
@@ -417,18 +620,29 @@ export function handedLines(sources, project) {
|
|
|
417
620
|
* A line that guards and holds no more than a release wrote is reported
|
|
418
621
|
* with nothing to say, as it always was. One with more on it is said as it
|
|
419
622
|
* is, and fails nothing. One the hook cannot read, or whose root is no
|
|
420
|
-
* directory, is
|
|
623
|
+
* directory, or is in no work tree that holds the project, is a guard that
|
|
624
|
+
* is off, and one that names a brief which is not
|
|
421
625
|
* one holds every write: each a problem, and doctor exits 1 for it. A line
|
|
422
626
|
* Claude Code waits on before no write is not the guard, whatever the hook
|
|
423
627
|
* would do with it.
|
|
424
628
|
*
|
|
425
629
|
* `pattern` reads a matcher that is a pattern, once for every entry under
|
|
426
630
|
* it in every file: the run's own reader, where something else in the run
|
|
427
|
-
* reads the same files ({@link patternReader}).
|
|
631
|
+
* reads the same files ({@link patternReader}). `matching` is how the
|
|
632
|
+
* Claude Code here reads a matcher of names, where its release was asked
|
|
633
|
+
* ({@link matchingUnder}); a caller that asks none reads one as every
|
|
634
|
+
* release from {@link HYPHEN_SINCE} on does.
|
|
635
|
+
*
|
|
636
|
+
* A file that holds an array for a matcher under `PreToolUse` or
|
|
637
|
+
* `PermissionRequest` has none of its hooks loaded ({@link arrayMatcher}):
|
|
638
|
+
* each line of the hook there is started for no write, and one that stood
|
|
639
|
+
* to be started for a write is a guard that is off.
|
|
428
640
|
*/
|
|
429
|
-
export function reportHooks(sources, project, read, pattern = patternReader()) {
|
|
641
|
+
export function reportHooks(sources, project, read, pattern = patternReader(), matching = TODAY) {
|
|
642
|
+
const reading = { pattern, matching };
|
|
430
643
|
return sources.flatMap(({ file, settings }) => {
|
|
431
644
|
const entries = harnessEntries(settings);
|
|
645
|
+
const array = arrayMatcher(settings);
|
|
432
646
|
return writtenHooks(settings).flatMap((hook) => {
|
|
433
647
|
const handed = read(handedWords(hook.words, project));
|
|
434
648
|
if (handed === null)
|
|
@@ -438,9 +652,23 @@ export function reportHooks(sources, project, read, pattern = patternReader()) {
|
|
|
438
652
|
// A line that is not under both events is under one.
|
|
439
653
|
const where = `"${hook.words.map((word) => (word === '' ? "''" : word)).join(' ')}" in ${file}${both ? '' : ` (under ${hook.events[0]} alone)`}`;
|
|
440
654
|
const mine = entries.filter((entry) => sameLine(entry, hook));
|
|
441
|
-
const before = toolsUnder(BEFORE, mine,
|
|
442
|
-
const after = toolsUnder(AFTER, mine,
|
|
443
|
-
|
|
655
|
+
const before = toolsUnder(BEFORE, mine, reading);
|
|
656
|
+
const after = toolsUnder(AFTER, mine, reading);
|
|
657
|
+
if (array !== null) {
|
|
658
|
+
// Nothing of the file is loaded, whatever the line holds and
|
|
659
|
+
// whatever the hook would do with it. A line that stood to be
|
|
660
|
+
// started for a write, or stands under an array itself, where
|
|
661
|
+
// someone meant a matcher, is a guard set up that checks nothing;
|
|
662
|
+
// one that stood to be started for none leaves no write unchecked
|
|
663
|
+
// 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 };
|
|
665
|
+
const said = `${where}: ${describeArrayMatcher(file, array)}`;
|
|
666
|
+
if (isStarted(before) || isStarted(after) || mine.some((entry) => Array.isArray(entry.matcher))) {
|
|
667
|
+
return [{ ...unloaded, state: 'off', detail: `${said}, so the guard is off and no write is checked; ${ONE_STRING}` }];
|
|
668
|
+
}
|
|
669
|
+
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
|
+
}
|
|
671
|
+
const written = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before, after, cover: describeCover(where, before, after, mine, reading) };
|
|
444
672
|
const note = describeOnFailure(where, hook.blocks, false);
|
|
445
673
|
if (hook.untyped !== null) {
|
|
446
674
|
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"`;
|
|
@@ -454,15 +682,15 @@ export function reportHooks(sources, project, read, pattern = patternReader()) {
|
|
|
454
682
|
const unstarted = { ...written, state: 'unstarted', detail: null, note };
|
|
455
683
|
const waited = before === null || before.length > 0;
|
|
456
684
|
if (handed.state === 'off') {
|
|
457
|
-
const detail = `${where}: ${handed.fault.wrong}, so the guard is off and no write is checked; ${handed.fault.mend}`;
|
|
685
|
+
const detail = `${where}: ${handed.fault.wrong}, so the guard is off and ${handed.unchecked ?? 'no write is checked'}; ${handed.fault.mend}`;
|
|
458
686
|
// A fault the hook says, it says with exit 1 after every write it is
|
|
459
687
|
// started for as well, so it is said wherever the line stands.
|
|
460
688
|
if (handed.silent !== true)
|
|
461
689
|
return [{ ...written, state: 'off', detail, note: describeOnFailure(where, hook.blocks, true) }];
|
|
462
|
-
// A root outside any work tree,
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
// leaves unchecked.
|
|
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.
|
|
466
694
|
return [waited ? { ...written, state: 'off', detail, note } : unstarted];
|
|
467
695
|
}
|
|
468
696
|
if (!waited)
|
|
@@ -477,7 +705,9 @@ export function reportHooks(sources, project, read, pattern = patternReader()) {
|
|
|
477
705
|
if (sameStrings(hook.words, ['hook', 'claude']))
|
|
478
706
|
return [{ ...written, state: 'guards', detail: null, note }];
|
|
479
707
|
const root = handed.root === undefined ? '' : `, and its root, ${handed.root}, is a directory`;
|
|
480
|
-
|
|
708
|
+
// Whose briefs a write is checked by, where they are not the project's own.
|
|
709
|
+
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 }];
|
|
481
711
|
});
|
|
482
712
|
});
|
|
483
713
|
}
|
|
@@ -672,6 +902,18 @@ export function describeSkipped(plugin, part, present, elsewhere = []) {
|
|
|
672
902
|
}
|
|
673
903
|
return `${isOn(plugin)} and brings ${words.brings}, so ${words.skipped}: with both, ${words.twice}. To have ${words.them} here instead, for every clone of the repository, ${turnOff(plugin)} and run init again`;
|
|
674
904
|
}
|
|
905
|
+
/**
|
|
906
|
+
* How init comes to write the guard hooks. Where an array unloads the
|
|
907
|
+
* project's settings file, init writes none there and says the array, so
|
|
908
|
+
* the advice is the array's, and init after it: advice that led to more
|
|
909
|
+
* advice is the advice at the end.
|
|
910
|
+
*/
|
|
911
|
+
function runInit(wiring) {
|
|
912
|
+
const unloaded = wiring.unloaded ?? null;
|
|
913
|
+
if (unloaded === null)
|
|
914
|
+
return 'run spec-harness init --write';
|
|
915
|
+
return `${describeArrayMatcher(PROJECT_SETTINGS, unloaded)}, those init writes among them: ${ONE_STRING}, then run spec-harness init --write`;
|
|
916
|
+
}
|
|
675
917
|
/** Why the plugin settings turn on is not on this machine, or `null` where it is, may be, or none is on. */
|
|
676
918
|
function absence(wiring) {
|
|
677
919
|
return wiring.presence?.state === 'absent' ? wiring.presence.detail : null;
|
|
@@ -722,11 +964,11 @@ export function describePresence(wiring) {
|
|
|
722
964
|
return `whether ${plugin.id} is on this machine cannot be told: ${presence.detail}. A plugin that is not there brings no hook: Claude Code's /plugin lists it, and its errors`;
|
|
723
965
|
if (wiring.hooks.length > 0)
|
|
724
966
|
return `${isAbsent(plugin, presence.detail, 'no hook')}. With the plugin installed as well, ${PARTS.hooks.twice}: ${turnOff(plugin)}`;
|
|
725
|
-
return wiring.server ? unguarded(plugin, presence.detail) : null;
|
|
967
|
+
return wiring.server ? unguarded(plugin, presence.detail, wiring) : null;
|
|
726
968
|
}
|
|
727
969
|
/** That the plugin settings turn on is not on this machine and no hook of init's stands in for it, with the two ways to a guard. */
|
|
728
|
-
function unguarded(plugin, detail) {
|
|
729
|
-
return `${isAbsent(plugin, detail, 'no hook')}, and Claude Code guards no write. To have the guard, ${bring(plugin)}, or ${turnOff(plugin)} and
|
|
970
|
+
function unguarded(plugin, detail, wiring) {
|
|
971
|
+
return `${isAbsent(plugin, detail, 'no hook')}, and Claude Code guards no write. To have the guard, ${bring(plugin)}, or ${turnOff(plugin)} and ${runInit(wiring)}`;
|
|
730
972
|
}
|
|
731
973
|
/** What `doctor` says of the wiring, and what to do when it is doubled or missing. */
|
|
732
974
|
export function describeClaudeCode(wiring) {
|
|
@@ -735,12 +977,12 @@ export function describeClaudeCode(wiring) {
|
|
|
735
977
|
const own = `init's entries: ${entries}`;
|
|
736
978
|
if (plugin === null) {
|
|
737
979
|
if (entries === '')
|
|
738
|
-
return
|
|
980
|
+
return `neither the spec-harness plugin nor the hooks init writes, so Claude Code guards no write: ${runInit(wiring)}, or install the plugin`;
|
|
739
981
|
return own;
|
|
740
982
|
}
|
|
741
983
|
const absent = absence(wiring);
|
|
742
984
|
if (absent !== null)
|
|
743
|
-
return entries === '' ? unguarded(plugin, absent) : own;
|
|
985
|
+
return entries === '' ? unguarded(plugin, absent, wiring) : own;
|
|
744
986
|
if (entries === '')
|
|
745
987
|
return `${isOn(plugin)} and brings the guard hooks and the server`;
|
|
746
988
|
const twice = [...(wiring.hooks.length > 0 ? [PARTS.hooks.twice] : []), ...(wiring.server ? [PARTS.server.twice] : [])].join(' and ');
|
|
@@ -748,8 +990,7 @@ export function describeClaudeCode(wiring) {
|
|
|
748
990
|
}
|
|
749
991
|
/** The spec-brief plugin this package ships, as spec-brief's `plugins` names it. */
|
|
750
992
|
export const PLUGIN = '@descent-vtt/spec-harness/spec-brief-plugin';
|
|
751
|
-
|
|
752
|
-
export const SPEC_BRIEF_CONFIGS = ['.spec-brief.json', 'spec-brief.json'];
|
|
993
|
+
export { SPEC_BRIEF_CONFIGS } from './config.js';
|
|
753
994
|
/** The conditions an import meets in Node, its `defaultConditions`, and `default`, which every one meets. */
|
|
754
995
|
const IMPORT_CONDITIONS = ['node', 'import', 'default'];
|
|
755
996
|
/** A target Node refuses as invalid, which an array passes over and anything else stops at. */
|
|
@@ -859,9 +1100,13 @@ export function chooseBase(facts) {
|
|
|
859
1100
|
const unrecorded = 'no remote records a default branch (refs/remotes/origin/HEAD)';
|
|
860
1101
|
if (branch !== null) {
|
|
861
1102
|
const conventional = branch === 'main' || branch === 'master';
|
|
862
|
-
if (conventional || facts.branches.every((
|
|
1103
|
+
if (conventional || facts.branches.every((local) => local.name === branch)) {
|
|
863
1104
|
const which = conventional ? 'the branch init runs on' : 'the only branch';
|
|
864
|
-
|
|
1105
|
+
// The base is a ref git and spec-brief read, so it is the name git
|
|
1106
|
+
// reads as the branch and nothing else: `heads/main` beside a tag
|
|
1107
|
+
// `main`. A branch with no commit yet is listed by no ref.
|
|
1108
|
+
const base = facts.branches.find((local) => local.name === branch)?.ref ?? branch;
|
|
1109
|
+
return { base, detail: `rounds are measured from ${base}, ${which}: ${unrecorded}; change "base" if rounds merge into another` };
|
|
865
1110
|
}
|
|
866
1111
|
}
|
|
867
1112
|
const where = branch === null ? 'HEAD is detached' : `${branch} is not main, master or the only branch`;
|
|
@@ -943,20 +1188,30 @@ export const PROTECT_RULE_FILES = "Have the forge protect this file, the ADRs, t
|
|
|
943
1188
|
export const GIT_HOOK_LINE = `node ${BIN} hook git`;
|
|
944
1189
|
/**
|
|
945
1190
|
* The hook init writes. It is a POSIX sh script on every platform, since Git
|
|
946
|
-
* for Windows runs a hook with the sh it ships.
|
|
947
|
-
*
|
|
1191
|
+
* for Windows runs a hook with the sh it ships. git starts it at the top of
|
|
1192
|
+
* the work tree it commits in, so the project's install is read from there,
|
|
1193
|
+
* and where a linked worktree has none, from the repository's main work
|
|
1194
|
+
* tree: the work tree whose `.git` the common directory is, where `git
|
|
1195
|
+
* worktree add` was run and the project installed (ADR-0005). Where neither
|
|
1196
|
+
* has the install - a worktree of a repository nobody installed into, a
|
|
948
1197
|
* repository that shares its hooks with one that uses the harness - node
|
|
949
|
-
* would end on a stack trace, so the hook says what is missing and where,
|
|
950
|
-
* the commit waits, as it did when npx found nothing to run.
|
|
1198
|
+
* would end on a stack trace, so the hook says what is missing and where,
|
|
1199
|
+
* and the commit waits, as it did when npx found nothing to run.
|
|
951
1200
|
*/
|
|
952
1201
|
export const PRE_COMMIT = `#!/bin/sh
|
|
953
1202
|
# spec-harness: refuse a commit that changes what the active brief protects.
|
|
954
1203
|
# git runs this at the top of the work tree it commits in, a linked worktree's
|
|
955
|
-
# own, wherever the hook is kept: the project's install is read from there
|
|
1204
|
+
# own, wherever the hook is kept: the project's install is read from there,
|
|
1205
|
+
# or, where a linked worktree has none, from the repository's main work tree.
|
|
956
1206
|
bin=${BIN}
|
|
957
1207
|
if [ ! -f "$bin" ]; then
|
|
958
|
-
|
|
959
|
-
|
|
1208
|
+
common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
1209
|
+
if [ "\${common##*/}" = .git ] && [ -f "\${common%/.git}/$bin" ]; then
|
|
1210
|
+
bin=\${common%/.git}/$bin
|
|
1211
|
+
else
|
|
1212
|
+
printf '%s\\n' "spec-harness: $bin is not in $PWD, the work tree git commits in, nor in its repository's main work tree: install the project's dependencies there, as with npm ci, and commit again" >&2
|
|
1213
|
+
exit 2
|
|
1214
|
+
fi
|
|
960
1215
|
fi
|
|
961
1216
|
exec node "$bin" hook git
|
|
962
1217
|
`;
|
|
@@ -983,6 +1238,14 @@ export function describeGitHook(hook) {
|
|
|
983
1238
|
return `${hook.file} runs spec-harness, but is not executable, so git skips it: chmod +x ${hook.file}`;
|
|
984
1239
|
case 'other':
|
|
985
1240
|
return `${hook.file} does not run spec-harness: add the line "${GIT_HOOK_LINE}" to it`;
|
|
1241
|
+
case 'stops':
|
|
1242
|
+
return `${hook.file} is ${hook.what}, not a hook, and git stops every commit at it, since it cannot run it ("cannot spawn" on Windows, "cannot exec" elsewhere): nothing is checked there, and nothing is committed. Take it away, then run spec-harness init --git-hook --write`;
|
|
1243
|
+
case 'ignored':
|
|
1244
|
+
return `${hook.file} is ${hook.what}, not a hook, and is not executable, so git skips it and runs no hook: take it away, then run spec-harness init --git-hook --write`;
|
|
1245
|
+
case 'unreadable':
|
|
1246
|
+
if (!hook.runs)
|
|
1247
|
+
return `${hook.file} cannot be read (${hook.reason}), and is not executable, so git skips it and runs no hook: make it readable and executable, or take it away and run spec-harness init --git-hook --write`;
|
|
1248
|
+
return `${hook.file} cannot be read (${hook.reason}), so whether it runs spec-harness cannot be told: git runs it at a commit, and a script there that cannot be read stops every commit, where a program runs; make it readable`;
|
|
986
1249
|
case 'absent':
|
|
987
1250
|
return missingGitHook(hook.file);
|
|
988
1251
|
case 'unknown':
|