vigiles 16.1.1 → 16.1.3

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.
@@ -0,0 +1,262 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classify = classify;
4
+ exports.suggest = suggest;
5
+ exports.termIssue = termIssue;
6
+ exports.scoredIssues = scoredIssues;
7
+ exports.advisoryIssues = advisoryIssues;
8
+ exports.authoringIssues = authoringIssues;
9
+ exports.vocabularyFromLists = vocabularyFromLists;
10
+ /**
11
+ * HarnessVocabulary — the platform's own words, with a STATUS per word.
12
+ *
13
+ * The three vocabulary checks (`hook-events`, `subagent-tool-contract`, and the
14
+ * `disallowedTools:` mirror) used to model a harness's vocabulary as two
15
+ * hand-kept arrays — "these names are fine", "these names are dead" — and then
16
+ * decide what to SAY about a name in neither by its EDIT DISTANCE to the first
17
+ * array. That is the defect this module exists to remove, and it failed in both
18
+ * directions at once (measured against the vendor docs, 2026-08-17):
19
+ *
20
+ * - FALSE ACCUSATION. `Setup` is a documented Claude Code hook event. It was
21
+ * reported as "a hook here never fires … Did you mean Stop?" — and the
22
+ * suggested repair rewires a one-shot setup hook onto every turn's Stop.
23
+ * It was caught for one reason only: `setup` happens to sit at edit distance
24
+ * 2 from `stop`.
25
+ * - SILENCE. Of the 31 events the vendor documents, the old catalog held 9.
26
+ * The other 22 were not "accepted" — they were unrecognised and >2 away from
27
+ * any known name, so nothing was said. `SubagentStart` and `PostCompact` sat
28
+ * at distance 3: one character from becoming the next `Setup`.
29
+ *
30
+ * So which of the two a valid name got — an accusation or silence — was decided
31
+ * by spelling luck. Neither is a verdict, and a tool whose pitch is precision
32
+ * cannot ship either as its answer to "I don't know this word".
33
+ *
34
+ * The fix is not a longer array. It is to stop asking "is this string in my
35
+ * list?" (a boolean, which has no room for "I don't know") and start asking
36
+ * "what does my catalog SAY about this string?" — a {@link TermVerdict}, which
37
+ * has four answers because the vendor's own documentation distinguishes four:
38
+ *
39
+ * - `available` — the platform provides it.
40
+ * - `withheld` — the platform removes it, unconditionally. A real defect.
41
+ * - `conditional` — the platform removes it only under a STATED condition
42
+ * (`Agent` at the depth limit; `ExitPlanMode` unless
43
+ * `permissionMode: plan`; every non-background built-in when
44
+ * the subagent runs in the background). vigiles cannot see
45
+ * the condition, so it must not assert — it reports the
46
+ * condition and stops.
47
+ * - `unrecognised` — not in the catalog. This is a statement about VIGILES,
48
+ * not about the repo being audited, and the message says so.
49
+ *
50
+ * THE THIRD OPTION. `unrecognised` is why this module exists. Silence reads as
51
+ * approval and hides our own staleness; an error blames the user for our gap.
52
+ * The honest third answer is an ADVISORY that names vigiles as the possibly-stale
53
+ * party and prints the capture the catalog came from. It is surfaced and it is
54
+ * never scored, so a name newer than our capture can never cost anyone a grade.
55
+ *
56
+ * WHY THE DISTANCE IS NOT ON THE VERDICT. {@link TermVerdict}'s `unrecognised`
57
+ * branch carries no near-match. A caller therefore CANNOT write
58
+ * `if (nearest) report()` — the shape that produced both bugs — because at the
59
+ * point where the report/skip decision is made, no distance is in scope.
60
+ * {@link suggest} exists solely to decorate a message that is already being
61
+ * emitted, and is called from the message builder, not from a branch.
62
+ *
63
+ * FAILING LOUDLY. `classify` is total: every name returns one of four cases, so
64
+ * a `switch` that forgets one is a `tsc` error via the `never` exhaustiveness
65
+ * check in {@link termIssue}. There is no `null`/`undefined` return to ignore.
66
+ *
67
+ * GOING STALE. It will. `capturedFrom` records the exact vendor artifact and
68
+ * version each catalog was read from, and every name we do not hold prints it.
69
+ * That is the difference from an ordinary allowlist: this one reports its own
70
+ * age at the moment it is out of date, rather than silently approving the word
71
+ * it has never heard of.
72
+ */
73
+ const edit_distance_js_1 = require("./edit-distance.js");
74
+ /** Look the name up. Total: always one of the four verdicts, never null. */
75
+ function classify(vocab, name) {
76
+ const term = vocab.terms.find((t) => t.name === name);
77
+ if (term === undefined)
78
+ return { kind: "unrecognised", name };
79
+ switch (term.status) {
80
+ case "available":
81
+ return { kind: "available", term };
82
+ case "withheld":
83
+ return { kind: "withheld", term };
84
+ case "conditional":
85
+ return { kind: "conditional", term };
86
+ }
87
+ }
88
+ /**
89
+ * Closest catalog name within edit distance 2, else null — a MESSAGE decoration
90
+ * only. Never call this to decide whether to report something; the verdict has
91
+ * already decided that. The ≤2 bound stays tight for the reason it always was:
92
+ * a loose bound mis-suggests (`TaskGet → Task?` is a different real tool, not a
93
+ * typo). Only `available` terms are offered — suggesting a name the platform
94
+ * withholds would trade one dead reference for another.
95
+ */
96
+ function suggest(vocab, name) {
97
+ const near = nearest(vocab, name);
98
+ return near !== null && near.distance <= SUGGEST_MAX ? near.name : null;
99
+ }
100
+ /** How far a hint may reach. Beyond this a "did you mean" mis-suggests. */
101
+ const SUGGEST_MAX = 2;
102
+ /**
103
+ * How close an unrecognised name must be to a real one before vigiles will call
104
+ * it a TYPO and put it in the grade, rather than treating it as a name it simply
105
+ * does not know.
106
+ *
107
+ * MEASURED, not assumed — and the measurement is the whole reason the old check
108
+ * failed. Over every pair of distinct names in the two shipped Claude Code
109
+ * vocabularies (465 hook-event pairs, 741 subagent-tool pairs):
110
+ *
111
+ * distance 1 : 0 pairs of the 1,206
112
+ * distance 2 : exactly 1 pair in each — `Setup`/`Stop` and `Bash`/`Task`
113
+ *
114
+ * So the vendor does not ship two real names one edit apart, which makes
115
+ * distance 1 strong evidence of a mistyped name. Distance 2, on the other hand,
116
+ * is exactly where two genuinely different real names DO collide — and the one
117
+ * event-pair that collides there is the bug itself: the old code used ≤2 as its
118
+ * threshold and therefore accused `Setup`, a real event, of being a typo of
119
+ * `Stop`. The old bound was not merely too loose, it was set precisely at the
120
+ * width of the collision.
121
+ *
122
+ * Hence: distance 1 is scored, distance 2 still earns a did-you-mean but stays
123
+ * advisory. RESIDUAL RISK, stated plainly: if the vendor ever ships a name one
124
+ * edit from an existing one, it will be scored as a typo until the catalog is
125
+ * updated. Zero of 1,206 current pairs are that close, and `dialect-drift` plus
126
+ * the conformance invariants are what surface the catalog falling behind.
127
+ */
128
+ const TYPO_MAX = 1;
129
+ /** Closest catalog name and its distance, or null when the vocabulary is empty. */
130
+ function nearest(vocab, name) {
131
+ let best = null;
132
+ let bestDistance = Infinity;
133
+ for (const t of vocab.terms) {
134
+ if (t.status === "withheld")
135
+ continue;
136
+ const d = (0, edit_distance_js_1.editDistance)(name.toLowerCase(), t.name.toLowerCase());
137
+ if (d < bestDistance) {
138
+ bestDistance = d;
139
+ best = t.name;
140
+ }
141
+ }
142
+ return best === null ? null : { name: best, distance: bestDistance };
143
+ }
144
+ /**
145
+ * Turn a verdict into the finding to report, or null when there is nothing to
146
+ * say. The severity is decided HERE, once, from the verdict — callers never
147
+ * invent their own policy, which is what let `scan` and `lint` drift apart from
148
+ * `compileAgent` before.
149
+ *
150
+ * `noun` names the thing in the message ("hook event" / "tool"); `subject`
151
+ * describes what listing it does, e.g. "a hook here never fires".
152
+ */
153
+ function termIssue(vocab, verdict, noun, deadConsequence) {
154
+ switch (verdict.kind) {
155
+ case "available":
156
+ return null;
157
+ case "conditional": {
158
+ const alias = verdict.term.aliasOf !== undefined
159
+ ? ` "${verdict.term.name}" is a still-supported deprecated alias of "${verdict.term.aliasOf}".`
160
+ : "";
161
+ return {
162
+ verdict: "conditional",
163
+ severity: "advisory",
164
+ suggestion: null,
165
+ condition: verdict.term.condition,
166
+ message: `${noun} "${verdict.term.name}" is available to a subagent, but the platform ` +
167
+ `removes it ${verdict.term.condition ?? "under a documented condition"}. ` +
168
+ `vigiles cannot see that condition from the file, so this is a note, not a defect.${alias}`,
169
+ };
170
+ }
171
+ case "withheld":
172
+ return {
173
+ verdict: "withheld",
174
+ severity: "scored",
175
+ suggestion: null,
176
+ message: `${noun} "${verdict.term.name}" is never available to a subagent — remove it from the tools list.`,
177
+ };
178
+ case "unrecognised": {
179
+ const near = nearest(vocab, verdict.name);
180
+ const suggestion = near !== null && near.distance <= SUGGEST_MAX ? near.name : null;
181
+ // One edit from a real name, and no two real names are that close (see
182
+ // TYPO_MAX): this is a mistyped name, and it is dead in the repo now.
183
+ if (near !== null && near.distance <= TYPO_MAX)
184
+ return {
185
+ verdict: "unrecognised",
186
+ severity: "scored",
187
+ suggestion,
188
+ message: `${noun} "${verdict.name}" matches no known name — ${deadConsequence}. ` +
189
+ `Did you mean "${near.name}"?`,
190
+ };
191
+ const hint = suggestion !== null ? ` Did you mean "${suggestion}"?` : "";
192
+ return {
193
+ verdict: "unrecognised",
194
+ severity: "advisory",
195
+ suggestion,
196
+ message: `${noun} "${verdict.name}" is not in vigiles's ${vocab.kind} catalog ` +
197
+ `(captured from ${vocab.capturedFrom}). If it is newer than that capture, or ` +
198
+ `custom to your harness, vigiles is out of date — not your config. ` +
199
+ `If it is a typo, ${deadConsequence}.${hint}`,
200
+ };
201
+ }
202
+ default: {
203
+ // Exhaustiveness: a new verdict kind with no branch is a tsc error here,
204
+ // so a future status cannot be silently dropped on the floor.
205
+ const never = verdict;
206
+ return never;
207
+ }
208
+ }
209
+ }
210
+ /**
211
+ * The issues that count toward a grade. Replaces the per-check
212
+ * `confidentToolIssues` / `confidentHookEventIssues` helpers, which asked "is
213
+ * there a near match?" — a question about spelling, answered by a helper each
214
+ * caller had to remember to apply and which `compileAgent` did not, so `scan`,
215
+ * `lint` and authoring could disagree about which issues were real. Severity now
216
+ * travels ON the issue, decided once in {@link termIssue}, so the split is the
217
+ * same wherever it is taken.
218
+ */
219
+ function scoredIssues(issues) {
220
+ return issues.filter((i) => i.severity === "scored");
221
+ }
222
+ /**
223
+ * The issues that are surfaced but never scored — `conditional` tools and any
224
+ * name newer than our capture. Kept out of the grade on purpose: vigiles's own
225
+ * staleness must not cost someone a letter.
226
+ */
227
+ function advisoryIssues(issues) {
228
+ return issues.filter((i) => i.severity === "advisory");
229
+ }
230
+ /**
231
+ * The issues an AUTHORING path treats as errors — everything except
232
+ * `conditional`. Authoring is a CLOSED world: you are writing this spec now,
233
+ * against the vigiles you have, so an unrecognised name is a typo worth stopping
234
+ * for. Auditing is an OPEN world: someone else wrote the file, possibly against
235
+ * a newer platform, so there the same verdict is only an advisory.
236
+ *
237
+ * `conditional` is an error in NEITHER. The tool is real and declaring it is
238
+ * correct; erroring on it is exactly what told delegating subagents to drop
239
+ * `Agent`, and what made `tools: Agent, Read, Bash` — a worked example in the
240
+ * vendor's own docs — fail to compile.
241
+ */
242
+ function authoringIssues(issues) {
243
+ return issues.filter((i) => i.verdict !== "conditional");
244
+ }
245
+ /**
246
+ * Build a vocabulary from a dialect that predates this module — `available` from
247
+ * its built-in catalog, `withheld` from its never-available list. A dialect on
248
+ * the legacy shape keeps working and its unknowns become `unrecognised`
249
+ * ADVISORIES rather than silence, which is the honest reading: a catalog with no
250
+ * recorded capture cannot claim a name is invalid.
251
+ */
252
+ function vocabularyFromLists(kind, capturedFrom, available, withheld = []) {
253
+ return {
254
+ kind,
255
+ capturedFrom,
256
+ terms: [
257
+ ...available.map((name) => ({ name, status: "available" })),
258
+ ...withheld.map((name) => ({ name, status: "withheld" })),
259
+ ],
260
+ };
261
+ }
262
+ //# sourceMappingURL=vocabulary.js.map
@@ -93,6 +93,7 @@ exports.declaredSurfaceName = declaredSurfaceName;
93
93
  */
94
94
  const frontmatter_read_js_1 = require("./core/frontmatter-read.js");
95
95
  const posix_path_js_1 = require("./posix-path.js");
96
+ const source_refs_js_1 = require("./core/source-refs.js");
96
97
  /**
97
98
  * Is `testPath` the colocated test OF this surface — NAMED after it, SITTING
98
99
  * BESIDE it?
@@ -142,9 +143,20 @@ function posixly(p) {
142
143
  function prepareTest(path) {
143
144
  return { path };
144
145
  }
145
- /** A path that looks like a script, inside a `command` string. Both twins used
146
- * to declare this separately; it lives here now so they cannot drift on it. */
147
- const SCRIPT_RE = /[\w./${}@-]+\.(?:sh|mjs|cjs|js|ts|py|rb)/g;
146
+ /**
147
+ * A path that looks like a script, inside a `command` string. Both twins used to
148
+ * declare this separately; the extension vocabulary and the trailing boundary
149
+ * now live in `core/source-refs.ts`, shared with the hook scanner, so the three
150
+ * cannot drift and none can omit the boundary (without it, `hooks.json` matched
151
+ * as `hooks.js`).
152
+ *
153
+ * ⚠️ This one scans a SERIALIZED settings blob rather than a shell parse, so it
154
+ * cannot tell an operand from inline program text the way `commandWords` can.
155
+ * It stays a regex because every hit is gated on the file EXISTING before it
156
+ * becomes a surface — a stray match is dropped, never reported — so the failure
157
+ * mode here is under-counting, not accusation.
158
+ */
159
+ const SCRIPT_RE = (0, source_refs_js_1.scriptRefPattern)();
148
160
  /**
149
161
  * Is this filename one the PAID real-model runner would actually run?
150
162
  *
@@ -38,6 +38,7 @@ const node_path_1 = require("node:path");
38
38
  const toml_1 = require("@iarna/toml");
39
39
  const hash_js_1 = require("./core/hash.js");
40
40
  const fs_walk_js_1 = require("./fs-walk.js");
41
+ const source_refs_js_1 = require("./core/source-refs.js");
41
42
  const MAX_SKILL_FILE_BYTES = 256 * 1024;
42
43
  /** Parse a JSON file, or null on any error (missing / malformed). */
43
44
  function safeReadJson(path) {
@@ -319,9 +320,11 @@ function hasMcp(root, layout) {
319
320
  }
320
321
  // A plugin-relative path reference to a file under a standard surface dir, with a
321
322
  // known extension — e.g. a hook script that `cat`s `skills/using-superpowers/SKILL.md`.
322
- const INTRA_REF_EXTS = "md|sh|cmd|mjs|cjs|js|ts|py|rb|txt|json";
323
+ // The extension vocabulary and BOTH token boundaries live in core/source-refs.ts,
324
+ // so this and its browser twin (scan-files.ts) cannot disagree on them and
325
+ // neither can omit one. See that module for the two boundary defects.
323
326
  function intraRefRe(layout) {
324
- return new RegExp(`(?:${layout.intraRefDirs.join("|")})/[A-Za-z0-9._/-]+\\.(?:${INTRA_REF_EXTS})`, "g");
327
+ return (0, source_refs_js_1.intraRefPattern)(layout.intraRefDirs);
325
328
  }
326
329
  // Shell vars that root a path OUTSIDE the plugin (the user's project / home), so
327
330
  // a `surface/…` after one is NOT a plugin-root ref. Anything else ($ROOT,
@@ -340,10 +343,21 @@ const NON_PLUGIN_VARS = new Set([
340
343
  * gmickel/flow-next false positive) or a project var (`$CLAUDE_PROJECT_DIR/…`)
341
344
  * is NOT a plugin ref. A bare ref (`cat skills/…`) or one after a plugin-root
342
345
  * var (`${PLUGIN_ROOT}/skills/…`, obra/superpowers) IS.
346
+ *
347
+ * 🔴 THE FIRST QUESTION IS WHETHER THE MATCH STARTS AT A PATH BOUNDARY AT ALL.
348
+ * "Not preceded by a slash" used to be read as "bare reference", which made
349
+ * `claude-agents/fable-advisor.md` a bare reference to `agents/fable-advisor.md`
350
+ * — a resolving path reported broken (`fcakyon/claude-codex-settings`,
351
+ * 2026-08-17). A preceding path-segment character means the match landed inside
352
+ * a longer name, which is not a reference to anything.
343
353
  */
344
354
  function isPluginRooted(content, idx) {
345
- if (idx === 0 || content[idx - 1] !== "/")
346
- return true; // bare / after quote-space
355
+ // No `idx === 0` case: start-of-content IS a boundary, and that rule belongs
356
+ // to `startsAtSeparator` (`idx <= 0 → true`), which the next line reaches with
357
+ // `content[-1] === undefined`. Restating it here would be a second copy of a
358
+ // boundary this module deliberately does not own.
359
+ if (content[idx - 1] !== "/")
360
+ return (0, source_refs_js_1.startsAtSeparator)(content, idx);
347
361
  // The path component immediately before the separating slash.
348
362
  const seg = /([^\s"'`(=:/]*)$/.exec(content.slice(0, idx - 1))?.[1] ?? "";
349
363
  const varName = /^\$\{?(\w+)\}?$/.exec(seg)?.[1];
@@ -359,27 +373,6 @@ function isPluginRooted(content, idx) {
359
373
  // A path in an executable hook/helper script (incl. extensionless ones like
360
374
  // obra/superpowers' `hooks/session-start`) IS a real file op — those we scan.
361
375
  const DOC_SOURCE_RE = /\.(?:md|markdown|mdx|txt|rst)$/i;
362
- // A `.sh`/`.bash`/`.cmd` SCRIPT's own `#`-led comments are prose, not code — a
363
- // usage comment (`# bash skills/<plugin>/hooks/setup.sh`, written as it would be
364
- // invoked from the REPO CHECKOUT root) is not a real file operation any more than
365
- // a path mentioned in a doc file is (issue #110). Narrowly scoped to the shell/cmd
366
- // shapes that conventionally use `#` for a comment (unlike e.g. `.mjs`/`.js`).
367
- const SCRIPT_COMMENT_RE = /\.(?:sh|bash|cmd)$/i;
368
- /**
369
- * Drop FULL-LINE `#` comments (including the shebang, which also starts with
370
- * `#`) from a `.sh`/`.bash`/`.cmd` script's content before it's scanned for
371
- * intra-plugin refs — a line whose first non-whitespace character is `#` is
372
- * prose, never a real command. Full-line only: a `#` inside a quoted string
373
- * mid-line is left alone, so a genuine ref on a real code line is never
374
- * dropped. Mirrors `hookBlockIssues`' `stripFullLineComments` discipline
375
- * (core/hook-block-ineffective.ts).
376
- */
377
- function stripShellComments(content) {
378
- return content
379
- .split("\n")
380
- .filter((line) => !line.trimStart().startsWith("#"))
381
- .join("\n");
382
- }
383
376
  /**
384
377
  * Intra-plugin file references that don't resolve — the partial-vendor / broken-
385
378
  * path class (e.g. obra/superpowers' `hooks/session-start` reads
@@ -438,9 +431,10 @@ function executableSources(root, layout) {
438
431
  for (const [path, content] of Object.entries(readTree(dir, root))) {
439
432
  if (DOC_SOURCE_RE.test(path))
440
433
  continue; // skip prose
441
- sources[path] = SCRIPT_COMMENT_RE.test(path)
442
- ? stripShellComments(content)
443
- : content;
434
+ // A full-line comment is prose in EVERY language, not only in shell —
435
+ // both remaining corpus false positives here came out of JSDoc. See
436
+ // core/source-refs.ts.
437
+ sources[path] = (0, source_refs_js_1.stripFullLineComments)(path, content);
444
438
  }
445
439
  }
446
440
  return sources;
@@ -1,3 +1,4 @@
1
+ import type { HookEventIssue } from "./core/hook-events.js";
1
2
  import { type DescriptionOverlap } from "./core/description-overlap.js";
2
3
  import { type DescriptionBudgetIssue } from "./core/skill-description-budget.js";
3
4
  import type { SkillRefSource } from "./skill-refs.js";
@@ -6,7 +7,7 @@ import type { HarnessDialect } from "./core/dialect.js";
6
7
  import type { HookRegistration } from "./core/hook-normalize.js";
7
8
  import type { HookScriptEntry } from "./core/hook-block-ineffective.js";
8
9
  import type { HookMatcherEntry } from "./core/hook-matcher.js";
9
- import type { ScanSkill, ScanAgent, ScanHook, FrontmatterIssue, FrontmatterValueIssue, FrontmatterParseIssue, ScanTrifectaFinding, ScanSkillResourceFinding, ScanSkillFenceFinding, ScanDelegationFinding } from "./scan.js";
10
+ import type { ScanSkill, ScanAgent, ScanHook, FrontmatterIssue, FrontmatterValueIssue, FrontmatterParseIssue, ScanTrifectaFinding, ScanSkillResourceFinding, ScanSkillFenceFinding, ScanDelegationFinding, VocabularyNote } from "./scan.js";
10
11
  /**
11
12
  * Per-kind surface classifiers, built from the harness `PluginLayout`'s
12
13
  * `skillDir`/`agentDir`/`commandDir` — so adding a harness whose subagents live
@@ -201,4 +202,10 @@ export declare function summarizePurity(agents: readonly ScanAgent[]): {
201
202
  bounded: number;
202
203
  unrestricted: number;
203
204
  };
205
+ /**
206
+ * Gather the advisory half of the vocabulary findings for the report. Kept in
207
+ * one place so hook events and subagent tools present identically — the two used
208
+ * to answer the same question with different policies.
209
+ */
210
+ export declare function collectVocabularyNotes(hookEventIssues: readonly HookEventIssue[], agents: readonly ScanAgent[]): VocabularyNote[];
204
211
  //# sourceMappingURL=scan-core.d.ts.map
package/dist/scan-core.js CHANGED
@@ -20,6 +20,7 @@ exports.collectDelegationTrifecta = collectDelegationTrifecta;
20
20
  exports.collectHookBlockEntries = collectHookBlockEntries;
21
21
  exports.collectHookMatchers = collectHookMatchers;
22
22
  exports.summarizePurity = summarizePurity;
23
+ exports.collectVocabularyNotes = collectVocabularyNotes;
23
24
  /**
24
25
  * scan-core — the PURE, NODE-FREE detector runtime behind `vigiles audit`.
25
26
  *
@@ -45,18 +46,45 @@ const skill_description_budget_js_1 = require("./core/skill-description-budget.j
45
46
  const mcp_tool_js_1 = require("./core/mcp-tool.js");
46
47
  const lethal_trifecta_js_1 = require("./core/lethal-trifecta.js");
47
48
  const skill_resources_js_1 = require("./core/skill-resources.js");
49
+ const bash_effects_js_1 = require("./core/bash-effects.js");
50
+ const source_refs_js_1 = require("./core/source-refs.js");
48
51
  const skill_missing_fence_js_1 = require("./core/skill-missing-fence.js");
49
52
  const delegation_trifecta_js_1 = require("./core/delegation-trifecta.js");
50
53
  const effects_js_1 = require("./core/effects.js");
51
54
  const agent_tools_js_1 = require("./adapters/claude-code/agent-tools.js");
52
- // A script-path token inside a hook command. The token class is `\S` MINUS the
53
- // glob metacharacters `*` and `?` (dogfood D1): a real, resolvable hook path
54
- // never contains them, but a command that merely MENTIONS a glob — e.g.
55
+ // A script-path token, matched against a WHOLE shell WORD. The token class is
56
+ // `\S` MINUS the glob metacharacters `*` and `?` (dogfood D1): a real, resolvable
57
+ // hook path never contains them, but a command that merely MENTIONS a glob — e.g.
55
58
  // `find . -name "*.js"` in a hook body — would otherwise have `"*.js"` grabbed as
56
59
  // a "script" and reported MISSING (a false positive). Shell vars / braces /
57
- // quotes / slashes ARE kept (`${CLAUDE_PLUGIN_ROOT}/hooks/x.sh`, `"$HOME"/y.sh`),
58
- // since resolveScript expands + strips those; only glob patterns are dropped.
59
- const SCRIPT_RE = /[^\s*?]+\.(?:sh|mjs|cjs|js|ts|py|rb)\b/g;
60
+ // slashes ARE kept (`${CLAUDE_PLUGIN_ROOT}/hooks/x.sh`), since resolveScript
61
+ // expands those. See core/source-refs.ts for the boundary rules.
62
+ const SCRIPT_WORD_RE = (0, source_refs_js_1.scriptWordPattern)();
63
+ /**
64
+ * The script-path operands a hook `command` names.
65
+ *
66
+ * 🔴 THE COMMAND IS SHELL, SO IT IS PARSED AS SHELL. This used to run
67
+ * `SCRIPT_RE` over the raw command string, which cannot tell an operand from
68
+ * the text of an inline program. Against the standard portable-plugin idiom
69
+ * `node -e "…await import(…join(root,'hooks','always-on.mjs'))…"` it produced
70
+ * the script name
71
+ * `import(require(node:url).pathToFileURL(require(node:path).join(root,hooks,always-on.mjs`
72
+ * and reported it MISSING, while `hooks/always-on.mjs` sat on disk. Nine such
73
+ * findings across the 32-repo dogfood corpus (2026-08-17), contributing to two
74
+ * `F/0` grades.
75
+ *
76
+ * `commandWords` returns the words a shell would resolve, with inline program
77
+ * text subtracted, so the report cannot name a fragment of a JavaScript
78
+ * expression. A command that does not parse as shell yields `null`, and the
79
+ * caller treats it as an inline one-liner rather than guessing — abstaining is
80
+ * the direction that cannot accuse.
81
+ */
82
+ function scriptTokens(command) {
83
+ const words = (0, bash_effects_js_1.commandWords)(command);
84
+ if (words === null)
85
+ return null;
86
+ return words.filter((w) => SCRIPT_WORD_RE.test(w));
87
+ }
60
88
  // The scalar fields scan reads from a skill/agent `---` block, via the shared
61
89
  // lenient reader (core/frontmatter-read.ts) — a real YAML parse with a regex
62
90
  // salvage on malformed input, so block scalars / multi-line quoted values parse
@@ -411,6 +439,9 @@ ctx) {
411
439
  // including every side-effecting one — pass the wildcard sentinel so
412
440
  // effectSurface correctly classifies it as `"unrestricted"`.
413
441
  const surface = (0, effects_js_1.effectSurface)(tools ?? ["*"], dialect);
442
+ // Classify ONCE; the scored and advisory halves are two views of one result,
443
+ // so they cannot disagree about what the vocabulary said.
444
+ const vocabIssues = tools ? (0, tool_contract_js_1.verifyToolContract)(tools, dialect) : [];
414
445
  out.push({
415
446
  name: (0, posix_path_js_1.basename)(path, ".md"),
416
447
  path: ctx
@@ -418,12 +449,16 @@ ctx) {
418
449
  : path,
419
450
  tools,
420
451
  // Cross-reference the declared rail against the dialect catalog — the moat.
421
- // Auditing third-party plugins → only the HIGH-CONFIDENCE issues (never-
422
- // available + close typos); a bare unrecognized tool is likely plugin/MCP-
423
- // provided, not a defect (the TaskCreate/TaskGet lesson). See tool-contract.ts.
424
- toolIssues: tools
425
- ? (0, tool_contract_js_1.confidentToolIssues)((0, tool_contract_js_1.verifyToolContract)(tools, dialect))
426
- : [],
452
+ // The SCORED half only: a tool the platform withholds unconditionally, or a
453
+ // name one edit from a real one (no two real names are that close, so that
454
+ // is a typo). Everything else the vocabulary has an opinion about goes to
455
+ // `toolNotes` — surfaced, never graded. See core/vocabulary.ts.
456
+ toolIssues: tools ? (0, tool_contract_js_1.scoredIssues)(vocabIssues) : [],
457
+ // Advisory: a real tool the platform withholds only under a condition
458
+ // vigiles cannot see (`Agent` at the depth limit), and a name that is
459
+ // simply not in our capture — which may mean the catalog is stale, not
460
+ // that the contract is wrong.
461
+ toolNotes: tools ? (0, tool_contract_js_1.advisoryIssues)(vocabIssues) : [],
427
462
  // The MCP half of the moat: an `mcp__server__tool` whose server isn't in the
428
463
  // plugin's declared set can't resolve. High-precision (gated on a declared
429
464
  // set, built-ins allowlisted, plugin-namespaced form skipped). See mcp-tool.ts.
@@ -518,7 +553,7 @@ function preferCompiledHooksMessage(count) {
518
553
  function eventsByScript(regs) {
519
554
  const map = new Map();
520
555
  for (const reg of regs) {
521
- for (const tok of reg.command.match(SCRIPT_RE) ?? []) {
556
+ for (const tok of scriptTokens(reg.command) ?? []) {
522
557
  if (!map.has(tok))
523
558
  map.set(tok, reg.event);
524
559
  }
@@ -537,7 +572,7 @@ function scanHooks(regs, root, pluginRootToken, exists) {
537
572
  const byScript = new Map();
538
573
  let inline = 0;
539
574
  for (const cmd of commands) {
540
- const found = cmd.match(SCRIPT_RE);
575
+ const found = scriptTokens(cmd);
541
576
  if (!found || found.length === 0) {
542
577
  inline++;
543
578
  continue;
@@ -798,13 +833,13 @@ function collectHookBlockEntries(regs, root, pluginRootToken, exists) {
798
833
  // A wrapper command runs MORE than one script (`node run.cjs guard.mjs`),
799
834
  // so resolve EVERY candidate and inspect each — reading only the first
800
835
  // (the wrapper) would miss the guard's block logic. Candidates: extensioned
801
- // script tokens (SCRIPT_RE) PLUS path-like words with NO extension
836
+ // script-shaped words PLUS path-like words with NO extension
802
837
  // (`bash hooks/guard`, `${ROOT}/hooks/session-start`) that resolve to a file.
803
- const candidates = new Set(cmd.match(SCRIPT_RE) ?? []);
804
- for (const word of cmd.split(/\s+/)) {
805
- const w = word.replace(/^["']+|["']+$/g, "");
806
- if (w.startsWith("-"))
807
- continue; // a flag, not a path
838
+ // Words the SHELL would resolve — not a split on whitespace, which cannot
839
+ // see quoting and cannot tell `node -e '<program>'` from an operand.
840
+ const words = (0, bash_effects_js_1.commandWords)(cmd) ?? [];
841
+ const candidates = new Set(words.filter((w) => SCRIPT_WORD_RE.test(w)));
842
+ for (const w of words) {
808
843
  if (w.includes("/") || w.includes(pluginRootToken))
809
844
  candidates.add(w);
810
845
  }
@@ -852,4 +887,54 @@ function summarizePurity(agents) {
852
887
  return acc;
853
888
  }, { pure: 0, bounded: 0, unrestricted: 0 });
854
889
  }
890
+ // ---------------------------------------------------------------------------
891
+ // Vocabulary notes — the advisory half of the findings
892
+ // ---------------------------------------------------------------------------
893
+ // These live HERE, not in scan.ts, because both engines produce them and only
894
+ // this module is node-free. Importing them from scan.ts pulled the node-only
895
+ // graph (down to `@ast-grep/napi`'s native .node binding) into the browser
896
+ // bundle and broke the site build — the gate that owns this invariant.
897
+ /**
898
+ * Gather the advisory half of the vocabulary findings for the report. Kept in
899
+ * one place so hook events and subagent tools present identically — the two used
900
+ * to answer the same question with different policies.
901
+ */
902
+ function collectVocabularyNotes(hookEventIssues, agents) {
903
+ return [
904
+ ...(0, tool_contract_js_1.advisoryIssues)(hookEventIssues).map((i) => ({
905
+ where: `hook event "${i.event}"`,
906
+ message: i.message,
907
+ })),
908
+ ...agents.flatMap((a) => groupAgentToolNotes(a)),
909
+ ];
910
+ }
911
+ /**
912
+ * One agent's advisory tool notes, with the `conditional` ones GROUPED by the
913
+ * condition they share. A delegating subagent legitimately declares eight
914
+ * foreground-only tools; printing the same sentence eight times is noise, and
915
+ * noise is what this whole change exists to stop producing. Unrecognised names
916
+ * stay one-per-tool — each carries its own did-you-mean.
917
+ */
918
+ function groupAgentToolNotes(agent) {
919
+ const notes = (0, tool_contract_js_1.advisoryIssues)(agent.toolNotes ?? []);
920
+ const byCondition = new Map();
921
+ const out = [];
922
+ for (const i of notes) {
923
+ if (i.verdict === "conditional" && i.condition !== undefined) {
924
+ const at = byCondition.get(i.condition) ?? [];
925
+ at.push(i.tool);
926
+ byCondition.set(i.condition, at);
927
+ continue;
928
+ }
929
+ out.push({ where: agent.path, message: i.message });
930
+ }
931
+ for (const [condition, tools] of byCondition)
932
+ out.push({
933
+ where: agent.path,
934
+ message: `${tools.join(", ")} ${tools.length === 1 ? "is a real tool" : "are real tools"}, ` +
935
+ `but the platform removes ${tools.length === 1 ? "it" : "them"} ${condition}. ` +
936
+ `vigiles cannot see that condition from the file, so this is a note, not a defect.`,
937
+ });
938
+ return out;
939
+ }
855
940
  //# sourceMappingURL=scan-core.js.map