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