@descent-vtt/spec-harness 0.17.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.
@@ -8,7 +8,8 @@
8
8
  * nothing to add.
9
9
  */
10
10
  import type { AllowedSigner } from './signers.js';
11
- import type { HookLine } from './usage.js';
11
+ import type { LocalBranch } from './types.js';
12
+ import { type HookLine } from './usage.js';
12
13
  import { type ClaudeCodeCheck } from './versions.js';
13
14
  type Json = Record<string, unknown>;
14
15
  /** The project root, as Claude Code substitutes it in a hook's arguments and in a plugin server's. */
@@ -24,6 +25,44 @@ export declare const PROJECT_DIR_OR_HERE = "${CLAUDE_PROJECT_DIR:-.}";
24
25
  * path is one argument whatever it holds, and no shell is involved.
25
26
  */
26
27
  export declare const GUARD_HOOK: Readonly<Json>;
28
+ /**
29
+ * The release of Claude Code from which a comma in a matcher separates
30
+ * names as a `|` does. Its page on debugging a configuration (Check hooks,
31
+ * read 2026-10-14) has a comma before it fall through to the reading of a
32
+ * pattern, under which the matcher never matched, and its changelog has the
33
+ * fix under this release: hooks with comma-separated matchers no longer
34
+ * silently never firing.
35
+ */
36
+ export declare const COMMA_SINCE = "2.1.191";
37
+ /**
38
+ * The release of Claude Code from which a hyphenated name in a matcher of
39
+ * names is matched exactly. Its changelog
40
+ * (https://code.claude.com/docs/en/changelog, 2.1.195, read 2026-10-18) has,
41
+ * among its fixes, hook matchers with hyphenated identifiers that matched as
42
+ * substrings, and match exactly from this release on; it does not say how
43
+ * the rest of such a matcher was read.
44
+ */
45
+ export declare const HYPHEN_SINCE = "2.1.195";
46
+ /**
47
+ * How the Claude Code that runs the hooks reads a matcher of names: as every
48
+ * release from {@link HYPHEN_SINCE} on, and the hooks reference today; as
49
+ * the release named, which is older; or it cannot be told, where the release
50
+ * cannot.
51
+ */
52
+ export type Matching = {
53
+ readonly as: 'today';
54
+ } | {
55
+ readonly as: 'older';
56
+ readonly release: string;
57
+ } | {
58
+ readonly as: 'untold';
59
+ };
60
+ /**
61
+ * How the Claude Code doctor asked its release reads a matcher of names. A
62
+ * release that was not asked, or could not be told, leaves the reading
63
+ * untold: asked or not, nothing says which it is.
64
+ */
65
+ export declare function matchingUnder(release: ClaudeCodeCheck | null): Matching;
27
66
  /** What reads a matcher that is a pattern: the writing tools it matches, or `null` where that cannot be told. */
28
67
  export type PatternReader = (source: string) => readonly string[] | null;
29
68
  /**
@@ -57,8 +96,34 @@ export declare function patternReader(read?: PatternReader): PatternReader;
57
96
  *
58
97
  * `pattern` reads a matcher that is a pattern: the run's own reader, where
59
98
  * the run has read the file's matchers already ({@link patternReader}).
99
+ * `matching` is how the Claude Code here reads a matcher of names, where
100
+ * the run asked its release ({@link matchingUnder}): a line that
101
+ * release starts for no write is no guard, and init's own is written beside
102
+ * it.
60
103
  */
61
- export declare function mergeClaudeSettings(current: Json, isHook?: (words: readonly string[]) => boolean, pattern?: PatternReader): Json | null;
104
+ export declare function mergeClaudeSettings(current: Json, isHook?: (words: readonly string[]) => boolean, pattern?: PatternReader, matching?: Matching): Json | null;
105
+ /**
106
+ * Settings with init's entry under `SessionStart` added beside its two, or
107
+ * `null` where it is not to be: where init's own line does not stand
108
+ * under both `PreToolUse` and `PostToolUse`, a file init writes no hook
109
+ * into or one it has yet to complete; and where a line of the hook stands
110
+ * under `SessionStart` already, init's or one `isHook` knows, which would
111
+ * say the same twice (ADR-0012).
112
+ */
113
+ export declare function withSessionStart(settings: Json, isHook: (words: readonly string[]) => boolean): Json | null;
114
+ /**
115
+ * Whether settings have Claude Code start the hook as a session starts: an
116
+ * entry under `SessionStart` whose line is init's, or one `isHook` knows
117
+ * as the hook's. Its matcher is not read: one that names some of the ways a
118
+ * session starts has the check made for those.
119
+ */
120
+ export declare function startsSessions(settings: unknown, isHook: (words: readonly string[]) => boolean): boolean;
121
+ /**
122
+ * What doctor notes where settings hold the guard hooks and none under
123
+ * `SessionStart`: what would hold every write is said at the first write,
124
+ * which it holds, and not as the session starts.
125
+ */
126
+ export declare 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";
62
127
  /** The server's entry, reading the project at `projectDir`: {@link PROJECT_DIR} for the plugin, {@link PROJECT_DIR_OR_HERE} for a project. */
63
128
  export declare function mcpServer(projectDir: string): Json;
64
129
  /**
@@ -113,7 +178,8 @@ export interface HookReport {
113
178
  * `guards`: Claude Code waits on the hook before a write, and the hook
114
179
  * checks it. `off`: the hook checks no write, for a line it cannot read, a
115
180
  * root that is no directory, or a root that is a directory outside any
116
- * work tree. `held`: the hook holds every write, for a
181
+ * work tree; or it is never started, in a file an array for a matcher
182
+ * keeps Claude Code from loading the hooks of. `held`: the hook holds every write, for a
117
183
  * brief the line names that is not one. `unstarted`: Claude Code waits on
118
184
  * the line before no write, so it is not the guard. `unknown`: what the
119
185
  * hook is handed, which writes it is started for, or whether Claude Code
@@ -132,6 +198,30 @@ export interface HookReport {
132
198
  /** What `onFailure` changes, where the entry sets it to `"block"`. */
133
199
  readonly note: string | null;
134
200
  }
201
+ /** An array that stands for a matcher where it unloads a settings file's hooks: the event it stands under, and the array as written. */
202
+ export interface ArrayMatcher {
203
+ readonly event: string;
204
+ readonly matcher: string;
205
+ }
206
+ /**
207
+ * The first array that stands for a matcher under `PreToolUse` or
208
+ * `PermissionRequest` in a settings file, or `null`.
209
+ *
210
+ * Claude Code's page on debugging a configuration
211
+ * (https://code.claude.com/docs/en/debug-your-config, Check hooks, read
212
+ * 2026-10-14) has a matcher that is an array, where it must be one string,
213
+ * as an entry Claude Code lists as an invalid setting, at the start of an
214
+ * interactive session and in `claude doctor`; and where the array is under
215
+ * one of these two events, none of that file's other hooks load either. It
216
+ * names no release. So a file that holds one runs no hook at all, whoever
217
+ * wrote the entry and whatever it runs: the array's own is not loaded, and
218
+ * neither is any other.
219
+ */
220
+ export declare function arrayMatcher(settings: unknown): ArrayMatcher | null;
221
+ /** What is said of a settings file that an array for a matcher keeps Claude Code from loading the hooks of. */
222
+ export declare function describeArrayMatcher(file: string, { event, matcher }: ArrayMatcher): string;
223
+ /** How a matcher that is an array is mended. */
224
+ export declare const ONE_STRING = "write the matcher as one string, its names between \"|\"";
135
225
  /** 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. */
136
226
  export declare function handedLines(sources: readonly ClaudeSettings[], project: string): string[][];
137
227
  /**
@@ -144,16 +234,25 @@ export declare function handedLines(sources: readonly ClaudeSettings[], project:
144
234
  * A line that guards and holds no more than a release wrote is reported
145
235
  * with nothing to say, as it always was. One with more on it is said as it
146
236
  * is, and fails nothing. One the hook cannot read, or whose root is no
147
- * directory, is a guard that is off, and one that names a brief which is not
237
+ * directory, or is in no work tree that holds the project, is a guard that
238
+ * is off, and one that names a brief which is not
148
239
  * one holds every write: each a problem, and doctor exits 1 for it. A line
149
240
  * Claude Code waits on before no write is not the guard, whatever the hook
150
241
  * would do with it.
151
242
  *
152
243
  * `pattern` reads a matcher that is a pattern, once for every entry under
153
244
  * it in every file: the run's own reader, where something else in the run
154
- * reads the same files ({@link patternReader}).
245
+ * reads the same files ({@link patternReader}). `matching` is how the
246
+ * Claude Code here reads a matcher of names, where its release was asked
247
+ * ({@link matchingUnder}); a caller that asks none reads one as every
248
+ * release from {@link HYPHEN_SINCE} on does.
249
+ *
250
+ * A file that holds an array for a matcher under `PreToolUse` or
251
+ * `PermissionRequest` has none of its hooks loaded ({@link arrayMatcher}):
252
+ * each line of the hook there is started for no write, and one that stood
253
+ * to be started for a write is a guard that is off.
155
254
  */
156
- export declare function reportHooks(sources: readonly ClaudeSettings[], project: string, read: (words: readonly string[]) => HookLine | null, pattern?: PatternReader): HookReport[];
255
+ export declare function reportHooks(sources: readonly ClaudeSettings[], project: string, read: (words: readonly string[]) => HookLine | null, pattern?: PatternReader, matching?: Matching): HookReport[];
157
256
  /** Whether a project's `.mcp.json` registers a server under the harness's name, however it runs it. */
158
257
  export declare function registersServer(mcp: unknown): boolean;
159
258
  /** A Claude Code settings file, named as a person finds it, and what reading it gave: an object, or nothing usable. */
@@ -278,11 +377,26 @@ export interface ClaudeCodeWiring {
278
377
  readonly lines: readonly HookReport[];
279
378
  /** Whether `.mcp.json` registers the server. */
280
379
  readonly server: boolean;
380
+ /** Whether a settings file has Claude Code start the hook as a session starts (startsSessions). */
381
+ readonly sessionStart: boolean;
382
+ /**
383
+ * The release of the Claude Code on `PATH`, where it was asked: a matcher
384
+ * that lists names with a comma is read as that release reads it
385
+ * ({@link matchingUnder}). `null` where it was not asked.
386
+ */
387
+ readonly release: ClaudeCodeCheck | null;
388
+ /**
389
+ * An array for a matcher that keeps Claude Code from loading the hooks of
390
+ * the project's settings file, the file init writes its hooks into, or
391
+ * `null`: init writes none there while it stands ({@link arrayMatcher}).
392
+ */
393
+ readonly unloaded: ArrayMatcher | null;
281
394
  }
282
395
  export type WiringState = 'plugin' | 'init' | 'both' | 'none';
283
- /** The wiring as its descriptions read it: what a caller knows of the plugin's presence, which one that does not ask leaves out. */
396
+ /** The wiring as its descriptions read it: what a caller knows of the plugin's presence, and of an array in the project's settings, which one that does not ask leaves out. */
284
397
  type Wired = Pick<ClaudeCodeWiring, 'plugin' | 'hooks' | 'server'> & {
285
398
  readonly presence?: PluginPresence | null;
399
+ readonly unloaded?: ArrayMatcher | null;
286
400
  };
287
401
  /**
288
402
  * Which of the four the wiring is. `both` is a double install, and doctor
@@ -315,8 +429,7 @@ export declare function describePresence(wiring: Wired): string | null;
315
429
  export declare function describeClaudeCode(wiring: Wired): string;
316
430
  /** The spec-brief plugin this package ships, as spec-brief's `plugins` names it. */
317
431
  export declare const PLUGIN = "@descent-vtt/spec-harness/spec-brief-plugin";
318
- /** spec-brief's configuration files, in the order spec-brief looks for them in a directory. */
319
- export declare const SPEC_BRIEF_CONFIGS: readonly string[];
432
+ export { SPEC_BRIEF_CONFIGS } from './config.js';
320
433
  /**
321
434
  * The file a package's `exports` target names for an import, as Node's
322
435
  * PACKAGE_TARGET_RESOLVE finds it, or `null` when it names none: a string
@@ -362,10 +475,10 @@ export declare function describePlugin(state: PluginState): string;
362
475
  export interface BaseFacts {
363
476
  /** The remote's default branch as a local ref, such as `origin/main`, or `null`. */
364
477
  readonly remoteDefault: string | null;
365
- /** The branch checked out, or `null` on a detached head. */
478
+ /** The name of the branch checked out, or `null` on a detached head. */
366
479
  readonly branch: string | null;
367
- /** Every local branch. */
368
- readonly branches: readonly string[];
480
+ /** Every local branch, by its name and the ref a base names it by. */
481
+ readonly branches: readonly LocalBranch[];
369
482
  }
370
483
  /** The base `init` names, and why; `base` is `null` when none can be told. */
371
484
  export interface BaseChoice {
@@ -412,6 +525,30 @@ export declare function describeClaudeRelease(check: ClaudeCodeCheck): string;
412
525
  export type GitHookState = {
413
526
  readonly state: 'runs' | 'absent' | 'other' | 'inert';
414
527
  readonly file: string;
528
+ }
529
+ /**
530
+ * What stands at the path is no file, `what` it is instead. `stops`: git
531
+ * can execute it, tries to run it as the hook, fails, and stops every
532
+ * commit. `ignored`: git cannot execute it, and skips it as it skips a
533
+ * hook that is not executable.
534
+ */
535
+ | {
536
+ readonly state: 'stops' | 'ignored';
537
+ readonly file: string;
538
+ readonly what: string;
539
+ }
540
+ /**
541
+ * A file the account may not read, `reason` being why, and `runs` whether
542
+ * git may execute it. One git may not execute it skips, with a hint, and
543
+ * commits. One it may it runs, and a script the shell cannot read either
544
+ * stops every commit there (measured with git 2.47 as an ordinary account),
545
+ * where a program runs without being read: which it is cannot be read.
546
+ */
547
+ | {
548
+ readonly state: 'unreadable';
549
+ readonly file: string;
550
+ readonly runs: boolean;
551
+ readonly reason: string;
415
552
  } | {
416
553
  readonly state: 'unknown';
417
554
  readonly reason: string;
@@ -438,13 +575,17 @@ export declare const PROTECT_RULE_FILES: string;
438
575
  export declare const GIT_HOOK_LINE = "node node_modules/@descent-vtt/spec-harness/bin/spec-harness.js hook git";
439
576
  /**
440
577
  * The hook init writes. It is a POSIX sh script on every platform, since Git
441
- * for Windows runs a hook with the sh it ships. Where the project's install
442
- * is not in the work tree - a linked worktree nobody installed into, a
578
+ * for Windows runs a hook with the sh it ships. git starts it at the top of
579
+ * the work tree it commits in, so the project's install is read from there,
580
+ * and where a linked worktree has none, from the repository's main work
581
+ * tree: the work tree whose `.git` the common directory is, where `git
582
+ * worktree add` was run and the project installed (ADR-0005). Where neither
583
+ * has the install - a worktree of a repository nobody installed into, a
443
584
  * repository that shares its hooks with one that uses the harness - node
444
- * would end on a stack trace, so the hook says what is missing and where, and
445
- * the commit waits, as it did when npx found nothing to run.
585
+ * would end on a stack trace, so the hook says what is missing and where,
586
+ * and the commit waits, as it did when npx found nothing to run.
446
587
  */
447
- export declare const PRE_COMMIT = "#!/bin/sh\n# spec-harness: refuse a commit that changes what the active brief protects.\n# git runs this at the top of the work tree it commits in, a linked worktree's\n# own, wherever the hook is kept: the project's install is read from there.\nbin=node_modules/@descent-vtt/spec-harness/bin/spec-harness.js\nif [ ! -f \"$bin\" ]; then\n 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\n exit 2\nfi\nexec node \"$bin\" hook git\n";
588
+ export declare const PRE_COMMIT = "#!/bin/sh\n# spec-harness: refuse a commit that changes what the active brief protects.\n# git runs this at the top of the work tree it commits in, a linked worktree's\n# own, wherever the hook is kept: the project's install is read from there,\n# or, where a linked worktree has none, from the repository's main work tree.\nbin=node_modules/@descent-vtt/spec-harness/bin/spec-harness.js\nif [ ! -f \"$bin\" ]; then\n common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)\n if [ \"${common##*/}\" = .git ] && [ -f \"${common%/.git}/$bin\" ]; then\n bin=${common%/.git}/$bin\n else\n 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\n exit 2\n fi\nfi\nexec node \"$bin\" hook git\n";
448
589
  /**
449
590
  * What doctor and init add of a hook whose text runs the harness through
450
591
  * npx: the hook init wrote through 0.9.1, or the line it advised for a hook
@@ -469,4 +610,3 @@ export declare function graphReadsBriefs(config: Json, briefs: string): boolean;
469
610
  export declare function describeGraph(archiveGlob: string, briefs: string, reads: boolean, added: boolean): string;
470
611
  /** spec-graph's configuration with the archive read as history, or `null` when it already is. */
471
612
  export declare function mergeSpecGraph(current: Json, archiveGlob: string): Json | null;
472
- export {};