@descent-vtt/spec-harness 0.16.0 → 0.18.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 +229 -0
- package/README.md +19 -4
- package/dist/cli.d.ts +15 -1
- package/dist/cli.js +112 -45
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +12 -2
- package/dist/commands.js.map +1 -1
- package/dist/configure.d.ts +243 -11
- package/dist/configure.js +468 -62
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +10 -0
- package/dist/fs.js +14 -0
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +31 -0
- package/dist/git.js +38 -0
- package/dist/git.js.map +1 -1
- package/dist/hooks.d.ts +17 -1
- package/dist/hooks.js +17 -1
- package/dist/hooks.js.map +1 -1
- package/dist/round.d.ts +43 -7
- package/dist/round.js +159 -45
- package/dist/round.js.map +1 -1
- package/dist/setup.d.ts +7 -0
- package/dist/setup.js +96 -17
- package/dist/setup.js.map +1 -1
- package/dist/usage.d.ts +59 -0
- package/dist/usage.js +53 -0
- package/dist/usage.js.map +1 -1
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.js +40 -8
- package/dist/workspace.js.map +1 -1
- package/package.json +1 -1
package/dist/configure.js
CHANGED
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
* Each merge keeps what a person wrote and returns `null` when there is
|
|
8
8
|
* nothing to add.
|
|
9
9
|
*/
|
|
10
|
-
import { isAbsolute } from 'node:path';
|
|
10
|
+
import { isAbsolute, join } from 'node:path';
|
|
11
11
|
import { runInNewContext } from 'node:vm';
|
|
12
|
-
import {
|
|
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
|
}
|
|
@@ -37,6 +38,14 @@ export const PROJECT_DIR = '${CLAUDE_PROJECT_DIR}';
|
|
|
37
38
|
* default; `.` is right, since a project's server starts in the project.
|
|
38
39
|
*/
|
|
39
40
|
export const PROJECT_DIR_OR_HERE = '${CLAUDE_PROJECT_DIR:-.}';
|
|
41
|
+
/**
|
|
42
|
+
* The matcher every release wrote: each name the hook reads a file from
|
|
43
|
+
* (hooks.ts). `MultiEdit` is one no supported Claude Code has a tool for,
|
|
44
|
+
* and Claude Code compares a listed name with a tool's exactly, so it starts
|
|
45
|
+
* the hook for nothing there. It stays, since the hook is asked about a name
|
|
46
|
+
* only under a matcher that lets the name through, and it reads this one for
|
|
47
|
+
* whatever still sends it.
|
|
48
|
+
*/
|
|
40
49
|
const HOOK_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
|
|
41
50
|
/** The event Claude Code runs before a tool call, the only one of the two where a hook can refuse the call. */
|
|
42
51
|
const BEFORE = 'PreToolUse';
|
|
@@ -70,26 +79,80 @@ function upgraded(group) {
|
|
|
70
79
|
return { ...group, hooks: hooks.map((hook) => (isLegacyGuard(hook) ? { ...hook, command: 'node', args: [...GUARD_ARGS] } : hook)) };
|
|
71
80
|
}
|
|
72
81
|
/**
|
|
73
|
-
* The tools, of those that write a file
|
|
74
|
-
*
|
|
82
|
+
* The tools, of those that write a file and a supported Claude Code can call
|
|
83
|
+
* (hooks.ts), that a group's matcher has Claude Code start the group's hooks
|
|
84
|
+
* for, by its hooks reference
|
|
75
85
|
* (https://code.claude.com/docs/en/hooks, Matcher patterns, read
|
|
76
86
|
* 2026-10-12): left out, empty or `*`, every tool; made of letters, digits,
|
|
77
87
|
* `_`, `-`, spaces, `,` and `|` alone, the tools it names, one between each
|
|
78
88
|
* `|` or `,`; with any other character in it, a JavaScript regular
|
|
79
89
|
* expression that may match anywhere in a tool's name. `null` where the
|
|
80
|
-
* reference does not say, or it cannot be told: a matcher that is
|
|
81
|
-
* is no pattern, or is a pattern whose reading runs out
|
|
90
|
+
* reference does not say, or it cannot be told: a matcher that is neither
|
|
91
|
+
* text nor an array, is no pattern, or is a pattern whose reading runs out
|
|
92
|
+
* its budget.
|
|
93
|
+
*
|
|
94
|
+
* Two things its page on debugging a configuration adds
|
|
95
|
+
* (https://code.claude.com/docs/en/debug-your-config, Check hooks and Check
|
|
96
|
+
* common causes, read 2026-10-14). A matcher that is an array is an entry
|
|
97
|
+
* Claude Code lists as an invalid setting, among the reasons a hook is not
|
|
98
|
+
* loaded and never fires: no tool. And a comma separates names only from
|
|
99
|
+
* {@link COMMA_SINCE} on, so a matcher that lists names with one is read as
|
|
100
|
+
* `commas` has the Claude Code here read it.
|
|
82
101
|
*/
|
|
83
|
-
function matchedTools(matcher) {
|
|
102
|
+
function matchedTools(matcher, pattern, commas) {
|
|
84
103
|
if (matcher === undefined || matcher === '' || matcher === '*')
|
|
85
|
-
return
|
|
104
|
+
return CALLABLE_TOOLS;
|
|
105
|
+
if (Array.isArray(matcher))
|
|
106
|
+
return [];
|
|
86
107
|
if (typeof matcher !== 'string')
|
|
87
108
|
return null;
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
109
|
+
if (!NAMES.test(matcher))
|
|
110
|
+
return pattern(matcher);
|
|
111
|
+
const comma = commas.as === 'separator' ? null : commaReading(matcher, pattern);
|
|
112
|
+
if (comma === null)
|
|
113
|
+
return listedTools(matcher);
|
|
114
|
+
if (commas.as === 'pattern')
|
|
115
|
+
return comma.earlier;
|
|
116
|
+
// Which of the two readings holds cannot be told, and where both are no
|
|
117
|
+
// tool that writes it need not be.
|
|
118
|
+
return comma.earlier !== null && comma.listed.length === 0 ? comma.listed : null;
|
|
119
|
+
}
|
|
120
|
+
/** A matcher that is a name, or names between `|` and `,`: what the reference compares with a tool's name exactly. */
|
|
121
|
+
const NAMES = /^[A-Za-z0-9_\- ,|]*$/;
|
|
122
|
+
/** The writing tools a matcher of names lists, one between each `|` or `,`. */
|
|
123
|
+
function listedTools(matcher) {
|
|
124
|
+
const names = matcher.split(/[|,]/).map((name) => name.trim());
|
|
125
|
+
return CALLABLE_TOOLS.filter((tool) => names.includes(tool));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The release of Claude Code from which a comma in a matcher separates
|
|
129
|
+
* names as a `|` does. Its page on debugging a configuration (Check hooks,
|
|
130
|
+
* read 2026-10-14) has a comma before it fall through to the reading of a
|
|
131
|
+
* pattern, under which the matcher never matched, and its changelog has the
|
|
132
|
+
* fix under this release: hooks with comma-separated matchers no longer
|
|
133
|
+
* silently never firing.
|
|
134
|
+
*/
|
|
135
|
+
export const COMMA_SINCE = '2.1.191';
|
|
136
|
+
/** The reading every release from {@link COMMA_SINCE} on has, and what a caller that asks no release is given. */
|
|
137
|
+
const SEPARATOR = { as: 'separator' };
|
|
138
|
+
/**
|
|
139
|
+
* How the Claude Code doctor asked its release reads a comma. A release that
|
|
140
|
+
* was not asked, or could not be told, leaves the reading untold: asked or
|
|
141
|
+
* not, nothing says which it is.
|
|
142
|
+
*/
|
|
143
|
+
export function commasUnder(release) {
|
|
144
|
+
if (release === null || release.state === 'unknown')
|
|
145
|
+
return { as: 'untold' };
|
|
146
|
+
// A release that was told is one whose version was read as one, and the
|
|
147
|
+
// constant is a version, so both parse.
|
|
148
|
+
return meets(parseVersion(release.version), parseVersion(COMMA_SINCE)) ? SEPARATOR : { as: 'pattern', release: release.version };
|
|
149
|
+
}
|
|
150
|
+
/** The two readings of a matcher of names that holds a comma, or `null` for any other matcher, which every release reads one way. */
|
|
151
|
+
function commaReading(matcher, pattern) {
|
|
152
|
+
if (typeof matcher !== 'string' || !NAMES.test(matcher) || !matcher.includes(','))
|
|
153
|
+
return null;
|
|
154
|
+
const read = pattern(matcher);
|
|
155
|
+
return { listed: listedTools(matcher), earlier: read !== null && read.length === 0 ? read : null };
|
|
93
156
|
}
|
|
94
157
|
/**
|
|
95
158
|
* How long the reading of one pattern may take, in milliseconds of the
|
|
@@ -104,7 +167,7 @@ const PATTERN_BUDGET = 100;
|
|
|
104
167
|
* is no pattern, or its reading runs out its budget.
|
|
105
168
|
*
|
|
106
169
|
* The pattern is a line of a settings file, which arrives with a clone, and
|
|
107
|
-
* one can be written that a test of
|
|
170
|
+
* one can be written that a test of a few short names never finishes:
|
|
108
171
|
* `^((((([A-Za-z]*)*)*)*)*)*$X` ran for more than ten seconds. Tested as
|
|
109
172
|
* written, one such entry and neither doctor nor init would come back, with
|
|
110
173
|
* no word. So it is made and tested in a context of its own, whose run
|
|
@@ -115,13 +178,39 @@ const PATTERN_BUDGET = 100;
|
|
|
115
178
|
*/
|
|
116
179
|
function patternTools(source) {
|
|
117
180
|
try {
|
|
118
|
-
const matched = runInNewContext('tools.filter((tool) => new RegExp(source).test(tool))', { tools:
|
|
181
|
+
const matched = runInNewContext('tools.filter((tool) => new RegExp(source).test(tool))', { tools: CALLABLE_TOOLS, source }, { timeout: PATTERN_BUDGET });
|
|
119
182
|
return matched;
|
|
120
183
|
}
|
|
121
184
|
catch {
|
|
122
185
|
return null;
|
|
123
186
|
}
|
|
124
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* A reader for one run: a pattern is read the first time it is asked of,
|
|
190
|
+
* and answered from that reading after.
|
|
191
|
+
*
|
|
192
|
+
* A reading that runs out its budget costs the whole of it, a settings file
|
|
193
|
+
* can hold one matcher over any number of entries, and init asks of an
|
|
194
|
+
* entry twice, for what doctor says of the file and for what to write into
|
|
195
|
+
* it. Fifty entries under one such pattern were fifty readings, five and a
|
|
196
|
+
* half seconds, and fifty-one for init; they are one. Nothing is spared a
|
|
197
|
+
* file that holds a pattern of its own for each entry.
|
|
198
|
+
*
|
|
199
|
+
* Made where a run begins and handed down, never kept by this module: a
|
|
200
|
+
* reading is of the clock, so one kept past its run would answer for a
|
|
201
|
+
* machine that is busy no longer.
|
|
202
|
+
*/
|
|
203
|
+
export function patternReader(read = patternTools) {
|
|
204
|
+
const known = new Map();
|
|
205
|
+
return (source) => {
|
|
206
|
+
const before = known.get(source);
|
|
207
|
+
if (before !== undefined)
|
|
208
|
+
return before;
|
|
209
|
+
const tools = read(source);
|
|
210
|
+
known.set(source, tools);
|
|
211
|
+
return tools;
|
|
212
|
+
};
|
|
213
|
+
}
|
|
125
214
|
/** Whether Claude Code runs a hook's entry in the background, where it waits on no answer of it. */
|
|
126
215
|
function inBackground(hook) {
|
|
127
216
|
return hook['async'] === true || hook['asyncRewake'] === true;
|
|
@@ -134,12 +223,12 @@ function inBackground(hook) {
|
|
|
134
223
|
* entry started only for the calls its rule matches, in the syntax of a
|
|
135
224
|
* permission rule, which is not read here.
|
|
136
225
|
*/
|
|
137
|
-
function answered(event, matcher, hook) {
|
|
226
|
+
function answered(event, matcher, hook, pattern, commas) {
|
|
138
227
|
if (event === BEFORE && inBackground(hook))
|
|
139
228
|
return [];
|
|
140
229
|
if (hook['if'] !== undefined)
|
|
141
230
|
return null;
|
|
142
|
-
return matchedTools(matcher);
|
|
231
|
+
return matchedTools(matcher, pattern, commas);
|
|
143
232
|
}
|
|
144
233
|
/**
|
|
145
234
|
* `.claude/settings.json`, or the plugin's hooks, with the guard hooks merged
|
|
@@ -153,10 +242,17 @@ function answered(event, matcher, hook) {
|
|
|
153
242
|
* init rewrites none but the one 0.1 wrote. Without `isHook`, only init's
|
|
154
243
|
* own line is known: the default's `false` and an `undefined` read the same
|
|
155
244
|
* in `some`, so that mutant is equivalent.
|
|
245
|
+
*
|
|
246
|
+
* `pattern` reads a matcher that is a pattern: the run's own reader, where
|
|
247
|
+
* the run has read the file's matchers already ({@link patternReader}).
|
|
248
|
+
* `commas` is how the Claude Code here reads a comma in a matcher of names,
|
|
249
|
+
* where the run asked its release ({@link commasUnder}): a line that
|
|
250
|
+
* release starts for no write is no guard, and init's own is written beside
|
|
251
|
+
* it.
|
|
156
252
|
*/
|
|
157
|
-
export function mergeClaudeSettings(current, isHook = () => false) {
|
|
253
|
+
export function mergeClaudeSettings(current, isHook = () => false, pattern = patternReader(), commas = SEPARATOR) {
|
|
158
254
|
const hooks = isObject(current['hooks']) ? current['hooks'] : {};
|
|
159
|
-
const theirs = harnessEntries(current).some((entry) => !isGuard(entry.hook) && !isLegacyGuard(entry.hook) && isHook(entry.words) && isStarted(toolsOf(entry)));
|
|
255
|
+
const theirs = harnessEntries(current).some((entry) => !isGuard(entry.hook) && !isLegacyGuard(entry.hook) && isHook(entry.words) && isStarted(toolsOf(entry, pattern, commas)));
|
|
160
256
|
if (theirs)
|
|
161
257
|
return null;
|
|
162
258
|
let changed = false;
|
|
@@ -166,7 +262,7 @@ export function mergeClaudeSettings(current, isHook = () => false) {
|
|
|
166
262
|
const before = Array.isArray(listed) ? listed : [];
|
|
167
263
|
const groups = before.map(upgraded);
|
|
168
264
|
const replaced = groups.some((group, index) => group !== before[index]);
|
|
169
|
-
const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry)));
|
|
265
|
+
const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry, pattern, commas)));
|
|
170
266
|
if (!present)
|
|
171
267
|
groups.push({ matcher: HOOK_MATCHER, hooks: [{ ...GUARD_HOOK, args: [...GUARD_ARGS] }] });
|
|
172
268
|
if (replaced || !present) {
|
|
@@ -207,9 +303,24 @@ function isLauncher(word) {
|
|
|
207
303
|
}
|
|
208
304
|
/** Anything in a word that a shell reads before the program is handed it: a quote, a variable, an operator, a pattern. */
|
|
209
305
|
const SHELL_READS = /[^A-Za-z0-9_./:=@%+,-]/;
|
|
306
|
+
/**
|
|
307
|
+
* The handler types that are no command, by Claude Code's hooks reference
|
|
308
|
+
* (https://code.claude.com/docs/en/hooks, Hook handler fields, read
|
|
309
|
+
* 2026-10-12): an entry of one sends a request, calls a tool of an MCP
|
|
310
|
+
* server, or asks a model or an agent. It runs no command, the harness's or
|
|
311
|
+
* any other, whatever else the entry holds.
|
|
312
|
+
*/
|
|
313
|
+
const OTHER_HANDLERS = ['http', 'mcp_tool', 'prompt', 'agent'];
|
|
314
|
+
/**
|
|
315
|
+
* The line of an entry that runs the harness, or `null` for one that does
|
|
316
|
+
* not. Only an entry Claude Code runs as a command is one: the reference
|
|
317
|
+
* has `type` as a field every entry must have, with `"command"` for a
|
|
318
|
+
* command and four other words, and says nothing of an entry with none or
|
|
319
|
+
* with another word, which is kept, and said as one that cannot be told.
|
|
320
|
+
*/
|
|
210
321
|
function writtenHook(hook) {
|
|
211
|
-
const { command, args } = hook;
|
|
212
|
-
if (typeof command !== 'string')
|
|
322
|
+
const { command, args, type } = hook;
|
|
323
|
+
if (typeof command !== 'string' || OTHER_HANDLERS.includes(type))
|
|
213
324
|
return null;
|
|
214
325
|
let line;
|
|
215
326
|
// Without arguments the line is a shell's, which splits it at its spaces:
|
|
@@ -227,7 +338,8 @@ function writtenHook(hook) {
|
|
|
227
338
|
if (at === -1)
|
|
228
339
|
return null;
|
|
229
340
|
const words = line.slice(at + 1);
|
|
230
|
-
|
|
341
|
+
const untyped = type === 'command' ? null : type === undefined ? 'no "type"' : `the "type" ${JSON.stringify(type)}`;
|
|
342
|
+
return { words, shell: shell && words.some((word) => SHELL_READS.test(word)), blocks: hook['onFailure'] === 'block', untyped };
|
|
231
343
|
}
|
|
232
344
|
/**
|
|
233
345
|
* Every line in Claude Code's settings that runs the harness, under the two
|
|
@@ -238,25 +350,25 @@ function writtenHook(hook) {
|
|
|
238
350
|
*/
|
|
239
351
|
export function writtenHooks(settings) {
|
|
240
352
|
const found = [];
|
|
241
|
-
for (const { event, words, shell, blocks } of harnessEntries(settings)) {
|
|
242
|
-
const same = found.find((other) => sameLine(other, { words, shell, blocks }));
|
|
353
|
+
for (const { event, words, shell, blocks, untyped } of harnessEntries(settings)) {
|
|
354
|
+
const same = found.find((other) => sameLine(other, { words, shell, blocks, untyped }));
|
|
243
355
|
if (same === undefined)
|
|
244
|
-
found.push({ events: [event], words, shell, blocks });
|
|
356
|
+
found.push({ events: [event], words, shell, blocks, untyped });
|
|
245
357
|
else if (!same.events.includes(event))
|
|
246
358
|
same.events.push(event);
|
|
247
359
|
}
|
|
248
360
|
return found;
|
|
249
361
|
}
|
|
250
362
|
function sameLine(one, other) {
|
|
251
|
-
return one.shell === other.shell && one.blocks === other.blocks && sameStrings(one.words, other.words);
|
|
363
|
+
return one.shell === other.shell && one.blocks === other.blocks && one.untyped === other.untyped && sameStrings(one.words, other.words);
|
|
252
364
|
}
|
|
253
365
|
/**
|
|
254
366
|
* The writing tools whose call an entry is handed and waited on for, or
|
|
255
367
|
* `null` where that cannot be told. Read where it is asked for and no
|
|
256
368
|
* sooner: a matcher that is a pattern can cost its whole budget.
|
|
257
369
|
*/
|
|
258
|
-
function toolsOf(entry) {
|
|
259
|
-
return answered(entry.event, entry.matcher, entry.hook);
|
|
370
|
+
function toolsOf(entry, pattern, commas) {
|
|
371
|
+
return answered(entry.event, entry.matcher, entry.hook, pattern, commas);
|
|
260
372
|
}
|
|
261
373
|
/** Every entry that runs the harness under the two events the guard answers, in the order the settings hold them. */
|
|
262
374
|
function harnessEntries(settings) {
|
|
@@ -301,18 +413,39 @@ function named(names) {
|
|
|
301
413
|
return names.length === 1 ? last : `${names.slice(0, -1).join(', ')} and ${last}`;
|
|
302
414
|
}
|
|
303
415
|
/** 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. */
|
|
304
|
-
function toolsUnder(event, entries) {
|
|
305
|
-
const read = entries.filter((entry) => entry.event === event).map(toolsOf);
|
|
306
|
-
const sure =
|
|
307
|
-
return sure.length <
|
|
416
|
+
function toolsUnder(event, entries, reading) {
|
|
417
|
+
const read = entries.filter((entry) => entry.event === event).map((entry) => toolsOf(entry, reading.pattern, reading.commas));
|
|
418
|
+
const sure = CALLABLE_TOOLS.filter((tool) => read.some((tools) => tools !== null && tools.includes(tool)));
|
|
419
|
+
return sure.length < CALLABLE_TOOLS.length && read.some((tools) => tools === null) ? null : sure;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* What is said after a matcher that Claude Code does not read as its text
|
|
423
|
+
* suggests: an array, which it loads no hook under, and names with a comma
|
|
424
|
+
* where the release here reads the comma another way than the newest does,
|
|
425
|
+
* or cannot be told. Nothing for any other matcher, and nothing for a comma
|
|
426
|
+
* where both readings come to no tool that writes.
|
|
427
|
+
*/
|
|
428
|
+
function saidOfMatcher(matcher, { pattern, commas }) {
|
|
429
|
+
if (Array.isArray(matcher))
|
|
430
|
+
return ', an array, which Claude Code lists as an invalid setting and loads no hook under';
|
|
431
|
+
if (commas.as === 'separator')
|
|
432
|
+
return '';
|
|
433
|
+
const comma = commaReading(matcher, pattern);
|
|
434
|
+
if (comma === null || (comma.earlier !== null && comma.listed.length === 0))
|
|
435
|
+
return '';
|
|
436
|
+
const listed = comma.listed.length === 0 ? 'no tool that writes' : named(comma.listed);
|
|
437
|
+
const earlier = comma.earlier === null ? 'a pattern whose matches its documentation does not give' : 'a pattern that matches no tool';
|
|
438
|
+
if (commas.as === 'untold')
|
|
439
|
+
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`;
|
|
440
|
+
return `, which Claude Code ${commas.release} reads as ${earlier}: a comma separates names from ${COMMA_SINCE} on, where the matcher is ${listed}`;
|
|
308
441
|
}
|
|
309
442
|
/** What a line's entries under `event` hold that decides when Claude Code starts them, as written. */
|
|
310
|
-
function entriesUnder(event, entries) {
|
|
443
|
+
function entriesUnder(event, entries, reading) {
|
|
311
444
|
const under = entries.filter((entry) => entry.event === event);
|
|
312
445
|
if (under.length === 0)
|
|
313
446
|
return `it stands under no ${event}`;
|
|
314
447
|
const said = under.map(({ matcher, hook }) => {
|
|
315
|
-
const pattern = matcher === undefined ? 'no matcher' : `the matcher ${JSON.stringify(matcher)}`;
|
|
448
|
+
const pattern = matcher === undefined ? 'no matcher' : `the matcher ${JSON.stringify(matcher)}${saidOfMatcher(matcher, reading)}`;
|
|
316
449
|
const background = inBackground(hook) ? ' in the background' : '';
|
|
317
450
|
const rule = hook['if'] === undefined ? '' : ` with "if" ${JSON.stringify(hook['if'])}`;
|
|
318
451
|
return `${pattern}${background}${rule}`;
|
|
@@ -323,25 +456,62 @@ function entriesUnder(event, entries) {
|
|
|
323
456
|
* What is said of the writes Claude Code starts a line for, by the event and
|
|
324
457
|
* the matcher of each entry that holds it; `null` where it is waited on
|
|
325
458
|
* before every tool that writes and started after each, as init writes it.
|
|
459
|
+
* A tool is one a supported Claude Code can call: `MultiEdit`, which the
|
|
460
|
+
* hook still reads a path from, is named as unchecked for no line, since no
|
|
461
|
+
* such Claude Code has it to call (hooks.ts).
|
|
326
462
|
*/
|
|
327
|
-
function describeCover(where, before, after, entries) {
|
|
463
|
+
function describeCover(where, before, after, entries, reading) {
|
|
328
464
|
const parts = [];
|
|
329
|
-
const left = (tools) =>
|
|
465
|
+
const left = (tools) => CALLABLE_TOOLS.filter((tool) => !tools.includes(tool));
|
|
330
466
|
if (before === null)
|
|
331
|
-
parts.push(`which writes Claude Code waits on it before cannot be told here (${entriesUnder(BEFORE, entries)})`);
|
|
467
|
+
parts.push(`which writes Claude Code waits on it before cannot be told here (${entriesUnder(BEFORE, entries, reading)})`);
|
|
332
468
|
else if (before.length === 0)
|
|
333
|
-
parts.push(`Claude Code waits on it before no write, so it is not the guard and refuses none (${entriesUnder(BEFORE, entries)})`);
|
|
469
|
+
parts.push(`Claude Code waits on it before no write, so it is not the guard and refuses none (${entriesUnder(BEFORE, entries, reading)})`);
|
|
334
470
|
else if (left(before).length > 0) {
|
|
335
|
-
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)})`);
|
|
471
|
+
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)})`);
|
|
336
472
|
}
|
|
337
473
|
if (after === null)
|
|
338
|
-
parts.push(`which writes it is started after cannot be told here (${entriesUnder(AFTER, entries)})`);
|
|
474
|
+
parts.push(`which writes it is started after cannot be told here (${entriesUnder(AFTER, entries, reading)})`);
|
|
339
475
|
else if (after.length === 0)
|
|
340
|
-
parts.push(`it is started after no write, so it warns of none (${entriesUnder(AFTER, entries)})`);
|
|
476
|
+
parts.push(`it is started after no write, so it warns of none (${entriesUnder(AFTER, entries, reading)})`);
|
|
341
477
|
else if (left(after).length > 0)
|
|
342
|
-
parts.push(`it is started after a write by ${named(after)} alone (${entriesUnder(AFTER, entries)})`);
|
|
478
|
+
parts.push(`it is started after a write by ${named(after)} alone (${entriesUnder(AFTER, entries, reading)})`);
|
|
343
479
|
return parts.length === 0 ? null : `${where}: ${parts.join('; ')}`;
|
|
344
480
|
}
|
|
481
|
+
/** The events under which an array for a matcher keeps Claude Code from loading the rest of a file's hooks. */
|
|
482
|
+
const UNLOADING = [BEFORE, 'PermissionRequest'];
|
|
483
|
+
/**
|
|
484
|
+
* The first array that stands for a matcher under `PreToolUse` or
|
|
485
|
+
* `PermissionRequest` in a settings file, or `null`.
|
|
486
|
+
*
|
|
487
|
+
* Claude Code's page on debugging a configuration
|
|
488
|
+
* (https://code.claude.com/docs/en/debug-your-config, Check hooks, read
|
|
489
|
+
* 2026-10-14) has a matcher that is an array, where it must be one string,
|
|
490
|
+
* as an entry Claude Code lists as an invalid setting, at the start of an
|
|
491
|
+
* interactive session and in `claude doctor`; and where the array is under
|
|
492
|
+
* one of these two events, none of that file's other hooks load either. It
|
|
493
|
+
* names no release. So a file that holds one runs no hook at all, whoever
|
|
494
|
+
* wrote the entry and whatever it runs: the array's own is not loaded, and
|
|
495
|
+
* neither is any other.
|
|
496
|
+
*/
|
|
497
|
+
export function arrayMatcher(settings) {
|
|
498
|
+
const hooks = field(settings, 'hooks');
|
|
499
|
+
for (const event of UNLOADING) {
|
|
500
|
+
const groups = field(hooks, event);
|
|
501
|
+
if (!Array.isArray(groups))
|
|
502
|
+
continue;
|
|
503
|
+
const group = groups.find((candidate) => Array.isArray(field(candidate, 'matcher')));
|
|
504
|
+
if (group !== undefined)
|
|
505
|
+
return { event, matcher: JSON.stringify(field(group, 'matcher')) };
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
/** What is said of a settings file that an array for a matcher keeps Claude Code from loading the hooks of. */
|
|
510
|
+
export function describeArrayMatcher(file, { event, matcher }) {
|
|
511
|
+
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`;
|
|
512
|
+
}
|
|
513
|
+
/** How a matcher that is an array is mended. */
|
|
514
|
+
export const ONE_STRING = 'write the matcher as one string, its names between "|"';
|
|
345
515
|
/** The words Claude Code hands the hook for a line: what follows the harness's command, each `${CLAUDE_PROJECT_DIR}` filled with `project`. */
|
|
346
516
|
function handedWords(words, project) {
|
|
347
517
|
return words.map((word) => word.split(PROJECT_DIR).join(project));
|
|
@@ -360,14 +530,29 @@ export function handedLines(sources, project) {
|
|
|
360
530
|
* A line that guards and holds no more than a release wrote is reported
|
|
361
531
|
* with nothing to say, as it always was. One with more on it is said as it
|
|
362
532
|
* is, and fails nothing. One the hook cannot read, or whose root is no
|
|
363
|
-
* directory, is
|
|
533
|
+
* directory, or is in no work tree that holds the project, is a guard that
|
|
534
|
+
* is off, and one that names a brief which is not
|
|
364
535
|
* one holds every write: each a problem, and doctor exits 1 for it. A line
|
|
365
536
|
* Claude Code waits on before no write is not the guard, whatever the hook
|
|
366
537
|
* would do with it.
|
|
538
|
+
*
|
|
539
|
+
* `pattern` reads a matcher that is a pattern, once for every entry under
|
|
540
|
+
* it in every file: the run's own reader, where something else in the run
|
|
541
|
+
* reads the same files ({@link patternReader}). `commas` is how the Claude
|
|
542
|
+
* Code here reads a comma in a matcher of names, where its release was
|
|
543
|
+
* asked ({@link commasUnder}); a caller that asks none reads a comma as
|
|
544
|
+
* every release from {@link COMMA_SINCE} on does.
|
|
545
|
+
*
|
|
546
|
+
* A file that holds an array for a matcher under `PreToolUse` or
|
|
547
|
+
* `PermissionRequest` has none of its hooks loaded ({@link arrayMatcher}):
|
|
548
|
+
* each line of the hook there is started for no write, and one that stood
|
|
549
|
+
* to be started for a write is a guard that is off.
|
|
367
550
|
*/
|
|
368
|
-
export function reportHooks(sources, project, read) {
|
|
551
|
+
export function reportHooks(sources, project, read, pattern = patternReader(), commas = SEPARATOR) {
|
|
552
|
+
const reading = { pattern, commas };
|
|
369
553
|
return sources.flatMap(({ file, settings }) => {
|
|
370
554
|
const entries = harnessEntries(settings);
|
|
555
|
+
const array = arrayMatcher(settings);
|
|
371
556
|
return writtenHooks(settings).flatMap((hook) => {
|
|
372
557
|
const handed = read(handedWords(hook.words, project));
|
|
373
558
|
if (handed === null)
|
|
@@ -377,21 +562,49 @@ export function reportHooks(sources, project, read) {
|
|
|
377
562
|
// A line that is not under both events is under one.
|
|
378
563
|
const where = `"${hook.words.map((word) => (word === '' ? "''" : word)).join(' ')}" in ${file}${both ? '' : ` (under ${hook.events[0]} alone)`}`;
|
|
379
564
|
const mine = entries.filter((entry) => sameLine(entry, hook));
|
|
380
|
-
const before = toolsUnder(BEFORE, mine);
|
|
381
|
-
const after = toolsUnder(AFTER, mine);
|
|
382
|
-
|
|
565
|
+
const before = toolsUnder(BEFORE, mine, reading);
|
|
566
|
+
const after = toolsUnder(AFTER, mine, reading);
|
|
567
|
+
if (array !== null) {
|
|
568
|
+
// Nothing of the file is loaded, whatever the line holds and
|
|
569
|
+
// whatever the hook would do with it. A line that stood to be
|
|
570
|
+
// started for a write, or stands under an array itself, where
|
|
571
|
+
// someone meant a matcher, is a guard set up that checks nothing;
|
|
572
|
+
// one that stood to be started for none leaves no write unchecked
|
|
573
|
+
// that it was ever asked about.
|
|
574
|
+
const unloaded = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before: [], after: [], cover: null, note: null };
|
|
575
|
+
const said = `${where}: ${describeArrayMatcher(file, array)}`;
|
|
576
|
+
if (isStarted(before) || isStarted(after) || mine.some((entry) => Array.isArray(entry.matcher))) {
|
|
577
|
+
return [{ ...unloaded, state: 'off', detail: `${said}, so the guard is off and no write is checked; ${ONE_STRING}` }];
|
|
578
|
+
}
|
|
579
|
+
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` }];
|
|
580
|
+
}
|
|
581
|
+
const written = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before, after, cover: describeCover(where, before, after, mine, reading) };
|
|
582
|
+
const note = describeOnFailure(where, hook.blocks, false);
|
|
583
|
+
if (hook.untyped !== null) {
|
|
584
|
+
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"`;
|
|
585
|
+
return [{ ...written, state: 'unknown', detail, note }];
|
|
586
|
+
}
|
|
383
587
|
if (hook.shell) {
|
|
384
588
|
const detail = `${where} is a shell's to read before the hook is handed it, so what the hook does with it cannot be told here: written with "args", each word reaches the hook as it stands`;
|
|
385
|
-
return [{ ...written, state: 'unknown', detail, note
|
|
589
|
+
return [{ ...written, state: 'unknown', detail, note }];
|
|
386
590
|
}
|
|
591
|
+
// What the hook would do with a line it is never asked on before a write is not what guards.
|
|
592
|
+
const unstarted = { ...written, state: 'unstarted', detail: null, note };
|
|
593
|
+
const waited = before === null || before.length > 0;
|
|
387
594
|
if (handed.state === 'off') {
|
|
388
|
-
const detail = `${where}: ${handed.fault.wrong}, so the guard is off and no write is checked; ${handed.fault.mend}`;
|
|
389
|
-
|
|
595
|
+
const detail = `${where}: ${handed.fault.wrong}, so the guard is off and ${handed.unchecked ?? 'no write is checked'}; ${handed.fault.mend}`;
|
|
596
|
+
// A fault the hook says, it says with exit 1 after every write it is
|
|
597
|
+
// started for as well, so it is said wherever the line stands.
|
|
598
|
+
if (handed.silent !== true)
|
|
599
|
+
return [{ ...written, state: 'off', detail, note: describeOnFailure(where, hook.blocks, true) }];
|
|
600
|
+
// A root outside any work tree, or in one that does not hold the
|
|
601
|
+
// project, of which the hook says nothing: no run of it fails
|
|
602
|
+
// there, so onFailure holds no write for it, and where the line is
|
|
603
|
+
// waited on before no write there is none it leaves unchecked.
|
|
604
|
+
return [waited ? { ...written, state: 'off', detail, note } : unstarted];
|
|
390
605
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (before !== null && before.length === 0)
|
|
394
|
-
return [{ ...written, state: 'unstarted', detail: null, note }];
|
|
606
|
+
if (!waited)
|
|
607
|
+
return [unstarted];
|
|
395
608
|
if (handed.state === 'held') {
|
|
396
609
|
const detail = `${where}: ${handed.wrong}, so the hook holds every write until the line is mended: name a brief spec-brief lists, or take --brief off the line`;
|
|
397
610
|
return [{ ...written, state: 'held', detail, note }];
|
|
@@ -402,7 +615,9 @@ export function reportHooks(sources, project, read) {
|
|
|
402
615
|
if (sameStrings(hook.words, ['hook', 'claude']))
|
|
403
616
|
return [{ ...written, state: 'guards', detail: null, note }];
|
|
404
617
|
const root = handed.root === undefined ? '' : `, and its root, ${handed.root}, is a directory`;
|
|
405
|
-
|
|
618
|
+
// Whose briefs a write is checked by, where they are not the project's own.
|
|
619
|
+
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`;
|
|
620
|
+
return [{ ...written, state: 'guards', detail: `${where} is more than init writes, and guards: the hook reads the line${root}${holder}`, note }];
|
|
406
621
|
});
|
|
407
622
|
});
|
|
408
623
|
}
|
|
@@ -434,6 +649,119 @@ export function enabledPlugin(sources) {
|
|
|
434
649
|
}
|
|
435
650
|
return [...decided.values()].find((entry) => entry !== null) ?? null;
|
|
436
651
|
}
|
|
652
|
+
/**
|
|
653
|
+
* The settings file whose `disableAllHooks` turns Claude Code's hooks off,
|
|
654
|
+
* or `null` where none does.
|
|
655
|
+
*
|
|
656
|
+
* By its settings reference
|
|
657
|
+
* (https://code.claude.com/docs/en/settings-reference, disableAllHooks, read
|
|
658
|
+
* 2026-10-12) the key is a Boolean any settings file may hold, and `true`
|
|
659
|
+
* turns hooks off. Set outside managed settings, it turns off the hooks of
|
|
660
|
+
* the user's, the project's and the local settings and of plugins, and the
|
|
661
|
+
* hooks of managed settings, of the Agent SDK and of a plugin that managed
|
|
662
|
+
* settings force on keep running. By its hooks reference (Disable or remove
|
|
663
|
+
* hooks) the value that counts is the one left after settings precedence:
|
|
664
|
+
* of the files given, lowest precedence first, the last that sets a Boolean
|
|
665
|
+
* decides. A value that is no Boolean is passed over, as the key left out.
|
|
666
|
+
*/
|
|
667
|
+
export function hooksDisabled(sources) {
|
|
668
|
+
let file = null;
|
|
669
|
+
for (const source of sources) {
|
|
670
|
+
const value = field(source.settings, 'disableAllHooks');
|
|
671
|
+
if (typeof value === 'boolean')
|
|
672
|
+
file = value ? source.file : null;
|
|
673
|
+
}
|
|
674
|
+
return file;
|
|
675
|
+
}
|
|
676
|
+
/** The project's settings file for Claude Code, as it is named among the sources. */
|
|
677
|
+
export const PROJECT_SETTINGS = '.claude/settings.json';
|
|
678
|
+
/** Where Claude Code records the marketplaces it has fetched, in a directory it keeps plugins in. */
|
|
679
|
+
export const MARKETPLACE_REGISTRY = 'known_marketplaces.json';
|
|
680
|
+
/** Where it records the marketplaces hosted on claude.ai, in a shape its reference does not give. */
|
|
681
|
+
export const HOSTED_REGISTRY = 'known_marketplaces_claudeai.json';
|
|
682
|
+
/**
|
|
683
|
+
* The origins in a plugin's id that are no marketplace, by Claude Code's
|
|
684
|
+
* plugin loading reference (https://code.claude.com/docs/en/plugins/loading,
|
|
685
|
+
* Find where a plugin came from, read 2026-10-12): a plugin started from a
|
|
686
|
+
* directory or an archive, one saved under a skills directory, and one
|
|
687
|
+
* synced from a claude.ai account. None has a marketplace, and the first
|
|
688
|
+
* and the last no record on disk that the reference describes.
|
|
689
|
+
*/
|
|
690
|
+
const OTHER_ORIGINS = ['inline', 'skills-dir', 'synced'];
|
|
691
|
+
/** A name a directory can be looked up by: no separator, and no way out of the directory it is looked up in. */
|
|
692
|
+
const DIRECTORY_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
693
|
+
/**
|
|
694
|
+
* The marketplace a plugin's id names, where it names one whose directory
|
|
695
|
+
* can be looked up: `null` for an origin that is no marketplace, and for
|
|
696
|
+
* text that names no directory.
|
|
697
|
+
*/
|
|
698
|
+
export function pluginMarketplace(id) {
|
|
699
|
+
const origin = id.slice(id.indexOf('@') + 1);
|
|
700
|
+
return OTHER_ORIGINS.includes(origin) || !DIRECTORY_NAME.test(origin) ? null : origin;
|
|
701
|
+
}
|
|
702
|
+
function turnsOn(settings, id) {
|
|
703
|
+
const plugins = field(settings, 'enabledPlugins');
|
|
704
|
+
return isObject(plugins) && plugins[id] === true;
|
|
705
|
+
}
|
|
706
|
+
function holds(record, name) {
|
|
707
|
+
return isObject(record) && Object.hasOwn(record, name);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Whether the plugin that settings turn on is on this machine, by Claude
|
|
711
|
+
* Code's plugin loading reference
|
|
712
|
+
* (https://code.claude.com/docs/en/plugins/loading, read 2026-10-12), from
|
|
713
|
+
* the settings files and from what the directories it keeps plugins in
|
|
714
|
+
* hold: its plugins root, and each seed directory.
|
|
715
|
+
*
|
|
716
|
+
* `present`: a version of it is in a cache, where the reference has one
|
|
717
|
+
* directory for each installed version of a marketplace plugin (Find
|
|
718
|
+
* plugins on disk).
|
|
719
|
+
*
|
|
720
|
+
* `absent` is said only where the reference leaves the plugin no way to
|
|
721
|
+
* load (Enabled in project settings but not installed): its only `true` is
|
|
722
|
+
* in the project's `.claude/settings.json`, for which Claude Code fetches
|
|
723
|
+
* no plugin a machine lacks, and neither of the page's two exceptions is
|
|
724
|
+
* met, a marketplace that lists it by a relative path, since no marketplace
|
|
725
|
+
* of that name is recorded as fetched or declared in settings, and a seed
|
|
726
|
+
* directory that holds it.
|
|
727
|
+
*
|
|
728
|
+
* Everything else is `unknown`: an origin that is no marketplace, a record
|
|
729
|
+
* that is not there or cannot be read, a marketplace that is there or is
|
|
730
|
+
* declared, whose own list is not read here, and a `true` in a settings
|
|
731
|
+
* file of the person's own, for which Claude Code fetches.
|
|
732
|
+
*/
|
|
733
|
+
export function pluginPresence(plugin, sources, roots) {
|
|
734
|
+
const untold = (detail) => ({ state: 'unknown', detail });
|
|
735
|
+
const marketplace = pluginMarketplace(plugin.id);
|
|
736
|
+
if (marketplace === null)
|
|
737
|
+
return untold(`${plugin.id} names no marketplace whose plugins are kept in a cache: a plugin from --plugin-dir, a skills directory or a claude.ai account comes another way`);
|
|
738
|
+
if (roots.length === 0)
|
|
739
|
+
return untold('where Claude Code keeps its plugins is not known here: none of CLAUDE_CODE_PLUGIN_CACHE_DIR, CLAUDE_CONFIG_DIR and a home directory is set');
|
|
740
|
+
if (roots.some((root) => root.cached))
|
|
741
|
+
return { state: 'present', detail: null };
|
|
742
|
+
const unread = roots.find((root) => !isObject(root.registry));
|
|
743
|
+
if (unread !== undefined) {
|
|
744
|
+
const wrong = unread.registry === null ? 'is not there' : 'cannot be read as JSON';
|
|
745
|
+
return untold(`${join(unread.directory, MARKETPLACE_REGISTRY)}, where Claude Code records the marketplaces it has fetched, ${wrong}`);
|
|
746
|
+
}
|
|
747
|
+
const fetched = roots.find((root) => holds(root.registry, marketplace));
|
|
748
|
+
if (fetched !== undefined) {
|
|
749
|
+
return untold(`its marketplace, ${marketplace}, is on this machine (${join(fetched.directory, MARKETPLACE_REGISTRY)}) and no version of the plugin is in the cache: Claude Code loads a plugin that its marketplace lists by a relative path from the marketplace itself, and the marketplace's list is not read here`);
|
|
750
|
+
}
|
|
751
|
+
const declaring = sources.find(({ settings }) => holds(field(settings, 'extraKnownMarketplaces'), marketplace));
|
|
752
|
+
if (declaring !== undefined)
|
|
753
|
+
return untold(`${declaring.file} declares its marketplace, ${marketplace}, under extraKnownMarketplaces, which Claude Code fetches after a session has started: nothing of it is on this machine now`);
|
|
754
|
+
const hosted = roots.find((root) => root.hosted);
|
|
755
|
+
if (hosted !== undefined)
|
|
756
|
+
return untold(`${join(hosted.directory, HOSTED_REGISTRY)} records the marketplaces hosted on claude.ai, in a shape Claude Code's reference does not give, so whether ${marketplace} is one cannot be read`);
|
|
757
|
+
const own = sources.filter(({ file, settings }) => file !== PROJECT_SETTINGS && turnsOn(settings, plugin.id)).map(({ file }) => file);
|
|
758
|
+
if (own.length > 0)
|
|
759
|
+
return untold(`${named(own)} ${own.length === 1 ? 'turns' : 'turn'} it on, and Claude Code fetches a plugin that settings of the person's own turn on: neither a version of it nor its marketplace, ${marketplace}, is on this machine now`);
|
|
760
|
+
return {
|
|
761
|
+
state: 'absent',
|
|
762
|
+
detail: `${PROJECT_SETTINGS} alone turns it on, and Claude Code fetches no plugin that a project's settings alone turn on, unless its marketplace lists it by a relative path or a seed directory holds it: no marketplace ${marketplace} is recorded as fetched or declared in settings, and no version of the plugin is in a cache (${roots.map((root) => join(root.directory, 'cache')).join(', ')})`,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
437
765
|
/** Where the plugin is on, as a person would look it up. */
|
|
438
766
|
function isOn(plugin) {
|
|
439
767
|
return `the spec-harness plugin is on (${plugin.id} in ${plugin.file})`;
|
|
@@ -443,9 +771,30 @@ function turnOff(plugin) {
|
|
|
443
771
|
return `turn the plugin off for this project with claude plugin disable ${plugin.id} --scope local`;
|
|
444
772
|
}
|
|
445
773
|
const PARTS = literal({
|
|
446
|
-
hooks: { brings: 'the guard hooks', twice: 'every write is guarded twice', skipped: 'init writes none', entry: "spec-harness's hooks", them: 'them' },
|
|
447
|
-
server: { brings: 'the server', twice: 'the server is registered twice', skipped: 'init registers none', entry: 'the spec-harness server', them: 'it' },
|
|
774
|
+
hooks: { brings: 'the guard hooks', twice: 'every write is guarded twice', skipped: 'init writes none', entry: "spec-harness's hooks", them: 'them', none: 'no hook' },
|
|
775
|
+
server: { brings: 'the server', twice: 'the server is registered twice', skipped: 'init registers none', entry: 'the spec-harness server', them: 'it', none: 'no server' },
|
|
448
776
|
});
|
|
777
|
+
/** How to have a plugin that is not on this machine brought to it, in the commands Claude Code's plugin pages give. */
|
|
778
|
+
function bring(plugin) {
|
|
779
|
+
return `install it here, with claude plugin install ${plugin.id} once its marketplace is added`;
|
|
780
|
+
}
|
|
781
|
+
/** That the plugin settings turn on is not on this machine, and why. */
|
|
782
|
+
function isAbsent(plugin, detail, brought) {
|
|
783
|
+
return `${isOn(plugin)} and is not on this machine, so it brings ${brought} here: ${detail}`;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* What init says of a Claude Code file where the plugin that settings turn
|
|
787
|
+
* on is not on this machine. It writes nothing on that reading alone: a
|
|
788
|
+
* plugin that is there after all would bring the same `part` a second time.
|
|
789
|
+
* A file that holds init's entry already is `present`.
|
|
790
|
+
*/
|
|
791
|
+
export function describeAbsent(plugin, detail, part, present) {
|
|
792
|
+
const words = PARTS[part];
|
|
793
|
+
const absent = isAbsent(plugin, detail, words.none);
|
|
794
|
+
if (present)
|
|
795
|
+
return `${absent}. This file holds ${words.entry}; with the plugin installed as well, ${words.twice}: ${turnOff(plugin)}`;
|
|
796
|
+
return `${absent}. On that reading alone ${words.skipped}, since with a plugin that is there after all ${words.twice}: ${bring(plugin)}, or to have ${words.them} here instead, ${turnOff(plugin)} and run init again`;
|
|
797
|
+
}
|
|
449
798
|
/**
|
|
450
799
|
* What init says of a Claude Code file it leaves alone because the plugin is
|
|
451
800
|
* on and brings the same `part`. A file that holds init's entry already is a
|
|
@@ -463,22 +812,75 @@ export function describeSkipped(plugin, part, present, elsewhere = []) {
|
|
|
463
812
|
}
|
|
464
813
|
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`;
|
|
465
814
|
}
|
|
466
|
-
/**
|
|
815
|
+
/** Why the plugin settings turn on is not on this machine, or `null` where it is, may be, or none is on. */
|
|
816
|
+
function absence(wiring) {
|
|
817
|
+
return wiring.presence?.state === 'absent' ? wiring.presence.detail : null;
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Which of the four the wiring is. `both` is a double install, and doctor
|
|
821
|
+
* fails it; either of init's entries counts. A plugin that is not on this
|
|
822
|
+
* machine brings nothing to it, and is not counted.
|
|
823
|
+
*/
|
|
467
824
|
export function wiringState(wiring) {
|
|
468
825
|
const init = wiring.hooks.length > 0 || wiring.server;
|
|
469
|
-
if (wiring.plugin === null)
|
|
826
|
+
if (wiring.plugin === null || absence(wiring) !== null)
|
|
470
827
|
return init ? 'init' : 'none';
|
|
471
828
|
return init ? 'both' : 'plugin';
|
|
472
829
|
}
|
|
830
|
+
/**
|
|
831
|
+
* Whether hooks that would guard are switched off: `disableAllHooks` in a
|
|
832
|
+
* settings file, where a settings file holds the guard hooks or a plugin
|
|
833
|
+
* that may be on this machine brings them. A guard someone set up, which
|
|
834
|
+
* checks no write.
|
|
835
|
+
*/
|
|
836
|
+
export function guardSwitchedOff(wiring) {
|
|
837
|
+
return wiring.hooksOff !== null && (wiring.hooks.length > 0 || (wiring.plugin !== null && absence(wiring) === null));
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* What doctor and init say of `disableAllHooks` in `file`. `guarded` is
|
|
841
|
+
* whether there are guard hooks for it to switch off; without any it is a
|
|
842
|
+
* note on what the key would do to them.
|
|
843
|
+
*/
|
|
844
|
+
export function describeHooksOff(file, guarded) {
|
|
845
|
+
const off = `"disableAllHooks" is true in ${file}, so Claude Code runs no hook of the user's, the project's or the local settings, and none of a plugin`;
|
|
846
|
+
if (!guarded)
|
|
847
|
+
return `note: ${off}: the hooks init writes, and those the plugin brings, would not run while it stands`;
|
|
848
|
+
return `${off}: no write is guarded. Take the key out of ${file}, or set it to false, to have the guard back. Hooks of managed settings, and of a plugin managed settings force on, keep running, and no managed settings are read here`;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* What doctor adds of the plugin settings turn on, where it is not simply on
|
|
852
|
+
* this machine: that it cannot be told, which is never fine, or that it is
|
|
853
|
+
* not, beside entries of init's. `null` where there is no more to say: no
|
|
854
|
+
* plugin, one that is there, and one that is not where nothing else is
|
|
855
|
+
* wired, which the wiring's own sentence says.
|
|
856
|
+
*/
|
|
857
|
+
export function describePresence(wiring) {
|
|
858
|
+
const { plugin, presence } = wiring;
|
|
859
|
+
if (plugin === null || presence === undefined || presence === null || presence.state === 'present')
|
|
860
|
+
return null;
|
|
861
|
+
if (presence.state === 'unknown')
|
|
862
|
+
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`;
|
|
863
|
+
if (wiring.hooks.length > 0)
|
|
864
|
+
return `${isAbsent(plugin, presence.detail, 'no hook')}. With the plugin installed as well, ${PARTS.hooks.twice}: ${turnOff(plugin)}`;
|
|
865
|
+
return wiring.server ? unguarded(plugin, presence.detail) : null;
|
|
866
|
+
}
|
|
867
|
+
/** 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. */
|
|
868
|
+
function unguarded(plugin, detail) {
|
|
869
|
+
return `${isAbsent(plugin, detail, 'no hook')}, and Claude Code guards no write. To have the guard, ${bring(plugin)}, or ${turnOff(plugin)} and run spec-harness init --write`;
|
|
870
|
+
}
|
|
473
871
|
/** What `doctor` says of the wiring, and what to do when it is doubled or missing. */
|
|
474
872
|
export function describeClaudeCode(wiring) {
|
|
475
873
|
const { plugin } = wiring;
|
|
476
874
|
const entries = [...wiring.hooks.map((file) => `the guard hooks in ${file}`), ...(wiring.server ? ['the server in .mcp.json'] : [])].join(', ');
|
|
875
|
+
const own = `init's entries: ${entries}`;
|
|
477
876
|
if (plugin === null) {
|
|
478
877
|
if (entries === '')
|
|
479
878
|
return 'neither the spec-harness plugin nor the hooks init writes, so Claude Code guards no write: run spec-harness init --write, or install the plugin';
|
|
480
|
-
return
|
|
879
|
+
return own;
|
|
481
880
|
}
|
|
881
|
+
const absent = absence(wiring);
|
|
882
|
+
if (absent !== null)
|
|
883
|
+
return entries === '' ? unguarded(plugin, absent) : own;
|
|
482
884
|
if (entries === '')
|
|
483
885
|
return `${isOn(plugin)} and brings the guard hooks and the server`;
|
|
484
886
|
const twice = [...(wiring.hooks.length > 0 ? [PARTS.hooks.twice] : []), ...(wiring.server ? [PARTS.server.twice] : [])].join(' and ');
|
|
@@ -721,6 +1123,10 @@ export function describeGitHook(hook) {
|
|
|
721
1123
|
return `${hook.file} runs spec-harness, but is not executable, so git skips it: chmod +x ${hook.file}`;
|
|
722
1124
|
case 'other':
|
|
723
1125
|
return `${hook.file} does not run spec-harness: add the line "${GIT_HOOK_LINE}" to it`;
|
|
1126
|
+
case 'stops':
|
|
1127
|
+
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`;
|
|
1128
|
+
case 'ignored':
|
|
1129
|
+
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`;
|
|
724
1130
|
case 'absent':
|
|
725
1131
|
return missingGitHook(hook.file);
|
|
726
1132
|
case 'unknown':
|