vigiles 14.7.0 → 14.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/adapters/claude-code/agent-runtime.d.ts +2 -19
  2. package/dist/adapters/claude-code/agent-runtime.js +5 -30
  3. package/dist/adapters/claude-code/agent-tools.d.ts +20 -0
  4. package/dist/adapters/claude-code/agent-tools.js +40 -0
  5. package/dist/audit-report.d.ts +1 -1
  6. package/dist/audit-report.template.html +37 -32
  7. package/dist/audit-score.d.ts +1 -1
  8. package/dist/audit-score.js +20 -20
  9. package/dist/audit-verdict.d.ts +1 -1
  10. package/dist/audit-verdict.js +3 -3
  11. package/dist/core/assert-never.d.ts +9 -0
  12. package/dist/core/assert-never.js +14 -0
  13. package/dist/core/description-overlap.js +2 -2
  14. package/dist/core/effects.js +3 -3
  15. package/dist/core/hash.d.ts +1 -2
  16. package/dist/core/hash.js +6 -4
  17. package/dist/core/hook-block-ineffective.d.ts +55 -6
  18. package/dist/core/hook-block-ineffective.js +9 -14
  19. package/dist/core/mcp-contract-message.d.ts +22 -0
  20. package/dist/core/mcp-contract-message.js +29 -0
  21. package/dist/core/mcp.d.ts +4 -12
  22. package/dist/core/mcp.js +3 -14
  23. package/dist/core/ncd.d.ts +12 -0
  24. package/dist/core/ncd.js +50 -0
  25. package/dist/core/plugin-dir-layout.d.ts +5 -5
  26. package/dist/core/plugin-dir-layout.js +10 -22
  27. package/dist/core/proofs.d.ts +2 -11
  28. package/dist/core/proofs.js +4 -39
  29. package/dist/core/skill-resources.d.ts +3 -3
  30. package/dist/core/skill-resources.js +9 -8
  31. package/dist/leaderboard.d.ts +2 -51
  32. package/dist/leaderboard.js +20 -225
  33. package/dist/optimize.d.ts +1 -1
  34. package/dist/optimize.js +3 -3
  35. package/dist/posix-path.d.ts +40 -0
  36. package/dist/posix-path.js +293 -0
  37. package/dist/scan-core.d.ts +154 -0
  38. package/dist/scan-core.js +690 -0
  39. package/dist/scan-files.d.ts +28 -0
  40. package/dist/scan-files.js +489 -0
  41. package/dist/scan.d.ts +11 -34
  42. package/dist/scan.js +55 -668
  43. package/dist/score-core.d.ts +83 -0
  44. package/dist/score-core.js +236 -0
  45. package/dist/test-coverage-files.d.ts +11 -0
  46. package/dist/test-coverage-files.js +208 -0
  47. package/package.json +1 -1
package/dist/scan.js CHANGED
@@ -12,9 +12,21 @@
12
12
  * need to RUN the plugin (observed egress under the sandbox, real trigger-rate)
13
13
  * stack on top later; this core stays pure so it runs anywhere in CI for free.
14
14
  */
15
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ var desc = Object.getOwnPropertyDescriptor(m, k);
18
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
19
+ desc = { enumerable: true, get: function() { return m[k]; } };
20
+ }
21
+ Object.defineProperty(o, k2, desc);
22
+ }) : (function(o, m, k, k2) {
23
+ if (k2 === undefined) k2 = k;
24
+ o[k2] = m[k];
25
+ }));
26
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
27
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
28
+ };
15
29
  Object.defineProperty(exports, "__esModule", { value: true });
16
- exports.isManagedHookCommand = isManagedHookCommand;
17
- exports.preferCompiledHooksMessage = preferCompiledHooksMessage;
18
30
  exports.scanPlugin = scanPlugin;
19
31
  exports.verifyLiveMcpTools = verifyLiveMcpTools;
20
32
  exports.formatMcpContractReport = formatMcpContractReport;
@@ -27,538 +39,25 @@ const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
27
39
  const layout_js_1 = require("./adapters/claude-code/layout.js");
28
40
  const dialect_js_1 = require("./adapters/claude-code/dialect.js");
29
41
  const plugin_loader_js_2 = require("./plugin-loader.js");
30
- const tool_contract_js_1 = require("./core/tool-contract.js");
31
42
  const hook_events_js_1 = require("./core/hook-events.js");
32
43
  const mcp_config_js_1 = require("./core/mcp-config.js");
33
44
  const hook_normalize_js_1 = require("./core/hook-normalize.js");
34
- const linters_js_1 = require("./core/linters.js");
35
- const frontmatter_read_js_1 = require("./core/frontmatter-read.js");
36
- const description_overlap_js_1 = require("./core/description-overlap.js");
37
- const skill_description_budget_js_1 = require("./core/skill-description-budget.js");
38
- const mcp_tool_js_1 = require("./core/mcp-tool.js");
39
45
  const mcp_js_1 = require("./core/mcp.js");
46
+ const mcp_contract_message_js_1 = require("./core/mcp-contract-message.js");
40
47
  const mcp_hook_js_1 = require("./core/mcp-hook.js");
41
- const lethal_trifecta_js_1 = require("./core/lethal-trifecta.js");
42
- const skill_resources_js_1 = require("./core/skill-resources.js");
43
- const skill_missing_fence_js_1 = require("./core/skill-missing-fence.js");
44
48
  const plugin_dir_layout_js_1 = require("./core/plugin-dir-layout.js");
45
- const delegation_trifecta_js_1 = require("./core/delegation-trifecta.js");
46
49
  const hook_block_ineffective_js_1 = require("./core/hook-block-ineffective.js");
47
50
  const hook_matcher_js_1 = require("./core/hook-matcher.js");
48
- const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
49
51
  const test_coverage_js_1 = require("./test-coverage.js");
50
- const effects_js_1 = require("./core/effects.js");
52
+ const scan_core_js_1 = require("./scan-core.js");
53
+ // Re-export the pure detectors (and their public types: SurfaceClassifier,
54
+ // SkillScanContext, isManagedHookCommand, preferCompiledHooksMessage, ...) that
55
+ // moved to the node-free `./scan-core.js`, so every existing consumer of
56
+ // `./scan.js` is unchanged — one detector, no drift.
57
+ __exportStar(require("./scan-core.js"), exports);
51
58
  // ---------------------------------------------------------------------------
52
59
  // Internals
53
60
  // ---------------------------------------------------------------------------
54
- const SCRIPT_RE = /\S+\.(?:sh|mjs|cjs|js|ts|py|rb)\b/g;
55
- // The scalar fields scan reads from a skill/agent `---` block, via the shared
56
- // lenient reader (core/frontmatter-read.ts) — a real YAML parse with a regex
57
- // salvage on malformed input, so block scalars / multi-line quoted values parse
58
- // for free and a bad block still yields what it can. One reader, no drift.
59
- function frontmatter(md) {
60
- const fm = (0, frontmatter_read_js_1.readFrontmatter)(md);
61
- return {
62
- name: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "name"),
63
- description: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "description"),
64
- model: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "model"),
65
- color: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "color"),
66
- };
67
- }
68
- function escapeRe(s) {
69
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
70
- }
71
- function makeClassifier(layout) {
72
- // An empty dir means "this harness has no such surface" → never matches.
73
- const at = (dir) => dir ? `(?:^|/)${escapeRe(dir)}/` : null;
74
- const skill = at(layout.skillDir);
75
- const agent = at(layout.agentDir);
76
- const command = at(layout.commandDir);
77
- const skillRe = skill ? new RegExp(`${skill}[^/]+/SKILL\\.md$`) : null;
78
- const agentRe = agent ? new RegExp(`${agent}[^/]+\\.md$`) : null;
79
- const commandRe = command ? new RegExp(`${command}.+\\.md$`) : null;
80
- // A subagent lives at the plugin's TOP-LEVEL `agents/` dir (e.g. `agents/foo.md`
81
- // or `.claude/agents/foo.md`), never recursively under ANOTHER surface dir. Two
82
- // real-world nesting traps are excluded as false positives:
83
- // - `skills/<x>/agents/…` — skill-internal worker docs (Anthropic's skill-creator)
84
- // - `commands/agents/…` — a COMMAND namespaced `/agents:…` (ruvnet/claude-flow),
85
- // incl. a `README.md`; these are commands, not dispatchable subagents.
86
- // Flagging either as a subagent missing frontmatter is a false positive (it
87
- // mis-graded a real plugin F). A genuine top-level `agents/foo.md` still
88
- // matches. Both excluded dirs are read from the layout (adapter-agnostic). See
89
- // scan.test.ts for the regressions.
90
- const nestedUnder = [
91
- layout.skillDir &&
92
- `${escapeRe(layout.skillDir)}/.+/${escapeRe(layout.agentDir)}/`,
93
- layout.commandDir &&
94
- `${escapeRe(layout.commandDir)}/(?:.+/)?${escapeRe(layout.agentDir)}/`,
95
- ].filter((x) => Boolean(x));
96
- const nestedAgentRe = layout.agentDir && nestedUnder.length
97
- ? new RegExp(`(?:^|/)(?:${nestedUnder.join("|")})`)
98
- : null;
99
- const isAgent = (f) => (agentRe?.test(f) ?? false) &&
100
- !f.endsWith(".spec.ts") &&
101
- !(nestedAgentRe?.test(f) ?? false);
102
- return {
103
- isSkill: (f) => skillRe?.test(f) ?? false,
104
- isAgent,
105
- isCommand: (f) => commandRe?.test(f) ?? false,
106
- };
107
- }
108
- function skillName(path) {
109
- return (path
110
- .replace(/\/SKILL\.md$/, "")
111
- .split("/")
112
- .pop() ?? path);
113
- }
114
- /**
115
- * The first prose paragraph of a SKILL.md body (after the frontmatter and any
116
- * leading `#` headings) — Claude Code's FALLBACK skill description when the
117
- * frontmatter omits `description`. Used so the trigger-surface check doesn't
118
- * overclaim "can't trigger" for a skill that has a usable body paragraph.
119
- */
120
- function firstBodyParagraph(md) {
121
- const body = md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
122
- const para = [];
123
- for (const line of body.split(/\r?\n/)) {
124
- const t = line.trim();
125
- if (t === "" || t.startsWith("#")) {
126
- if (para.length > 0)
127
- break; // end of the first paragraph
128
- continue; // skip leading blanks / headings
129
- }
130
- para.push(t);
131
- }
132
- return para.join(" ").trim() || undefined;
133
- }
134
- /** The body of a SKILL.md with the leading `---` frontmatter block stripped. */
135
- function skillBody(md) {
136
- return md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
137
- }
138
- /**
139
- * The on-disk path for a materialized file key. `loadPlugin` prefixes each
140
- * surface file with the layout's `materializeRoot` (e.g. `.claude/`), but the file
141
- * lives on disk WITHOUT that prefix (under the real surface dir), so a
142
- * bundled-resource existence check must strip it back off. Mirrors how
143
- * `resolveScript` resolves a hook path against the real plugin root.
144
- */
145
- function onDiskPath(materializedKey, materializeRoot) {
146
- if (!materializeRoot)
147
- return materializedKey;
148
- const prefix = `${materializeRoot}/`;
149
- return materializedKey.startsWith(prefix)
150
- ? materializedKey.slice(prefix.length)
151
- : materializedKey;
152
- }
153
- function scanSkills(files, cls, ctx) {
154
- const { root, materializeRoot, dialect, sharedDirs } = ctx;
155
- // `sharedDirs` are declared relative to the REPO root (config location), which
156
- // is `root` for a whole-repo scan but a PARENT when the scan is scoped to a
157
- // subdir. Resolve them against that, not the scoped subdir.
158
- const sharedDirsRoot = ctx.sharedDirsRoot ?? root;
159
- const out = [];
160
- for (const [path, md] of Object.entries(files)) {
161
- if (!cls.isSkill(path))
162
- continue;
163
- // Prefer the real on-disk dir (a `.claude/skills/…` skill materializes under
164
- // the same canonical key as a repo-root one, but lives elsewhere on disk).
165
- const onDiskDir = ctx.sources?.[path];
166
- const fm = frontmatter(md);
167
- // A skill's trigger surface is its frontmatter `description` OR — when that's
168
- // absent — Claude Code's fallback to the first body paragraph. Only when
169
- // NEITHER exists is the skill genuinely undescribed (can't be selected). The
170
- // explicit-frontmatter best-practice is the separate `skill-frontmatter` rule.
171
- const effectiveDesc = fm.description ?? firstBodyParagraph(md);
172
- const userInvoked = /^\s*disable-model-invocation:\s*true\s*$/m.test(md);
173
- // Bundled-resource refs resolve against the skill's OWN dir (resources ship
174
- // beside the SKILL.md), built from the plugin root + the file's ON-DISK dir
175
- // (the materialize-root prefix the loader added is stripped back off).
176
- const skillDir = onDiskDir
177
- ? (0, node_path_1.dirname)(onDiskDir)
178
- : (0, node_path_1.resolve)(root, (0, node_path_1.dirname)(onDiskPath(path, materializeRoot)));
179
- // Bundled refs resolve against the skill's OWN dir. A repo that shares a
180
- // top-level tree across skills (`sharedDirs` in .vigilesrc.json) ALSO resolves
181
- // a ref under one of those declared dirs against the repo root — OPT-IN, so a
182
- // repo that doesn't set it is byte-identical to before (no masking of a real
183
- // missing bundled resource). See feedback P1-4.
184
- const resourceIssues = (0, skill_resources_js_1.skillResourceIssues)(skillBody(md), skillDir, {
185
- repoRoot: sharedDirsRoot,
186
- sharedDirs,
187
- });
188
- // The lethal trifecta is a property of what a unit CAN do, which for a skill is
189
- // its declared `allowed-tools` (the CC skill tool contract). Only a model-
190
- // invocable skill can be hijacked by attacker content, so a user-invoked one is
191
- // excluded. A skill with no `allowed-tools` line inherits all → advisory.
192
- const skillTools = (0, agent_runtime_js_1.parseAgentToolList)(md, "allowed-tools");
193
- // No `allowed-tools:` line (null) → inherits all → wildcard sentinel; an
194
- // EXPLICIT empty `[]` means zero tools → no trifecta (don't collapse them).
195
- const trifecta = userInvoked
196
- ? null
197
- : (0, lethal_trifecta_js_1.lethalTrifectaIssues)(skillTools ?? ["*"], dialect);
198
- out.push({
199
- name: fm.name ?? skillName(path),
200
- path,
201
- hasDescription: Boolean(effectiveDesc && effectiveDesc.length >= 20),
202
- description: effectiveDesc?.trim(),
203
- userInvoked,
204
- resourceIssues,
205
- trifecta,
206
- // A SKILL.md opening with `name:`/`description:` but no `---` fence loads
207
- // as plain body → the skill is invisible. Inspect the RAW md (not the
208
- // frontmatter-stripped body) so the unfenced keys are visible.
209
- fenceIssue: (0, skill_missing_fence_js_1.skillMissingFence)(md),
210
- });
211
- }
212
- return out.sort((a, b) => a.name.localeCompare(b.name));
213
- }
214
- /**
215
- * Near-duplicate description pairs among the MODEL-INVOCABLE skills — the ones
216
- * that actually compete for auto-selection (a user-invoked skill is picked by
217
- * explicit command, so it can't collide). Uses the same effective-description
218
- * logic as `scanSkills` (frontmatter `description` ← first body paragraph), then
219
- * the NCD precision-proxy. See description-overlap.ts.
220
- */
221
- function modelInvocableSkillSurfaces(files, cls) {
222
- const surfaces = [];
223
- for (const [path, md] of Object.entries(files)) {
224
- if (!cls.isSkill(path))
225
- continue;
226
- if (/^\s*disable-model-invocation:\s*true\s*$/m.test(md))
227
- continue;
228
- const fm = frontmatter(md);
229
- const description = fm.description ?? firstBodyParagraph(md);
230
- if (!description || description.length < 20)
231
- continue;
232
- surfaces.push({ name: fm.name ?? skillName(path), description });
233
- }
234
- return surfaces;
235
- }
236
- function descriptionOverlapsFor(files, cls) {
237
- return (0, description_overlap_js_1.findDescriptionOverlaps)(modelInvocableSkillSurfaces(files, cls));
238
- }
239
- /**
240
- * Model-invocable skills whose description is so long the trigger signal is
241
- * buried (heuristic proxy; degrades recall + precision). Same surfaces as the
242
- * overlap check. See skill-description-budget.ts.
243
- */
244
- function descriptionBudgetFor(files, cls) {
245
- return (0, skill_description_budget_js_1.findDescriptionBudgetIssues)(modelInvocableSkillSurfaces(files, cls));
246
- }
247
- function scanAgents(files, dialect, declaredServers, cls) {
248
- const out = [];
249
- for (const [path, md] of Object.entries(files)) {
250
- if (!cls.isAgent(path))
251
- continue;
252
- const tools = (0, agent_runtime_js_1.parseAgentTools)(md);
253
- // An inherits-all agent (no `tools:` line) grants access to every tool
254
- // including every side-effecting one — pass the wildcard sentinel so
255
- // effectSurface correctly classifies it as `"unrestricted"`.
256
- const surface = (0, effects_js_1.effectSurface)(tools ?? ["*"], dialect);
257
- out.push({
258
- name: (0, node_path_1.basename)(path, ".md"),
259
- path,
260
- tools,
261
- // Cross-reference the declared rail against the dialect catalog — the moat.
262
- // Auditing third-party plugins → only the HIGH-CONFIDENCE issues (never-
263
- // available + close typos); a bare unrecognized tool is likely plugin/MCP-
264
- // provided, not a defect (the TaskCreate/TaskGet lesson). See tool-contract.ts.
265
- toolIssues: tools
266
- ? (0, tool_contract_js_1.confidentToolIssues)((0, tool_contract_js_1.verifyToolContract)(tools, dialect))
267
- : [],
268
- // The MCP half of the moat: an `mcp__server__tool` whose server isn't in the
269
- // plugin's declared set can't resolve. High-precision (gated on a declared
270
- // set, built-ins allowlisted, plugin-namespaced form skipped). See mcp-tool.ts.
271
- mcpToolIssues: tools
272
- ? (0, mcp_tool_js_1.verifyMcpToolServers)(tools, declaredServers, dialect)
273
- : [],
274
- // The block-list mirror: a `disallowedTools:` entry that's a typo of a real
275
- // tool blocks nothing (close-typo only — high-precision). See tool-contract.ts.
276
- disallowedToolIssues: (0, tool_contract_js_1.disallowedToolIssues)((0, agent_runtime_js_1.parseAgentToolList)(md, "disallowedTools") ?? [], dialect),
277
- purity: surface.purity,
278
- effectBuckets: {
279
- readOnly: surface.readOnly,
280
- sideEffecting: surface.sideEffecting,
281
- unknown: surface.unknown,
282
- },
283
- // The lethal trifecta: a subagent whose contract grants all three legs. An
284
- // inherits-all agent (no `tools:` line → tools === null) is the advisory
285
- // case — pass the wildcard sentinel so it's distinguished from an EXPLICIT
286
- // empty `tools: []` (zero tools → no trifecta). One detector, no drift.
287
- trifecta: (0, lethal_trifecta_js_1.lethalTrifectaIssues)(tools ?? ["*"], dialect),
288
- });
289
- }
290
- return out.sort((a, b) => a.name.localeCompare(b.name));
291
- }
292
- /**
293
- * Resolve a hook script token to a checkable path. `loadPlugin` expands the
294
- * braced plugin-root token (`${CLAUDE_PLUGIN_ROOT}`, Codex `${PLUGIN_ROOT}`, …);
295
- * the unbraced shell form survives, so resolve BOTH forms of the HARNESS's token
296
- * (from the layout, not hard-coded) against the plugin root and strip shell
297
- * quotes. A token that still carries any `$VAR` after that is genuinely
298
- * uncheckable.
299
- */
300
- function resolveScript(token, root, pluginRootToken, fullCommand) {
301
- // "${CLAUDE_PLUGIN_ROOT}" → unbraced "$CLAUDE_PLUGIN_ROOT".
302
- const unbraced = pluginRootToken.replace(/^\$\{(.+)\}$/, "$$$1");
303
- const cleaned = token
304
- .replace(/["']/g, "")
305
- .replaceAll(pluginRootToken, root)
306
- .replaceAll(unbraced, root);
307
- if (cleaned.includes("$"))
308
- return { command: fullCommand, script: token, status: "unresolved" };
309
- // A relative hook path (`./hooks/x.sh`, `scripts/x.py`) is the plugin's own —
310
- // resolve it against the PLUGIN ROOT, not the scanner's cwd. Without this, a
311
- // plugin that references `./hooks/x.sh` (the file IS present) was reported
312
- // MISSING because existsSync() checked cwd-relative (a false positive caught on
313
- // ananddtyagi/cc-marketplace). The displayed `script` stays as the author wrote it.
314
- const abs = (0, node_path_1.isAbsolute)(cleaned) ? cleaned : (0, node_path_1.resolve)(root, cleaned);
315
- // Resolve the full command the same way we resolve the script token (expand
316
- // plugin-root, strip outer quotes) so the CLI can pass it to verifyGuardrail.
317
- const resolvedCommand = fullCommand
318
- .replaceAll(pluginRootToken, root)
319
- .replaceAll(unbraced, root);
320
- return {
321
- command: resolvedCommand,
322
- script: cleaned,
323
- status: (0, node_fs_1.existsSync)(abs) ? "ok" : "missing",
324
- };
325
- }
326
- // A shell existence guard around a command — `[ ! -f x ] || x`, `[ -f x ] && x`,
327
- // `test -f x && …`. Authors use it to make a hook OPTIONAL (run the script only
328
- // if present; a no-op otherwise — e.g. a runtime-generated guard), so a missing
329
- // target is INTENTIONAL, not a broken reference. Don't flag scripts in such a
330
- // command as MISSING (a false positive caught on gmickel/flow-next's ralph-guard).
331
- const EXISTENCE_GUARD = /(?:\[\[?\s*!?\s*-[efsx]\s)|(?:\btest\s+!?\s*-[efsx]\s)/;
332
- /**
333
- * A compiled `vigiles/hook` artifact runs through the `hook-runtime run-program`
334
- * runtime entrypoint; any other hook command is hand-written (a shell script or
335
- * an inline one-liner) the author maintains directly. The basis for the
336
- * `prefer-compiled-hooks` nudge.
337
- */
338
- function isManagedHookCommand(command) {
339
- return /\bhook-runtime\b/.test(command);
340
- }
341
- /** The `prefer-compiled-hooks` recommendation message (shared by `lint` + `scan`). */
342
- function preferCompiledHooksMessage(count) {
343
- return (`${String(count)} hand-written hook command(s) — if any gate the agent ` +
344
- `(a block/deny decision), compiled hooks (\`vigiles/hook\`) make whole hook ` +
345
- `bug classes unrepresentable at authoring time, and \`guardrail-check\` proves ` +
346
- `an existing one blocks. See docs/compiled-hooks.md.`);
347
- }
348
- /** Pull script-file hook commands out of the resolved settings; count inline ones. */
349
- /**
350
- * Best-effort map of each script token → the hook EVENT it's registered under,
351
- * by walking the canonical object-keyed-by-event settings shape
352
- * (`{ PreToolUse: [{ hooks: [{ command }] }], … }`). Lets the safety battery
353
- * scope itself to `PreToolUse` (the only event that can block a tool call), so a
354
- * `SessionStart`/`PostToolUse`/`Stop` hook isn't tested against the disaster
355
- * catalog. Returns an empty map for a non-object/array config (event → unknown).
356
- */
357
- function eventsByScript(regs) {
358
- const map = new Map();
359
- for (const reg of regs) {
360
- for (const tok of reg.command.match(SCRIPT_RE) ?? []) {
361
- if (!map.has(tok))
362
- map.set(tok, reg.event);
363
- }
364
- }
365
- return map;
366
- }
367
- function scanHooks(regs, root, pluginRootToken) {
368
- const commands = regs.map((r) => r.command);
369
- const evMap = eventsByScript(regs);
370
- // A hand-written hook is any non-empty command that isn't a vigiles-managed
371
- // (compiled) hook-runtime invocation — the basis for the prefer-compiled-hooks nudge.
372
- const manual = commands.filter((c) => {
373
- const u = c.trim();
374
- return u !== "" && !isManagedHookCommand(u);
375
- }).length;
376
- const byScript = new Map();
377
- let inline = 0;
378
- for (const cmd of commands) {
379
- const found = cmd.match(SCRIPT_RE);
380
- if (!found || found.length === 0) {
381
- inline++;
382
- continue;
383
- }
384
- // A guarded command runs its script only if it exists — an optional hook, not
385
- // a broken one. Treat it as a conditional one-liner (inline), don't path-check.
386
- if (EXISTENCE_GUARD.test(cmd)) {
387
- inline++;
388
- continue;
389
- }
390
- for (const tok of found) {
391
- const hook = resolveScript(tok, root, pluginRootToken, cmd);
392
- const event = evMap.get(tok);
393
- byScript.set(hook.script, event ? { ...hook, event } : hook);
394
- }
395
- }
396
- const hooks = [...byScript.values()].sort((a, b) => a.script.localeCompare(b.script));
397
- return { hooks, inline, manual };
398
- }
399
- // ---------------------------------------------------------------------------
400
- // Public API
401
- // ---------------------------------------------------------------------------
402
- /**
403
- * Frontmatter-schema check — **subagents only**. Per the Claude Code docs, a
404
- * subagent (`agents/*.md`) REQUIRES `name` + `description` (no fallback) or it
405
- * won't register. A SKILL.md requires NOTHING: `name` falls back to the directory
406
- * name and `description` to the first body paragraph, so a frontmatter-less skill
407
- * still loads — flagging it would be a false positive (skill description QUALITY
408
- * is a separate, behavioral concern). See https://code.claude.com/docs/en/skills
409
- * and …/sub-agents.
410
- */
411
- function frontmatterIssuesFor(files, cls) {
412
- const out = [];
413
- for (const [path, md] of Object.entries(files)) {
414
- if (!cls.isAgent(path))
415
- continue; // skills require no frontmatter (dir/body fallbacks)
416
- const fm = frontmatter(md);
417
- const missing = [];
418
- if (!fm.name)
419
- missing.push("name");
420
- if (!fm.description)
421
- missing.push("description");
422
- if (missing.length === 0)
423
- continue;
424
- out.push({
425
- path,
426
- kind: "agent",
427
- missing,
428
- message: `agent ${path} is missing required frontmatter: ${missing.join(", ")} — it won't register.`,
429
- });
430
- }
431
- return out.sort((a, b) => a.path.localeCompare(b.path));
432
- }
433
- // The canonical subagent `model:` aliases and `color:` enum (Claude Code). The
434
- // model check skips a full/dated id (`claude-sonnet-4-5`) — that's a valid
435
- // explicit form, not a typo — so only an alias misspelling is caught.
436
- const MODEL_ALIASES = ["inherit", "sonnet", "opus", "haiku"];
437
- const AGENT_COLORS = [
438
- "red",
439
- "blue",
440
- "green",
441
- "yellow",
442
- "purple",
443
- "orange",
444
- "pink",
445
- "cyan",
446
- ];
447
- /**
448
- * Closest candidate by edit distance, ONLY when it's a high-confidence typo: the
449
- * value isn't already a candidate, and the nearest is within 2 edits. Returns
450
- * null otherwise — a far-off value is more likely an unknown-we-don't-know than a
451
- * typo (the high-precision discipline), so it's suppressed, not flagged.
452
- */
453
- function closeCandidate(value, candidates) {
454
- const v = value.toLowerCase();
455
- if (candidates.includes(v))
456
- return null;
457
- let best = null;
458
- let bestDistance = Infinity;
459
- for (const c of candidates) {
460
- const dist = (0, linters_js_1.editDistance)(v, c);
461
- if (dist < bestDistance) {
462
- bestDistance = dist;
463
- best = c;
464
- }
465
- }
466
- return bestDistance > 0 && bestDistance <= 2 ? best : null;
467
- }
468
- /**
469
- * Agent frontmatter VALUE validity — a `model:` or `color:` that's a close typo
470
- * of a real one. A bad `model:` silently falls back; a bad `color:` is ignored.
471
- * High-precision (close-typo only); a full/dated model id is left alone. Folded
472
- * into the `subagent-frontmatter` rule. Agents only (skills have no model/color).
473
- */
474
- function frontmatterValueIssuesFor(files, cls) {
475
- const out = [];
476
- for (const [path, md] of Object.entries(files)) {
477
- if (!cls.isAgent(path))
478
- continue;
479
- const fm = frontmatter(md);
480
- // A model id with a digit/hyphen is an explicit form, not an alias typo.
481
- if (fm.model && !/[0-9-]/.test(fm.model)) {
482
- const near = closeCandidate(fm.model, MODEL_ALIASES);
483
- if (near) {
484
- out.push({
485
- path,
486
- field: "model",
487
- value: fm.model,
488
- suggestion: near,
489
- message: `agent ${path} has model "${fm.model}", not a known alias — it silently falls back. Did you mean "${near}"?`,
490
- });
491
- }
492
- }
493
- if (fm.color) {
494
- const near = closeCandidate(fm.color, AGENT_COLORS);
495
- if (near) {
496
- out.push({
497
- path,
498
- field: "color",
499
- value: fm.color,
500
- suggestion: near,
501
- message: `agent ${path} has color "${fm.color}", not a valid color — it's ignored. Did you mean "${near}"?`,
502
- });
503
- }
504
- }
505
- }
506
- return out.sort((a, b) => a.path.localeCompare(b.path));
507
- }
508
- /**
509
- * Frontmatter that EXISTS but isn't valid YAML — the `frontmatter-valid` signal.
510
- * Reported for skills + agents via the shared reader's `malformed` flag. Honest
511
- * caveat (see docs/rules/frontmatter-valid.md): js-yaml is stricter than some
512
- * loaders, so a one-line `description:` containing a `: ` colon or an `<example>`
513
- * block is flagged even though it may still load — which is why scan surfaces it
514
- * as an informational note (NOT a structural defect) and the lint rule is a
515
- * warn, not an error. The file's other fields are still salvaged.
516
- */
517
- function malformedFrontmatterFor(files, cls) {
518
- const out = [];
519
- for (const [path, md] of Object.entries(files)) {
520
- if (!cls.isSkill(path) && !cls.isAgent(path))
521
- continue;
522
- if (!(0, frontmatter_read_js_1.readFrontmatter)(md).malformed)
523
- continue;
524
- out.push({
525
- path,
526
- message: `${path}: frontmatter is not valid YAML — fields may not parse as intended (a colon, quote, or bracket likely needs escaping/quoting).`,
527
- });
528
- }
529
- return out.sort((a, b) => a.path.localeCompare(b.path));
530
- }
531
- /**
532
- * Skill-metadata RECOMMENDATION (not a correctness check): a `SKILL.md` loads
533
- * fine without frontmatter (`name` ← dir, `description` ← first body paragraph),
534
- * but relying on those fallbacks is fragile — the dir name may be unclear and the
535
- * first paragraph is often a heading or boilerplate, making a weak trigger
536
- * surface. Best practice is an EXPLICIT `name` + `description`. Flags skills
537
- * missing either; surfaced as a soft note in scan (NOT a structural defect, NOT
538
- * scored) and gated by the `skill-frontmatter` lint rule (warn by default).
539
- */
540
- function skillMetaIssuesFor(files, cls) {
541
- const out = [];
542
- for (const [path, md] of Object.entries(files)) {
543
- if (!cls.isSkill(path))
544
- continue;
545
- const fm = frontmatter(md);
546
- const missing = [];
547
- if (!fm.name)
548
- missing.push("name");
549
- if (!fm.description)
550
- missing.push("description");
551
- if (missing.length === 0)
552
- continue;
553
- out.push({
554
- path,
555
- kind: "skill",
556
- missing,
557
- message: `skill ${path} has no explicit frontmatter ${missing.join(" / ")} — recommended for a reliable trigger surface (it still loads via the dir-name / first-paragraph fallback).`,
558
- });
559
- }
560
- return out.sort((a, b) => a.path.localeCompare(b.path));
561
- }
562
61
  /**
563
62
  * Collect declared MCP servers from the JSON sources (`.mcp.json` + the plugin
564
63
  * manifest's `mcpServers`). Codex's TOML `[mcp_servers]` isn't parsed here (a
@@ -586,149 +85,35 @@ function collectMcpServers(root, layout) {
586
85
  collect(layout.manifestPath);
587
86
  return servers;
588
87
  }
589
- /**
590
- * Flatten the per-surface lethal-trifecta + skill-resource findings into the
591
- * path-tagged report lists the `audit` report AND the `lethal-trifecta` /
592
- * `skill-resource-resolves` lint rules both consume (one detector, no drift).
593
- */
594
- function collectSurfaceFindings(agents, skills) {
595
- const trifectaFindings = [];
596
- for (const a of agents) {
597
- if (a.trifecta) {
598
- trifectaFindings.push({
599
- path: a.path,
600
- kind: "subagent",
601
- name: a.name,
602
- finding: a.trifecta,
603
- });
604
- }
88
+ // Disk-backed IO the moved detectors take by injection (they never import
89
+ // `node:fs`, so the browser engine can inject a map-backed pair instead).
90
+ const nodeReadFile = (p) => {
91
+ try {
92
+ return (0, node_fs_1.readFileSync)(p, "utf8");
605
93
  }
606
- for (const s of skills) {
607
- if (s.trifecta) {
608
- trifectaFindings.push({
609
- path: s.path,
610
- kind: "skill",
611
- name: s.name,
612
- finding: s.trifecta,
613
- });
614
- }
94
+ catch {
95
+ return "";
615
96
  }
616
- const skillResourceFindings = skills.flatMap((s) => s.resourceIssues.map((finding) => ({
617
- path: s.path,
618
- name: s.name,
619
- finding,
620
- })));
621
- const skillFenceFindings = skills.flatMap((s) => s.fenceIssue ? [{ path: s.path, name: s.name, finding: s.fenceIssue }] : []);
622
- return { trifectaFindings, skillResourceFindings, skillFenceFindings };
623
- }
624
- /**
625
- * Build the subagent delegation graph and flag a lethal trifecta that EMERGES
626
- * across an edge (own ∪ delegated-to capability) though no single unit trips it.
627
- *
628
- * Edge source (deterministic, audit-available): a subagent that lists the `Task`
629
- * tool can dispatch any sibling subagent, so it `delegatesTo` every OTHER agent.
630
- * An inherits-all agent (`tools === null`) carries the wildcard, which the
631
- * detector's FP-safe guard skips (that maximal-blast case is the per-unit
632
- * advisory's job). One detector, no drift. (Richer edge sources — a typed
633
- * railway's `delegate()` chain, a Flue subagent inheritance tree — plug in here.)
634
- */
635
- function collectDelegationTrifecta(agents, dialect) {
636
- const allNames = agents.map((a) => a.name);
637
- const nodes = agents.map((a) => {
638
- const canDispatch = a.tools === null ||
639
- a.tools.some((t) => t === "Task" || t.startsWith("Task("));
640
- return {
641
- name: a.name,
642
- kind: "agent",
643
- tools: a.tools ?? ["*"],
644
- delegatesTo: canDispatch ? allNames.filter((n) => n !== a.name) : [],
645
- };
646
- });
647
- const pathByName = new Map(agents.map((a) => [a.name, a.path]));
648
- return (0, delegation_trifecta_js_1.delegationTrifectaIssues)(nodes, dialect).map((finding) => ({
649
- path: pathByName.get(finding.name) ?? "",
650
- finding,
651
- }));
652
- }
653
- /**
654
- * Build `hookBlockIssues` entries by walking the canonical object-keyed-by-event
655
- * settings shape PER REGISTRATION — so a script registered under several events
656
- * is inspected under EACH (no de-dup by script path), inline one-liners are
657
- * included (script token → null, inspect the command), and a script token is
658
- * resolved to its ABSOLUTE on-disk path against the plugin root (not the caller's
659
- * cwd). Addresses the gaps a de-duplicated `ScanHook[]` would miss.
660
- */
661
- function collectHookBlockEntries(regs, root, pluginRootToken) {
662
- const out = [];
663
- for (const { event, command: cmd } of regs) {
664
- // A wrapper command runs MORE than one script (`node run.cjs guard.mjs`),
665
- // so resolve EVERY candidate and inspect each — reading only the first
666
- // (the wrapper) would miss the guard's block logic. Candidates: extensioned
667
- // script tokens (SCRIPT_RE) PLUS path-like words with NO extension
668
- // (`bash hooks/guard`, `${ROOT}/hooks/session-start`) that resolve to a file.
669
- const candidates = new Set(cmd.match(SCRIPT_RE) ?? []);
670
- for (const word of cmd.split(/\s+/)) {
671
- const w = word.replace(/^["']+|["']+$/g, "");
672
- if (w.startsWith("-"))
673
- continue; // a flag, not a path
674
- if (w.includes("/") || w.includes(pluginRootToken))
675
- candidates.add(w);
676
- }
677
- const resolvedPaths = [];
678
- for (const tok of candidates) {
679
- const r = resolveScript(tok, root, pluginRootToken, cmd);
680
- if (r.status === "ok") {
681
- const abs = (0, node_path_1.isAbsolute)(r.script) ? r.script : (0, node_path_1.resolve)(root, r.script);
682
- if (!resolvedPaths.includes(abs))
683
- resolvedPaths.push(abs);
684
- }
685
- }
686
- if (resolvedPaths.length > 0) {
687
- // One entry per resolvable script (each is inspected on its own).
688
- for (const sp of resolvedPaths) {
689
- out.push({ event, command: cmd, scriptPath: sp });
690
- }
691
- }
692
- else {
693
- // No script file resolved → inline one-liner; inspect the command text.
694
- out.push({ event, command: cmd, scriptPath: null });
695
- }
97
+ };
98
+ const nodeIsDirectory = (p) => {
99
+ try {
100
+ return (0, node_fs_1.statSync)(p).isDirectory();
696
101
  }
697
- return out;
698
- }
699
- /** Extract (event, matcher) pairs from the normalized hook registrations. */
700
- function collectHookMatchers(regs) {
701
- const seen = new Set();
702
- const out = [];
703
- for (const { event, matcher } of regs) {
704
- if (matcher === null)
705
- continue;
706
- const key = `${event}${matcher}`;
707
- if (seen.has(key))
708
- continue;
709
- seen.add(key);
710
- out.push({ event, matcher });
102
+ catch {
103
+ return false;
711
104
  }
712
- return out;
713
- }
714
- /** Tally how many scanned agents fall into each purity rung (effectSurface). */
715
- function summarizePurity(agents) {
716
- return agents.reduce((acc, a) => {
717
- acc[a.purity]++;
718
- return acc;
719
- }, { pure: 0, bounded: 0, unrestricted: 0 });
720
- }
105
+ };
721
106
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
722
107
  function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts = {}) {
723
108
  const lay = layout ?? layout_js_1.claudeCodeLayout;
724
- const cls = makeClassifier(lay);
109
+ const cls = (0, scan_core_js_1.makeClassifier)(lay);
725
110
  const loaded = (0, plugin_loader_js_1.loadPlugin)(dir, lay);
726
111
  // Parse the raw `settings.hooks` ONCE at the boundary (parse-don't-validate):
727
112
  // tolerant of the Claude Code nested shape AND the Codex flat shape, so every
728
113
  // hook detector below consumes typed `HookRegistration[]` instead of re-walking
729
114
  // `unknown`. The single seam where the per-harness hook shape is absorbed.
730
115
  const hookRegs = (0, hook_normalize_js_1.normalizeHooks)(loaded.settings.hooks);
731
- const { hooks, inline, manual } = scanHooks(hookRegs, (0, node_path_1.resolve)(dir), lay.pluginRootToken);
116
+ const { hooks, inline, manual } = (0, scan_core_js_1.scanHooks)(hookRegs, (0, node_path_1.resolve)(dir), lay.pluginRootToken, node_fs_1.existsSync);
732
117
  // Hook-event keys are a CLOSED platform set — an unrecognized one is a dead
733
118
  // registration (the hook never fires), so flag every unknown (not just typos).
734
119
  // ONLY for the canonical object-keyed-by-event shape: a plugin shipping a
@@ -746,17 +131,18 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
746
131
  : null;
747
132
  const mcpServers = collectMcpServers((0, node_path_1.resolve)(dir), lay);
748
133
  const declaredServers = Object.keys(mcpServers);
749
- const agents = scanAgents(loaded.files, dialect, declaredServers, cls);
750
- const skills = scanSkills(loaded.files, cls, {
134
+ const agents = (0, scan_core_js_1.scanAgents)(loaded.files, dialect, declaredServers, cls);
135
+ const skills = (0, scan_core_js_1.scanSkills)(loaded.files, cls, {
751
136
  root: (0, node_path_1.resolve)(dir),
752
137
  materializeRoot: lay.materializeRoot,
753
138
  dialect,
754
139
  sources: loaded.sources,
755
140
  sharedDirs: opts.sharedDirs,
756
141
  sharedDirsRoot: opts.sharedDirsRoot,
142
+ existsSync: node_fs_1.existsSync,
757
143
  });
758
- const puritySummary = summarizePurity(agents);
759
- const { trifectaFindings, skillResourceFindings, skillFenceFindings } = collectSurfaceFindings(agents, skills);
144
+ const puritySummary = (0, scan_core_js_1.summarizePurity)(agents);
145
+ const { trifectaFindings, skillResourceFindings, skillFenceFindings } = (0, scan_core_js_1.collectSurfaceFindings)(agents, skills);
760
146
  return {
761
147
  dir,
762
148
  instructions,
@@ -769,13 +155,13 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
769
155
  mcp: loaded.warnings.some((w) => w.includes("MCP server")),
770
156
  danglingRefs: (0, plugin_loader_js_2.danglingRefs)((0, node_path_1.resolve)(dir), lay),
771
157
  hookEventIssues,
772
- frontmatterIssues: frontmatterIssuesFor(loaded.files, cls),
773
- frontmatterValueIssues: frontmatterValueIssuesFor(loaded.files, cls),
774
- skillMetaIssues: skillMetaIssuesFor(loaded.files, cls),
158
+ frontmatterIssues: (0, scan_core_js_1.frontmatterIssuesFor)(loaded.files, cls),
159
+ frontmatterValueIssues: (0, scan_core_js_1.frontmatterValueIssuesFor)(loaded.files, cls),
160
+ skillMetaIssues: (0, scan_core_js_1.skillMetaIssuesFor)(loaded.files, cls),
775
161
  mcpIssues: (0, mcp_config_js_1.verifyMcpServers)(mcpServers),
776
162
  mcpHookIssues: (0, mcp_hook_js_1.verifyMcpHookTargets)(loaded.settings.hooks, declaredServers, dialect),
777
- descriptionOverlaps: descriptionOverlapsFor(loaded.files, cls),
778
- descriptionBudgetIssues: descriptionBudgetFor(loaded.files, cls),
163
+ descriptionOverlaps: (0, scan_core_js_1.descriptionOverlapsFor)(loaded.files, cls),
164
+ descriptionBudgetIssues: (0, scan_core_js_1.descriptionBudgetFor)(loaded.files, cls),
779
165
  trifectaFindings,
780
166
  skillResourceIssues: skillResourceFindings,
781
167
  skillFenceIssues: skillFenceFindings,
@@ -783,16 +169,17 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
783
169
  // The hooks dir is a misplaceable functional surface too, but it lives in
784
170
  // the layout as a convention PATH (`hooks/hooks.json`), not in surfaceDirs
785
171
  // — derive its first segment and dedupe so the detector watches it as well.
786
- [...new Set([...lay.surfaceDirs, lay.hooksConventionPath.split("/")[0]])]),
787
- delegationTrifecta: collectDelegationTrifecta(agents, dialect),
172
+ [...new Set([...lay.surfaceDirs, lay.hooksConventionPath.split("/")[0]])], { existsSync: node_fs_1.existsSync, isDirectory: nodeIsDirectory }),
173
+ delegationTrifecta: (0, scan_core_js_1.collectDelegationTrifecta)(agents, dialect),
788
174
  hookBlockFindings: dialect.noEffectHookEvents
789
- ? (0, hook_block_ineffective_js_1.hookBlockIssues)(collectHookBlockEntries(hookRegs, (0, node_path_1.resolve)(dir), lay.pluginRootToken), {
175
+ ? (0, hook_block_ineffective_js_1.hookBlockIssues)((0, scan_core_js_1.collectHookBlockEntries)(hookRegs, (0, node_path_1.resolve)(dir), lay.pluginRootToken, node_fs_1.existsSync), {
790
176
  noEffectEvents: new Set(dialect.noEffectHookEvents),
791
177
  permissionDecisionEvents: new Set(dialect.permissionDecisionHookEvents ?? []),
178
+ readFileSync: nodeReadFile,
792
179
  })
793
180
  : [],
794
- hookMatcherFindings: (0, hook_matcher_js_1.hookMatcherIssues)(collectHookMatchers(hookRegs), declaredServers, dialect),
795
- malformedFrontmatter: malformedFrontmatterFor(loaded.files, cls),
181
+ hookMatcherFindings: (0, hook_matcher_js_1.hookMatcherIssues)((0, scan_core_js_1.collectHookMatchers)(hookRegs), declaredServers, dialect),
182
+ malformedFrontmatter: (0, scan_core_js_1.malformedFrontmatterFor)(loaded.files, cls),
796
183
  warnings: loaded.warnings,
797
184
  untested: (0, test_coverage_js_1.findUntestedSurfaces)({ basePath: dir, layout: lay }).untested
798
185
  .length,
@@ -827,7 +214,7 @@ function formatMcpContractReport(errors) {
827
214
  }
828
215
  const lines = [`Live MCP tool check — ${String(errors.length)} issue(s):`];
829
216
  for (const e of errors)
830
- lines.push(" ✗ " + (0, mcp_js_1.mcpContractToolMessage)(e));
217
+ lines.push(" ✗ " + (0, mcp_contract_message_js_1.mcpContractToolMessage)(e));
831
218
  return lines.join("\n");
832
219
  }
833
220
  /**
@@ -1023,7 +410,7 @@ function formatScanReport(r) {
1023
410
  // One discovery nudge toward compiled hooks (never per-hook); the hand-written
1024
411
  // shell lane stays first-class, so this is a recommendation, not a defect.
1025
412
  if (r.manualHookCount > 0) {
1026
- out.push(`ℹ ${preferCompiledHooksMessage(r.manualHookCount)}`, "");
413
+ out.push(`ℹ ${(0, scan_core_js_1.preferCompiledHooksMessage)(r.manualHookCount)}`, "");
1027
414
  }
1028
415
  // Malformed-YAML frontmatter is INFORMATIONAL, not a structural defect: js-yaml
1029
416
  // is stricter than some loaders (a colon/quote/<example> in a one-line