vigiles 5.0.0 → 5.1.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 (38) hide show
  1. package/README.md +82 -116
  2. package/dist/adapters/claude-code/agent-runtime.d.ts +10 -0
  3. package/dist/adapters/claude-code/agent-runtime.js +15 -29
  4. package/dist/adapters/claude-code/dialect.js +18 -2
  5. package/dist/adapters/codex/eval.d.ts +94 -0
  6. package/dist/adapters/codex/eval.js +227 -0
  7. package/dist/cli.js +464 -8
  8. package/dist/codex.d.ts +1 -0
  9. package/dist/codex.js +3 -0
  10. package/dist/core/compile.js +8 -36
  11. package/dist/core/description-overlap.d.ts +27 -0
  12. package/dist/core/description-overlap.js +53 -0
  13. package/dist/core/dialect.d.ts +8 -0
  14. package/dist/core/frontmatter-read.d.ts +25 -0
  15. package/dist/core/frontmatter-read.js +138 -0
  16. package/dist/core/hook-events.d.ts +34 -0
  17. package/dist/core/hook-events.js +48 -0
  18. package/dist/core/mcp-config.d.ts +20 -0
  19. package/dist/core/mcp-config.js +40 -0
  20. package/dist/core/mcp-hook.d.ts +35 -0
  21. package/dist/core/mcp-hook.js +70 -0
  22. package/dist/core/mcp-tool.d.ts +50 -0
  23. package/dist/core/mcp-tool.js +61 -0
  24. package/dist/core/tool-contract.d.ts +68 -0
  25. package/dist/core/tool-contract.js +113 -0
  26. package/dist/core/types.d.ts +89 -0
  27. package/dist/core/validate.js +22 -0
  28. package/dist/eval.d.ts +69 -13
  29. package/dist/eval.js +106 -51
  30. package/dist/leaderboard.js +61 -3
  31. package/dist/plugin-loader.d.ts +1 -0
  32. package/dist/plugin-loader.js +71 -18
  33. package/dist/scan-behavioral.d.ts +73 -0
  34. package/dist/scan-behavioral.js +150 -0
  35. package/dist/scan.d.ts +126 -1
  36. package/dist/scan.js +559 -40
  37. package/package.json +27 -4
  38. package/skills/migrate-to-spec/SKILL.md +0 -2
package/dist/scan.js CHANGED
@@ -13,55 +13,200 @@
13
13
  * stack on top later; this core stays pure so it runs anywhere in CI for free.
14
14
  */
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.unexpectedScript = unexpectedScript;
16
17
  exports.scanPlugin = scanPlugin;
18
+ exports.inspectMarketplace = inspectMarketplace;
19
+ exports.expandMarketplace = expandMarketplace;
17
20
  exports.formatScanReport = formatScanReport;
18
21
  const node_fs_1 = require("node:fs");
19
22
  const node_path_1 = require("node:path");
20
23
  const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
24
+ const layout_js_1 = require("./adapters/claude-code/layout.js");
25
+ const dialect_js_1 = require("./adapters/claude-code/dialect.js");
26
+ const plugin_loader_js_2 = require("./plugin-loader.js");
27
+ const tool_contract_js_1 = require("./core/tool-contract.js");
28
+ const hook_events_js_1 = require("./core/hook-events.js");
29
+ const mcp_config_js_1 = require("./core/mcp-config.js");
30
+ const linters_js_1 = require("./core/linters.js");
31
+ const frontmatter_read_js_1 = require("./core/frontmatter-read.js");
32
+ const description_overlap_js_1 = require("./core/description-overlap.js");
33
+ const mcp_tool_js_1 = require("./core/mcp-tool.js");
34
+ const mcp_hook_js_1 = require("./core/mcp-hook.js");
21
35
  const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
22
36
  const test_coverage_js_1 = require("./test-coverage.js");
23
37
  // ---------------------------------------------------------------------------
24
38
  // Internals
25
39
  // ---------------------------------------------------------------------------
26
40
  const SCRIPT_RE = /\S+\.(?:sh|mjs|cjs|js|ts|py|rb)\b/g;
41
+ // The scalar fields scan reads from a skill/agent `---` block, via the shared
42
+ // lenient reader (core/frontmatter-read.ts) — a real YAML parse with a regex
43
+ // salvage on malformed input, so block scalars / multi-line quoted values parse
44
+ // for free and a bad block still yields what it can. One reader, no drift.
27
45
  function frontmatter(md) {
28
- const m = /(?:^|\n)---\r?\n([\s\S]*?)\r?\n---/.exec(md);
29
- if (!m)
30
- return {};
31
- const name = /^name:\s*(.+)$/m.exec(m[1])?.[1]?.trim();
32
- const description = /^description:\s*(.+)$/m.exec(m[1])?.[1]?.trim();
33
- return { name, description };
34
- }
35
- const isSkill = (f) => /skills\/[^/]+\/SKILL\.md$/.test(f);
36
- const isAgent = (f) => /agents\/[^/]+\.md$/.test(f) && !f.endsWith(".spec.ts");
37
- const isCommand = (f) => /commands\/.+\.md$/.test(f);
46
+ const fm = (0, frontmatter_read_js_1.readFrontmatter)(md);
47
+ return {
48
+ name: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "name"),
49
+ description: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "description"),
50
+ model: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "model"),
51
+ color: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "color"),
52
+ };
53
+ }
54
+ // Anchor each surface on a real path boundary (start-of-path or a `/`), so a
55
+ // directory whose NAME merely ends in the keyword isn't misclassified — e.g.
56
+ // the skill `skills/dispatching-parallel-agents/SKILL.md` must NOT register as
57
+ // an agent named "SKILL" (the `-agents/` substring), which real plugins like
58
+ // obra/superpowers ship. See scan.test.ts for the regression cases.
59
+ const isSkill = (f) => /(?:^|\/)skills\/[^/]+\/SKILL\.md$/.test(f);
60
+ const isAgent = (f) => /(?:^|\/)agents\/[^/]+\.md$/.test(f) && !f.endsWith(".spec.ts");
61
+ const isCommand = (f) => /(?:^|\/)commands\/.+\.md$/.test(f);
38
62
  function skillName(path) {
39
63
  return (path
40
64
  .replace(/\/SKILL\.md$/, "")
41
65
  .split("/")
42
66
  .pop() ?? path);
43
67
  }
68
+ // [Unicode \p{Script=…} property value (Node native, no dependency), our Script
69
+ // label]. Japanese kana fold to "Japanese". Latin is the DEFAULT expectation (the
70
+ // selector is English-centric), but it's just a default — a language-matched pack
71
+ // can declare a different expectation, and then the OTHER script is the mismatch.
72
+ const SCRIPTS = [
73
+ ["Latin", "Latin"],
74
+ ["Cyrillic", "Cyrillic"],
75
+ ["Han", "Han"],
76
+ ["Hiragana", "Japanese"],
77
+ ["Katakana", "Japanese"],
78
+ ["Hangul", "Korean"],
79
+ ["Arabic", "Arabic"],
80
+ ["Hebrew", "Hebrew"],
81
+ ["Greek", "Greek"],
82
+ ["Devanagari", "Devanagari"],
83
+ ["Thai", "Thai"],
84
+ ];
85
+ /** Letter counts per named script label (Japanese kana folded together). */
86
+ function scriptCounts(text) {
87
+ const counts = new Map();
88
+ for (const [script, label] of SCRIPTS) {
89
+ const n = (text.match(new RegExp(`\\p{Script=${script}}`, "gu")) ?? [])
90
+ .length;
91
+ if (n > 0)
92
+ counts.set(label, (counts.get(label) ?? 0) + n);
93
+ }
94
+ return counts;
95
+ }
96
+ /**
97
+ * The description's dominant alphabetic script when it DIFFERS from `expected`
98
+ * (default `"Latin"`) — the cross-language trigger-risk signal. The model's
99
+ * skill-selection context is English-centric, so a description written mostly in
100
+ * another script may under-fire on English prompts. `expected` is a configurable
101
+ * default, not a value judgement: a Russian-targeted pack sets it to `"Cyrillic"`
102
+ * so its Cyrillic descriptions pass and an English one is flagged instead.
103
+ * Returns null when the dominant script IS the expected one (or there's no
104
+ * alphabetic content). Shared by `scan` and the future lint rule (one detector,
105
+ * no drift). The ≥20% guard avoids a near-empty string tripping on one letter.
106
+ */
107
+ function unexpectedScript(text, expected = "Latin") {
108
+ const counts = scriptCounts(text);
109
+ let total = 0;
110
+ let dominant = null;
111
+ for (const [label, count] of counts) {
112
+ total += count;
113
+ if (!dominant || count > dominant.count)
114
+ dominant = { label, count };
115
+ }
116
+ if (!dominant || dominant.label === expected)
117
+ return null;
118
+ return dominant.count / total >= 0.2 ? dominant.label : null;
119
+ }
120
+ /**
121
+ * The first prose paragraph of a SKILL.md body (after the frontmatter and any
122
+ * leading `#` headings) — Claude Code's FALLBACK skill description when the
123
+ * frontmatter omits `description`. Used so the trigger-surface check doesn't
124
+ * overclaim "can't trigger" for a skill that has a usable body paragraph.
125
+ */
126
+ function firstBodyParagraph(md) {
127
+ const body = md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
128
+ const para = [];
129
+ for (const line of body.split(/\r?\n/)) {
130
+ const t = line.trim();
131
+ if (t === "" || t.startsWith("#")) {
132
+ if (para.length > 0)
133
+ break; // end of the first paragraph
134
+ continue; // skip leading blanks / headings
135
+ }
136
+ para.push(t);
137
+ }
138
+ return para.join(" ").trim() || undefined;
139
+ }
44
140
  function scanSkills(files) {
45
141
  const out = [];
46
142
  for (const [path, md] of Object.entries(files)) {
47
143
  if (!isSkill(path))
48
144
  continue;
49
145
  const fm = frontmatter(md);
146
+ // A skill's trigger surface is its frontmatter `description` OR — when that's
147
+ // absent — Claude Code's fallback to the first body paragraph. Only when
148
+ // NEITHER exists is the skill genuinely undescribed (can't be selected). The
149
+ // explicit-frontmatter best-practice is the separate `skill-frontmatter` rule.
150
+ const effectiveDesc = fm.description ?? firstBodyParagraph(md);
50
151
  out.push({
51
152
  name: fm.name ?? skillName(path),
52
153
  path,
53
- hasDescription: Boolean(fm.description && fm.description.length >= 20),
154
+ hasDescription: Boolean(effectiveDesc && effectiveDesc.length >= 20),
54
155
  userInvoked: /^\s*disable-model-invocation:\s*true\s*$/m.test(md),
156
+ descriptionScript: effectiveDesc ? unexpectedScript(effectiveDesc) : null,
55
157
  });
56
158
  }
57
159
  return out.sort((a, b) => a.name.localeCompare(b.name));
58
160
  }
59
- function scanAgents(files) {
161
+ /**
162
+ * Near-duplicate description pairs among the MODEL-INVOCABLE skills — the ones
163
+ * that actually compete for auto-selection (a user-invoked skill is picked by
164
+ * explicit command, so it can't collide). Uses the same effective-description
165
+ * logic as `scanSkills` (frontmatter `description` ← first body paragraph), then
166
+ * the NCD precision-proxy. See description-overlap.ts.
167
+ */
168
+ function descriptionOverlapsFor(files) {
169
+ const surfaces = [];
170
+ for (const [path, md] of Object.entries(files)) {
171
+ if (!isSkill(path))
172
+ continue;
173
+ if (/^\s*disable-model-invocation:\s*true\s*$/m.test(md))
174
+ continue;
175
+ const fm = frontmatter(md);
176
+ const description = fm.description ?? firstBodyParagraph(md);
177
+ if (!description || description.length < 20)
178
+ continue;
179
+ surfaces.push({ name: fm.name ?? skillName(path), description });
180
+ }
181
+ return (0, description_overlap_js_1.findDescriptionOverlaps)(surfaces);
182
+ }
183
+ function scanAgents(files, dialect, declaredServers) {
60
184
  const out = [];
61
185
  for (const [path, md] of Object.entries(files)) {
62
186
  if (!isAgent(path))
63
187
  continue;
64
- out.push({ name: (0, node_path_1.basename)(path, ".md"), path, tools: (0, agent_runtime_js_1.parseAgentTools)(md) });
188
+ const tools = (0, agent_runtime_js_1.parseAgentTools)(md);
189
+ out.push({
190
+ name: (0, node_path_1.basename)(path, ".md"),
191
+ path,
192
+ tools,
193
+ // Cross-reference the declared rail against the dialect catalog — the moat.
194
+ // Auditing third-party plugins → only the HIGH-CONFIDENCE issues (never-
195
+ // available + close typos); a bare unrecognized tool is likely plugin/MCP-
196
+ // provided, not a defect (the TaskCreate/TaskGet lesson). See tool-contract.ts.
197
+ toolIssues: tools
198
+ ? (0, tool_contract_js_1.confidentToolIssues)((0, tool_contract_js_1.verifyToolContract)(tools, dialect))
199
+ : [],
200
+ // The MCP half of the moat: an `mcp__server__tool` whose server isn't in the
201
+ // plugin's declared set can't resolve. High-precision (gated on a declared
202
+ // set, built-ins allowlisted, plugin-namespaced form skipped). See mcp-tool.ts.
203
+ mcpToolIssues: tools
204
+ ? (0, mcp_tool_js_1.verifyMcpToolServers)(tools, declaredServers, dialect)
205
+ : [],
206
+ // The block-list mirror: a `disallowedTools:` entry that's a typo of a real
207
+ // tool blocks nothing (close-typo only — high-precision). See tool-contract.ts.
208
+ disallowedToolIssues: (0, tool_contract_js_1.disallowedToolIssues)((0, agent_runtime_js_1.parseAgentToolList)(md, "disallowedTools") ?? [], dialect),
209
+ });
65
210
  }
66
211
  return out.sort((a, b) => a.name.localeCompare(b.name));
67
212
  }
@@ -72,14 +217,26 @@ function scanAgents(files) {
72
217
  * A token that still carries any `$VAR` after that is genuinely uncheckable.
73
218
  */
74
219
  function resolveScript(token, root) {
75
- const path = token
220
+ const cleaned = token
76
221
  .replace(/["']/g, "")
77
222
  .replaceAll("${CLAUDE_PLUGIN_ROOT}", root)
78
223
  .replaceAll("$CLAUDE_PLUGIN_ROOT", root);
79
- if (path.includes("$"))
224
+ if (cleaned.includes("$"))
80
225
  return { script: token, status: "unresolved" };
81
- return { script: path, status: (0, node_fs_1.existsSync)(path) ? "ok" : "missing" };
226
+ // A relative hook path (`./hooks/x.sh`, `scripts/x.py`) is the plugin's own
227
+ // resolve it against the PLUGIN ROOT, not the scanner's cwd. Without this, a
228
+ // plugin that references `./hooks/x.sh` (the file IS present) was reported
229
+ // MISSING because existsSync() checked cwd-relative (a false positive caught on
230
+ // ananddtyagi/cc-marketplace). The displayed `script` stays as the author wrote it.
231
+ const abs = (0, node_path_1.isAbsolute)(cleaned) ? cleaned : (0, node_path_1.resolve)(root, cleaned);
232
+ return { script: cleaned, status: (0, node_fs_1.existsSync)(abs) ? "ok" : "missing" };
82
233
  }
234
+ // A shell existence guard around a command — `[ ! -f x ] || x`, `[ -f x ] && x`,
235
+ // `test -f x && …`. Authors use it to make a hook OPTIONAL (run the script only
236
+ // if present; a no-op otherwise — e.g. a runtime-generated guard), so a missing
237
+ // target is INTENTIONAL, not a broken reference. Don't flag scripts in such a
238
+ // command as MISSING (a false positive caught on gmickel/flow-next's ralph-guard).
239
+ const EXISTENCE_GUARD = /(?:\[\[?\s*!?\s*-[efsx]\s)|(?:\btest\s+!?\s*-[efsx]\s)/;
83
240
  /** Pull script-file hook commands out of the resolved settings; count inline ones. */
84
241
  function scanHooks(settings, root) {
85
242
  const text = JSON.stringify(settings.hooks ?? {});
@@ -93,6 +250,12 @@ function scanHooks(settings, root) {
93
250
  inline++;
94
251
  continue;
95
252
  }
253
+ // A guarded command runs its script only if it exists — an optional hook, not
254
+ // a broken one. Treat it as a conditional one-liner (inline), don't path-check.
255
+ if (EXISTENCE_GUARD.test(unescaped)) {
256
+ inline++;
257
+ continue;
258
+ }
96
259
  for (const tok of found) {
97
260
  const hook = resolveScript(tok, root);
98
261
  byScript.set(hook.script, hook);
@@ -104,45 +267,360 @@ function scanHooks(settings, root) {
104
267
  // ---------------------------------------------------------------------------
105
268
  // Public API
106
269
  // ---------------------------------------------------------------------------
270
+ /**
271
+ * Frontmatter-schema check — **subagents only**. Per the Claude Code docs, a
272
+ * subagent (`agents/*.md`) REQUIRES `name` + `description` (no fallback) or it
273
+ * won't register. A SKILL.md requires NOTHING: `name` falls back to the directory
274
+ * name and `description` to the first body paragraph, so a frontmatter-less skill
275
+ * still loads — flagging it would be a false positive (skill description QUALITY
276
+ * is a separate, behavioral concern). See https://code.claude.com/docs/en/skills
277
+ * and …/sub-agents.
278
+ */
279
+ function frontmatterIssuesFor(files) {
280
+ const out = [];
281
+ for (const [path, md] of Object.entries(files)) {
282
+ if (!isAgent(path))
283
+ continue; // skills require no frontmatter (dir/body fallbacks)
284
+ const fm = frontmatter(md);
285
+ const missing = [];
286
+ if (!fm.name)
287
+ missing.push("name");
288
+ if (!fm.description)
289
+ missing.push("description");
290
+ if (missing.length === 0)
291
+ continue;
292
+ out.push({
293
+ path,
294
+ kind: "agent",
295
+ missing,
296
+ message: `agent ${path} is missing required frontmatter: ${missing.join(", ")} — it won't register.`,
297
+ });
298
+ }
299
+ return out.sort((a, b) => a.path.localeCompare(b.path));
300
+ }
301
+ // The canonical subagent `model:` aliases and `color:` enum (Claude Code). The
302
+ // model check skips a full/dated id (`claude-sonnet-4-5`) — that's a valid
303
+ // explicit form, not a typo — so only an alias misspelling is caught.
304
+ const MODEL_ALIASES = ["inherit", "sonnet", "opus", "haiku"];
305
+ const AGENT_COLORS = [
306
+ "red",
307
+ "blue",
308
+ "green",
309
+ "yellow",
310
+ "purple",
311
+ "orange",
312
+ "pink",
313
+ "cyan",
314
+ ];
315
+ /**
316
+ * Closest candidate by edit distance, ONLY when it's a high-confidence typo: the
317
+ * value isn't already a candidate, and the nearest is within 2 edits. Returns
318
+ * null otherwise — a far-off value is more likely an unknown-we-don't-know than a
319
+ * typo (the high-precision discipline), so it's suppressed, not flagged.
320
+ */
321
+ function closeCandidate(value, candidates) {
322
+ const v = value.toLowerCase();
323
+ if (candidates.includes(v))
324
+ return null;
325
+ let best = null;
326
+ let bestDistance = Infinity;
327
+ for (const c of candidates) {
328
+ const dist = (0, linters_js_1.editDistance)(v, c);
329
+ if (dist < bestDistance) {
330
+ bestDistance = dist;
331
+ best = c;
332
+ }
333
+ }
334
+ return bestDistance > 0 && bestDistance <= 2 ? best : null;
335
+ }
336
+ /**
337
+ * Agent frontmatter VALUE validity — a `model:` or `color:` that's a close typo
338
+ * of a real one. A bad `model:` silently falls back; a bad `color:` is ignored.
339
+ * High-precision (close-typo only); a full/dated model id is left alone. Folded
340
+ * into the `agent-frontmatter` rule. Agents only (skills have no model/color).
341
+ */
342
+ function frontmatterValueIssuesFor(files) {
343
+ const out = [];
344
+ for (const [path, md] of Object.entries(files)) {
345
+ if (!isAgent(path))
346
+ continue;
347
+ const fm = frontmatter(md);
348
+ // A model id with a digit/hyphen is an explicit form, not an alias typo.
349
+ if (fm.model && !/[0-9-]/.test(fm.model)) {
350
+ const near = closeCandidate(fm.model, MODEL_ALIASES);
351
+ if (near) {
352
+ out.push({
353
+ path,
354
+ field: "model",
355
+ value: fm.model,
356
+ suggestion: near,
357
+ message: `agent ${path} has model "${fm.model}", not a known alias — it silently falls back. Did you mean "${near}"?`,
358
+ });
359
+ }
360
+ }
361
+ if (fm.color) {
362
+ const near = closeCandidate(fm.color, AGENT_COLORS);
363
+ if (near) {
364
+ out.push({
365
+ path,
366
+ field: "color",
367
+ value: fm.color,
368
+ suggestion: near,
369
+ message: `agent ${path} has color "${fm.color}", not a valid color — it's ignored. Did you mean "${near}"?`,
370
+ });
371
+ }
372
+ }
373
+ }
374
+ return out.sort((a, b) => a.path.localeCompare(b.path));
375
+ }
376
+ /**
377
+ * Frontmatter that EXISTS but isn't valid YAML — the `frontmatter-valid` signal.
378
+ * Reported for skills + agents via the shared reader's `malformed` flag. Honest
379
+ * caveat (see docs/rules/frontmatter-valid.md): js-yaml is stricter than some
380
+ * loaders, so a one-line `description:` containing a `: ` colon or an `<example>`
381
+ * block is flagged even though it may still load — which is why scan surfaces it
382
+ * as an informational note (NOT a structural defect) and the lint rule is a
383
+ * warn, not an error. The file's other fields are still salvaged.
384
+ */
385
+ function malformedFrontmatterFor(files) {
386
+ const out = [];
387
+ for (const [path, md] of Object.entries(files)) {
388
+ if (!isSkill(path) && !isAgent(path))
389
+ continue;
390
+ if (!(0, frontmatter_read_js_1.readFrontmatter)(md).malformed)
391
+ continue;
392
+ out.push({
393
+ path,
394
+ message: `${path}: frontmatter is not valid YAML — fields may not parse as intended (a colon, quote, or bracket likely needs escaping/quoting).`,
395
+ });
396
+ }
397
+ return out.sort((a, b) => a.path.localeCompare(b.path));
398
+ }
399
+ /**
400
+ * Skill-metadata RECOMMENDATION (not a correctness check): a `SKILL.md` loads
401
+ * fine without frontmatter (`name` ← dir, `description` ← first body paragraph),
402
+ * but relying on those fallbacks is fragile — the dir name may be unclear and the
403
+ * first paragraph is often a heading or boilerplate, making a weak trigger
404
+ * surface. Best practice is an EXPLICIT `name` + `description`. Flags skills
405
+ * missing either; surfaced as a soft note in scan (NOT a structural defect, NOT
406
+ * scored) and gated by the `skill-frontmatter` lint rule (warn by default).
407
+ */
408
+ function skillMetaIssuesFor(files) {
409
+ const out = [];
410
+ for (const [path, md] of Object.entries(files)) {
411
+ if (!isSkill(path))
412
+ continue;
413
+ const fm = frontmatter(md);
414
+ const missing = [];
415
+ if (!fm.name)
416
+ missing.push("name");
417
+ if (!fm.description)
418
+ missing.push("description");
419
+ if (missing.length === 0)
420
+ continue;
421
+ out.push({
422
+ path,
423
+ kind: "skill",
424
+ missing,
425
+ 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).`,
426
+ });
427
+ }
428
+ return out.sort((a, b) => a.path.localeCompare(b.path));
429
+ }
430
+ /**
431
+ * Collect declared MCP servers from the JSON sources (`.mcp.json` + the plugin
432
+ * manifest's `mcpServers`). Codex's TOML `[mcp_servers]` isn't parsed here (a
433
+ * documented gap); the JSON CC shape is the common case. Merged so a server
434
+ * defined in both sources appears once. Shared by the `mcp-config` check (does
435
+ * each server start?) and `mcp-tool-resolves` (is each referenced server here?).
436
+ */
437
+ function collectMcpServers(root, layout) {
438
+ const servers = {};
439
+ const collect = (file) => {
440
+ const p = (0, node_path_1.join)(root, file);
441
+ if (!(0, node_fs_1.existsSync)(p))
442
+ return;
443
+ try {
444
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(p, "utf-8"));
445
+ if (parsed.mcpServers !== null && typeof parsed.mcpServers === "object") {
446
+ Object.assign(servers, parsed.mcpServers);
447
+ }
448
+ }
449
+ catch {
450
+ /* malformed JSON is the loader's concern, not this check's */
451
+ }
452
+ };
453
+ collect(".mcp.json");
454
+ collect(layout.manifestPath);
455
+ return servers;
456
+ }
107
457
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
108
- function scanPlugin(dir, layout) {
109
- const loaded = (0, plugin_loader_js_1.loadPlugin)(dir, layout);
458
+ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
459
+ const lay = layout ?? layout_js_1.claudeCodeLayout;
460
+ const loaded = (0, plugin_loader_js_1.loadPlugin)(dir, lay);
110
461
  const { hooks, inline } = scanHooks(loaded.settings, (0, node_path_1.resolve)(dir));
462
+ // Hook-event keys are a CLOSED platform set — an unrecognized one is a dead
463
+ // registration (the hook never fires), so flag every unknown (not just typos).
464
+ // ONLY for the canonical object-keyed-by-event shape: a plugin shipping a
465
+ // hooks ARRAY uses a non-CC/custom format whose events live INSIDE each entry
466
+ // (e.g. ananddtyagi/sugar's `[{event:"tool-use",…}]`) — Object.keys would read
467
+ // array INDICES, a false positive. We don't interpret a format we don't own.
468
+ const hooksObj = loaded.settings.hooks;
469
+ const eventNames = hooksObj !== null &&
470
+ typeof hooksObj === "object" &&
471
+ !Array.isArray(hooksObj)
472
+ ? Object.keys(hooksObj)
473
+ : [];
474
+ const hookEventIssues = (0, hook_events_js_1.confidentHookEventIssues)((0, hook_events_js_1.verifyHookEvents)(eventNames, dialect));
475
+ const instructions = loaded.files[lay.instructionFile] !== undefined
476
+ ? {
477
+ file: lay.instructionFile,
478
+ hasSpec: (0, node_fs_1.existsSync)((0, node_path_1.join)((0, node_path_1.resolve)(dir), `${lay.instructionFile}.spec.ts`)),
479
+ }
480
+ : null;
481
+ const mcpServers = collectMcpServers((0, node_path_1.resolve)(dir), lay);
482
+ const declaredServers = Object.keys(mcpServers);
111
483
  return {
112
484
  dir,
485
+ instructions,
113
486
  skills: scanSkills(loaded.files),
114
- agents: scanAgents(loaded.files),
487
+ agents: scanAgents(loaded.files, dialect, declaredServers),
115
488
  hooks,
116
489
  inlineHooks: inline,
117
490
  commands: Object.keys(loaded.files).filter(isCommand).length,
118
491
  mcp: loaded.warnings.some((w) => w.includes("MCP server")),
492
+ danglingRefs: (0, plugin_loader_js_2.danglingRefs)((0, node_path_1.resolve)(dir), lay),
493
+ hookEventIssues,
494
+ frontmatterIssues: frontmatterIssuesFor(loaded.files),
495
+ frontmatterValueIssues: frontmatterValueIssuesFor(loaded.files),
496
+ skillMetaIssues: skillMetaIssuesFor(loaded.files),
497
+ mcpIssues: (0, mcp_config_js_1.verifyMcpServers)(mcpServers),
498
+ mcpHookIssues: (0, mcp_hook_js_1.verifyMcpHookTargets)(loaded.settings.hooks, declaredServers, dialect),
499
+ descriptionOverlaps: descriptionOverlapsFor(loaded.files),
500
+ malformedFrontmatter: malformedFrontmatterFor(loaded.files),
119
501
  warnings: loaded.warnings,
120
502
  untested: (0, test_coverage_js_1.findUntestedSurfaces)({ basePath: dir }).untested.length,
121
503
  };
122
504
  }
123
- function section(title, lines) {
505
+ /**
506
+ * Read a `marketplace.json` beside the layout's plugin manifest and classify its
507
+ * members into on-disk vs external. Returns `null` when `dir` is not a
508
+ * marketplace. The source of truth behind {@link expandMarketplace} and the
509
+ * curated-marketplace report in `vigiles scan`.
510
+ */
511
+ function inspectMarketplace(dir, layout = layout_js_1.claudeCodeLayout) {
512
+ const mpPath = (0, node_path_1.join)(dir, (0, node_path_1.dirname)(layout.manifestPath), "marketplace.json");
513
+ if (!(0, node_fs_1.existsSync)(mpPath))
514
+ return null;
515
+ let parsed;
516
+ try {
517
+ parsed = JSON.parse((0, node_fs_1.readFileSync)(mpPath, "utf-8"));
518
+ }
519
+ catch {
520
+ return null;
521
+ }
522
+ const plugins = parsed.plugins;
523
+ if (!Array.isArray(plugins))
524
+ return null;
525
+ const name = parsed.name;
526
+ // Dedupe by resolved path: a marketplace may map several named entries to the
527
+ // SAME plugin dir (TheBushidoCollective/han aliases 338 names onto 159 dirs).
528
+ // Scanning a dir twice is pure noise, so each on-disk member counts once.
529
+ const onDisk = [];
530
+ const seen = new Set();
531
+ let external = 0;
532
+ for (const entry of plugins) {
533
+ const source = entry.source;
534
+ if (typeof source !== "string") {
535
+ external++; // external plugin (url/git/github object), not on disk
536
+ continue;
537
+ }
538
+ const abs = (0, node_path_1.resolve)(dir, source);
539
+ if ((0, node_fs_1.existsSync)(abs) && (0, node_fs_1.statSync)(abs).isDirectory()) {
540
+ if (!seen.has(abs)) {
541
+ seen.add(abs);
542
+ onDisk.push(abs);
543
+ }
544
+ }
545
+ else {
546
+ external++; // a string source that doesn't resolve on disk
547
+ }
548
+ }
549
+ return {
550
+ name: typeof name === "string" ? name : (0, node_path_1.basename)(dir),
551
+ onDisk,
552
+ external,
553
+ total: plugins.length,
554
+ };
555
+ }
556
+ /**
557
+ * If `dir` is a plugin MARKETPLACE (a `marketplace.json` beside the layout's
558
+ * plugin manifest, e.g. `.claude-plugin/marketplace.json`), expand it into the
559
+ * absolute dirs of its member plugins. Returns `null` when there's no
560
+ * marketplace, `[]` when it's a marketplace whose members are all external (not
561
+ * on disk). Used by `vigiles scan` to rank a whole marketplace — wshobson/agents
562
+ * alone ships 80+ plugins under one `marketplace.json`. See {@link inspectMarketplace}.
563
+ */
564
+ function expandMarketplace(dir, layout = layout_js_1.claudeCodeLayout) {
565
+ const mp = inspectMarketplace(dir, layout);
566
+ return mp ? [...mp.onDisk] : null;
567
+ }
568
+ // `count` defaults to the number of lines, but a section whose entries span
569
+ // multiple lines (Agents: a header + indented issue lines; Hooks: file hooks +
570
+ // an inline-summary line) passes the real entity count so the header isn't
571
+ // inflated by sub-lines.
572
+ function section(title, lines, count = lines.length) {
124
573
  if (lines.length === 0)
125
574
  return [];
126
- return [`${title} (${String(lines.length)}):`, ...lines, ""];
575
+ return [`${title} (${String(count)}):`, ...lines, ""];
576
+ }
577
+ /** One skill's report line: ✓/⚠ + name + notes (no-trigger, user-invoked, language risk). */
578
+ function skillLine(s) {
579
+ if (!s.hasDescription) {
580
+ return ` ⚠ ${s.name} (no usable description — no frontmatter description and no body text — can't trigger)`;
581
+ }
582
+ const notes = [];
583
+ if (s.userInvoked)
584
+ notes.push("user-invoked");
585
+ if (s.descriptionScript) {
586
+ notes.push(`description in ${s.descriptionScript} — cross-language trigger risk`);
587
+ }
588
+ const mark = s.descriptionScript ? "⚠" : "✓";
589
+ return ` ${mark} ${s.name}${notes.length ? ` (${notes.join("; ")})` : ""}`;
590
+ }
591
+ /** One agent's report block: ✗ (broken contract) / ⚠ (inherits all) / ✓ + issues. */
592
+ function agentLines(a) {
593
+ const tools = a.tools === null
594
+ ? "tools: (inherits all — no contract)"
595
+ : `tools: ${a.tools.join(", ") || "(none)"}`;
596
+ const broken = a.toolIssues.length +
597
+ a.mcpToolIssues.length +
598
+ a.disallowedToolIssues.length;
599
+ let mark = "✓";
600
+ if (broken > 0)
601
+ mark = "✗";
602
+ else if (a.tools === null)
603
+ mark = "⚠";
604
+ const lines = [` ${mark} ${a.name} — ${tools}`];
605
+ for (const issue of a.toolIssues)
606
+ lines.push(` ✗ ${issue.message}`);
607
+ for (const issue of a.mcpToolIssues)
608
+ lines.push(` ✗ ${issue.message}`);
609
+ for (const issue of a.disallowedToolIssues)
610
+ lines.push(` ✗ ${issue.message}`);
611
+ return lines;
127
612
  }
128
613
  /** Format a scan report as human-readable text. */
129
614
  function formatScanReport(r) {
130
615
  const out = [`Scan: ${r.dir}`, ""];
131
- out.push(...section("Skills", r.skills.map((s) => {
132
- const mark = s.hasDescription ? "✓" : "⚠";
133
- const note = s.hasDescription
134
- ? s.userInvoked
135
- ? "(user-invoked)"
136
- : ""
137
- : "(missing/short description — can't trigger)";
138
- return ` ${mark} ${s.name} ${note}`.trimEnd();
139
- })));
140
- out.push(...section("Agents", r.agents.map((a) => {
141
- const tools = a.tools === null
142
- ? "tools: (inherits all — no contract)"
143
- : `tools: ${a.tools.join(", ") || "(none)"}`;
144
- return ` ${a.tools === null ? "⚠" : "✓"} ${a.name} — ${tools}`;
145
- })));
616
+ if (r.instructions) {
617
+ const tag = r.instructions.hasSpec
618
+ ? "spec-managed"
619
+ : "hand-written, no spec";
620
+ out.push(`Instructions: ${r.instructions.file} (${tag})`, "");
621
+ }
622
+ out.push(...section("Skills", r.skills.map(skillLine)));
623
+ out.push(...section("Agents", r.agents.flatMap(agentLines), r.agents.length));
146
624
  const hookMark = {
147
625
  ok: "✓",
148
626
  missing: "✗",
@@ -157,18 +635,59 @@ function formatScanReport(r) {
157
635
  if (r.inlineHooks > 0) {
158
636
  hookLines.push(` · ${String(r.inlineHooks)} inline hook(s) (no script file)`);
159
637
  }
160
- out.push(...section("Hooks", hookLines));
638
+ out.push(...section("Hooks", hookLines, r.hooks.length + r.inlineHooks));
639
+ out.push(...section("Broken references", r.danglingRefs.map((ref) => ` ✗ ${ref} (referenced but MISSING)`)));
640
+ out.push(...section("Hook events", r.hookEventIssues.map((i) => ` ✗ ${i.message}`)));
641
+ out.push(...section("Frontmatter", [
642
+ ...r.frontmatterIssues.map((i) => ` ✗ ${i.message}`),
643
+ ...r.frontmatterValueIssues.map((i) => ` ✗ ${i.message}`),
644
+ ]));
645
+ out.push(...section("MCP config", r.mcpIssues.map((i) => ` ✗ ${i.message}`)));
646
+ out.push(...section("MCP hook targets", r.mcpHookIssues.map((i) => ` ✗ ${i.message}`)));
647
+ out.push(...section("Description overlap (precision risk)", r.descriptionOverlaps.map((o) => ` ⚠ ${o.message}`)));
161
648
  const facts = [];
162
649
  if (r.commands > 0)
163
650
  facts.push(`Commands: ${String(r.commands)}`);
164
651
  facts.push(`MCP servers: ${r.mcp ? "yes" : "no"}`);
165
652
  facts.push(`Untested surfaces: ${String(r.untested)}`);
166
653
  out.push(...facts, "");
167
- if (r.warnings.length > 0) {
168
- out.push("Warnings:", ...r.warnings.map((w) => ` - ${w}`), "");
654
+ // The dangling-ref warning is now shown as a first-class ✗ section above, so
655
+ // drop it from the free-text list to avoid saying the same thing twice.
656
+ const warnings = r.warnings.filter((w) => !w.includes("intra-plugin file(s) that don't exist"));
657
+ if (warnings.length > 0) {
658
+ out.push("Warnings:", ...warnings.map((w) => ` - ${w}`), "");
659
+ }
660
+ // Cross-language trigger risk is a RISK, not a structural defect (a
661
+ // language-matched audience is fine), so it's reported separately from the
662
+ // verdict — it points at the behavioral column, it doesn't fail the scan.
663
+ const mismatched = r.skills.filter((s) => s.descriptionScript);
664
+ if (mismatched.length > 0) {
665
+ out.push(`⚠ ${String(mismatched.length)} skill(s) have descriptions in an unexpected script (cross-language trigger risk) — measure with \`scan --trigger\``, "");
666
+ }
667
+ // Skill-metadata is a RECOMMENDATION, not a structural defect (the skill loads
668
+ // via fallbacks) — reported as a soft note, never counted in the verdict.
669
+ if (r.skillMetaIssues.length > 0) {
670
+ out.push(`ℹ ${String(r.skillMetaIssues.length)} skill(s) lack an explicit frontmatter name/description (recommended for a reliable trigger surface) — they still load via fallback`, "");
671
+ }
672
+ // Malformed-YAML frontmatter is INFORMATIONAL, not a structural defect: js-yaml
673
+ // is stricter than some loaders (a colon/quote/<example> in a one-line
674
+ // description trips it though the file may still load), and the other fields are
675
+ // salvaged. Surfaced as a note; the frontmatter-valid lint rule warns on it.
676
+ if (r.malformedFrontmatter.length > 0) {
677
+ out.push(`ℹ ${String(r.malformedFrontmatter.length)} file(s) have frontmatter that isn't valid YAML — fields may not parse as intended (verify before enforcing \`frontmatter-valid\`)`, "");
169
678
  }
170
679
  const broken = r.hooks.filter((h) => h.status === "missing").length +
171
- r.skills.filter((s) => !s.hasDescription).length;
680
+ r.skills.filter((s) => !s.hasDescription).length +
681
+ r.agents.reduce((n, a) => n +
682
+ a.toolIssues.length +
683
+ a.mcpToolIssues.length +
684
+ a.disallowedToolIssues.length, 0) +
685
+ r.danglingRefs.length +
686
+ r.hookEventIssues.length +
687
+ r.frontmatterIssues.length +
688
+ r.frontmatterValueIssues.length +
689
+ r.mcpIssues.length +
690
+ r.mcpHookIssues.length;
172
691
  out.push(broken === 0
173
692
  ? "✓ no structural issues found"
174
693
  : `⚠ ${String(broken)} structural issue(s) — see ✗/⚠ above`);