@descent-vtt/spec-harness 0.15.0 → 0.17.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 +198 -0
- package/README.md +18 -3
- package/dist/branch.js +12 -0
- package/dist/branch.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +76 -56
- package/dist/cli.js.map +1 -1
- package/dist/configure.d.ts +200 -20
- package/dist/configure.js +514 -69
- package/dist/configure.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/reader.js +5 -1
- package/dist/reader.js.map +1 -1
- package/dist/round.d.ts +35 -6
- package/dist/round.js +137 -17
- package/dist/round.js.map +1 -1
- package/dist/rulings.js +6 -0
- package/dist/rulings.js.map +1 -1
- package/dist/setup.d.ts +1 -1
- package/dist/setup.js +72 -17
- package/dist/setup.js.map +1 -1
- package/dist/signers.js +5 -1
- package/dist/signers.js.map +1 -1
- package/dist/usage.d.ts +48 -8
- package/dist/usage.js +35 -1
- package/dist/usage.js.map +1 -1
- package/dist/vendor/spec-core/jsonrpc/mcp.js.map +1 -1
- package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -1
- package/dist/vendor/spec-core/markdown/headings.js.map +1 -1
- package/dist/vendor/spec-core/markdown/links.js.map +1 -1
- package/dist/vendor/spec-core/markdown/scan.js.map +1 -1
- package/dist/vendor/spec-core/pattern/automaton.js.map +1 -1
- package/dist/vendor/spec-core/pattern/charset.js.map +1 -1
- package/dist/vendor/spec-core/pattern/regex.js.map +1 -1
- package/dist/vendor/spec-core/text/lines.js.map +1 -1
- package/dist/vendor/spec-core/text/width.js.map +1 -1
- package/dist/workspace.d.ts +12 -3
- package/dist/workspace.js +20 -3
- package/dist/workspace.js.map +1 -1
- package/package.json +1 -1
package/dist/configure.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
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
|
+
import { runInNewContext } from 'node:vm';
|
|
12
|
+
import { CALLABLE_TOOLS } from './hooks.js';
|
|
11
13
|
import { literal } from './literal.js';
|
|
12
14
|
import { parseGlobList } from './vendor/spec-core/pattern/index.js';
|
|
13
15
|
import { CLAUDE_CODE_MINIMUM } from './versions.js';
|
|
@@ -35,8 +37,20 @@ export const PROJECT_DIR = '${CLAUDE_PROJECT_DIR}';
|
|
|
35
37
|
* default; `.` is right, since a project's server starts in the project.
|
|
36
38
|
*/
|
|
37
39
|
export const PROJECT_DIR_OR_HERE = '${CLAUDE_PROJECT_DIR:-.}';
|
|
40
|
+
/**
|
|
41
|
+
* The matcher every release wrote: each name the hook reads a file from
|
|
42
|
+
* (hooks.ts). `MultiEdit` is one no supported Claude Code has a tool for,
|
|
43
|
+
* and Claude Code compares a listed name with a tool's exactly, so it starts
|
|
44
|
+
* the hook for nothing there. It stays, since the hook is asked about a name
|
|
45
|
+
* only under a matcher that lets the name through, and it reads this one for
|
|
46
|
+
* whatever still sends it.
|
|
47
|
+
*/
|
|
38
48
|
const HOOK_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
|
|
39
|
-
|
|
49
|
+
/** The event Claude Code runs before a tool call, the only one of the two where a hook can refuse the call. */
|
|
50
|
+
const BEFORE = 'PreToolUse';
|
|
51
|
+
/** The event after a call that succeeded, where a hook can only tell the model what it wrote. */
|
|
52
|
+
const AFTER = 'PostToolUse';
|
|
53
|
+
const GUARDED_EVENTS = [BEFORE, AFTER];
|
|
40
54
|
const GUARD_ARGS = Object.freeze([`${PROJECT_DIR}/${BIN}`, 'hook', 'claude']);
|
|
41
55
|
/**
|
|
42
56
|
* The guard as a Claude Code hook: exec form, `args` given, so the project's
|
|
@@ -63,9 +77,126 @@ function upgraded(group) {
|
|
|
63
77
|
return group;
|
|
64
78
|
return { ...group, hooks: hooks.map((hook) => (isLegacyGuard(hook) ? { ...hook, command: 'node', args: [...GUARD_ARGS] } : hook)) };
|
|
65
79
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The tools, of those that write a file and a supported Claude Code can call
|
|
82
|
+
* (hooks.ts), that a group's matcher has Claude Code start the group's hooks
|
|
83
|
+
* for, by its hooks reference
|
|
84
|
+
* (https://code.claude.com/docs/en/hooks, Matcher patterns, read
|
|
85
|
+
* 2026-10-12): left out, empty or `*`, every tool; made of letters, digits,
|
|
86
|
+
* `_`, `-`, spaces, `,` and `|` alone, the tools it names, one between each
|
|
87
|
+
* `|` or `,`; with any other character in it, a JavaScript regular
|
|
88
|
+
* 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 not text,
|
|
90
|
+
* is no pattern, or is a pattern whose reading runs out its budget.
|
|
91
|
+
*/
|
|
92
|
+
function matchedTools(matcher, pattern) {
|
|
93
|
+
if (matcher === undefined || matcher === '' || matcher === '*')
|
|
94
|
+
return CALLABLE_TOOLS;
|
|
95
|
+
if (typeof matcher !== 'string')
|
|
96
|
+
return null;
|
|
97
|
+
if (/^[A-Za-z0-9_\- ,|]*$/.test(matcher)) {
|
|
98
|
+
const names = matcher.split(/[|,]/).map((name) => name.trim());
|
|
99
|
+
return CALLABLE_TOOLS.filter((tool) => names.includes(tool));
|
|
100
|
+
}
|
|
101
|
+
return pattern(matcher);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* How long the reading of one pattern may take, in milliseconds of the
|
|
105
|
+
* clock. A sane matcher is read in about half of one (ADR-0005), so only a
|
|
106
|
+
* pattern that backtracks without end meets it; on a machine under load an
|
|
107
|
+
* ordinary one may, and is then said as one that cannot be told, where an
|
|
108
|
+
* idle machine reads it.
|
|
109
|
+
*/
|
|
110
|
+
const PATTERN_BUDGET = 100;
|
|
111
|
+
/**
|
|
112
|
+
* The writing tools a matcher that is a pattern matches, or `null` where it
|
|
113
|
+
* is no pattern, or its reading runs out its budget.
|
|
114
|
+
*
|
|
115
|
+
* The pattern is a line of a settings file, which arrives with a clone, and
|
|
116
|
+
* one can be written that a test of a few short names never finishes:
|
|
117
|
+
* `^((((([A-Za-z]*)*)*)*)*)*$X` ran for more than ten seconds. Tested as
|
|
118
|
+
* written, one such entry and neither doctor nor init would come back, with
|
|
119
|
+
* no word. So it is made and tested in a context of its own, whose run
|
|
120
|
+
* takes a timeout where a test in this one takes none, and what throws
|
|
121
|
+
* there, a pattern that is none or the stop itself, is a matcher that
|
|
122
|
+
* cannot be told. The shape of a pattern does not say which will: groups in
|
|
123
|
+
* a row backtrack as nested ones do.
|
|
124
|
+
*/
|
|
125
|
+
function patternTools(source) {
|
|
126
|
+
try {
|
|
127
|
+
const matched = runInNewContext('tools.filter((tool) => new RegExp(source).test(tool))', { tools: CALLABLE_TOOLS, source }, { timeout: PATTERN_BUDGET });
|
|
128
|
+
return matched;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* A reader for one run: a pattern is read the first time it is asked of,
|
|
136
|
+
* and answered from that reading after.
|
|
137
|
+
*
|
|
138
|
+
* A reading that runs out its budget costs the whole of it, a settings file
|
|
139
|
+
* can hold one matcher over any number of entries, and init asks of an
|
|
140
|
+
* entry twice, for what doctor says of the file and for what to write into
|
|
141
|
+
* it. Fifty entries under one such pattern were fifty readings, five and a
|
|
142
|
+
* half seconds, and fifty-one for init; they are one. Nothing is spared a
|
|
143
|
+
* file that holds a pattern of its own for each entry.
|
|
144
|
+
*
|
|
145
|
+
* Made where a run begins and handed down, never kept by this module: a
|
|
146
|
+
* reading is of the clock, so one kept past its run would answer for a
|
|
147
|
+
* machine that is busy no longer.
|
|
148
|
+
*/
|
|
149
|
+
export function patternReader(read = patternTools) {
|
|
150
|
+
const known = new Map();
|
|
151
|
+
return (source) => {
|
|
152
|
+
const before = known.get(source);
|
|
153
|
+
if (before !== undefined)
|
|
154
|
+
return before;
|
|
155
|
+
const tools = read(source);
|
|
156
|
+
known.set(source, tools);
|
|
157
|
+
return tools;
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Whether Claude Code runs a hook's entry in the background, where it waits on no answer of it. */
|
|
161
|
+
function inBackground(hook) {
|
|
162
|
+
return hook['async'] === true || hook['asyncRewake'] === true;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The writing tools whose call Claude Code hands an entry under `event` and
|
|
166
|
+
* waits for it on, or `null` where that cannot be told here. Before a write
|
|
167
|
+
* an entry in the background answers too late to refuse it, so it is waited
|
|
168
|
+
* on for none (the reference, Run hooks in the background). An `if` has the
|
|
169
|
+
* entry started only for the calls its rule matches, in the syntax of a
|
|
170
|
+
* permission rule, which is not read here.
|
|
171
|
+
*/
|
|
172
|
+
function answered(event, matcher, hook, pattern) {
|
|
173
|
+
if (event === BEFORE && inBackground(hook))
|
|
174
|
+
return [];
|
|
175
|
+
if (hook['if'] !== undefined)
|
|
176
|
+
return null;
|
|
177
|
+
return matchedTools(matcher, pattern);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* `.claude/settings.json`, or the plugin's hooks, with the guard hooks merged
|
|
181
|
+
* in; `null` when they are already there.
|
|
182
|
+
*
|
|
183
|
+
* An event has the guard where Claude Code starts init's own line under it
|
|
184
|
+
* for a write. Where the settings hold another line of the hook that it
|
|
185
|
+
* starts for a write, one `isHook` knows as the hook's by the words after
|
|
186
|
+
* the harness's command, nothing is merged in at all: the line is a
|
|
187
|
+
* person's, a second one beside it would run for every write as well, and
|
|
188
|
+
* init rewrites none but the one 0.1 wrote. Without `isHook`, only init's
|
|
189
|
+
* own line is known: the default's `false` and an `undefined` read the same
|
|
190
|
+
* in `some`, so that mutant is equivalent.
|
|
191
|
+
*
|
|
192
|
+
* `pattern` reads a matcher that is a pattern: the run's own reader, where
|
|
193
|
+
* the run has read the file's matchers already ({@link patternReader}).
|
|
194
|
+
*/
|
|
195
|
+
export function mergeClaudeSettings(current, isHook = () => false, pattern = patternReader()) {
|
|
68
196
|
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)));
|
|
198
|
+
if (theirs)
|
|
199
|
+
return null;
|
|
69
200
|
let changed = false;
|
|
70
201
|
const next = { ...hooks };
|
|
71
202
|
for (const event of GUARDED_EVENTS) {
|
|
@@ -73,7 +204,7 @@ export function mergeClaudeSettings(current) {
|
|
|
73
204
|
const before = Array.isArray(listed) ? listed : [];
|
|
74
205
|
const groups = before.map(upgraded);
|
|
75
206
|
const replaced = groups.some((group, index) => group !== before[index]);
|
|
76
|
-
const present = groups.some((
|
|
207
|
+
const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry, pattern)));
|
|
77
208
|
if (!present)
|
|
78
209
|
groups.push({ matcher: HOOK_MATCHER, hooks: [{ ...GUARD_HOOK, args: [...GUARD_ARGS] }] });
|
|
79
210
|
if (replaced || !present) {
|
|
@@ -107,16 +238,6 @@ export function mergeMcp(current) {
|
|
|
107
238
|
function field(file, key) {
|
|
108
239
|
return isObject(file) ? file[key] : undefined;
|
|
109
240
|
}
|
|
110
|
-
/** Whether Claude Code settings hold the guard hooks, as this release or 0.1 wrote them. */
|
|
111
|
-
export function holdsGuard(settings) {
|
|
112
|
-
const hooks = field(settings, 'hooks');
|
|
113
|
-
if (!isObject(hooks))
|
|
114
|
-
return false;
|
|
115
|
-
return GUARDED_EVENTS.some((event) => {
|
|
116
|
-
const groups = hooks[event];
|
|
117
|
-
return Array.isArray(groups) && groups.some((group) => hooksOf(group).some((hook) => isGuard(hook) || isLegacyGuard(hook)));
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
241
|
/** The harness's command on a line: its script, or its name, alone or at the end of a path. */
|
|
121
242
|
function isLauncher(word) {
|
|
122
243
|
const name = word.split(/[\\/]/).at(-1);
|
|
@@ -124,11 +245,24 @@ function isLauncher(word) {
|
|
|
124
245
|
}
|
|
125
246
|
/** Anything in a word that a shell reads before the program is handed it: a quote, a variable, an operator, a pattern. */
|
|
126
247
|
const SHELL_READS = /[^A-Za-z0-9_./:=@%+,-]/;
|
|
248
|
+
/**
|
|
249
|
+
* The handler types that are no command, by Claude Code's hooks reference
|
|
250
|
+
* (https://code.claude.com/docs/en/hooks, Hook handler fields, read
|
|
251
|
+
* 2026-10-12): an entry of one sends a request, calls a tool of an MCP
|
|
252
|
+
* server, or asks a model or an agent. It runs no command, the harness's or
|
|
253
|
+
* any other, whatever else the entry holds.
|
|
254
|
+
*/
|
|
255
|
+
const OTHER_HANDLERS = ['http', 'mcp_tool', 'prompt', 'agent'];
|
|
256
|
+
/**
|
|
257
|
+
* The line of an entry that runs the harness, or `null` for one that does
|
|
258
|
+
* not. Only an entry Claude Code runs as a command is one: the reference
|
|
259
|
+
* has `type` as a field every entry must have, with `"command"` for a
|
|
260
|
+
* command and four other words, and says nothing of an entry with none or
|
|
261
|
+
* with another word, which is kept, and said as one that cannot be told.
|
|
262
|
+
*/
|
|
127
263
|
function writtenHook(hook) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const { command, args } = hook;
|
|
131
|
-
if (typeof command !== 'string')
|
|
264
|
+
const { command, args, type } = hook;
|
|
265
|
+
if (typeof command !== 'string' || OTHER_HANDLERS.includes(type))
|
|
132
266
|
return null;
|
|
133
267
|
let line;
|
|
134
268
|
// Without arguments the line is a shell's, which splits it at its spaces:
|
|
@@ -146,7 +280,8 @@ function writtenHook(hook) {
|
|
|
146
280
|
if (at === -1)
|
|
147
281
|
return null;
|
|
148
282
|
const words = line.slice(at + 1);
|
|
149
|
-
|
|
283
|
+
const untyped = type === 'command' ? null : type === undefined ? 'no "type"' : `the "type" ${JSON.stringify(type)}`;
|
|
284
|
+
return { words, shell: shell && words.some((word) => SHELL_READS.test(word)), blocks: hook['onFailure'] === 'block', untyped };
|
|
150
285
|
}
|
|
151
286
|
/**
|
|
152
287
|
* Every line in Claude Code's settings that runs the harness, under the two
|
|
@@ -156,26 +291,49 @@ function writtenHook(hook) {
|
|
|
156
291
|
* alone.
|
|
157
292
|
*/
|
|
158
293
|
export function writtenHooks(settings) {
|
|
294
|
+
const found = [];
|
|
295
|
+
for (const { event, words, shell, blocks, untyped } of harnessEntries(settings)) {
|
|
296
|
+
const same = found.find((other) => sameLine(other, { words, shell, blocks, untyped }));
|
|
297
|
+
if (same === undefined)
|
|
298
|
+
found.push({ events: [event], words, shell, blocks, untyped });
|
|
299
|
+
else if (!same.events.includes(event))
|
|
300
|
+
same.events.push(event);
|
|
301
|
+
}
|
|
302
|
+
return found;
|
|
303
|
+
}
|
|
304
|
+
function sameLine(one, other) {
|
|
305
|
+
return one.shell === other.shell && one.blocks === other.blocks && one.untyped === other.untyped && sameStrings(one.words, other.words);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The writing tools whose call an entry is handed and waited on for, or
|
|
309
|
+
* `null` where that cannot be told. Read where it is asked for and no
|
|
310
|
+
* sooner: a matcher that is a pattern can cost its whole budget.
|
|
311
|
+
*/
|
|
312
|
+
function toolsOf(entry, pattern) {
|
|
313
|
+
return answered(entry.event, entry.matcher, entry.hook, pattern);
|
|
314
|
+
}
|
|
315
|
+
/** Every entry that runs the harness under the two events the guard answers, in the order the settings hold them. */
|
|
316
|
+
function harnessEntries(settings) {
|
|
159
317
|
const hooks = field(settings, 'hooks');
|
|
160
318
|
if (!isObject(hooks))
|
|
161
319
|
return [];
|
|
162
|
-
|
|
163
|
-
for (const event of GUARDED_EVENTS) {
|
|
320
|
+
return GUARDED_EVENTS.flatMap((event) => {
|
|
164
321
|
const groups = hooks[event];
|
|
165
322
|
if (!Array.isArray(groups))
|
|
166
|
-
|
|
167
|
-
|
|
323
|
+
return [];
|
|
324
|
+
return groups.flatMap((group) => hooksOf(group).flatMap((hook) => {
|
|
325
|
+
if (!isObject(hook))
|
|
326
|
+
return [];
|
|
168
327
|
const written = writtenHook(hook);
|
|
169
328
|
if (written === null)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return found;
|
|
329
|
+
return [];
|
|
330
|
+
return [{ ...written, event, matcher: field(group, 'matcher'), hook }];
|
|
331
|
+
}));
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/** Whether Claude Code starts a line for a write, by the tools it is waited on for: one whose tools cannot be told is taken as started. */
|
|
335
|
+
export function isStarted(tools) {
|
|
336
|
+
return tools === null || tools.length > 0;
|
|
179
337
|
}
|
|
180
338
|
/**
|
|
181
339
|
* The release of Claude Code that reads `onFailure` on a hook's entry, by its
|
|
@@ -191,42 +349,137 @@ function describeOnFailure(where, blocks, off) {
|
|
|
191
349
|
return `${holds}, which with this line is every write, the one that mends the line among them: mend it by hand`;
|
|
192
350
|
return `${holds}, where it lets the write go ahead without it: by its exit 1 for a question it cannot read, by not starting, or by running past its timeout`;
|
|
193
351
|
}
|
|
352
|
+
/** Names as a sentence lists them: one, two with "and", more with commas before it. Never asked of none. */
|
|
353
|
+
function named(names) {
|
|
354
|
+
const last = names.at(-1);
|
|
355
|
+
return names.length === 1 ? last : `${names.slice(0, -1).join(', ')} and ${last}`;
|
|
356
|
+
}
|
|
357
|
+
/** 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, pattern) {
|
|
359
|
+
const read = entries.filter((entry) => entry.event === event).map((entry) => toolsOf(entry, pattern));
|
|
360
|
+
const sure = CALLABLE_TOOLS.filter((tool) => read.some((tools) => tools !== null && tools.includes(tool)));
|
|
361
|
+
return sure.length < CALLABLE_TOOLS.length && read.some((tools) => tools === null) ? null : sure;
|
|
362
|
+
}
|
|
363
|
+
/** What a line's entries under `event` hold that decides when Claude Code starts them, as written. */
|
|
364
|
+
function entriesUnder(event, entries) {
|
|
365
|
+
const under = entries.filter((entry) => entry.event === event);
|
|
366
|
+
if (under.length === 0)
|
|
367
|
+
return `it stands under no ${event}`;
|
|
368
|
+
const said = under.map(({ matcher, hook }) => {
|
|
369
|
+
const pattern = matcher === undefined ? 'no matcher' : `the matcher ${JSON.stringify(matcher)}`;
|
|
370
|
+
const background = inBackground(hook) ? ' in the background' : '';
|
|
371
|
+
const rule = hook['if'] === undefined ? '' : ` with "if" ${JSON.stringify(hook['if'])}`;
|
|
372
|
+
return `${pattern}${background}${rule}`;
|
|
373
|
+
});
|
|
374
|
+
return `under ${event} it has ${named([...new Set(said)])}`;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* What is said of the writes Claude Code starts a line for, by the event and
|
|
378
|
+
* the matcher of each entry that holds it; `null` where it is waited on
|
|
379
|
+
* before every tool that writes and started after each, as init writes it.
|
|
380
|
+
* A tool is one a supported Claude Code can call: `MultiEdit`, which the
|
|
381
|
+
* hook still reads a path from, is named as unchecked for no line, since no
|
|
382
|
+
* such Claude Code has it to call (hooks.ts).
|
|
383
|
+
*/
|
|
384
|
+
function describeCover(where, before, after, entries) {
|
|
385
|
+
const parts = [];
|
|
386
|
+
const left = (tools) => CALLABLE_TOOLS.filter((tool) => !tools.includes(tool));
|
|
387
|
+
if (before === null)
|
|
388
|
+
parts.push(`which writes Claude Code waits on it before cannot be told here (${entriesUnder(BEFORE, entries)})`);
|
|
389
|
+
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)})`);
|
|
391
|
+
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)})`);
|
|
393
|
+
}
|
|
394
|
+
if (after === null)
|
|
395
|
+
parts.push(`which writes it is started after cannot be told here (${entriesUnder(AFTER, entries)})`);
|
|
396
|
+
else if (after.length === 0)
|
|
397
|
+
parts.push(`it is started after no write, so it warns of none (${entriesUnder(AFTER, entries)})`);
|
|
398
|
+
else if (left(after).length > 0)
|
|
399
|
+
parts.push(`it is started after a write by ${named(after)} alone (${entriesUnder(AFTER, entries)})`);
|
|
400
|
+
return parts.length === 0 ? null : `${where}: ${parts.join('; ')}`;
|
|
401
|
+
}
|
|
402
|
+
/** The words Claude Code hands the hook for a line: what follows the harness's command, each `${CLAUDE_PROJECT_DIR}` filled with `project`. */
|
|
403
|
+
function handedWords(words, project) {
|
|
404
|
+
return words.map((word) => word.split(PROJECT_DIR).join(project));
|
|
405
|
+
}
|
|
406
|
+
/** The words handed over for every line of the harness's in the settings files, for a caller that has to ask something of each before it reports them. */
|
|
407
|
+
export function handedLines(sources, project) {
|
|
408
|
+
return sources.flatMap(({ settings }) => writtenHooks(settings).map((hook) => handedWords(hook.words, project)));
|
|
409
|
+
}
|
|
194
410
|
/**
|
|
195
411
|
* The lines of Claude Code's hook in the settings files, each with what the
|
|
196
|
-
* hook does with it
|
|
197
|
-
* handed (usage.ts), `null` for a
|
|
198
|
-
* to it as Claude Code hands them
|
|
199
|
-
* filled with `project`.
|
|
412
|
+
* hook does with it and which writes Claude Code starts it for. `read` is
|
|
413
|
+
* the hook's own reading of the words it is handed (usage.ts), `null` for a
|
|
414
|
+
* line that is not its; the words are handed to it as Claude Code hands them
|
|
415
|
+
* to the hook, each `${CLAUDE_PROJECT_DIR}` filled with `project`.
|
|
200
416
|
*
|
|
201
417
|
* A line that guards and holds no more than a release wrote is reported
|
|
202
418
|
* with nothing to say, as it always was. One with more on it is said as it
|
|
203
419
|
* is, and fails nothing. One the hook cannot read, or whose root is no
|
|
204
|
-
* directory, is a guard that is off
|
|
420
|
+
* directory, is a guard that is off, and one that names a brief which is not
|
|
421
|
+
* one holds every write: each a problem, and doctor exits 1 for it. A line
|
|
422
|
+
* Claude Code waits on before no write is not the guard, whatever the hook
|
|
423
|
+
* would do with it.
|
|
424
|
+
*
|
|
425
|
+
* `pattern` reads a matcher that is a pattern, once for every entry under
|
|
426
|
+
* it in every file: the run's own reader, where something else in the run
|
|
427
|
+
* reads the same files ({@link patternReader}).
|
|
205
428
|
*/
|
|
206
|
-
export function reportHooks(sources, project, read) {
|
|
207
|
-
return sources.flatMap(({ file, settings }) =>
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
429
|
+
export function reportHooks(sources, project, read, pattern = patternReader()) {
|
|
430
|
+
return sources.flatMap(({ file, settings }) => {
|
|
431
|
+
const entries = harnessEntries(settings);
|
|
432
|
+
return writtenHooks(settings).flatMap((hook) => {
|
|
433
|
+
const handed = read(handedWords(hook.words, project));
|
|
434
|
+
if (handed === null)
|
|
435
|
+
return [];
|
|
436
|
+
const both = GUARDED_EVENTS.every((event) => hook.events.includes(event));
|
|
437
|
+
// An empty word is shown as a shell spells one, where joined it would not show at all.
|
|
438
|
+
// A line that is not under both events is under one.
|
|
439
|
+
const where = `"${hook.words.map((word) => (word === '' ? "''" : word)).join(' ')}" in ${file}${both ? '' : ` (under ${hook.events[0]} alone)`}`;
|
|
440
|
+
const mine = entries.filter((entry) => sameLine(entry, hook));
|
|
441
|
+
const before = toolsUnder(BEFORE, mine, pattern);
|
|
442
|
+
const after = toolsUnder(AFTER, mine, pattern);
|
|
443
|
+
const written = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before, after, cover: describeCover(where, before, after, mine) };
|
|
444
|
+
const note = describeOnFailure(where, hook.blocks, false);
|
|
445
|
+
if (hook.untyped !== null) {
|
|
446
|
+
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"`;
|
|
447
|
+
return [{ ...written, state: 'unknown', detail, note }];
|
|
448
|
+
}
|
|
449
|
+
if (hook.shell) {
|
|
450
|
+
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`;
|
|
451
|
+
return [{ ...written, state: 'unknown', detail, note }];
|
|
452
|
+
}
|
|
453
|
+
// What the hook would do with a line it is never asked on before a write is not what guards.
|
|
454
|
+
const unstarted = { ...written, state: 'unstarted', detail: null, note };
|
|
455
|
+
const waited = before === null || before.length > 0;
|
|
456
|
+
if (handed.state === 'off') {
|
|
457
|
+
const detail = `${where}: ${handed.fault.wrong}, so the guard is off and no write is checked; ${handed.fault.mend}`;
|
|
458
|
+
// A fault the hook says, it says with exit 1 after every write it is
|
|
459
|
+
// started for as well, so it is said wherever the line stands.
|
|
460
|
+
if (handed.silent !== true)
|
|
461
|
+
return [{ ...written, state: 'off', detail, note: describeOnFailure(where, hook.blocks, true) }];
|
|
462
|
+
// A root outside any work tree, of which the hook says nothing: no
|
|
463
|
+
// run of it fails there, so onFailure holds no write for it, and
|
|
464
|
+
// where the line is waited on before no write there is none it
|
|
465
|
+
// leaves unchecked.
|
|
466
|
+
return [waited ? { ...written, state: 'off', detail, note } : unstarted];
|
|
467
|
+
}
|
|
468
|
+
if (!waited)
|
|
469
|
+
return [unstarted];
|
|
470
|
+
if (handed.state === 'held') {
|
|
471
|
+
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`;
|
|
472
|
+
return [{ ...written, state: 'held', detail, note }];
|
|
473
|
+
}
|
|
474
|
+
if (before === null)
|
|
475
|
+
return [{ ...written, state: 'unknown', detail: null, note }];
|
|
476
|
+
// What a release wrote: its hook, and nothing after it.
|
|
477
|
+
if (sameStrings(hook.words, ['hook', 'claude']))
|
|
478
|
+
return [{ ...written, state: 'guards', detail: null, note }];
|
|
479
|
+
const root = handed.root === undefined ? '' : `, and its root, ${handed.root}, is a directory`;
|
|
480
|
+
return [{ ...written, state: 'guards', detail: `${where} is more than init writes, and guards: the hook reads the line${root}`, note }];
|
|
481
|
+
});
|
|
482
|
+
});
|
|
230
483
|
}
|
|
231
484
|
/** Whether a project's `.mcp.json` registers a server under the harness's name, however it runs it. */
|
|
232
485
|
export function registersServer(mcp) {
|
|
@@ -256,6 +509,119 @@ export function enabledPlugin(sources) {
|
|
|
256
509
|
}
|
|
257
510
|
return [...decided.values()].find((entry) => entry !== null) ?? null;
|
|
258
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* The settings file whose `disableAllHooks` turns Claude Code's hooks off,
|
|
514
|
+
* or `null` where none does.
|
|
515
|
+
*
|
|
516
|
+
* By its settings reference
|
|
517
|
+
* (https://code.claude.com/docs/en/settings-reference, disableAllHooks, read
|
|
518
|
+
* 2026-10-12) the key is a Boolean any settings file may hold, and `true`
|
|
519
|
+
* turns hooks off. Set outside managed settings, it turns off the hooks of
|
|
520
|
+
* the user's, the project's and the local settings and of plugins, and the
|
|
521
|
+
* hooks of managed settings, of the Agent SDK and of a plugin that managed
|
|
522
|
+
* settings force on keep running. By its hooks reference (Disable or remove
|
|
523
|
+
* hooks) the value that counts is the one left after settings precedence:
|
|
524
|
+
* of the files given, lowest precedence first, the last that sets a Boolean
|
|
525
|
+
* decides. A value that is no Boolean is passed over, as the key left out.
|
|
526
|
+
*/
|
|
527
|
+
export function hooksDisabled(sources) {
|
|
528
|
+
let file = null;
|
|
529
|
+
for (const source of sources) {
|
|
530
|
+
const value = field(source.settings, 'disableAllHooks');
|
|
531
|
+
if (typeof value === 'boolean')
|
|
532
|
+
file = value ? source.file : null;
|
|
533
|
+
}
|
|
534
|
+
return file;
|
|
535
|
+
}
|
|
536
|
+
/** The project's settings file for Claude Code, as it is named among the sources. */
|
|
537
|
+
export const PROJECT_SETTINGS = '.claude/settings.json';
|
|
538
|
+
/** Where Claude Code records the marketplaces it has fetched, in a directory it keeps plugins in. */
|
|
539
|
+
export const MARKETPLACE_REGISTRY = 'known_marketplaces.json';
|
|
540
|
+
/** Where it records the marketplaces hosted on claude.ai, in a shape its reference does not give. */
|
|
541
|
+
export const HOSTED_REGISTRY = 'known_marketplaces_claudeai.json';
|
|
542
|
+
/**
|
|
543
|
+
* The origins in a plugin's id that are no marketplace, by Claude Code's
|
|
544
|
+
* plugin loading reference (https://code.claude.com/docs/en/plugins/loading,
|
|
545
|
+
* Find where a plugin came from, read 2026-10-12): a plugin started from a
|
|
546
|
+
* directory or an archive, one saved under a skills directory, and one
|
|
547
|
+
* synced from a claude.ai account. None has a marketplace, and the first
|
|
548
|
+
* and the last no record on disk that the reference describes.
|
|
549
|
+
*/
|
|
550
|
+
const OTHER_ORIGINS = ['inline', 'skills-dir', 'synced'];
|
|
551
|
+
/** A name a directory can be looked up by: no separator, and no way out of the directory it is looked up in. */
|
|
552
|
+
const DIRECTORY_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
553
|
+
/**
|
|
554
|
+
* The marketplace a plugin's id names, where it names one whose directory
|
|
555
|
+
* can be looked up: `null` for an origin that is no marketplace, and for
|
|
556
|
+
* text that names no directory.
|
|
557
|
+
*/
|
|
558
|
+
export function pluginMarketplace(id) {
|
|
559
|
+
const origin = id.slice(id.indexOf('@') + 1);
|
|
560
|
+
return OTHER_ORIGINS.includes(origin) || !DIRECTORY_NAME.test(origin) ? null : origin;
|
|
561
|
+
}
|
|
562
|
+
function turnsOn(settings, id) {
|
|
563
|
+
const plugins = field(settings, 'enabledPlugins');
|
|
564
|
+
return isObject(plugins) && plugins[id] === true;
|
|
565
|
+
}
|
|
566
|
+
function holds(record, name) {
|
|
567
|
+
return isObject(record) && Object.hasOwn(record, name);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Whether the plugin that settings turn on is on this machine, by Claude
|
|
571
|
+
* Code's plugin loading reference
|
|
572
|
+
* (https://code.claude.com/docs/en/plugins/loading, read 2026-10-12), from
|
|
573
|
+
* the settings files and from what the directories it keeps plugins in
|
|
574
|
+
* hold: its plugins root, and each seed directory.
|
|
575
|
+
*
|
|
576
|
+
* `present`: a version of it is in a cache, where the reference has one
|
|
577
|
+
* directory for each installed version of a marketplace plugin (Find
|
|
578
|
+
* plugins on disk).
|
|
579
|
+
*
|
|
580
|
+
* `absent` is said only where the reference leaves the plugin no way to
|
|
581
|
+
* load (Enabled in project settings but not installed): its only `true` is
|
|
582
|
+
* in the project's `.claude/settings.json`, for which Claude Code fetches
|
|
583
|
+
* no plugin a machine lacks, and neither of the page's two exceptions is
|
|
584
|
+
* met, a marketplace that lists it by a relative path, since no marketplace
|
|
585
|
+
* of that name is recorded as fetched or declared in settings, and a seed
|
|
586
|
+
* directory that holds it.
|
|
587
|
+
*
|
|
588
|
+
* Everything else is `unknown`: an origin that is no marketplace, a record
|
|
589
|
+
* that is not there or cannot be read, a marketplace that is there or is
|
|
590
|
+
* declared, whose own list is not read here, and a `true` in a settings
|
|
591
|
+
* file of the person's own, for which Claude Code fetches.
|
|
592
|
+
*/
|
|
593
|
+
export function pluginPresence(plugin, sources, roots) {
|
|
594
|
+
const untold = (detail) => ({ state: 'unknown', detail });
|
|
595
|
+
const marketplace = pluginMarketplace(plugin.id);
|
|
596
|
+
if (marketplace === null)
|
|
597
|
+
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`);
|
|
598
|
+
if (roots.length === 0)
|
|
599
|
+
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');
|
|
600
|
+
if (roots.some((root) => root.cached))
|
|
601
|
+
return { state: 'present', detail: null };
|
|
602
|
+
const unread = roots.find((root) => !isObject(root.registry));
|
|
603
|
+
if (unread !== undefined) {
|
|
604
|
+
const wrong = unread.registry === null ? 'is not there' : 'cannot be read as JSON';
|
|
605
|
+
return untold(`${join(unread.directory, MARKETPLACE_REGISTRY)}, where Claude Code records the marketplaces it has fetched, ${wrong}`);
|
|
606
|
+
}
|
|
607
|
+
const fetched = roots.find((root) => holds(root.registry, marketplace));
|
|
608
|
+
if (fetched !== undefined) {
|
|
609
|
+
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`);
|
|
610
|
+
}
|
|
611
|
+
const declaring = sources.find(({ settings }) => holds(field(settings, 'extraKnownMarketplaces'), marketplace));
|
|
612
|
+
if (declaring !== undefined)
|
|
613
|
+
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`);
|
|
614
|
+
const hosted = roots.find((root) => root.hosted);
|
|
615
|
+
if (hosted !== undefined)
|
|
616
|
+
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`);
|
|
617
|
+
const own = sources.filter(({ file, settings }) => file !== PROJECT_SETTINGS && turnsOn(settings, plugin.id)).map(({ file }) => file);
|
|
618
|
+
if (own.length > 0)
|
|
619
|
+
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`);
|
|
620
|
+
return {
|
|
621
|
+
state: 'absent',
|
|
622
|
+
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(', ')})`,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
259
625
|
/** Where the plugin is on, as a person would look it up. */
|
|
260
626
|
function isOn(plugin) {
|
|
261
627
|
return `the spec-harness plugin is on (${plugin.id} in ${plugin.file})`;
|
|
@@ -265,37 +631,116 @@ function turnOff(plugin) {
|
|
|
265
631
|
return `turn the plugin off for this project with claude plugin disable ${plugin.id} --scope local`;
|
|
266
632
|
}
|
|
267
633
|
const PARTS = literal({
|
|
268
|
-
hooks: { brings: 'the guard hooks', twice: 'every write is guarded twice', skipped: 'init writes none', entry: "spec-harness's hooks", them: 'them' },
|
|
269
|
-
server: { brings: 'the server', twice: 'the server is registered twice', skipped: 'init registers none', entry: 'the spec-harness server', them: 'it' },
|
|
634
|
+
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' },
|
|
635
|
+
server: { brings: 'the server', twice: 'the server is registered twice', skipped: 'init registers none', entry: 'the spec-harness server', them: 'it', none: 'no server' },
|
|
270
636
|
});
|
|
637
|
+
/** How to have a plugin that is not on this machine brought to it, in the commands Claude Code's plugin pages give. */
|
|
638
|
+
function bring(plugin) {
|
|
639
|
+
return `install it here, with claude plugin install ${plugin.id} once its marketplace is added`;
|
|
640
|
+
}
|
|
641
|
+
/** That the plugin settings turn on is not on this machine, and why. */
|
|
642
|
+
function isAbsent(plugin, detail, brought) {
|
|
643
|
+
return `${isOn(plugin)} and is not on this machine, so it brings ${brought} here: ${detail}`;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* What init says of a Claude Code file where the plugin that settings turn
|
|
647
|
+
* on is not on this machine. It writes nothing on that reading alone: a
|
|
648
|
+
* plugin that is there after all would bring the same `part` a second time.
|
|
649
|
+
* A file that holds init's entry already is `present`.
|
|
650
|
+
*/
|
|
651
|
+
export function describeAbsent(plugin, detail, part, present) {
|
|
652
|
+
const words = PARTS[part];
|
|
653
|
+
const absent = isAbsent(plugin, detail, words.none);
|
|
654
|
+
if (present)
|
|
655
|
+
return `${absent}. This file holds ${words.entry}; with the plugin installed as well, ${words.twice}: ${turnOff(plugin)}`;
|
|
656
|
+
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`;
|
|
657
|
+
}
|
|
271
658
|
/**
|
|
272
659
|
* What init says of a Claude Code file it leaves alone because the plugin is
|
|
273
660
|
* on and brings the same `part`. A file that holds init's entry already is a
|
|
274
661
|
* double install for the person to settle: init removes nothing, since the
|
|
275
|
-
* file is the repository's and a clone without the plugin relies on it.
|
|
662
|
+
* file is the repository's and a clone without the plugin relies on it. So
|
|
663
|
+
* is an entry `elsewhere`, in a settings file of the person's own, which
|
|
664
|
+
* init does not write and doctor fails all the same.
|
|
276
665
|
*/
|
|
277
|
-
export function describeSkipped(plugin, part, present) {
|
|
666
|
+
export function describeSkipped(plugin, part, present, elsewhere = []) {
|
|
278
667
|
const words = PARTS[part];
|
|
279
668
|
if (present)
|
|
280
669
|
return `${isOn(plugin)} and brings ${words.brings} this file holds as well, so ${words.twice}: take ${words.entry} out of this file, or ${turnOff(plugin)}`;
|
|
670
|
+
if (elsewhere.length > 0) {
|
|
671
|
+
return `${isOn(plugin)} and brings ${words.brings} ${named(elsewhere)} ${elsewhere.length === 1 ? 'holds' : 'hold'} as well, so ${words.twice}: take ${words.entry} out there, or ${turnOff(plugin)}. init writes none here`;
|
|
672
|
+
}
|
|
281
673
|
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`;
|
|
282
674
|
}
|
|
283
|
-
/**
|
|
675
|
+
/** Why the plugin settings turn on is not on this machine, or `null` where it is, may be, or none is on. */
|
|
676
|
+
function absence(wiring) {
|
|
677
|
+
return wiring.presence?.state === 'absent' ? wiring.presence.detail : null;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Which of the four the wiring is. `both` is a double install, and doctor
|
|
681
|
+
* fails it; either of init's entries counts. A plugin that is not on this
|
|
682
|
+
* machine brings nothing to it, and is not counted.
|
|
683
|
+
*/
|
|
284
684
|
export function wiringState(wiring) {
|
|
285
685
|
const init = wiring.hooks.length > 0 || wiring.server;
|
|
286
|
-
if (wiring.plugin === null)
|
|
686
|
+
if (wiring.plugin === null || absence(wiring) !== null)
|
|
287
687
|
return init ? 'init' : 'none';
|
|
288
688
|
return init ? 'both' : 'plugin';
|
|
289
689
|
}
|
|
690
|
+
/**
|
|
691
|
+
* Whether hooks that would guard are switched off: `disableAllHooks` in a
|
|
692
|
+
* settings file, where a settings file holds the guard hooks or a plugin
|
|
693
|
+
* that may be on this machine brings them. A guard someone set up, which
|
|
694
|
+
* checks no write.
|
|
695
|
+
*/
|
|
696
|
+
export function guardSwitchedOff(wiring) {
|
|
697
|
+
return wiring.hooksOff !== null && (wiring.hooks.length > 0 || (wiring.plugin !== null && absence(wiring) === null));
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* What doctor and init say of `disableAllHooks` in `file`. `guarded` is
|
|
701
|
+
* whether there are guard hooks for it to switch off; without any it is a
|
|
702
|
+
* note on what the key would do to them.
|
|
703
|
+
*/
|
|
704
|
+
export function describeHooksOff(file, guarded) {
|
|
705
|
+
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`;
|
|
706
|
+
if (!guarded)
|
|
707
|
+
return `note: ${off}: the hooks init writes, and those the plugin brings, would not run while it stands`;
|
|
708
|
+
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`;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* What doctor adds of the plugin settings turn on, where it is not simply on
|
|
712
|
+
* this machine: that it cannot be told, which is never fine, or that it is
|
|
713
|
+
* not, beside entries of init's. `null` where there is no more to say: no
|
|
714
|
+
* plugin, one that is there, and one that is not where nothing else is
|
|
715
|
+
* wired, which the wiring's own sentence says.
|
|
716
|
+
*/
|
|
717
|
+
export function describePresence(wiring) {
|
|
718
|
+
const { plugin, presence } = wiring;
|
|
719
|
+
if (plugin === null || presence === undefined || presence === null || presence.state === 'present')
|
|
720
|
+
return null;
|
|
721
|
+
if (presence.state === 'unknown')
|
|
722
|
+
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
|
+
if (wiring.hooks.length > 0)
|
|
724
|
+
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;
|
|
726
|
+
}
|
|
727
|
+
/** 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 run spec-harness init --write`;
|
|
730
|
+
}
|
|
290
731
|
/** What `doctor` says of the wiring, and what to do when it is doubled or missing. */
|
|
291
732
|
export function describeClaudeCode(wiring) {
|
|
292
733
|
const { plugin } = wiring;
|
|
293
734
|
const entries = [...wiring.hooks.map((file) => `the guard hooks in ${file}`), ...(wiring.server ? ['the server in .mcp.json'] : [])].join(', ');
|
|
735
|
+
const own = `init's entries: ${entries}`;
|
|
294
736
|
if (plugin === null) {
|
|
295
737
|
if (entries === '')
|
|
296
738
|
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';
|
|
297
|
-
return
|
|
739
|
+
return own;
|
|
298
740
|
}
|
|
741
|
+
const absent = absence(wiring);
|
|
742
|
+
if (absent !== null)
|
|
743
|
+
return entries === '' ? unguarded(plugin, absent) : own;
|
|
299
744
|
if (entries === '')
|
|
300
745
|
return `${isOn(plugin)} and brings the guard hooks and the server`;
|
|
301
746
|
const twice = [...(wiring.hooks.length > 0 ? [PARTS.hooks.twice] : []), ...(wiring.server ? [PARTS.server.twice] : [])].join(' and ');
|