@descent-vtt/spec-harness 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/configure.js CHANGED
@@ -52,6 +52,12 @@ const BEFORE = 'PreToolUse';
52
52
  /** The event after a call that succeeded, where a hook can only tell the model what it wrote. */
53
53
  const AFTER = 'PostToolUse';
54
54
  const GUARDED_EVENTS = [BEFORE, AFTER];
55
+ /**
56
+ * The event Claude Code runs as a session starts, under which the hook says
57
+ * what would hold every write of it (ADR-0005). Its entry has no matcher:
58
+ * left out, it is every way a session starts.
59
+ */
60
+ const AT_START = 'SessionStart';
55
61
  const GUARD_ARGS = Object.freeze([`${PROJECT_DIR}/${BIN}`, 'hook', 'claude']);
56
62
  /**
57
63
  * The guard as a Claude Code hook: exec form, `args` given, so the project's
@@ -96,10 +102,13 @@ function upgraded(group) {
96
102
  * common causes, read 2026-10-14). A matcher that is an array is an entry
97
103
  * Claude Code lists as an invalid setting, among the reasons a hook is not
98
104
  * loaded and never fires: no tool. And a comma separates names only from
99
- * {@link COMMA_SINCE} on, so a matcher that lists names with one is read as
100
- * `commas` has the Claude Code here read it.
105
+ * {@link COMMA_SINCE} on; its changelog adds that a hyphenated name is
106
+ * matched exactly only from {@link HYPHEN_SINCE} on. So a matcher of names
107
+ * with either in it is read as `matching` has the Claude Code here read it
108
+ * ({@link namesReadings}), and where the readings it may have differ, it
109
+ * cannot be told.
101
110
  */
102
- function matchedTools(matcher, pattern, commas) {
111
+ function matchedTools(matcher, pattern, matching) {
103
112
  if (matcher === undefined || matcher === '' || matcher === '*')
104
113
  return CALLABLE_TOOLS;
105
114
  if (Array.isArray(matcher))
@@ -108,14 +117,11 @@ function matchedTools(matcher, pattern, commas) {
108
117
  return null;
109
118
  if (!NAMES.test(matcher))
110
119
  return pattern(matcher);
111
- const comma = commas.as === 'separator' ? null : commaReading(matcher, pattern);
112
- if (comma === null)
113
- return listedTools(matcher);
114
- if (commas.as === 'pattern')
115
- return comma.earlier;
116
- // Which of the two readings holds cannot be told, and where both are no
117
- // tool that writes it need not be.
118
- return comma.earlier !== null && comma.listed.length === 0 ? comma.listed : null;
120
+ // A reading that cannot be told, or two that differ, leave the matcher one
121
+ // whose tools cannot be told; where every reading comes to the same tools,
122
+ // nothing turns on which the release has.
123
+ const readings = namesReadings(matcher, pattern, matching);
124
+ return readings.every((tools) => sameStrings(tools, readings[0])) ? readings[0] : null;
119
125
  }
120
126
  /** A matcher that is a name, or names between `|` and `,`: what the reference compares with a tool's name exactly. */
121
127
  const NAMES = /^[A-Za-z0-9_\- ,|]*$/;
@@ -133,26 +139,63 @@ function listedTools(matcher) {
133
139
  * silently never firing.
134
140
  */
135
141
  export const COMMA_SINCE = '2.1.191';
136
- /** The reading every release from {@link COMMA_SINCE} on has, and what a caller that asks no release is given. */
137
- const SEPARATOR = { as: 'separator' };
138
142
  /**
139
- * How the Claude Code doctor asked its release reads a comma. A release that
140
- * was not asked, or could not be told, leaves the reading untold: asked or
141
- * not, nothing says which it is.
143
+ * The release of Claude Code from which a hyphenated name in a matcher of
144
+ * names is matched exactly. Its changelog
145
+ * (https://code.claude.com/docs/en/changelog, 2.1.195, read 2026-10-18) has,
146
+ * among its fixes, hook matchers with hyphenated identifiers that matched as
147
+ * substrings, and match exactly from this release on; it does not say how
148
+ * the rest of such a matcher was read.
149
+ */
150
+ export const HYPHEN_SINCE = '2.1.195';
151
+ /** The reading every release from {@link HYPHEN_SINCE} on has, and what a caller that asks no release is given. */
152
+ const TODAY = { as: 'today' };
153
+ /**
154
+ * How the Claude Code doctor asked its release reads a matcher of names. A
155
+ * release that was not asked, or could not be told, leaves the reading
156
+ * untold: asked or not, nothing says which it is.
142
157
  */
143
- export function commasUnder(release) {
158
+ export function matchingUnder(release) {
144
159
  if (release === null || release.state === 'unknown')
145
160
  return { as: 'untold' };
146
161
  // A release that was told is one whose version was read as one, and the
147
162
  // constant is a version, so both parse.
148
- return meets(parseVersion(release.version), parseVersion(COMMA_SINCE)) ? SEPARATOR : { as: 'pattern', release: release.version };
149
- }
150
- /** The two readings of a matcher of names that holds a comma, or `null` for any other matcher, which every release reads one way. */
151
- function commaReading(matcher, pattern) {
152
- if (typeof matcher !== 'string' || !NAMES.test(matcher) || !matcher.includes(','))
153
- return null;
163
+ return meets(parseVersion(release.version), parseVersion(HYPHEN_SINCE)) ? TODAY : { as: 'older', release: release.version };
164
+ }
165
+ /** Whether `release` reads a comma in `matcher` as part of a pattern: one before {@link COMMA_SINCE}, where the matcher holds one. */
166
+ function commaAsPattern(matcher, release) {
167
+ return matcher.includes(',') && !meets(parseVersion(release), parseVersion(COMMA_SINCE));
168
+ }
169
+ /** A matcher of names that holds a comma, as a release before {@link COMMA_SINCE} reads it. */
170
+ function commaEarlier(matcher, pattern) {
171
+ // No tool, where the matcher read as a pattern matches none of the writing
172
+ // tools, which is what the page has such a matcher do; and where it matches
173
+ // one, as `Edit|Write,Bash` matches `Edit`, the page's two sentences, that
174
+ // a comma is read as part of a pattern and that the matcher never matched,
175
+ // disagree, and it cannot be told.
154
176
  const read = pattern(matcher);
155
- return { listed: listedTools(matcher), earlier: read !== null && read.length === 0 ? read : null };
177
+ return read !== null && read.length === 0 ? read : null;
178
+ }
179
+ /**
180
+ * Each reading the Claude Code here may have of a matcher of names: the
181
+ * writing tools it lists, or `null` where a reading cannot be told. From
182
+ * {@link HYPHEN_SINCE} on there is one, the names matched exactly. Before
183
+ * {@link COMMA_SINCE} a comma in it makes it a pattern ({@link commaEarlier}).
184
+ * Before {@link HYPHEN_SINCE} a hyphen in it made it match as a substring,
185
+ * and the changelog does not say which way: each name matched exactly but
186
+ * the one with the hyphen, the matcher read as a pattern, which matches
187
+ * anywhere in a tool's name, or its whole text found in a tool's name, which
188
+ * no writing tool's holds, since none has a hyphen. A release that cannot be
189
+ * told may read it any way a release does.
190
+ */
191
+ function namesReadings(matcher, pattern, matching) {
192
+ const listed = listedTools(matcher);
193
+ if (matching.as === 'today')
194
+ return [listed];
195
+ const substring = matcher.includes('-') ? [pattern(matcher), []] : [];
196
+ if (matching.as === 'older')
197
+ return commaAsPattern(matcher, matching.release) ? [commaEarlier(matcher, pattern)] : [listed, ...substring];
198
+ return [listed, ...(matcher.includes(',') ? [commaEarlier(matcher, pattern)] : []), ...substring];
156
199
  }
157
200
  /**
158
201
  * How long the reading of one pattern may take, in milliseconds of the
@@ -223,12 +266,12 @@ function inBackground(hook) {
223
266
  * entry started only for the calls its rule matches, in the syntax of a
224
267
  * permission rule, which is not read here.
225
268
  */
226
- function answered(event, matcher, hook, pattern, commas) {
269
+ function answered(event, matcher, hook, pattern, matching) {
227
270
  if (event === BEFORE && inBackground(hook))
228
271
  return [];
229
272
  if (hook['if'] !== undefined)
230
273
  return null;
231
- return matchedTools(matcher, pattern, commas);
274
+ return matchedTools(matcher, pattern, matching);
232
275
  }
233
276
  /**
234
277
  * `.claude/settings.json`, or the plugin's hooks, with the guard hooks merged
@@ -245,14 +288,14 @@ function answered(event, matcher, hook, pattern, commas) {
245
288
  *
246
289
  * `pattern` reads a matcher that is a pattern: the run's own reader, where
247
290
  * the run has read the file's matchers already ({@link patternReader}).
248
- * `commas` is how the Claude Code here reads a comma in a matcher of names,
249
- * where the run asked its release ({@link commasUnder}): a line that
291
+ * `matching` is how the Claude Code here reads a matcher of names, where
292
+ * the run asked its release ({@link matchingUnder}): a line that
250
293
  * release starts for no write is no guard, and init's own is written beside
251
294
  * it.
252
295
  */
253
- export function mergeClaudeSettings(current, isHook = () => false, pattern = patternReader(), commas = SEPARATOR) {
296
+ export function mergeClaudeSettings(current, isHook = () => false, pattern = patternReader(), matching = TODAY) {
254
297
  const hooks = isObject(current['hooks']) ? current['hooks'] : {};
255
- const theirs = harnessEntries(current).some((entry) => !isGuard(entry.hook) && !isLegacyGuard(entry.hook) && isHook(entry.words) && isStarted(toolsOf(entry, pattern, commas)));
298
+ const theirs = harnessEntries(current).some((entry) => !isGuard(entry.hook) && !isLegacyGuard(entry.hook) && isHook(entry.words) && isStarted(toolsOf(entry, pattern, matching)));
256
299
  if (theirs)
257
300
  return null;
258
301
  let changed = false;
@@ -262,7 +305,7 @@ export function mergeClaudeSettings(current, isHook = () => false, pattern = pat
262
305
  const before = Array.isArray(listed) ? listed : [];
263
306
  const groups = before.map(upgraded);
264
307
  const replaced = groups.some((group, index) => group !== before[index]);
265
- const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry, pattern, commas)));
308
+ const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry, pattern, matching)));
266
309
  if (!present)
267
310
  groups.push({ matcher: HOOK_MATCHER, hooks: [{ ...GUARD_HOOK, args: [...GUARD_ARGS] }] });
268
311
  if (replaced || !present) {
@@ -272,6 +315,45 @@ export function mergeClaudeSettings(current, isHook = () => false, pattern = pat
272
315
  }
273
316
  return changed ? { ...current, hooks: next } : null;
274
317
  }
318
+ /**
319
+ * Settings with init's entry under `SessionStart` added beside its two, or
320
+ * `null` where it is not to be: where init's own line does not stand
321
+ * under both `PreToolUse` and `PostToolUse`, a file init writes no hook
322
+ * into or one it has yet to complete; and where a line of the hook stands
323
+ * under `SessionStart` already, init's or one `isHook` knows, which would
324
+ * say the same twice (ADR-0012).
325
+ */
326
+ export function withSessionStart(settings, isHook) {
327
+ const hooks = isObject(settings['hooks']) ? settings['hooks'] : {};
328
+ const own = GUARDED_EVENTS.every((event) => harnessEntries({ hooks: { [event]: hooks[event] } }).some((entry) => isGuard(entry.hook)));
329
+ if (!own || startsSessions(settings, isHook))
330
+ return null;
331
+ const listed = hooks[AT_START];
332
+ return { ...settings, hooks: { ...hooks, [AT_START]: [...(Array.isArray(listed) ? listed : []), { hooks: [{ ...GUARD_HOOK, args: [...GUARD_ARGS] }] }] } };
333
+ }
334
+ /**
335
+ * Whether settings have Claude Code start the hook as a session starts: an
336
+ * entry under `SessionStart` whose line is init's, or one `isHook` knows
337
+ * as the hook's. Its matcher is not read: one that names some of the ways a
338
+ * session starts has the check made for those.
339
+ */
340
+ export function startsSessions(settings, isHook) {
341
+ const groups = field(field(settings, 'hooks'), AT_START);
342
+ if (!Array.isArray(groups))
343
+ return false;
344
+ return groups.some((group) => hooksOf(group).some((hook) => isObject(hook) && (isGuard(hook) || knownAs(hook, isHook))));
345
+ }
346
+ /** Whether `isHook` knows an entry's line as the hook's. */
347
+ function knownAs(hook, isHook) {
348
+ const written = writtenHook(hook);
349
+ return written !== null && isHook(written.words);
350
+ }
351
+ /**
352
+ * What doctor notes where settings hold the guard hooks and none under
353
+ * `SessionStart`: what would hold every write is said at the first write,
354
+ * which it holds, and not as the session starts.
355
+ */
356
+ export const NO_SESSION_START = 'note: no settings file has Claude Code start the hook as a session starts, so a configuration that does not load, or a brief named that spec-brief does not list, is said only at the first write, which it holds: spec-harness init --write adds the entry beside the two it wrote';
275
357
  /** The server's entry, reading the project at `projectDir`: {@link PROJECT_DIR} for the plugin, {@link PROJECT_DIR_OR_HERE} for a project. */
276
358
  export function mcpServer(projectDir) {
277
359
  return { command: 'node', args: [`${projectDir}/${BIN}`, 'mcp', '--root', projectDir] };
@@ -367,8 +449,8 @@ function sameLine(one, other) {
367
449
  * `null` where that cannot be told. Read where it is asked for and no
368
450
  * sooner: a matcher that is a pattern can cost its whole budget.
369
451
  */
370
- function toolsOf(entry, pattern, commas) {
371
- return answered(entry.event, entry.matcher, entry.hook, pattern, commas);
452
+ function toolsOf(entry, pattern, matching) {
453
+ return answered(entry.event, entry.matcher, entry.hook, pattern, matching);
372
454
  }
373
455
  /** Every entry that runs the harness under the two events the guard answers, in the order the settings hold them. */
374
456
  function harnessEntries(settings) {
@@ -414,30 +496,38 @@ function named(names) {
414
496
  }
415
497
  /** The writing tools a line's entries under `event` are waited on for between them, or `null` where an entry that cannot be told might add one. */
416
498
  function toolsUnder(event, entries, reading) {
417
- const read = entries.filter((entry) => entry.event === event).map((entry) => toolsOf(entry, reading.pattern, reading.commas));
499
+ const read = entries.filter((entry) => entry.event === event).map((entry) => toolsOf(entry, reading.pattern, reading.matching));
418
500
  const sure = CALLABLE_TOOLS.filter((tool) => read.some((tools) => tools !== null && tools.includes(tool)));
419
501
  return sure.length < CALLABLE_TOOLS.length && read.some((tools) => tools === null) ? null : sure;
420
502
  }
421
503
  /**
422
504
  * What is said after a matcher that Claude Code does not read as its text
423
- * suggests: an array, which it loads no hook under, and names with a comma
424
- * where the release here reads the comma another way than the newest does,
425
- * or cannot be told. Nothing for any other matcher, and nothing for a comma
426
- * where both readings come to no tool that writes.
505
+ * suggests: an array, which it loads no hook under, and names with a comma or
506
+ * a hyphen where the release here reads them another way than the newest
507
+ * does, or cannot be told. Nothing for any other matcher, and nothing where
508
+ * every reading the release may have is the names as listed.
427
509
  */
428
- function saidOfMatcher(matcher, { pattern, commas }) {
510
+ function saidOfMatcher(matcher, { pattern, matching }) {
429
511
  if (Array.isArray(matcher))
430
512
  return ', an array, which Claude Code lists as an invalid setting and loads no hook under';
431
- if (commas.as === 'separator')
513
+ if (typeof matcher !== 'string' || !NAMES.test(matcher))
432
514
  return '';
433
- const comma = commaReading(matcher, pattern);
434
- if (comma === null || (comma.earlier !== null && comma.listed.length === 0))
515
+ const tools = listedTools(matcher);
516
+ if (namesReadings(matcher, pattern, matching).every((read) => sameStrings(read, tools)))
435
517
  return '';
436
- const listed = comma.listed.length === 0 ? 'no tool that writes' : named(comma.listed);
437
- const earlier = comma.earlier === null ? 'a pattern whose matches its documentation does not give' : 'a pattern that matches no tool';
438
- if (commas.as === 'untold')
518
+ const listed = tools.length === 0 ? 'no tool that writes' : named(tools);
519
+ const earlier = commaEarlier(matcher, pattern) === null ? 'a pattern whose matches its documentation does not give' : 'a pattern that matches no tool';
520
+ const substring = "a matcher with a hyphenated name in it matched as a substring, and Claude Code's changelog does not say how the rest of it was read";
521
+ if (matching.as === 'older') {
522
+ if (commaAsPattern(matcher, matching.release))
523
+ return `, which Claude Code ${matching.release} reads as ${earlier}: a comma separates names from ${COMMA_SINCE} on, where the matcher is ${listed}`;
524
+ return `, which Claude Code ${matching.release} may read otherwise: before ${HYPHEN_SINCE} ${substring}; from ${HYPHEN_SINCE} on it is ${listed}`;
525
+ }
526
+ // The release cannot be told: a comma alone turns on 2.1.191, and a hyphen on 2.1.195.
527
+ if (!matcher.includes('-'))
439
528
  return `, which is ${listed} from Claude Code ${COMMA_SINCE} on, where a comma separates names, and ${earlier} before it: the release here cannot be told`;
440
- return `, which Claude Code ${commas.release} reads as ${earlier}: a comma separates names from ${COMMA_SINCE} on, where the matcher is ${listed}`;
529
+ const comma = matcher.includes(',') ? `, and before ${COMMA_SINCE} ${earlier}, where a comma separated no names` : '';
530
+ return `, which is ${listed} from Claude Code ${HYPHEN_SINCE} on, and may be read otherwise before it, where ${substring}${comma}: the release here cannot be told`;
441
531
  }
442
532
  /** What a line's entries under `event` hold that decides when Claude Code starts them, as written. */
443
533
  function entriesUnder(event, entries, reading) {
@@ -538,18 +628,18 @@ export function handedLines(sources, project) {
538
628
  *
539
629
  * `pattern` reads a matcher that is a pattern, once for every entry under
540
630
  * it in every file: the run's own reader, where something else in the run
541
- * reads the same files ({@link patternReader}). `commas` is how the Claude
542
- * Code here reads a comma in a matcher of names, where its release was
543
- * asked ({@link commasUnder}); a caller that asks none reads a comma as
544
- * every release from {@link COMMA_SINCE} on does.
631
+ * reads the same files ({@link patternReader}). `matching` is how the
632
+ * Claude Code here reads a matcher of names, where its release was asked
633
+ * ({@link matchingUnder}); a caller that asks none reads one as every
634
+ * release from {@link HYPHEN_SINCE} on does.
545
635
  *
546
636
  * A file that holds an array for a matcher under `PreToolUse` or
547
637
  * `PermissionRequest` has none of its hooks loaded ({@link arrayMatcher}):
548
638
  * each line of the hook there is started for no write, and one that stood
549
639
  * to be started for a write is a guard that is off.
550
640
  */
551
- export function reportHooks(sources, project, read, pattern = patternReader(), commas = SEPARATOR) {
552
- const reading = { pattern, commas };
641
+ export function reportHooks(sources, project, read, pattern = patternReader(), matching = TODAY) {
642
+ const reading = { pattern, matching };
553
643
  return sources.flatMap(({ file, settings }) => {
554
644
  const entries = harnessEntries(settings);
555
645
  const array = arrayMatcher(settings);
@@ -812,6 +902,18 @@ export function describeSkipped(plugin, part, present, elsewhere = []) {
812
902
  }
813
903
  return `${isOn(plugin)} and brings ${words.brings}, so ${words.skipped}: with both, ${words.twice}. To have ${words.them} here instead, for every clone of the repository, ${turnOff(plugin)} and run init again`;
814
904
  }
905
+ /**
906
+ * How init comes to write the guard hooks. Where an array unloads the
907
+ * project's settings file, init writes none there and says the array, so
908
+ * the advice is the array's, and init after it: advice that led to more
909
+ * advice is the advice at the end.
910
+ */
911
+ function runInit(wiring) {
912
+ const unloaded = wiring.unloaded ?? null;
913
+ if (unloaded === null)
914
+ return 'run spec-harness init --write';
915
+ return `${describeArrayMatcher(PROJECT_SETTINGS, unloaded)}, those init writes among them: ${ONE_STRING}, then run spec-harness init --write`;
916
+ }
815
917
  /** Why the plugin settings turn on is not on this machine, or `null` where it is, may be, or none is on. */
816
918
  function absence(wiring) {
817
919
  return wiring.presence?.state === 'absent' ? wiring.presence.detail : null;
@@ -862,11 +964,11 @@ export function describePresence(wiring) {
862
964
  return `whether ${plugin.id} is on this machine cannot be told: ${presence.detail}. A plugin that is not there brings no hook: Claude Code's /plugin lists it, and its errors`;
863
965
  if (wiring.hooks.length > 0)
864
966
  return `${isAbsent(plugin, presence.detail, 'no hook')}. With the plugin installed as well, ${PARTS.hooks.twice}: ${turnOff(plugin)}`;
865
- return wiring.server ? unguarded(plugin, presence.detail) : null;
967
+ return wiring.server ? unguarded(plugin, presence.detail, wiring) : null;
866
968
  }
867
969
  /** That the plugin settings turn on is not on this machine and no hook of init's stands in for it, with the two ways to a guard. */
868
- function unguarded(plugin, detail) {
869
- return `${isAbsent(plugin, detail, 'no hook')}, and Claude Code guards no write. To have the guard, ${bring(plugin)}, or ${turnOff(plugin)} and run spec-harness init --write`;
970
+ function unguarded(plugin, detail, wiring) {
971
+ return `${isAbsent(plugin, detail, 'no hook')}, and Claude Code guards no write. To have the guard, ${bring(plugin)}, or ${turnOff(plugin)} and ${runInit(wiring)}`;
870
972
  }
871
973
  /** What `doctor` says of the wiring, and what to do when it is doubled or missing. */
872
974
  export function describeClaudeCode(wiring) {
@@ -875,12 +977,12 @@ export function describeClaudeCode(wiring) {
875
977
  const own = `init's entries: ${entries}`;
876
978
  if (plugin === null) {
877
979
  if (entries === '')
878
- return 'neither the spec-harness plugin nor the hooks init writes, so Claude Code guards no write: run spec-harness init --write, or install the plugin';
980
+ return `neither the spec-harness plugin nor the hooks init writes, so Claude Code guards no write: ${runInit(wiring)}, or install the plugin`;
879
981
  return own;
880
982
  }
881
983
  const absent = absence(wiring);
882
984
  if (absent !== null)
883
- return entries === '' ? unguarded(plugin, absent) : own;
985
+ return entries === '' ? unguarded(plugin, absent, wiring) : own;
884
986
  if (entries === '')
885
987
  return `${isOn(plugin)} and brings the guard hooks and the server`;
886
988
  const twice = [...(wiring.hooks.length > 0 ? [PARTS.hooks.twice] : []), ...(wiring.server ? [PARTS.server.twice] : [])].join(' and ');
@@ -888,8 +990,7 @@ export function describeClaudeCode(wiring) {
888
990
  }
889
991
  /** The spec-brief plugin this package ships, as spec-brief's `plugins` names it. */
890
992
  export const PLUGIN = '@descent-vtt/spec-harness/spec-brief-plugin';
891
- /** spec-brief's configuration files, in the order spec-brief looks for them in a directory. */
892
- export const SPEC_BRIEF_CONFIGS = ['.spec-brief.json', 'spec-brief.json'];
993
+ export { SPEC_BRIEF_CONFIGS } from './config.js';
893
994
  /** The conditions an import meets in Node, its `defaultConditions`, and `default`, which every one meets. */
894
995
  const IMPORT_CONDITIONS = ['node', 'import', 'default'];
895
996
  /** A target Node refuses as invalid, which an array passes over and anything else stops at. */
@@ -999,9 +1100,13 @@ export function chooseBase(facts) {
999
1100
  const unrecorded = 'no remote records a default branch (refs/remotes/origin/HEAD)';
1000
1101
  if (branch !== null) {
1001
1102
  const conventional = branch === 'main' || branch === 'master';
1002
- if (conventional || facts.branches.every((name) => name === branch)) {
1103
+ if (conventional || facts.branches.every((local) => local.name === branch)) {
1003
1104
  const which = conventional ? 'the branch init runs on' : 'the only branch';
1004
- return { base: branch, detail: `rounds are measured from ${branch}, ${which}: ${unrecorded}; change "base" if rounds merge into another` };
1105
+ // The base is a ref git and spec-brief read, so it is the name git
1106
+ // reads as the branch and nothing else: `heads/main` beside a tag
1107
+ // `main`. A branch with no commit yet is listed by no ref.
1108
+ const base = facts.branches.find((local) => local.name === branch)?.ref ?? branch;
1109
+ return { base, detail: `rounds are measured from ${base}, ${which}: ${unrecorded}; change "base" if rounds merge into another` };
1005
1110
  }
1006
1111
  }
1007
1112
  const where = branch === null ? 'HEAD is detached' : `${branch} is not main, master or the only branch`;
@@ -1083,20 +1188,30 @@ export const PROTECT_RULE_FILES = "Have the forge protect this file, the ADRs, t
1083
1188
  export const GIT_HOOK_LINE = `node ${BIN} hook git`;
1084
1189
  /**
1085
1190
  * The hook init writes. It is a POSIX sh script on every platform, since Git
1086
- * for Windows runs a hook with the sh it ships. Where the project's install
1087
- * is not in the work tree - a linked worktree nobody installed into, a
1191
+ * for Windows runs a hook with the sh it ships. git starts it at the top of
1192
+ * the work tree it commits in, so the project's install is read from there,
1193
+ * and where a linked worktree has none, from the repository's main work
1194
+ * tree: the work tree whose `.git` the common directory is, where `git
1195
+ * worktree add` was run and the project installed (ADR-0005). Where neither
1196
+ * has the install - a worktree of a repository nobody installed into, a
1088
1197
  * repository that shares its hooks with one that uses the harness - node
1089
- * would end on a stack trace, so the hook says what is missing and where, and
1090
- * the commit waits, as it did when npx found nothing to run.
1198
+ * would end on a stack trace, so the hook says what is missing and where,
1199
+ * and the commit waits, as it did when npx found nothing to run.
1091
1200
  */
1092
1201
  export const PRE_COMMIT = `#!/bin/sh
1093
1202
  # spec-harness: refuse a commit that changes what the active brief protects.
1094
1203
  # git runs this at the top of the work tree it commits in, a linked worktree's
1095
- # own, wherever the hook is kept: the project's install is read from there.
1204
+ # own, wherever the hook is kept: the project's install is read from there,
1205
+ # or, where a linked worktree has none, from the repository's main work tree.
1096
1206
  bin=${BIN}
1097
1207
  if [ ! -f "$bin" ]; then
1098
- printf '%s\\n' "spec-harness: $bin is not in $PWD, the work tree git commits in: install the project's dependencies there, as with npm ci, and commit again" >&2
1099
- exit 2
1208
+ common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
1209
+ if [ "\${common##*/}" = .git ] && [ -f "\${common%/.git}/$bin" ]; then
1210
+ bin=\${common%/.git}/$bin
1211
+ else
1212
+ printf '%s\\n' "spec-harness: $bin is not in $PWD, the work tree git commits in, nor in its repository's main work tree: install the project's dependencies there, as with npm ci, and commit again" >&2
1213
+ exit 2
1214
+ fi
1100
1215
  fi
1101
1216
  exec node "$bin" hook git
1102
1217
  `;
@@ -1127,6 +1242,10 @@ export function describeGitHook(hook) {
1127
1242
  return `${hook.file} is ${hook.what}, not a hook, and git stops every commit at it, since it cannot run it ("cannot spawn" on Windows, "cannot exec" elsewhere): nothing is checked there, and nothing is committed. Take it away, then run spec-harness init --git-hook --write`;
1128
1243
  case 'ignored':
1129
1244
  return `${hook.file} is ${hook.what}, not a hook, and is not executable, so git skips it and runs no hook: take it away, then run spec-harness init --git-hook --write`;
1245
+ case 'unreadable':
1246
+ if (!hook.runs)
1247
+ return `${hook.file} cannot be read (${hook.reason}), and is not executable, so git skips it and runs no hook: make it readable and executable, or take it away and run spec-harness init --git-hook --write`;
1248
+ return `${hook.file} cannot be read (${hook.reason}), so whether it runs spec-harness cannot be told: git runs it at a commit, and a script there that cannot be read stops every commit, where a program runs; make it readable`;
1130
1249
  case 'absent':
1131
1250
  return missingGitHook(hook.file);
1132
1251
  case 'unknown':