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