@descent-vtt/spec-harness 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +372 -0
- package/README.md +78 -11
- package/dist/briefs.d.ts +45 -0
- package/dist/briefs.js +73 -0
- package/dist/briefs.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +384 -84
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +7 -2
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +15 -1
- package/dist/config.js +16 -3
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +142 -32
- package/dist/configure.js +316 -89
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +150 -3
- package/dist/fs.js +459 -17
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +85 -9
- package/dist/git.js +140 -16
- package/dist/git.js.map +1 -1
- package/dist/hooks.d.ts +111 -0
- package/dist/hooks.js +172 -16
- package/dist/hooks.js.map +1 -1
- package/dist/round.d.ts +8 -0
- package/dist/round.js +32 -9
- package/dist/round.js.map +1 -1
- package/dist/sandbox.js +4 -3
- package/dist/sandbox.js.map +1 -1
- package/dist/server.d.ts +25 -1
- package/dist/server.js +194 -27
- package/dist/server.js.map +1 -1
- package/dist/setup.d.ts +6 -3
- package/dist/setup.js +74 -23
- package/dist/setup.js.map +1 -1
- package/dist/siblings.d.ts +42 -7
- package/dist/siblings.js +135 -30
- package/dist/siblings.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +66 -12
- package/dist/usage.js +134 -21
- package/dist/usage.js.map +1 -1
- package/dist/workspace.d.ts +80 -1
- package/dist/workspace.js +188 -8
- package/dist/workspace.js.map +1 -1
- package/hooks/hooks.json +16 -0
- package/package.json +2 -2
package/dist/configure.js
CHANGED
|
@@ -52,12 +52,34 @@ 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';
|
|
61
|
+
/**
|
|
62
|
+
* The event Claude Code runs as its main agent finishes a turn, under which
|
|
63
|
+
* the hook reports what the work tree holds where `turnReport` is on
|
|
64
|
+
* (ADR-0005). Its entry has no matcher: the event takes none.
|
|
65
|
+
*/
|
|
66
|
+
const AT_TURN_END = 'Stop';
|
|
55
67
|
const GUARD_ARGS = Object.freeze([`${PROJECT_DIR}/${BIN}`, 'hook', 'claude']);
|
|
56
68
|
/**
|
|
57
69
|
* The guard as a Claude Code hook: exec form, `args` given, so the project's
|
|
58
70
|
* path is one argument whatever it holds, and no shell is involved.
|
|
59
71
|
*/
|
|
60
72
|
export const GUARD_HOOK = Object.freeze({ type: 'command', command: 'node', args: GUARD_ARGS, timeout: 60 });
|
|
73
|
+
/**
|
|
74
|
+
* The hook's entry at the end of a turn: the guard's line, run in the
|
|
75
|
+
* background. Claude Code waits on no part of it, so the turn ends as it
|
|
76
|
+
* would, and it hands the hook's `systemMessage` to Claude as the next
|
|
77
|
+
* turn begins, where from an entry it waits on it shows the person the
|
|
78
|
+
* message instead (the hooks reference,
|
|
79
|
+
* https://code.claude.com/docs/en/hooks, *Run hooks in the background* and
|
|
80
|
+
* *JSON output*, read 2026-10-19).
|
|
81
|
+
*/
|
|
82
|
+
export const TURN_HOOK = Object.freeze({ ...GUARD_HOOK, async: true });
|
|
61
83
|
// bare-name: what 0.1 installed, an npx command found from wherever the
|
|
62
84
|
// session stood. Read here so that init replaces it, and never run.
|
|
63
85
|
const LEGACY_HOOK = 'npx --no-install spec-harness hook claude';
|
|
@@ -96,10 +118,13 @@ function upgraded(group) {
|
|
|
96
118
|
* common causes, read 2026-10-14). A matcher that is an array is an entry
|
|
97
119
|
* Claude Code lists as an invalid setting, among the reasons a hook is not
|
|
98
120
|
* loaded and never fires: no tool. And a comma separates names only from
|
|
99
|
-
* {@link COMMA_SINCE} on
|
|
100
|
-
*
|
|
121
|
+
* {@link COMMA_SINCE} on; its changelog adds that a hyphenated name is
|
|
122
|
+
* matched exactly only from {@link HYPHEN_SINCE} on. So a matcher of names
|
|
123
|
+
* with either in it is read as `matching` has the Claude Code here read it
|
|
124
|
+
* ({@link namesReadings}), and where the readings it may have differ, it
|
|
125
|
+
* cannot be told.
|
|
101
126
|
*/
|
|
102
|
-
function matchedTools(matcher, pattern,
|
|
127
|
+
function matchedTools(matcher, pattern, matching) {
|
|
103
128
|
if (matcher === undefined || matcher === '' || matcher === '*')
|
|
104
129
|
return CALLABLE_TOOLS;
|
|
105
130
|
if (Array.isArray(matcher))
|
|
@@ -108,14 +133,11 @@ function matchedTools(matcher, pattern, commas) {
|
|
|
108
133
|
return null;
|
|
109
134
|
if (!NAMES.test(matcher))
|
|
110
135
|
return pattern(matcher);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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;
|
|
136
|
+
// A reading that cannot be told, or two that differ, leave the matcher one
|
|
137
|
+
// whose tools cannot be told; where every reading comes to the same tools,
|
|
138
|
+
// nothing turns on which the release has.
|
|
139
|
+
const readings = namesReadings(matcher, pattern, matching);
|
|
140
|
+
return readings.every((tools) => sameStrings(tools, readings[0])) ? readings[0] : null;
|
|
119
141
|
}
|
|
120
142
|
/** A matcher that is a name, or names between `|` and `,`: what the reference compares with a tool's name exactly. */
|
|
121
143
|
const NAMES = /^[A-Za-z0-9_\- ,|]*$/;
|
|
@@ -133,26 +155,63 @@ function listedTools(matcher) {
|
|
|
133
155
|
* silently never firing.
|
|
134
156
|
*/
|
|
135
157
|
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
158
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
159
|
+
* The release of Claude Code from which a hyphenated name in a matcher of
|
|
160
|
+
* names is matched exactly. Its changelog
|
|
161
|
+
* (https://code.claude.com/docs/en/changelog, 2.1.195, read 2026-10-18) has,
|
|
162
|
+
* among its fixes, hook matchers with hyphenated identifiers that matched as
|
|
163
|
+
* substrings, and match exactly from this release on; it does not say how
|
|
164
|
+
* the rest of such a matcher was read.
|
|
142
165
|
*/
|
|
143
|
-
export
|
|
166
|
+
export const HYPHEN_SINCE = '2.1.195';
|
|
167
|
+
/** The reading every release from {@link HYPHEN_SINCE} on has, and what a caller that asks no release is given. */
|
|
168
|
+
const TODAY = { as: 'today' };
|
|
169
|
+
/**
|
|
170
|
+
* How the Claude Code doctor asked its release reads a matcher of names. A
|
|
171
|
+
* release that was not asked, or could not be told, leaves the reading
|
|
172
|
+
* untold: asked or not, nothing says which it is.
|
|
173
|
+
*/
|
|
174
|
+
export function matchingUnder(release) {
|
|
144
175
|
if (release === null || release.state === 'unknown')
|
|
145
176
|
return { as: 'untold' };
|
|
146
177
|
// A release that was told is one whose version was read as one, and the
|
|
147
178
|
// constant is a version, so both parse.
|
|
148
|
-
return meets(parseVersion(release.version), parseVersion(
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
179
|
+
return meets(parseVersion(release.version), parseVersion(HYPHEN_SINCE)) ? TODAY : { as: 'older', release: release.version };
|
|
180
|
+
}
|
|
181
|
+
/** Whether `release` reads a comma in `matcher` as part of a pattern: one before {@link COMMA_SINCE}, where the matcher holds one. */
|
|
182
|
+
function commaAsPattern(matcher, release) {
|
|
183
|
+
return matcher.includes(',') && !meets(parseVersion(release), parseVersion(COMMA_SINCE));
|
|
184
|
+
}
|
|
185
|
+
/** A matcher of names that holds a comma, as a release before {@link COMMA_SINCE} reads it. */
|
|
186
|
+
function commaEarlier(matcher, pattern) {
|
|
187
|
+
// No tool, where the matcher read as a pattern matches none of the writing
|
|
188
|
+
// tools, which is what the page has such a matcher do; and where it matches
|
|
189
|
+
// one, as `Edit|Write,Bash` matches `Edit`, the page's two sentences, that
|
|
190
|
+
// a comma is read as part of a pattern and that the matcher never matched,
|
|
191
|
+
// disagree, and it cannot be told.
|
|
154
192
|
const read = pattern(matcher);
|
|
155
|
-
return
|
|
193
|
+
return read !== null && read.length === 0 ? read : null;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Each reading the Claude Code here may have of a matcher of names: the
|
|
197
|
+
* writing tools it lists, or `null` where a reading cannot be told. From
|
|
198
|
+
* {@link HYPHEN_SINCE} on there is one, the names matched exactly. Before
|
|
199
|
+
* {@link COMMA_SINCE} a comma in it makes it a pattern ({@link commaEarlier}).
|
|
200
|
+
* Before {@link HYPHEN_SINCE} a hyphen in it made it match as a substring,
|
|
201
|
+
* and the changelog does not say which way: each name matched exactly but
|
|
202
|
+
* the one with the hyphen, the matcher read as a pattern, which matches
|
|
203
|
+
* anywhere in a tool's name, or its whole text found in a tool's name, which
|
|
204
|
+
* no writing tool's holds, since none has a hyphen. A release that cannot be
|
|
205
|
+
* told may read it any way a release does.
|
|
206
|
+
*/
|
|
207
|
+
function namesReadings(matcher, pattern, matching) {
|
|
208
|
+
const listed = listedTools(matcher);
|
|
209
|
+
if (matching.as === 'today')
|
|
210
|
+
return [listed];
|
|
211
|
+
const substring = matcher.includes('-') ? [pattern(matcher), []] : [];
|
|
212
|
+
if (matching.as === 'older')
|
|
213
|
+
return commaAsPattern(matcher, matching.release) ? [commaEarlier(matcher, pattern)] : [listed, ...substring];
|
|
214
|
+
return [listed, ...(matcher.includes(',') ? [commaEarlier(matcher, pattern)] : []), ...substring];
|
|
156
215
|
}
|
|
157
216
|
/**
|
|
158
217
|
* How long the reading of one pattern may take, in milliseconds of the
|
|
@@ -223,12 +282,12 @@ function inBackground(hook) {
|
|
|
223
282
|
* entry started only for the calls its rule matches, in the syntax of a
|
|
224
283
|
* permission rule, which is not read here.
|
|
225
284
|
*/
|
|
226
|
-
function answered(event, matcher, hook, pattern,
|
|
285
|
+
function answered(event, matcher, hook, pattern, matching) {
|
|
227
286
|
if (event === BEFORE && inBackground(hook))
|
|
228
287
|
return [];
|
|
229
288
|
if (hook['if'] !== undefined)
|
|
230
289
|
return null;
|
|
231
|
-
return matchedTools(matcher, pattern,
|
|
290
|
+
return matchedTools(matcher, pattern, matching);
|
|
232
291
|
}
|
|
233
292
|
/**
|
|
234
293
|
* `.claude/settings.json`, or the plugin's hooks, with the guard hooks merged
|
|
@@ -245,14 +304,14 @@ function answered(event, matcher, hook, pattern, commas) {
|
|
|
245
304
|
*
|
|
246
305
|
* `pattern` reads a matcher that is a pattern: the run's own reader, where
|
|
247
306
|
* the run has read the file's matchers already ({@link patternReader}).
|
|
248
|
-
* `
|
|
249
|
-
*
|
|
307
|
+
* `matching` is how the Claude Code here reads a matcher of names, where
|
|
308
|
+
* the run asked its release ({@link matchingUnder}): a line that
|
|
250
309
|
* release starts for no write is no guard, and init's own is written beside
|
|
251
310
|
* it.
|
|
252
311
|
*/
|
|
253
|
-
export function mergeClaudeSettings(current, isHook = () => false, pattern = patternReader(),
|
|
312
|
+
export function mergeClaudeSettings(current, isHook = () => false, pattern = patternReader(), matching = TODAY) {
|
|
254
313
|
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,
|
|
314
|
+
const theirs = harnessEntries(current).some((entry) => !isGuard(entry.hook) && !isLegacyGuard(entry.hook) && isHook(entry.words) && isStarted(toolsOf(entry, pattern, matching)));
|
|
256
315
|
if (theirs)
|
|
257
316
|
return null;
|
|
258
317
|
let changed = false;
|
|
@@ -262,7 +321,7 @@ export function mergeClaudeSettings(current, isHook = () => false, pattern = pat
|
|
|
262
321
|
const before = Array.isArray(listed) ? listed : [];
|
|
263
322
|
const groups = before.map(upgraded);
|
|
264
323
|
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,
|
|
324
|
+
const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry, pattern, matching)));
|
|
266
325
|
if (!present)
|
|
267
326
|
groups.push({ matcher: HOOK_MATCHER, hooks: [{ ...GUARD_HOOK, args: [...GUARD_ARGS] }] });
|
|
268
327
|
if (replaced || !present) {
|
|
@@ -272,6 +331,110 @@ export function mergeClaudeSettings(current, isHook = () => false, pattern = pat
|
|
|
272
331
|
}
|
|
273
332
|
return changed ? { ...current, hooks: next } : null;
|
|
274
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Settings with init's entry under `SessionStart` added beside its two, or
|
|
336
|
+
* `null` where it is not to be: where init's own line does not stand
|
|
337
|
+
* under both `PreToolUse` and `PostToolUse`, a file init writes no hook
|
|
338
|
+
* into or one it has yet to complete; and where a line of the hook stands
|
|
339
|
+
* under `SessionStart` already, init's or one `isHook` knows, which would
|
|
340
|
+
* say the same twice (ADR-0012).
|
|
341
|
+
*/
|
|
342
|
+
export function withSessionStart(settings, isHook) {
|
|
343
|
+
const hooks = isObject(settings['hooks']) ? settings['hooks'] : {};
|
|
344
|
+
const own = GUARDED_EVENTS.every((event) => harnessEntries({ hooks: { [event]: hooks[event] } }).some((entry) => isGuard(entry.hook)));
|
|
345
|
+
if (!own || startsSessions(settings, isHook))
|
|
346
|
+
return null;
|
|
347
|
+
const listed = hooks[AT_START];
|
|
348
|
+
return { ...settings, hooks: { ...hooks, [AT_START]: [...(Array.isArray(listed) ? listed : []), { hooks: [{ ...GUARD_HOOK, args: [...GUARD_ARGS] }] }] } };
|
|
349
|
+
}
|
|
350
|
+
/** The events init writes its own line under. */
|
|
351
|
+
const INIT_EVENTS = [BEFORE, AFTER, AT_START];
|
|
352
|
+
/**
|
|
353
|
+
* Settings with `onFailure: "block"` on each of init's own entries, under
|
|
354
|
+
* the three events it writes them under, or `null` where each has it
|
|
355
|
+
* already: `init --block-on-failure`. Claude Code's hooks reference
|
|
356
|
+
* (https://code.claude.com/docs/en/hooks, Block the action when a hook
|
|
357
|
+
* fails, read 2026-10-19) has the field read by Claude Code 2.1.295 and
|
|
358
|
+
* later, and a failure under it, a hook that cannot start or that reaches
|
|
359
|
+
* its timeout among them, do what exit 2 does on its event: before a write,
|
|
360
|
+
* hold it. What a release before 2.1.295 does with the key is on no page, so
|
|
361
|
+
* init writes it only where it is asked to (ADR-0012). An entry a person
|
|
362
|
+
* wrote is theirs, and is left as it is.
|
|
363
|
+
*/
|
|
364
|
+
export function withOnFailure(settings) {
|
|
365
|
+
const hooks = isObject(settings['hooks']) ? settings['hooks'] : {};
|
|
366
|
+
const unblocked = (hook) => isGuard(hook) && hook['onFailure'] !== 'block';
|
|
367
|
+
const next = { ...hooks };
|
|
368
|
+
let changed = false;
|
|
369
|
+
for (const event of INIT_EVENTS) {
|
|
370
|
+
const groups = hooks[event];
|
|
371
|
+
if (!Array.isArray(groups) || !groups.some((group) => hooksOf(group).some(unblocked)))
|
|
372
|
+
continue;
|
|
373
|
+
next[event] = groups.map((group) => (hooksOf(group).some(unblocked) ? { ...group, hooks: hooksOf(group).map((hook) => (unblocked(hook) ? { ...hook, onFailure: 'block' } : hook)) } : group));
|
|
374
|
+
changed = true;
|
|
375
|
+
}
|
|
376
|
+
return changed ? { ...settings, hooks: next } : null;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Whether settings have Claude Code start the hook as a session starts: an
|
|
380
|
+
* entry under `SessionStart` whose line is init's, or one `isHook` knows
|
|
381
|
+
* as the hook's. Its matcher is not read: one that names some of the ways a
|
|
382
|
+
* session starts has the check made for those.
|
|
383
|
+
*/
|
|
384
|
+
export function startsSessions(settings, isHook) {
|
|
385
|
+
const groups = field(field(settings, 'hooks'), AT_START);
|
|
386
|
+
if (!Array.isArray(groups))
|
|
387
|
+
return false;
|
|
388
|
+
return groups.some((group) => hooksOf(group).some((hook) => isObject(hook) && (isGuard(hook) || knownAs(hook, isHook))));
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Settings with init's entry under `Stop` added, or `null` where a line
|
|
392
|
+
* of the hook stands there already, init's or one `isHook` knows, which
|
|
393
|
+
* would report the turn twice (ADR-0012).
|
|
394
|
+
*/
|
|
395
|
+
export function withTurnReport(settings, isHook) {
|
|
396
|
+
if (reportsTurns(settings, isHook))
|
|
397
|
+
return null;
|
|
398
|
+
const hooks = isObject(settings['hooks']) ? settings['hooks'] : {};
|
|
399
|
+
const listed = hooks[AT_TURN_END];
|
|
400
|
+
return { ...settings, hooks: { ...hooks, [AT_TURN_END]: [...(Array.isArray(listed) ? listed : []), { hooks: [{ ...TURN_HOOK, args: [...GUARD_ARGS] }] }] } };
|
|
401
|
+
}
|
|
402
|
+
/** Whether settings have Claude Code start the hook as a turn ends: an entry under `Stop` whose line is init's, or one `isHook` knows as the hook's. */
|
|
403
|
+
export function reportsTurns(settings, isHook) {
|
|
404
|
+
const groups = field(field(settings, 'hooks'), AT_TURN_END);
|
|
405
|
+
if (!Array.isArray(groups))
|
|
406
|
+
return false;
|
|
407
|
+
return groups.some((group) => hooksOf(group).some((hook) => isObject(hook) && (isGuard(hook) || knownAs(hook, isHook))));
|
|
408
|
+
}
|
|
409
|
+
/** Whether `isHook` knows an entry's line as the hook's. */
|
|
410
|
+
function knownAs(hook, isHook) {
|
|
411
|
+
const written = writtenHook(hook);
|
|
412
|
+
return written !== null && isHook(written.words);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* What doctor notes where settings hold the guard hooks and none under
|
|
416
|
+
* `SessionStart`: what would hold every write is said at the first write,
|
|
417
|
+
* which it holds, and not as the session starts.
|
|
418
|
+
*/
|
|
419
|
+
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';
|
|
420
|
+
/**
|
|
421
|
+
* What doctor notes, run in a linked worktree, of the spec-harness the
|
|
422
|
+
* hook's line runs: the one installed where `${CLAUDE_PROJECT_DIR}` points,
|
|
423
|
+
* which is this work tree in a session started in it, and the main
|
|
424
|
+
* checkout in a worktree session of the desktop app's, where Claude Code
|
|
425
|
+
* reads the hooks from the main checkout's root and points the placeholder
|
|
426
|
+
* at it (its worktrees page, What worktrees share with the main checkout,
|
|
427
|
+
* read 2026-10-19). Said where the two are not one release, `null` for
|
|
428
|
+
* none, and `null` where they are. A note: from 0.19.0 on either decides a
|
|
429
|
+
* write by the work tree that holds it, so it says which release answers,
|
|
430
|
+
* not where a write is decided.
|
|
431
|
+
*/
|
|
432
|
+
export function describeInstalls(main, there, here) {
|
|
433
|
+
if (there === here)
|
|
434
|
+
return null;
|
|
435
|
+
const release = (version) => (version === null ? 'none' : version);
|
|
436
|
+
return `note: this is a linked worktree of ${main}, and the hook's line runs the spec-harness installed where \${CLAUDE_PROJECT_DIR} points: this work tree's, ${release(here)}, in a session started here, and ${main}'s, ${release(there)}, in one started there, as a worktree session of the desktop app is`;
|
|
437
|
+
}
|
|
275
438
|
/** The server's entry, reading the project at `projectDir`: {@link PROJECT_DIR} for the plugin, {@link PROJECT_DIR_OR_HERE} for a project. */
|
|
276
439
|
export function mcpServer(projectDir) {
|
|
277
440
|
return { command: 'node', args: [`${projectDir}/${BIN}`, 'mcp', '--root', projectDir] };
|
|
@@ -367,8 +530,8 @@ function sameLine(one, other) {
|
|
|
367
530
|
* `null` where that cannot be told. Read where it is asked for and no
|
|
368
531
|
* sooner: a matcher that is a pattern can cost its whole budget.
|
|
369
532
|
*/
|
|
370
|
-
function toolsOf(entry, pattern,
|
|
371
|
-
return answered(entry.event, entry.matcher, entry.hook, pattern,
|
|
533
|
+
function toolsOf(entry, pattern, matching) {
|
|
534
|
+
return answered(entry.event, entry.matcher, entry.hook, pattern, matching);
|
|
372
535
|
}
|
|
373
536
|
/** Every entry that runs the harness under the two events the guard answers, in the order the settings hold them. */
|
|
374
537
|
function harnessEntries(settings) {
|
|
@@ -395,17 +558,40 @@ export function isStarted(tools) {
|
|
|
395
558
|
}
|
|
396
559
|
/**
|
|
397
560
|
* The release of Claude Code that reads `onFailure` on a hook's entry, by its
|
|
398
|
-
* hooks reference (https://code.claude.com/docs/en/hooks,
|
|
561
|
+
* hooks reference (https://code.claude.com/docs/en/hooks, Command hook
|
|
562
|
+
* fields, read 2026-10-19) and its changelog, which adds the field under it.
|
|
563
|
+
*/
|
|
564
|
+
export const ON_FAILURE_SINCE = '2.1.295';
|
|
565
|
+
/** Whether the Claude Code doctor asked its release reads `onFailure`: one that was told, at {@link ON_FAILURE_SINCE} or later. */
|
|
566
|
+
export function readsOnFailure(release) {
|
|
567
|
+
// A release that was told is one whose version was read as one, and the
|
|
568
|
+
// constant is a version, so both parse.
|
|
569
|
+
return release !== null && release.state !== 'unknown' && meets(parseVersion(release.version), parseVersion(ON_FAILURE_SINCE));
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* What `onFailure: "block"` changes for a line, named by `where` it stands,
|
|
573
|
+
* or `null` where the entry does not set it. The hook fails for none of its
|
|
574
|
+
* own answers, a line of its own that turns the guard off among them, which
|
|
575
|
+
* it says with exit 0 (ADR-0005): what the field holds is a hook that does
|
|
576
|
+
* not run.
|
|
399
577
|
*/
|
|
400
|
-
|
|
401
|
-
/** What `onFailure: "block"` changes for a line, named by `where` it stands, or `null` where the entry does not set it. */
|
|
402
|
-
function describeOnFailure(where, blocks, off) {
|
|
578
|
+
function describeOnFailure(where, blocks) {
|
|
403
579
|
if (!blocks)
|
|
404
580
|
return null;
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
581
|
+
return `${where} sets onFailure to "block", so Claude Code ${ON_FAILURE_SINCE} or later holds a write when the hook fails, where it lets the write go ahead without it: by not starting, as where the script on its line is not there, or by running past its timeout`;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* What is said of a line that guards and whose entries set no `onFailure`,
|
|
585
|
+
* where the Claude Code here reads the field: a hook that cannot start lets
|
|
586
|
+
* the write go ahead, and the field would hold it. Advice, which fails
|
|
587
|
+
* nothing: the file is read by every clone's Claude Code, and what a release
|
|
588
|
+
* before {@link ON_FAILURE_SINCE} does with the key is on no page. `own` is
|
|
589
|
+
* whether the entries are init's, in the file init writes, which
|
|
590
|
+
* `init --block-on-failure` gives the field.
|
|
591
|
+
*/
|
|
592
|
+
function adviseOnFailure(where, own) {
|
|
593
|
+
const how = own ? 'spec-harness init --write --block-on-failure writes it on these entries' : 'it is written on each of its entries';
|
|
594
|
+
return `${where} sets no onFailure, so Claude Code lets a write go ahead where the hook cannot start, as where the script on its line is not there, or runs past its timeout: where every Claude Code that reads the file is ${ON_FAILURE_SINCE} or later, as the one here is, "onFailure": "block" holds the write instead, and ${how}`;
|
|
409
595
|
}
|
|
410
596
|
/** Names as a sentence lists them: one, two with "and", more with commas before it. Never asked of none. */
|
|
411
597
|
function named(names) {
|
|
@@ -414,30 +600,38 @@ function named(names) {
|
|
|
414
600
|
}
|
|
415
601
|
/** 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
602
|
function toolsUnder(event, entries, reading) {
|
|
417
|
-
const read = entries.filter((entry) => entry.event === event).map((entry) => toolsOf(entry, reading.pattern, reading.
|
|
603
|
+
const read = entries.filter((entry) => entry.event === event).map((entry) => toolsOf(entry, reading.pattern, reading.matching));
|
|
418
604
|
const sure = CALLABLE_TOOLS.filter((tool) => read.some((tools) => tools !== null && tools.includes(tool)));
|
|
419
605
|
return sure.length < CALLABLE_TOOLS.length && read.some((tools) => tools === null) ? null : sure;
|
|
420
606
|
}
|
|
421
607
|
/**
|
|
422
608
|
* 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
|
|
425
|
-
* or cannot be told. Nothing for any other matcher, and nothing
|
|
426
|
-
*
|
|
609
|
+
* suggests: an array, which it loads no hook under, and names with a comma or
|
|
610
|
+
* a hyphen where the release here reads them another way than the newest
|
|
611
|
+
* does, or cannot be told. Nothing for any other matcher, and nothing where
|
|
612
|
+
* every reading the release may have is the names as listed.
|
|
427
613
|
*/
|
|
428
|
-
function saidOfMatcher(matcher, { pattern,
|
|
614
|
+
function saidOfMatcher(matcher, { pattern, matching }) {
|
|
429
615
|
if (Array.isArray(matcher))
|
|
430
616
|
return ', an array, which Claude Code lists as an invalid setting and loads no hook under';
|
|
431
|
-
if (
|
|
617
|
+
if (typeof matcher !== 'string' || !NAMES.test(matcher))
|
|
432
618
|
return '';
|
|
433
|
-
const
|
|
434
|
-
if (
|
|
619
|
+
const tools = listedTools(matcher);
|
|
620
|
+
if (namesReadings(matcher, pattern, matching).every((read) => sameStrings(read, tools)))
|
|
435
621
|
return '';
|
|
436
|
-
const listed =
|
|
437
|
-
const earlier =
|
|
438
|
-
|
|
622
|
+
const listed = tools.length === 0 ? 'no tool that writes' : named(tools);
|
|
623
|
+
const earlier = commaEarlier(matcher, pattern) === null ? 'a pattern whose matches its documentation does not give' : 'a pattern that matches no tool';
|
|
624
|
+
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";
|
|
625
|
+
if (matching.as === 'older') {
|
|
626
|
+
if (commaAsPattern(matcher, matching.release))
|
|
627
|
+
return `, which Claude Code ${matching.release} reads as ${earlier}: a comma separates names from ${COMMA_SINCE} on, where the matcher is ${listed}`;
|
|
628
|
+
return `, which Claude Code ${matching.release} may read otherwise: before ${HYPHEN_SINCE} ${substring}; from ${HYPHEN_SINCE} on it is ${listed}`;
|
|
629
|
+
}
|
|
630
|
+
// The release cannot be told: a comma alone turns on 2.1.191, and a hyphen on 2.1.195.
|
|
631
|
+
if (!matcher.includes('-'))
|
|
439
632
|
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
|
-
|
|
633
|
+
const comma = matcher.includes(',') ? `, and before ${COMMA_SINCE} ${earlier}, where a comma separated no names` : '';
|
|
634
|
+
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
635
|
}
|
|
442
636
|
/** What a line's entries under `event` hold that decides when Claude Code starts them, as written. */
|
|
443
637
|
function entriesUnder(event, entries, reading) {
|
|
@@ -538,18 +732,18 @@ export function handedLines(sources, project) {
|
|
|
538
732
|
*
|
|
539
733
|
* `pattern` reads a matcher that is a pattern, once for every entry under
|
|
540
734
|
* it in every file: the run's own reader, where something else in the run
|
|
541
|
-
* reads the same files ({@link patternReader}). `
|
|
542
|
-
* Code here reads a
|
|
543
|
-
*
|
|
544
|
-
*
|
|
735
|
+
* reads the same files ({@link patternReader}). `matching` is how the
|
|
736
|
+
* Claude Code here reads a matcher of names, where its release was asked
|
|
737
|
+
* ({@link matchingUnder}); a caller that asks none reads one as every
|
|
738
|
+
* release from {@link HYPHEN_SINCE} on does.
|
|
545
739
|
*
|
|
546
740
|
* A file that holds an array for a matcher under `PreToolUse` or
|
|
547
741
|
* `PermissionRequest` has none of its hooks loaded ({@link arrayMatcher}):
|
|
548
742
|
* each line of the hook there is started for no write, and one that stood
|
|
549
743
|
* to be started for a write is a guard that is off.
|
|
550
744
|
*/
|
|
551
|
-
export function reportHooks(sources, project, read, pattern = patternReader(),
|
|
552
|
-
const reading = { pattern,
|
|
745
|
+
export function reportHooks(sources, project, read, pattern = patternReader(), matching = TODAY, onFailureHere = false) {
|
|
746
|
+
const reading = { pattern, matching };
|
|
553
747
|
return sources.flatMap(({ file, settings }) => {
|
|
554
748
|
const entries = harnessEntries(settings);
|
|
555
749
|
const array = arrayMatcher(settings);
|
|
@@ -579,7 +773,10 @@ export function reportHooks(sources, project, read, pattern = patternReader(), c
|
|
|
579
773
|
return [{ ...unloaded, state: 'unstarted', detail: `${said}: the line is not started, and stood to be started for no write as it is, so no guard is switched off` }];
|
|
580
774
|
}
|
|
581
775
|
const written = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before, after, cover: describeCover(where, before, after, mine, reading) };
|
|
582
|
-
const note = describeOnFailure(where, hook.blocks
|
|
776
|
+
const note = describeOnFailure(where, hook.blocks);
|
|
777
|
+
// Where the release here reads the field, a line that guards without it is advised it.
|
|
778
|
+
const own = file === PROJECT_SETTINGS && mine.every((entry) => isGuard(entry.hook));
|
|
779
|
+
const guards = (detail) => ({ ...written, state: 'guards', detail, note: note ?? (onFailureHere ? adviseOnFailure(where, own) : null) });
|
|
583
780
|
if (hook.untyped !== null) {
|
|
584
781
|
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"`;
|
|
585
782
|
return [{ ...written, state: 'unknown', detail, note }];
|
|
@@ -593,15 +790,12 @@ export function reportHooks(sources, project, read, pattern = patternReader(), c
|
|
|
593
790
|
const waited = before === null || before.length > 0;
|
|
594
791
|
if (handed.state === 'off') {
|
|
595
792
|
const detail = `${where}: ${handed.fault.wrong}, so the guard is off and ${handed.unchecked ?? 'no write is checked'}; ${handed.fault.mend}`;
|
|
596
|
-
// A fault the hook says, it says
|
|
597
|
-
//
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
//
|
|
601
|
-
|
|
602
|
-
// there, so onFailure holds no write for it, and where the line is
|
|
603
|
-
// waited on before no write there is none it leaves unchecked.
|
|
604
|
-
return [waited ? { ...written, state: 'off', detail, note } : unstarted];
|
|
793
|
+
// A fault the hook says, it says after every write it is started for
|
|
794
|
+
// as well, so it is said wherever the line stands. A root outside
|
|
795
|
+
// any work tree, or in one that does not hold the project, it says
|
|
796
|
+
// nothing of, and where the line is waited on before no write there
|
|
797
|
+
// is none it leaves unchecked.
|
|
798
|
+
return [handed.silent !== true || waited ? { ...written, state: 'off', detail, note } : unstarted];
|
|
605
799
|
}
|
|
606
800
|
if (!waited)
|
|
607
801
|
return [unstarted];
|
|
@@ -613,11 +807,11 @@ export function reportHooks(sources, project, read, pattern = patternReader(), c
|
|
|
613
807
|
return [{ ...written, state: 'unknown', detail: null, note }];
|
|
614
808
|
// What a release wrote: its hook, and nothing after it.
|
|
615
809
|
if (sameStrings(hook.words, ['hook', 'claude']))
|
|
616
|
-
return [
|
|
810
|
+
return [guards(null)];
|
|
617
811
|
const root = handed.root === undefined ? '' : `, and its root, ${handed.root}, is a directory`;
|
|
618
812
|
// Whose briefs a write is checked by, where they are not the project's own.
|
|
619
813
|
const holder = handed.holder === undefined ? '' : ` in ${namedTree(handed.holder)}, which holds this project: the hook checks a write here by that work tree's briefs and its branch, as a file under ${handed.holder.path}/ there`;
|
|
620
|
-
return [
|
|
814
|
+
return [guards(`${where} is more than init writes, and guards: the hook reads the line${root}${holder}`)];
|
|
621
815
|
});
|
|
622
816
|
});
|
|
623
817
|
}
|
|
@@ -812,6 +1006,18 @@ export function describeSkipped(plugin, part, present, elsewhere = []) {
|
|
|
812
1006
|
}
|
|
813
1007
|
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
1008
|
}
|
|
1009
|
+
/**
|
|
1010
|
+
* How init comes to write the guard hooks. Where an array unloads the
|
|
1011
|
+
* project's settings file, init writes none there and says the array, so
|
|
1012
|
+
* the advice is the array's, and init after it: advice that led to more
|
|
1013
|
+
* advice is the advice at the end.
|
|
1014
|
+
*/
|
|
1015
|
+
function runInit(wiring) {
|
|
1016
|
+
const unloaded = wiring.unloaded ?? null;
|
|
1017
|
+
if (unloaded === null)
|
|
1018
|
+
return 'run spec-harness init --write';
|
|
1019
|
+
return `${describeArrayMatcher(PROJECT_SETTINGS, unloaded)}, those init writes among them: ${ONE_STRING}, then run spec-harness init --write`;
|
|
1020
|
+
}
|
|
815
1021
|
/** Why the plugin settings turn on is not on this machine, or `null` where it is, may be, or none is on. */
|
|
816
1022
|
function absence(wiring) {
|
|
817
1023
|
return wiring.presence?.state === 'absent' ? wiring.presence.detail : null;
|
|
@@ -834,7 +1040,11 @@ export function wiringState(wiring) {
|
|
|
834
1040
|
* checks no write.
|
|
835
1041
|
*/
|
|
836
1042
|
export function guardSwitchedOff(wiring) {
|
|
837
|
-
return wiring.hooksOff !== null && (wiring
|
|
1043
|
+
return wiring.hooksOff !== null && guardWired(wiring);
|
|
1044
|
+
}
|
|
1045
|
+
/** Whether something brings the guard hooks: a settings file that holds them, or a plugin settings turn on that may be on this machine. */
|
|
1046
|
+
export function guardWired(wiring) {
|
|
1047
|
+
return wiring.hooks.length > 0 || (wiring.plugin !== null && absence(wiring) === null);
|
|
838
1048
|
}
|
|
839
1049
|
/**
|
|
840
1050
|
* What doctor and init say of `disableAllHooks` in `file`. `guarded` is
|
|
@@ -862,11 +1072,11 @@ export function describePresence(wiring) {
|
|
|
862
1072
|
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
1073
|
if (wiring.hooks.length > 0)
|
|
864
1074
|
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;
|
|
1075
|
+
return wiring.server ? unguarded(plugin, presence.detail, wiring) : null;
|
|
866
1076
|
}
|
|
867
1077
|
/** 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
|
|
1078
|
+
function unguarded(plugin, detail, wiring) {
|
|
1079
|
+
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
1080
|
}
|
|
871
1081
|
/** What `doctor` says of the wiring, and what to do when it is doubled or missing. */
|
|
872
1082
|
export function describeClaudeCode(wiring) {
|
|
@@ -875,12 +1085,12 @@ export function describeClaudeCode(wiring) {
|
|
|
875
1085
|
const own = `init's entries: ${entries}`;
|
|
876
1086
|
if (plugin === null) {
|
|
877
1087
|
if (entries === '')
|
|
878
|
-
return
|
|
1088
|
+
return `neither the spec-harness plugin nor the hooks init writes, so Claude Code guards no write: ${runInit(wiring)}, or install the plugin`;
|
|
879
1089
|
return own;
|
|
880
1090
|
}
|
|
881
1091
|
const absent = absence(wiring);
|
|
882
1092
|
if (absent !== null)
|
|
883
|
-
return entries === '' ? unguarded(plugin, absent) : own;
|
|
1093
|
+
return entries === '' ? unguarded(plugin, absent, wiring) : own;
|
|
884
1094
|
if (entries === '')
|
|
885
1095
|
return `${isOn(plugin)} and brings the guard hooks and the server`;
|
|
886
1096
|
const twice = [...(wiring.hooks.length > 0 ? [PARTS.hooks.twice] : []), ...(wiring.server ? [PARTS.server.twice] : [])].join(' and ');
|
|
@@ -888,8 +1098,7 @@ export function describeClaudeCode(wiring) {
|
|
|
888
1098
|
}
|
|
889
1099
|
/** The spec-brief plugin this package ships, as spec-brief's `plugins` names it. */
|
|
890
1100
|
export const PLUGIN = '@descent-vtt/spec-harness/spec-brief-plugin';
|
|
891
|
-
|
|
892
|
-
export const SPEC_BRIEF_CONFIGS = ['.spec-brief.json', 'spec-brief.json'];
|
|
1101
|
+
export { SPEC_BRIEF_CONFIGS } from './config.js';
|
|
893
1102
|
/** The conditions an import meets in Node, its `defaultConditions`, and `default`, which every one meets. */
|
|
894
1103
|
const IMPORT_CONDITIONS = ['node', 'import', 'default'];
|
|
895
1104
|
/** A target Node refuses as invalid, which an array passes over and anything else stops at. */
|
|
@@ -999,9 +1208,13 @@ export function chooseBase(facts) {
|
|
|
999
1208
|
const unrecorded = 'no remote records a default branch (refs/remotes/origin/HEAD)';
|
|
1000
1209
|
if (branch !== null) {
|
|
1001
1210
|
const conventional = branch === 'main' || branch === 'master';
|
|
1002
|
-
if (conventional || facts.branches.every((
|
|
1211
|
+
if (conventional || facts.branches.every((local) => local.name === branch)) {
|
|
1003
1212
|
const which = conventional ? 'the branch init runs on' : 'the only branch';
|
|
1004
|
-
|
|
1213
|
+
// The base is a ref git and spec-brief read, so it is the name git
|
|
1214
|
+
// reads as the branch and nothing else: `heads/main` beside a tag
|
|
1215
|
+
// `main`. A branch with no commit yet is listed by no ref.
|
|
1216
|
+
const base = facts.branches.find((local) => local.name === branch)?.ref ?? branch;
|
|
1217
|
+
return { base, detail: `rounds are measured from ${base}, ${which}: ${unrecorded}; change "base" if rounds merge into another` };
|
|
1005
1218
|
}
|
|
1006
1219
|
}
|
|
1007
1220
|
const where = branch === null ? 'HEAD is detached' : `${branch} is not main, master or the only branch`;
|
|
@@ -1083,20 +1296,30 @@ export const PROTECT_RULE_FILES = "Have the forge protect this file, the ADRs, t
|
|
|
1083
1296
|
export const GIT_HOOK_LINE = `node ${BIN} hook git`;
|
|
1084
1297
|
/**
|
|
1085
1298
|
* 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.
|
|
1087
|
-
*
|
|
1299
|
+
* for Windows runs a hook with the sh it ships. git starts it at the top of
|
|
1300
|
+
* the work tree it commits in, so the project's install is read from there,
|
|
1301
|
+
* and where a linked worktree has none, from the repository's main work
|
|
1302
|
+
* tree: the work tree whose `.git` the common directory is, where `git
|
|
1303
|
+
* worktree add` was run and the project installed (ADR-0005). Where neither
|
|
1304
|
+
* has the install - a worktree of a repository nobody installed into, a
|
|
1088
1305
|
* 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,
|
|
1090
|
-
* the commit waits, as it did when npx found nothing to run.
|
|
1306
|
+
* would end on a stack trace, so the hook says what is missing and where,
|
|
1307
|
+
* and the commit waits, as it did when npx found nothing to run.
|
|
1091
1308
|
*/
|
|
1092
1309
|
export const PRE_COMMIT = `#!/bin/sh
|
|
1093
1310
|
# spec-harness: refuse a commit that changes what the active brief protects.
|
|
1094
1311
|
# 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
|
|
1312
|
+
# own, wherever the hook is kept: the project's install is read from there,
|
|
1313
|
+
# or, where a linked worktree has none, from the repository's main work tree.
|
|
1096
1314
|
bin=${BIN}
|
|
1097
1315
|
if [ ! -f "$bin" ]; then
|
|
1098
|
-
|
|
1099
|
-
|
|
1316
|
+
common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
1317
|
+
if [ "\${common##*/}" = .git ] && [ -f "\${common%/.git}/$bin" ]; then
|
|
1318
|
+
bin=\${common%/.git}/$bin
|
|
1319
|
+
else
|
|
1320
|
+
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
|
|
1321
|
+
exit 2
|
|
1322
|
+
fi
|
|
1100
1323
|
fi
|
|
1101
1324
|
exec node "$bin" hook git
|
|
1102
1325
|
`;
|
|
@@ -1127,6 +1350,10 @@ export function describeGitHook(hook) {
|
|
|
1127
1350
|
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
1351
|
case 'ignored':
|
|
1129
1352
|
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`;
|
|
1353
|
+
case 'unreadable':
|
|
1354
|
+
if (!hook.runs)
|
|
1355
|
+
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`;
|
|
1356
|
+
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
1357
|
case 'absent':
|
|
1131
1358
|
return missingGitHook(hook.file);
|
|
1132
1359
|
case 'unknown':
|