@descent-vtt/spec-harness 0.14.0 → 0.16.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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +106 -0
  4. package/README.md +15 -2
  5. package/dist/answers.js +2 -1
  6. package/dist/answers.js.map +1 -1
  7. package/dist/branch.js +12 -0
  8. package/dist/branch.js.map +1 -1
  9. package/dist/cli.d.ts +1 -1
  10. package/dist/cli.js +36 -54
  11. package/dist/cli.js.map +1 -1
  12. package/dist/configure.d.ts +97 -9
  13. package/dist/configure.js +310 -14
  14. package/dist/configure.js.map +1 -1
  15. package/dist/context.js +2 -1
  16. package/dist/context.js.map +1 -1
  17. package/dist/fs.js +1 -0
  18. package/dist/fs.js.map +1 -1
  19. package/dist/git.js +2 -1
  20. package/dist/git.js.map +1 -1
  21. package/dist/hooks.js.map +1 -1
  22. package/dist/host.js.map +1 -1
  23. package/dist/literal.d.ts +18 -0
  24. package/dist/literal.js +21 -0
  25. package/dist/literal.js.map +1 -0
  26. package/dist/probe.js +1 -1
  27. package/dist/probe.js.map +1 -1
  28. package/dist/reader.js +7 -1
  29. package/dist/reader.js.map +1 -1
  30. package/dist/round.d.ts +28 -3
  31. package/dist/round.js +90 -9
  32. package/dist/round.js.map +1 -1
  33. package/dist/rulings.js +6 -0
  34. package/dist/rulings.js.map +1 -1
  35. package/dist/sandbox.js.map +1 -1
  36. package/dist/server.js +1 -1
  37. package/dist/server.js.map +1 -1
  38. package/dist/setup.d.ts +1 -1
  39. package/dist/setup.js +36 -14
  40. package/dist/setup.js.map +1 -1
  41. package/dist/siblings.js +2 -1
  42. package/dist/siblings.js.map +1 -1
  43. package/dist/signers.js +10 -1
  44. package/dist/signers.js.map +1 -1
  45. package/dist/usage.d.ts +98 -27
  46. package/dist/usage.js +67 -7
  47. package/dist/usage.js.map +1 -1
  48. package/dist/vendor/spec-core/jsonrpc/mcp.js.map +1 -1
  49. package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -1
  50. package/dist/vendor/spec-core/markdown/headings.js.map +1 -1
  51. package/dist/vendor/spec-core/markdown/links.js.map +1 -1
  52. package/dist/vendor/spec-core/markdown/scan.js.map +1 -1
  53. package/dist/vendor/spec-core/pattern/automaton.js.map +1 -1
  54. package/dist/vendor/spec-core/pattern/charset.js.map +1 -1
  55. package/dist/vendor/spec-core/pattern/regex.js.map +1 -1
  56. package/dist/vendor/spec-core/text/lines.js.map +1 -1
  57. package/dist/vendor/spec-core/text/width.js.map +1 -1
  58. package/dist/versions.js +3 -0
  59. package/dist/versions.js.map +1 -1
  60. package/dist/workspace.d.ts +3 -3
  61. package/dist/workspace.js +3 -1
  62. package/dist/workspace.js.map +1 -1
  63. package/package.json +3 -3
package/dist/configure.js CHANGED
@@ -8,6 +8,9 @@
8
8
  * nothing to add.
9
9
  */
10
10
  import { isAbsolute } from 'node:path';
11
+ import { runInNewContext } from 'node:vm';
12
+ import { WRITING_TOOLS } from './hooks.js';
13
+ import { literal } from './literal.js';
11
14
  import { parseGlobList } from './vendor/spec-core/pattern/index.js';
12
15
  import { CLAUDE_CODE_MINIMUM } from './versions.js';
13
16
  function isObject(value) {
@@ -35,7 +38,11 @@ export const PROJECT_DIR = '${CLAUDE_PROJECT_DIR}';
35
38
  */
36
39
  export const PROJECT_DIR_OR_HERE = '${CLAUDE_PROJECT_DIR:-.}';
37
40
  const HOOK_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
38
- const GUARDED_EVENTS = ['PreToolUse', 'PostToolUse'];
41
+ /** The event Claude Code runs before a tool call, the only one of the two where a hook can refuse the call. */
42
+ const BEFORE = 'PreToolUse';
43
+ /** The event after a call that succeeded, where a hook can only tell the model what it wrote. */
44
+ const AFTER = 'PostToolUse';
45
+ const GUARDED_EVENTS = [BEFORE, AFTER];
39
46
  const GUARD_ARGS = Object.freeze([`${PROJECT_DIR}/${BIN}`, 'hook', 'claude']);
40
47
  /**
41
48
  * The guard as a Claude Code hook: exec form, `args` given, so the project's
@@ -62,9 +69,96 @@ function upgraded(group) {
62
69
  return group;
63
70
  return { ...group, hooks: hooks.map((hook) => (isLegacyGuard(hook) ? { ...hook, command: 'node', args: [...GUARD_ARGS] } : hook)) };
64
71
  }
65
- /** `.claude/settings.json`, or the plugin's hooks, with the guard hooks merged in; `null` when they are already there. */
66
- export function mergeClaudeSettings(current) {
72
+ /**
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
75
+ * (https://code.claude.com/docs/en/hooks, Matcher patterns, read
76
+ * 2026-10-12): left out, empty or `*`, every tool; made of letters, digits,
77
+ * `_`, `-`, spaces, `,` and `|` alone, the tools it names, one between each
78
+ * `|` or `,`; with any other character in it, a JavaScript regular
79
+ * 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 not text,
81
+ * is no pattern, or is a pattern whose reading runs out its budget.
82
+ */
83
+ function matchedTools(matcher) {
84
+ if (matcher === undefined || matcher === '' || matcher === '*')
85
+ return WRITING_TOOLS;
86
+ if (typeof matcher !== 'string')
87
+ return null;
88
+ if (/^[A-Za-z0-9_\- ,|]*$/.test(matcher)) {
89
+ const names = matcher.split(/[|,]/).map((name) => name.trim());
90
+ return WRITING_TOOLS.filter((tool) => names.includes(tool));
91
+ }
92
+ return patternTools(matcher);
93
+ }
94
+ /**
95
+ * How long the reading of one pattern may take, in milliseconds of the
96
+ * clock. A sane matcher is read in about half of one (ADR-0005), so only a
97
+ * pattern that backtracks without end meets it; on a machine under load an
98
+ * ordinary one may, and is then said as one that cannot be told, where an
99
+ * idle machine reads it.
100
+ */
101
+ const PATTERN_BUDGET = 100;
102
+ /**
103
+ * The writing tools a matcher that is a pattern matches, or `null` where it
104
+ * is no pattern, or its reading runs out its budget.
105
+ *
106
+ * 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:
108
+ * `^((((([A-Za-z]*)*)*)*)*)*$X` ran for more than ten seconds. Tested as
109
+ * written, one such entry and neither doctor nor init would come back, with
110
+ * no word. So it is made and tested in a context of its own, whose run
111
+ * takes a timeout where a test in this one takes none, and what throws
112
+ * there, a pattern that is none or the stop itself, is a matcher that
113
+ * cannot be told. The shape of a pattern does not say which will: groups in
114
+ * a row backtrack as nested ones do.
115
+ */
116
+ function patternTools(source) {
117
+ try {
118
+ const matched = runInNewContext('tools.filter((tool) => new RegExp(source).test(tool))', { tools: WRITING_TOOLS, source }, { timeout: PATTERN_BUDGET });
119
+ return matched;
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ /** Whether Claude Code runs a hook's entry in the background, where it waits on no answer of it. */
126
+ function inBackground(hook) {
127
+ return hook['async'] === true || hook['asyncRewake'] === true;
128
+ }
129
+ /**
130
+ * The writing tools whose call Claude Code hands an entry under `event` and
131
+ * waits for it on, or `null` where that cannot be told here. Before a write
132
+ * an entry in the background answers too late to refuse it, so it is waited
133
+ * on for none (the reference, Run hooks in the background). An `if` has the
134
+ * entry started only for the calls its rule matches, in the syntax of a
135
+ * permission rule, which is not read here.
136
+ */
137
+ function answered(event, matcher, hook) {
138
+ if (event === BEFORE && inBackground(hook))
139
+ return [];
140
+ if (hook['if'] !== undefined)
141
+ return null;
142
+ return matchedTools(matcher);
143
+ }
144
+ /**
145
+ * `.claude/settings.json`, or the plugin's hooks, with the guard hooks merged
146
+ * in; `null` when they are already there.
147
+ *
148
+ * An event has the guard where Claude Code starts init's own line under it
149
+ * for a write. Where the settings hold another line of the hook that it
150
+ * starts for a write, one `isHook` knows as the hook's by the words after
151
+ * the harness's command, nothing is merged in at all: the line is a
152
+ * person's, a second one beside it would run for every write as well, and
153
+ * init rewrites none but the one 0.1 wrote. Without `isHook`, only init's
154
+ * own line is known: the default's `false` and an `undefined` read the same
155
+ * in `some`, so that mutant is equivalent.
156
+ */
157
+ export function mergeClaudeSettings(current, isHook = () => false) {
67
158
  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)));
160
+ if (theirs)
161
+ return null;
68
162
  let changed = false;
69
163
  const next = { ...hooks };
70
164
  for (const event of GUARDED_EVENTS) {
@@ -72,7 +166,7 @@ export function mergeClaudeSettings(current) {
72
166
  const before = Array.isArray(listed) ? listed : [];
73
167
  const groups = before.map(upgraded);
74
168
  const replaced = groups.some((group, index) => group !== before[index]);
75
- const present = groups.some((group) => hooksOf(group).some(isGuard));
169
+ const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry)));
76
170
  if (!present)
77
171
  groups.push({ matcher: HOOK_MATCHER, hooks: [{ ...GUARD_HOOK, args: [...GUARD_ARGS] }] });
78
172
  if (replaced || !present) {
@@ -106,14 +200,210 @@ export function mergeMcp(current) {
106
200
  function field(file, key) {
107
201
  return isObject(file) ? file[key] : undefined;
108
202
  }
109
- /** Whether Claude Code settings hold the guard hooks, as this release or 0.1 wrote them. */
110
- export function holdsGuard(settings) {
203
+ /** The harness's command on a line: its script, or its name, alone or at the end of a path. */
204
+ function isLauncher(word) {
205
+ const name = word.split(/[\\/]/).at(-1);
206
+ return name === 'spec-harness' || name === 'spec-harness.js';
207
+ }
208
+ /** Anything in a word that a shell reads before the program is handed it: a quote, a variable, an operator, a pattern. */
209
+ const SHELL_READS = /[^A-Za-z0-9_./:=@%+,-]/;
210
+ function writtenHook(hook) {
211
+ const { command, args } = hook;
212
+ if (typeof command !== 'string')
213
+ return null;
214
+ let line;
215
+ // Without arguments the line is a shell's, which splits it at its spaces:
216
+ // at each one here, and what stands between two of them is no word.
217
+ if (args === undefined)
218
+ line = command.split(/\s/).filter((word) => word !== '');
219
+ else if (Array.isArray(args) && args.every((arg) => typeof arg === 'string'))
220
+ line = [command, ...args];
221
+ // Arguments that are not all text are no line Claude Code can start.
222
+ else
223
+ return null;
224
+ const shell = args === undefined;
225
+ // A quote around the path of the command is the shell's, dropped to know the command by.
226
+ const at = line.findIndex((word) => isLauncher(shell ? word.replace(/["']/g, '') : word));
227
+ if (at === -1)
228
+ return null;
229
+ const words = line.slice(at + 1);
230
+ return { words, shell: shell && words.some((word) => SHELL_READS.test(word)), blocks: hook['onFailure'] === 'block' };
231
+ }
232
+ /**
233
+ * Every line in Claude Code's settings that runs the harness, under the two
234
+ * events the guard answers: a line that stands under both, as init writes
235
+ * it, is one line. Whose line each is, the guard's or another command's, is
236
+ * the reader's to say (usage.ts): this knows the harness by its command
237
+ * alone.
238
+ */
239
+ export function writtenHooks(settings) {
240
+ const found = [];
241
+ for (const { event, words, shell, blocks } of harnessEntries(settings)) {
242
+ const same = found.find((other) => sameLine(other, { words, shell, blocks }));
243
+ if (same === undefined)
244
+ found.push({ events: [event], words, shell, blocks });
245
+ else if (!same.events.includes(event))
246
+ same.events.push(event);
247
+ }
248
+ return found;
249
+ }
250
+ function sameLine(one, other) {
251
+ return one.shell === other.shell && one.blocks === other.blocks && sameStrings(one.words, other.words);
252
+ }
253
+ /**
254
+ * The writing tools whose call an entry is handed and waited on for, or
255
+ * `null` where that cannot be told. Read where it is asked for and no
256
+ * sooner: a matcher that is a pattern can cost its whole budget.
257
+ */
258
+ function toolsOf(entry) {
259
+ return answered(entry.event, entry.matcher, entry.hook);
260
+ }
261
+ /** Every entry that runs the harness under the two events the guard answers, in the order the settings hold them. */
262
+ function harnessEntries(settings) {
111
263
  const hooks = field(settings, 'hooks');
112
264
  if (!isObject(hooks))
113
- return false;
114
- return GUARDED_EVENTS.some((event) => {
265
+ return [];
266
+ return GUARDED_EVENTS.flatMap((event) => {
115
267
  const groups = hooks[event];
116
- return Array.isArray(groups) && groups.some((group) => hooksOf(group).some((hook) => isGuard(hook) || isLegacyGuard(hook)));
268
+ if (!Array.isArray(groups))
269
+ return [];
270
+ return groups.flatMap((group) => hooksOf(group).flatMap((hook) => {
271
+ if (!isObject(hook))
272
+ return [];
273
+ const written = writtenHook(hook);
274
+ if (written === null)
275
+ return [];
276
+ return [{ ...written, event, matcher: field(group, 'matcher'), hook }];
277
+ }));
278
+ });
279
+ }
280
+ /** 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. */
281
+ export function isStarted(tools) {
282
+ return tools === null || tools.length > 0;
283
+ }
284
+ /**
285
+ * The release of Claude Code that reads `onFailure` on a hook's entry, by its
286
+ * hooks reference (https://code.claude.com/docs/en/hooks, read 2026-10-12).
287
+ */
288
+ const ON_FAILURE_SINCE = '2.1.295';
289
+ /** What `onFailure: "block"` changes for a line, named by `where` it stands, or `null` where the entry does not set it. */
290
+ function describeOnFailure(where, blocks, off) {
291
+ if (!blocks)
292
+ return null;
293
+ const holds = `${where} sets onFailure to "block", so Claude Code ${ON_FAILURE_SINCE} or later holds a write when the hook fails`;
294
+ if (off)
295
+ return `${holds}, which with this line is every write, the one that mends the line among them: mend it by hand`;
296
+ 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`;
297
+ }
298
+ /** Names as a sentence lists them: one, two with "and", more with commas before it. Never asked of none. */
299
+ function named(names) {
300
+ const last = names.at(-1);
301
+ return names.length === 1 ? last : `${names.slice(0, -1).join(', ')} and ${last}`;
302
+ }
303
+ /** 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;
308
+ }
309
+ /** What a line's entries under `event` hold that decides when Claude Code starts them, as written. */
310
+ function entriesUnder(event, entries) {
311
+ const under = entries.filter((entry) => entry.event === event);
312
+ if (under.length === 0)
313
+ return `it stands under no ${event}`;
314
+ const said = under.map(({ matcher, hook }) => {
315
+ const pattern = matcher === undefined ? 'no matcher' : `the matcher ${JSON.stringify(matcher)}`;
316
+ const background = inBackground(hook) ? ' in the background' : '';
317
+ const rule = hook['if'] === undefined ? '' : ` with "if" ${JSON.stringify(hook['if'])}`;
318
+ return `${pattern}${background}${rule}`;
319
+ });
320
+ return `under ${event} it has ${named([...new Set(said)])}`;
321
+ }
322
+ /**
323
+ * What is said of the writes Claude Code starts a line for, by the event and
324
+ * the matcher of each entry that holds it; `null` where it is waited on
325
+ * before every tool that writes and started after each, as init writes it.
326
+ */
327
+ function describeCover(where, before, after, entries) {
328
+ const parts = [];
329
+ const left = (tools) => WRITING_TOOLS.filter((tool) => !tools.includes(tool));
330
+ if (before === null)
331
+ parts.push(`which writes Claude Code waits on it before cannot be told here (${entriesUnder(BEFORE, entries)})`);
332
+ 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)})`);
334
+ 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)})`);
336
+ }
337
+ if (after === null)
338
+ parts.push(`which writes it is started after cannot be told here (${entriesUnder(AFTER, entries)})`);
339
+ else if (after.length === 0)
340
+ parts.push(`it is started after no write, so it warns of none (${entriesUnder(AFTER, entries)})`);
341
+ else if (left(after).length > 0)
342
+ parts.push(`it is started after a write by ${named(after)} alone (${entriesUnder(AFTER, entries)})`);
343
+ return parts.length === 0 ? null : `${where}: ${parts.join('; ')}`;
344
+ }
345
+ /** The words Claude Code hands the hook for a line: what follows the harness's command, each `${CLAUDE_PROJECT_DIR}` filled with `project`. */
346
+ function handedWords(words, project) {
347
+ return words.map((word) => word.split(PROJECT_DIR).join(project));
348
+ }
349
+ /** 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. */
350
+ export function handedLines(sources, project) {
351
+ return sources.flatMap(({ settings }) => writtenHooks(settings).map((hook) => handedWords(hook.words, project)));
352
+ }
353
+ /**
354
+ * The lines of Claude Code's hook in the settings files, each with what the
355
+ * hook does with it and which writes Claude Code starts it for. `read` is
356
+ * the hook's own reading of the words it is handed (usage.ts), `null` for a
357
+ * line that is not its; the words are handed to it as Claude Code hands them
358
+ * to the hook, each `${CLAUDE_PROJECT_DIR}` filled with `project`.
359
+ *
360
+ * A line that guards and holds no more than a release wrote is reported
361
+ * with nothing to say, as it always was. One with more on it is said as it
362
+ * is, and fails nothing. One the hook cannot read, or whose root is no
363
+ * directory, is a guard that is off, and one that names a brief which is not
364
+ * one holds every write: each a problem, and doctor exits 1 for it. A line
365
+ * Claude Code waits on before no write is not the guard, whatever the hook
366
+ * would do with it.
367
+ */
368
+ export function reportHooks(sources, project, read) {
369
+ return sources.flatMap(({ file, settings }) => {
370
+ const entries = harnessEntries(settings);
371
+ return writtenHooks(settings).flatMap((hook) => {
372
+ const handed = read(handedWords(hook.words, project));
373
+ if (handed === null)
374
+ return [];
375
+ const both = GUARDED_EVENTS.every((event) => hook.events.includes(event));
376
+ // An empty word is shown as a shell spells one, where joined it would not show at all.
377
+ // A line that is not under both events is under one.
378
+ const where = `"${hook.words.map((word) => (word === '' ? "''" : word)).join(' ')}" in ${file}${both ? '' : ` (under ${hook.events[0]} alone)`}`;
379
+ const mine = entries.filter((entry) => sameLine(entry, hook));
380
+ const before = toolsUnder(BEFORE, mine);
381
+ const after = toolsUnder(AFTER, mine);
382
+ const written = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before, after, cover: describeCover(where, before, after, mine) };
383
+ if (hook.shell) {
384
+ 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) }];
386
+ }
387
+ 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
+ return [{ ...written, state: 'off', detail, note: describeOnFailure(where, hook.blocks, true) }];
390
+ }
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 }];
395
+ if (handed.state === 'held') {
396
+ 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
+ return [{ ...written, state: 'held', detail, note }];
398
+ }
399
+ if (before === null)
400
+ return [{ ...written, state: 'unknown', detail: null, note }];
401
+ // What a release wrote: its hook, and nothing after it.
402
+ if (sameStrings(hook.words, ['hook', 'claude']))
403
+ return [{ ...written, state: 'guards', detail: null, note }];
404
+ const root = handed.root === undefined ? '' : `, and its root, ${handed.root}, is a directory`;
405
+ return [{ ...written, state: 'guards', detail: `${where} is more than init writes, and guards: the hook reads the line${root}`, note }];
406
+ });
117
407
  });
118
408
  }
119
409
  /** Whether a project's `.mcp.json` registers a server under the harness's name, however it runs it. */
@@ -152,20 +442,25 @@ function isOn(plugin) {
152
442
  function turnOff(plugin) {
153
443
  return `turn the plugin off for this project with claude plugin disable ${plugin.id} --scope local`;
154
444
  }
155
- const PARTS = {
445
+ const PARTS = literal({
156
446
  hooks: { brings: 'the guard hooks', twice: 'every write is guarded twice', skipped: 'init writes none', entry: "spec-harness's hooks", them: 'them' },
157
447
  server: { brings: 'the server', twice: 'the server is registered twice', skipped: 'init registers none', entry: 'the spec-harness server', them: 'it' },
158
- };
448
+ });
159
449
  /**
160
450
  * What init says of a Claude Code file it leaves alone because the plugin is
161
451
  * on and brings the same `part`. A file that holds init's entry already is a
162
452
  * double install for the person to settle: init removes nothing, since the
163
- * file is the repository's and a clone without the plugin relies on it.
453
+ * file is the repository's and a clone without the plugin relies on it. So
454
+ * is an entry `elsewhere`, in a settings file of the person's own, which
455
+ * init does not write and doctor fails all the same.
164
456
  */
165
- export function describeSkipped(plugin, part, present) {
457
+ export function describeSkipped(plugin, part, present, elsewhere = []) {
166
458
  const words = PARTS[part];
167
459
  if (present)
168
460
  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)}`;
461
+ if (elsewhere.length > 0) {
462
+ 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`;
463
+ }
169
464
  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`;
170
465
  }
171
466
  /** Which of the four the wiring is. `both` is a double install, and doctor fails it; either of init's entries counts. */
@@ -461,7 +756,8 @@ export function describeGraph(archiveGlob, briefs, reads, added) {
461
756
  }
462
757
  /** spec-graph's configuration with the archive read as history, or `null` when it already is. */
463
758
  export function mergeSpecGraph(current, archiveGlob) {
464
- const history = Array.isArray(current['historyPatterns']) ? current['historyPatterns'].filter((p) => typeof p === 'string') : [];
759
+ const patterns = current['historyPatterns'];
760
+ const history = Array.isArray(patterns) ? patterns.filter((p) => typeof p === 'string') : [];
465
761
  if (history.includes(archiveGlob))
466
762
  return null;
467
763
  return { ...current, historyPatterns: [...history, archiveGlob] };