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