vigiles 16.1.2 → 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,138 @@
1
+ /** What the platform does with a term, per the vendor's own documentation. */
2
+ export type TermStatus = "available" | "withheld" | "conditional";
3
+ /** One word of a harness's vocabulary, with what the platform does with it. */
4
+ export interface VocabularyTerm {
5
+ readonly name: string;
6
+ readonly status: TermStatus;
7
+ /**
8
+ * The vendor's stated condition, near-verbatim. REQUIRED when `status` is
9
+ * `"conditional"` — a condition we cannot quote is a condition we cannot
10
+ * report, and reporting it is the whole point of the status.
11
+ */
12
+ readonly condition?: string;
13
+ /**
14
+ * The current name this term is a still-working deprecated alias of (e.g.
15
+ * `Task` → `Agent`, renamed in Claude Code 2.1.63). An alias is NOT a defect:
16
+ * the platform keeps honouring it.
17
+ */
18
+ readonly aliasOf?: string;
19
+ }
20
+ /** A named set of platform terms, tagged with where and when it was captured. */
21
+ export interface HarnessVocabulary {
22
+ /** Which vocabulary this is — used in messages, so it must read as English. */
23
+ readonly kind: string;
24
+ /**
25
+ * The exact vendor artifact + version this catalog was read from, e.g.
26
+ * `"code.claude.com/docs/en/hooks § Hook events (claude-code 2.1.233)"`.
27
+ * Printed with every `unrecognised` advisory, so our staleness is visible to
28
+ * the person who hit it rather than only to us.
29
+ */
30
+ readonly capturedFrom: string;
31
+ readonly terms: readonly VocabularyTerm[];
32
+ }
33
+ /**
34
+ * What the catalog says about one name. Total — there is no absent answer, and
35
+ * deliberately no near-match on the `unrecognised` branch (see the module note:
36
+ * a distance in scope at the decision point is what produced the bugs).
37
+ */
38
+ export type TermVerdict = {
39
+ readonly kind: "available";
40
+ readonly term: VocabularyTerm;
41
+ } | {
42
+ readonly kind: "withheld";
43
+ readonly term: VocabularyTerm;
44
+ } | {
45
+ readonly kind: "conditional";
46
+ readonly term: VocabularyTerm;
47
+ } | {
48
+ readonly kind: "unrecognised";
49
+ readonly name: string;
50
+ };
51
+ /** How much weight a finding carries — the ONLY input to whether it is scored. */
52
+ export type IssueSeverity =
53
+ /** A defect in the audited repo. Enters the grade. */
54
+ "scored"
55
+ /** True but not actionable, or a statement about vigiles. Never scored. */
56
+ | "advisory";
57
+ /** Look the name up. Total: always one of the four verdicts, never null. */
58
+ export declare function classify(vocab: HarnessVocabulary, name: string): TermVerdict;
59
+ /**
60
+ * Closest catalog name within edit distance 2, else null — a MESSAGE decoration
61
+ * only. Never call this to decide whether to report something; the verdict has
62
+ * already decided that. The ≤2 bound stays tight for the reason it always was:
63
+ * a loose bound mis-suggests (`TaskGet → Task?` is a different real tool, not a
64
+ * typo). Only `available` terms are offered — suggesting a name the platform
65
+ * withholds would trade one dead reference for another.
66
+ */
67
+ export declare function suggest(vocab: HarnessVocabulary, name: string): string | null;
68
+ /** A vocabulary finding: the message to show and whether it counts. */
69
+ export interface TermIssue {
70
+ /** Which verdict produced this — the input to every downstream policy. */
71
+ readonly verdict: TermVerdict["kind"];
72
+ readonly severity: IssueSeverity;
73
+ readonly message: string;
74
+ /** Near-match for the message only; null unless the term is unrecognised. */
75
+ readonly suggestion: string | null;
76
+ /**
77
+ * The vendor condition, present only for a `conditional` verdict. Carried so a
78
+ * report can GROUP the tools that share one condition instead of repeating the
79
+ * same sentence per tool — a delegating subagent legitimately declares eight of
80
+ * them, and eight identical paragraphs is noise from a tool that sells itself
81
+ * on not crying wolf.
82
+ */
83
+ readonly condition?: string;
84
+ }
85
+ /**
86
+ * Turn a verdict into the finding to report, or null when there is nothing to
87
+ * say. The severity is decided HERE, once, from the verdict — callers never
88
+ * invent their own policy, which is what let `scan` and `lint` drift apart from
89
+ * `compileAgent` before.
90
+ *
91
+ * `noun` names the thing in the message ("hook event" / "tool"); `subject`
92
+ * describes what listing it does, e.g. "a hook here never fires".
93
+ */
94
+ export declare function termIssue(vocab: HarnessVocabulary, verdict: TermVerdict, noun: string, deadConsequence: string): TermIssue | null;
95
+ /**
96
+ * The issues that count toward a grade. Replaces the per-check
97
+ * `confidentToolIssues` / `confidentHookEventIssues` helpers, which asked "is
98
+ * there a near match?" — a question about spelling, answered by a helper each
99
+ * caller had to remember to apply and which `compileAgent` did not, so `scan`,
100
+ * `lint` and authoring could disagree about which issues were real. Severity now
101
+ * travels ON the issue, decided once in {@link termIssue}, so the split is the
102
+ * same wherever it is taken.
103
+ */
104
+ export declare function scoredIssues<T extends {
105
+ readonly severity: IssueSeverity;
106
+ }>(issues: readonly T[]): T[];
107
+ /**
108
+ * The issues that are surfaced but never scored — `conditional` tools and any
109
+ * name newer than our capture. Kept out of the grade on purpose: vigiles's own
110
+ * staleness must not cost someone a letter.
111
+ */
112
+ export declare function advisoryIssues<T extends {
113
+ readonly severity: IssueSeverity;
114
+ }>(issues: readonly T[]): T[];
115
+ /**
116
+ * The issues an AUTHORING path treats as errors — everything except
117
+ * `conditional`. Authoring is a CLOSED world: you are writing this spec now,
118
+ * against the vigiles you have, so an unrecognised name is a typo worth stopping
119
+ * for. Auditing is an OPEN world: someone else wrote the file, possibly against
120
+ * a newer platform, so there the same verdict is only an advisory.
121
+ *
122
+ * `conditional` is an error in NEITHER. The tool is real and declaring it is
123
+ * correct; erroring on it is exactly what told delegating subagents to drop
124
+ * `Agent`, and what made `tools: Agent, Read, Bash` — a worked example in the
125
+ * vendor's own docs — fail to compile.
126
+ */
127
+ export declare function authoringIssues<T extends {
128
+ readonly verdict: TermVerdict["kind"];
129
+ }>(issues: readonly T[]): T[];
130
+ /**
131
+ * Build a vocabulary from a dialect that predates this module — `available` from
132
+ * its built-in catalog, `withheld` from its never-available list. A dialect on
133
+ * the legacy shape keeps working and its unknowns become `unrecognised`
134
+ * ADVISORIES rather than silence, which is the honest reading: a catalog with no
135
+ * recorded capture cannot claim a name is invalid.
136
+ */
137
+ export declare function vocabularyFromLists(kind: string, capturedFrom: string, available: readonly string[], withheld?: readonly string[]): HarnessVocabulary;
138
+ //# sourceMappingURL=vocabulary.d.ts.map
@@ -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
@@ -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
  *
@@ -438,6 +439,9 @@ ctx) {
438
439
  // including every side-effecting one — pass the wildcard sentinel so
439
440
  // effectSurface correctly classifies it as `"unrestricted"`.
440
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) : [];
441
445
  out.push({
442
446
  name: (0, posix_path_js_1.basename)(path, ".md"),
443
447
  path: ctx
@@ -445,12 +449,16 @@ ctx) {
445
449
  : path,
446
450
  tools,
447
451
  // Cross-reference the declared rail against the dialect catalog — the moat.
448
- // Auditing third-party plugins → only the HIGH-CONFIDENCE issues (never-
449
- // available + close typos); a bare unrecognized tool is likely plugin/MCP-
450
- // provided, not a defect (the TaskCreate/TaskGet lesson). See tool-contract.ts.
451
- toolIssues: tools
452
- ? (0, tool_contract_js_1.confidentToolIssues)((0, tool_contract_js_1.verifyToolContract)(tools, dialect))
453
- : [],
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) : [],
454
462
  // The MCP half of the moat: an `mcp__server__tool` whose server isn't in the
455
463
  // plugin's declared set can't resolve. High-precision (gated on a declared
456
464
  // set, built-ins allowlisted, plugin-namespaced form skipped). See mcp-tool.ts.
@@ -879,4 +887,54 @@ function summarizePurity(agents) {
879
887
  return acc;
880
888
  }, { pure: 0, bounded: 0, unrestricted: 0 });
881
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
+ }
882
940
  //# sourceMappingURL=scan-core.js.map
@@ -57,6 +57,7 @@ const scan_core_js_1 = require("./scan-core.js");
57
57
  // Zero imports of its own — pure string work, safe in the browser engine.
58
58
  const skill_refs_js_1 = require("./skill-refs.js");
59
59
  const merge_conflict_js_1 = require("./core/merge-conflict.js");
60
+ const scan_core_js_2 = require("./scan-core.js");
60
61
  /**
61
62
  * The synthetic absolute root every path in a browser scan resolves against. A
62
63
  * pure, deterministic string (never `process.cwd()`), so `join`/`relative` stay
@@ -469,7 +470,8 @@ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = diale
469
470
  const hookRegs = (0, hook_normalize_js_1.normalizeHooks)(loaded.settings.hooks);
470
471
  const { hooks, inline, manual } = (0, scan_core_js_1.scanHooks)(hookRegs, exports.BROWSER_ROOT, lay.pluginRootToken, exists);
471
472
  const eventNames = (0, hook_normalize_js_1.hookEventNames)(loaded.settings.hooks);
472
- const hookEventIssues = (0, hook_events_js_1.confidentHookEventIssues)((0, hook_events_js_1.verifyHookEvents)(eventNames, dialect));
473
+ const allHookEventIssues = (0, hook_events_js_1.verifyHookEvents)(eventNames, dialect);
474
+ const hookEventIssues = (0, hook_events_js_1.scoredIssues)(allHookEventIssues);
473
475
  const instructions = loaded.files[lay.instructionFile] !== undefined
474
476
  ? {
475
477
  file: lay.instructionFile,
@@ -528,6 +530,7 @@ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = diale
528
530
  sources: loaded.sources,
529
531
  })).map(skill_refs_js_1.formatSkillRefIssue),
530
532
  hookEventIssues,
533
+ vocabularyNotes: (0, scan_core_js_2.collectVocabularyNotes)(allHookEventIssues, agents),
531
534
  frontmatterIssues: remap((0, scan_core_js_1.frontmatterIssuesFor)(loaded.files, cls)),
532
535
  frontmatterValueIssues: remap((0, scan_core_js_1.frontmatterValueIssuesFor)(loaded.files, cls)),
533
536
  skillMetaIssues: remap((0, scan_core_js_1.skillMetaIssuesFor)(loaded.files, cls)),
package/dist/scan.d.ts CHANGED
@@ -78,6 +78,12 @@ export interface ScanAgent {
78
78
  readonly toolIssues: readonly ToolIssue[];
79
79
  /** MCP tool entries naming a server the plugin doesn't declare (can't resolve). */
80
80
  readonly mcpToolIssues: readonly McpToolIssue[];
81
+ /**
82
+ * ADVISORY tool findings — a real tool withheld only under a condition vigiles
83
+ * cannot see, or a name not in its capture. Surfaced via `vocabularyNotes`,
84
+ * never scored. Optional: absence is not a claim of zero.
85
+ */
86
+ readonly toolNotes?: readonly ToolIssue[];
81
87
  /** `disallowedTools:` block-list entries that are typos of a real tool (block nothing). */
82
88
  readonly disallowedToolIssues: readonly ToolIssue[];
83
89
  /**
@@ -102,6 +108,12 @@ export interface ScanAgent {
102
108
  readonly trifecta: TrifectaFinding | null;
103
109
  }
104
110
  /** A lethal-trifecta finding tagged with the surface (subagent/skill) that holds it. */
111
+ /** One advisory vocabulary finding, tagged with the surface that carries it. */
112
+ export interface VocabularyNote {
113
+ /** Where it was found — a hook event key, or an agent's path. */
114
+ readonly where: string;
115
+ readonly message: string;
116
+ }
105
117
  export interface ScanTrifectaFinding {
106
118
  readonly path: string;
107
119
  readonly kind: "subagent" | "skill";
@@ -218,6 +230,18 @@ export interface ScanReport {
218
230
  readonly skillRefIssues?: readonly string[];
219
231
  /** Hooks registered under an event name the harness doesn't define (typo / dead). */
220
232
  readonly hookEventIssues: readonly HookEventIssue[];
233
+ /**
234
+ * ADVISORY vocabulary findings — names vigiles could not confirm, and real
235
+ * names the platform withholds only under a condition it cannot see. Surfaced
236
+ * and NEVER scored, which is the point: the two failure modes this replaced
237
+ * were silence (which reads as approval and hides vigiles's own staleness) and
238
+ * an error (which blames the user for our gap). Neither is a verdict, so these
239
+ * get a third channel instead of a grade.
240
+ *
241
+ * OPTIONAL because absence is not a claim: a producer predating this field
242
+ * reports nothing rather than reporting zero.
243
+ */
244
+ readonly vocabularyNotes?: readonly VocabularyNote[];
221
245
  /** Skills/agents missing a required frontmatter field (name; agents also description). */
222
246
  readonly frontmatterIssues: readonly FrontmatterIssue[];
223
247
  /** Agent frontmatter fields with an invalid value (a typo of a real model/color). */
package/dist/scan.js CHANGED
@@ -153,7 +153,8 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
153
153
  // object keys only and returns [] for an array. We don't interpret a format we
154
154
  // don't own.
155
155
  const eventNames = (0, hook_normalize_js_1.hookEventNames)(loaded.settings.hooks);
156
- const hookEventIssues = (0, hook_events_js_1.confidentHookEventIssues)((0, hook_events_js_1.verifyHookEvents)(eventNames, dialect));
156
+ const allHookEventIssues = (0, hook_events_js_1.verifyHookEvents)(eventNames, dialect);
157
+ const hookEventIssues = (0, hook_events_js_1.scoredIssues)(allHookEventIssues);
157
158
  const instructions = loaded.files[lay.instructionFile] !== undefined
158
159
  ? {
159
160
  file: lay.instructionFile,
@@ -211,6 +212,7 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
211
212
  sources: loaded.sources,
212
213
  })).map(skill_refs_js_1.formatSkillRefIssue),
213
214
  hookEventIssues,
215
+ vocabularyNotes: (0, scan_core_js_1.collectVocabularyNotes)(allHookEventIssues, agents),
214
216
  frontmatterIssues: remap((0, scan_core_js_1.frontmatterIssuesFor)(loaded.files, cls)),
215
217
  frontmatterValueIssues: remap((0, scan_core_js_1.frontmatterValueIssuesFor)(loaded.files, cls)),
216
218
  skillMetaIssues: remap((0, scan_core_js_1.skillMetaIssuesFor)(loaded.files, cls)),
@@ -468,6 +470,11 @@ function formatScanReport(r) {
468
470
  out.push(...section("Hooks", hookLines, r.hooks.length + r.inlineHooks));
469
471
  out.push(...section("Broken references", r.danglingRefs.map((ref) => ` ✗ ${ref} (referenced but MISSING)`)));
470
472
  out.push(...section("Hook events", r.hookEventIssues.map((i) => ` ✗ ${i.message}`)));
473
+ // Advisory, never scored — a name vigiles cannot confirm, or a real one the
474
+ // platform withholds only under a condition it cannot see. Printed with `·`
475
+ // rather than `✗` so it reads as a note about vigiles's knowledge, not a
476
+ // defect in the repo being audited.
477
+ out.push(...section("Vocabulary notes (advisory, not graded)", (r.vocabularyNotes ?? []).map((n) => ` · ${n.where}: ${n.message}`)));
471
478
  out.push(...section("Frontmatter", [
472
479
  ...r.frontmatterIssues.map((i) => ` ✗ ${i.message}`),
473
480
  ...r.frontmatterValueIssues.map((i) => ` ✗ ${i.message}`),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "16.1.2",
3
+ "version": "16.1.3",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",