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.
- package/dist/adapter-conformance.js +17 -0
- package/dist/adapters/claude-code/dialect.d.ts +19 -13
- package/dist/adapters/claude-code/dialect.js +40 -62
- package/dist/adapters/claude-code/vocabulary.d.ts +133 -0
- package/dist/adapters/claude-code/vocabulary.js +208 -0
- package/dist/core/compile.js +6 -1
- package/dist/core/dialect.d.ts +27 -0
- package/dist/core/hook-events.d.ts +32 -15
- package/dist/core/hook-events.js +23 -29
- package/dist/core/hook-program.js +12 -4
- package/dist/core/rule-meta.js +2 -2
- package/dist/core/tool-contract.d.ts +69 -30
- package/dist/core/tool-contract.js +59 -57
- package/dist/core/vocabulary-consistency.d.ts +35 -0
- package/dist/core/vocabulary-consistency.js +81 -0
- package/dist/core/vocabulary.d.ts +138 -0
- package/dist/core/vocabulary.js +262 -0
- package/dist/scan-core.d.ts +8 -1
- package/dist/scan-core.js +64 -6
- package/dist/scan-files.js +4 -1
- package/dist/scan.d.ts +24 -0
- package/dist/scan.js +8 -1
- package/package.json +1 -1
|
@@ -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
|
package/dist/scan-core.d.ts
CHANGED
|
@@ -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
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
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
|
package/dist/scan-files.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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",
|