@descent-vtt/spec-harness 0.16.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/dist/configure.js CHANGED
@@ -7,9 +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
11
  import { runInNewContext } from 'node:vm';
12
- import { WRITING_TOOLS } from './hooks.js';
12
+ import { CALLABLE_TOOLS } from './hooks.js';
13
13
  import { literal } from './literal.js';
14
14
  import { parseGlobList } from './vendor/spec-core/pattern/index.js';
15
15
  import { CLAUDE_CODE_MINIMUM } from './versions.js';
@@ -37,6 +37,14 @@ export const PROJECT_DIR = '${CLAUDE_PROJECT_DIR}';
37
37
  * default; `.` is right, since a project's server starts in the project.
38
38
  */
39
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
+ */
40
48
  const HOOK_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
41
49
  /** The event Claude Code runs before a tool call, the only one of the two where a hook can refuse the call. */
42
50
  const BEFORE = 'PreToolUse';
@@ -70,8 +78,9 @@ function upgraded(group) {
70
78
  return { ...group, hooks: hooks.map((hook) => (isLegacyGuard(hook) ? { ...hook, command: 'node', args: [...GUARD_ARGS] } : hook)) };
71
79
  }
72
80
  /**
73
- * The tools, of those that write a file, that a group's matcher has Claude
74
- * Code start the group's hooks for, by its hooks reference
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
75
84
  * (https://code.claude.com/docs/en/hooks, Matcher patterns, read
76
85
  * 2026-10-12): left out, empty or `*`, every tool; made of letters, digits,
77
86
  * `_`, `-`, spaces, `,` and `|` alone, the tools it names, one between each
@@ -80,16 +89,16 @@ function upgraded(group) {
80
89
  * reference does not say, or it cannot be told: a matcher that is not text,
81
90
  * is no pattern, or is a pattern whose reading runs out its budget.
82
91
  */
83
- function matchedTools(matcher) {
92
+ function matchedTools(matcher, pattern) {
84
93
  if (matcher === undefined || matcher === '' || matcher === '*')
85
- return WRITING_TOOLS;
94
+ return CALLABLE_TOOLS;
86
95
  if (typeof matcher !== 'string')
87
96
  return null;
88
97
  if (/^[A-Za-z0-9_\- ,|]*$/.test(matcher)) {
89
98
  const names = matcher.split(/[|,]/).map((name) => name.trim());
90
- return WRITING_TOOLS.filter((tool) => names.includes(tool));
99
+ return CALLABLE_TOOLS.filter((tool) => names.includes(tool));
91
100
  }
92
- return patternTools(matcher);
101
+ return pattern(matcher);
93
102
  }
94
103
  /**
95
104
  * How long the reading of one pattern may take, in milliseconds of the
@@ -104,7 +113,7 @@ const PATTERN_BUDGET = 100;
104
113
  * is no pattern, or its reading runs out its budget.
105
114
  *
106
115
  * The pattern is a line of a settings file, which arrives with a clone, and
107
- * one can be written that a test of four short names never finishes:
116
+ * one can be written that a test of a few short names never finishes:
108
117
  * `^((((([A-Za-z]*)*)*)*)*)*$X` ran for more than ten seconds. Tested as
109
118
  * written, one such entry and neither doctor nor init would come back, with
110
119
  * no word. So it is made and tested in a context of its own, whose run
@@ -115,13 +124,39 @@ const PATTERN_BUDGET = 100;
115
124
  */
116
125
  function patternTools(source) {
117
126
  try {
118
- const matched = runInNewContext('tools.filter((tool) => new RegExp(source).test(tool))', { tools: WRITING_TOOLS, source }, { timeout: PATTERN_BUDGET });
127
+ const matched = runInNewContext('tools.filter((tool) => new RegExp(source).test(tool))', { tools: CALLABLE_TOOLS, source }, { timeout: PATTERN_BUDGET });
119
128
  return matched;
120
129
  }
121
130
  catch {
122
131
  return null;
123
132
  }
124
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
+ }
125
160
  /** Whether Claude Code runs a hook's entry in the background, where it waits on no answer of it. */
126
161
  function inBackground(hook) {
127
162
  return hook['async'] === true || hook['asyncRewake'] === true;
@@ -134,12 +169,12 @@ function inBackground(hook) {
134
169
  * entry started only for the calls its rule matches, in the syntax of a
135
170
  * permission rule, which is not read here.
136
171
  */
137
- function answered(event, matcher, hook) {
172
+ function answered(event, matcher, hook, pattern) {
138
173
  if (event === BEFORE && inBackground(hook))
139
174
  return [];
140
175
  if (hook['if'] !== undefined)
141
176
  return null;
142
- return matchedTools(matcher);
177
+ return matchedTools(matcher, pattern);
143
178
  }
144
179
  /**
145
180
  * `.claude/settings.json`, or the plugin's hooks, with the guard hooks merged
@@ -153,10 +188,13 @@ function answered(event, matcher, hook) {
153
188
  * init rewrites none but the one 0.1 wrote. Without `isHook`, only init's
154
189
  * own line is known: the default's `false` and an `undefined` read the same
155
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}).
156
194
  */
157
- export function mergeClaudeSettings(current, isHook = () => false) {
195
+ export function mergeClaudeSettings(current, isHook = () => false, pattern = patternReader()) {
158
196
  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)));
197
+ const theirs = harnessEntries(current).some((entry) => !isGuard(entry.hook) && !isLegacyGuard(entry.hook) && isHook(entry.words) && isStarted(toolsOf(entry, pattern)));
160
198
  if (theirs)
161
199
  return null;
162
200
  let changed = false;
@@ -166,7 +204,7 @@ export function mergeClaudeSettings(current, isHook = () => false) {
166
204
  const before = Array.isArray(listed) ? listed : [];
167
205
  const groups = before.map(upgraded);
168
206
  const replaced = groups.some((group, index) => group !== before[index]);
169
- const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry)));
207
+ const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry, pattern)));
170
208
  if (!present)
171
209
  groups.push({ matcher: HOOK_MATCHER, hooks: [{ ...GUARD_HOOK, args: [...GUARD_ARGS] }] });
172
210
  if (replaced || !present) {
@@ -207,9 +245,24 @@ function isLauncher(word) {
207
245
  }
208
246
  /** Anything in a word that a shell reads before the program is handed it: a quote, a variable, an operator, a pattern. */
209
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
+ */
210
263
  function writtenHook(hook) {
211
- const { command, args } = hook;
212
- if (typeof command !== 'string')
264
+ const { command, args, type } = hook;
265
+ if (typeof command !== 'string' || OTHER_HANDLERS.includes(type))
213
266
  return null;
214
267
  let line;
215
268
  // Without arguments the line is a shell's, which splits it at its spaces:
@@ -227,7 +280,8 @@ function writtenHook(hook) {
227
280
  if (at === -1)
228
281
  return null;
229
282
  const words = line.slice(at + 1);
230
- return { words, shell: shell && words.some((word) => SHELL_READS.test(word)), blocks: hook['onFailure'] === 'block' };
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 };
231
285
  }
232
286
  /**
233
287
  * Every line in Claude Code's settings that runs the harness, under the two
@@ -238,25 +292,25 @@ function writtenHook(hook) {
238
292
  */
239
293
  export function writtenHooks(settings) {
240
294
  const found = [];
241
- for (const { event, words, shell, blocks } of harnessEntries(settings)) {
242
- const same = found.find((other) => sameLine(other, { words, shell, blocks }));
295
+ for (const { event, words, shell, blocks, untyped } of harnessEntries(settings)) {
296
+ const same = found.find((other) => sameLine(other, { words, shell, blocks, untyped }));
243
297
  if (same === undefined)
244
- found.push({ events: [event], words, shell, blocks });
298
+ found.push({ events: [event], words, shell, blocks, untyped });
245
299
  else if (!same.events.includes(event))
246
300
  same.events.push(event);
247
301
  }
248
302
  return found;
249
303
  }
250
304
  function sameLine(one, other) {
251
- return one.shell === other.shell && one.blocks === other.blocks && sameStrings(one.words, other.words);
305
+ return one.shell === other.shell && one.blocks === other.blocks && one.untyped === other.untyped && sameStrings(one.words, other.words);
252
306
  }
253
307
  /**
254
308
  * The writing tools whose call an entry is handed and waited on for, or
255
309
  * `null` where that cannot be told. Read where it is asked for and no
256
310
  * sooner: a matcher that is a pattern can cost its whole budget.
257
311
  */
258
- function toolsOf(entry) {
259
- return answered(entry.event, entry.matcher, entry.hook);
312
+ function toolsOf(entry, pattern) {
313
+ return answered(entry.event, entry.matcher, entry.hook, pattern);
260
314
  }
261
315
  /** Every entry that runs the harness under the two events the guard answers, in the order the settings hold them. */
262
316
  function harnessEntries(settings) {
@@ -301,10 +355,10 @@ function named(names) {
301
355
  return names.length === 1 ? last : `${names.slice(0, -1).join(', ')} and ${last}`;
302
356
  }
303
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. */
304
- function toolsUnder(event, entries) {
305
- const read = entries.filter((entry) => entry.event === event).map(toolsOf);
306
- const sure = WRITING_TOOLS.filter((tool) => read.some((tools) => tools !== null && tools.includes(tool)));
307
- return sure.length < WRITING_TOOLS.length && read.some((tools) => tools === null) ? null : sure;
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;
308
362
  }
309
363
  /** What a line's entries under `event` hold that decides when Claude Code starts them, as written. */
310
364
  function entriesUnder(event, entries) {
@@ -323,10 +377,13 @@ function entriesUnder(event, entries) {
323
377
  * What is said of the writes Claude Code starts a line for, by the event and
324
378
  * the matcher of each entry that holds it; `null` where it is waited on
325
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).
326
383
  */
327
384
  function describeCover(where, before, after, entries) {
328
385
  const parts = [];
329
- const left = (tools) => WRITING_TOOLS.filter((tool) => !tools.includes(tool));
386
+ const left = (tools) => CALLABLE_TOOLS.filter((tool) => !tools.includes(tool));
330
387
  if (before === null)
331
388
  parts.push(`which writes Claude Code waits on it before cannot be told here (${entriesUnder(BEFORE, entries)})`);
332
389
  else if (before.length === 0)
@@ -364,8 +421,12 @@ export function handedLines(sources, project) {
364
421
  * one holds every write: each a problem, and doctor exits 1 for it. A line
365
422
  * Claude Code waits on before no write is not the guard, whatever the hook
366
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}).
367
428
  */
368
- export function reportHooks(sources, project, read) {
429
+ export function reportHooks(sources, project, read, pattern = patternReader()) {
369
430
  return sources.flatMap(({ file, settings }) => {
370
431
  const entries = harnessEntries(settings);
371
432
  return writtenHooks(settings).flatMap((hook) => {
@@ -377,21 +438,35 @@ export function reportHooks(sources, project, read) {
377
438
  // A line that is not under both events is under one.
378
439
  const where = `"${hook.words.map((word) => (word === '' ? "''" : word)).join(' ')}" in ${file}${both ? '' : ` (under ${hook.events[0]} alone)`}`;
379
440
  const mine = entries.filter((entry) => sameLine(entry, hook));
380
- const before = toolsUnder(BEFORE, mine);
381
- const after = toolsUnder(AFTER, mine);
441
+ const before = toolsUnder(BEFORE, mine, pattern);
442
+ const after = toolsUnder(AFTER, mine, pattern);
382
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
+ }
383
449
  if (hook.shell) {
384
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`;
385
- return [{ ...written, state: 'unknown', detail, note: describeOnFailure(where, hook.blocks, false) }];
451
+ return [{ ...written, state: 'unknown', detail, note }];
386
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;
387
456
  if (handed.state === 'off') {
388
457
  const detail = `${where}: ${handed.fault.wrong}, so the guard is off and no write is checked; ${handed.fault.mend}`;
389
- return [{ ...written, state: 'off', detail, note: describeOnFailure(where, hook.blocks, true) }];
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];
390
467
  }
391
- const note = describeOnFailure(where, hook.blocks, false);
392
- // What the hook would do with a line it is never asked on before a write is not what guards.
393
- if (before !== null && before.length === 0)
394
- return [{ ...written, state: 'unstarted', detail: null, note }];
468
+ if (!waited)
469
+ return [unstarted];
395
470
  if (handed.state === 'held') {
396
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`;
397
472
  return [{ ...written, state: 'held', detail, note }];
@@ -434,6 +509,119 @@ export function enabledPlugin(sources) {
434
509
  }
435
510
  return [...decided.values()].find((entry) => entry !== null) ?? null;
436
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
+ }
437
625
  /** Where the plugin is on, as a person would look it up. */
438
626
  function isOn(plugin) {
439
627
  return `the spec-harness plugin is on (${plugin.id} in ${plugin.file})`;
@@ -443,9 +631,30 @@ function turnOff(plugin) {
443
631
  return `turn the plugin off for this project with claude plugin disable ${plugin.id} --scope local`;
444
632
  }
445
633
  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' },
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' },
448
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
+ }
449
658
  /**
450
659
  * What init says of a Claude Code file it leaves alone because the plugin is
451
660
  * on and brings the same `part`. A file that holds init's entry already is a
@@ -463,22 +672,75 @@ export function describeSkipped(plugin, part, present, elsewhere = []) {
463
672
  }
464
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`;
465
674
  }
466
- /** Which of the four the wiring is. `both` is a double install, and doctor fails it; either of init's entries counts. */
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
+ */
467
684
  export function wiringState(wiring) {
468
685
  const init = wiring.hooks.length > 0 || wiring.server;
469
- if (wiring.plugin === null)
686
+ if (wiring.plugin === null || absence(wiring) !== null)
470
687
  return init ? 'init' : 'none';
471
688
  return init ? 'both' : 'plugin';
472
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
+ }
473
731
  /** What `doctor` says of the wiring, and what to do when it is doubled or missing. */
474
732
  export function describeClaudeCode(wiring) {
475
733
  const { plugin } = wiring;
476
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}`;
477
736
  if (plugin === null) {
478
737
  if (entries === '')
479
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';
480
- return `init's entries: ${entries}`;
739
+ return own;
481
740
  }
741
+ const absent = absence(wiring);
742
+ if (absent !== null)
743
+ return entries === '' ? unguarded(plugin, absent) : own;
482
744
  if (entries === '')
483
745
  return `${isOn(plugin)} and brings the guard hooks and the server`;
484
746
  const twice = [...(wiring.hooks.length > 0 ? [PARTS.hooks.twice] : []), ...(wiring.server ? [PARTS.server.twice] : [])].join(' and ');