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.
- 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/bash-effects.d.ts +57 -0
- package/dist/core/bash-effects.js +147 -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/markdown.d.ts +53 -0
- package/dist/core/markdown.js +99 -0
- package/dist/core/rule-meta.js +2 -2
- package/dist/core/skill-resources.js +58 -57
- package/dist/core/source-refs.d.ts +118 -0
- package/dist/core/source-refs.js +206 -0
- 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/coverage-evidence.js +15 -3
- package/dist/plugin-loader.js +22 -28
- package/dist/scan-core.d.ts +8 -1
- package/dist/scan-core.js +105 -20
- package/dist/scan-files.js +17 -20
- package/dist/scan.d.ts +24 -0
- package/dist/scan.js +8 -1
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
146
|
-
*
|
|
147
|
-
|
|
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
|
*
|
package/dist/plugin-loader.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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;
|
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
|
*
|
|
@@ -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
|
|
53
|
-
// glob metacharacters `*` and `?` (dogfood D1): a real, resolvable
|
|
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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
const
|
|
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
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|