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.
- package/README.md +15 -9
- package/dist/adapters/claude-code/adapter.js +1 -0
- package/dist/adapters/claude-code/agent-runtime.d.ts +30 -6
- package/dist/adapters/claude-code/agent-runtime.js +66 -37
- package/dist/adapters/claude-code/dialect.js +37 -2
- package/dist/adapters/claude-code/effect-region.d.ts +9 -0
- package/dist/adapters/claude-code/effect-region.js +45 -0
- package/dist/adapters/claude-code/layout.js +3 -0
- package/dist/adapters/claude-code/skill-runtime.d.ts +25 -0
- package/dist/adapters/claude-code/skill-runtime.js +48 -0
- package/dist/adapters/codex/adapter.js +3 -0
- package/dist/adapters/codex/eval.d.ts +94 -0
- package/dist/adapters/codex/eval.js +227 -0
- package/dist/adapters/codex/layout.js +3 -0
- package/dist/adapters/opencode/adapter.js +1 -0
- package/dist/adapters/opencode/layout.js +3 -0
- package/dist/check.d.ts +8 -0
- package/dist/check.js +27 -3
- package/dist/cli.js +712 -21
- package/dist/codex.d.ts +1 -0
- package/dist/codex.js +3 -0
- package/dist/core/adapter.d.ts +10 -0
- package/dist/core/bash-effects.d.ts +41 -0
- package/dist/core/bash-effects.js +405 -0
- package/dist/core/compile.d.ts +3 -1
- package/dist/core/compile.js +169 -74
- package/dist/core/description-overlap.d.ts +27 -0
- package/dist/core/description-overlap.js +53 -0
- package/dist/core/dialect.d.ts +18 -0
- package/dist/core/effects.d.ts +172 -0
- package/dist/core/effects.js +245 -0
- package/dist/core/frontmatter-read.d.ts +25 -0
- package/dist/core/frontmatter-read.js +138 -0
- package/dist/core/hook-events.d.ts +34 -0
- package/dist/core/hook-events.js +48 -0
- package/dist/core/layout.d.ts +6 -0
- package/dist/core/mcp-config.d.ts +20 -0
- package/dist/core/mcp-config.js +40 -0
- package/dist/core/mcp-hook.d.ts +35 -0
- package/dist/core/mcp-hook.js +70 -0
- package/dist/core/mcp-tool.d.ts +50 -0
- package/dist/core/mcp-tool.js +61 -0
- package/dist/core/orphans.js +21 -0
- package/dist/core/spec.d.ts +142 -3
- package/dist/core/spec.js +48 -0
- package/dist/core/tool-contract.d.ts +68 -0
- package/dist/core/tool-contract.js +113 -0
- package/dist/core/types.d.ts +91 -2
- package/dist/core/validate.js +23 -1
- package/dist/eval.d.ts +69 -13
- package/dist/eval.js +106 -51
- package/dist/harness-test.d.ts +7 -0
- package/dist/harness-test.js +19 -7
- package/dist/leaderboard.d.ts +2 -0
- package/dist/leaderboard.js +63 -3
- package/dist/optimize.d.ts +74 -0
- package/dist/optimize.js +94 -0
- package/dist/plugin-loader.d.ts +1 -0
- package/dist/plugin-loader.js +71 -18
- package/dist/scaffold-test.d.ts +30 -0
- package/dist/scaffold-test.js +158 -0
- package/dist/scan-behavioral.d.ts +73 -0
- package/dist/scan-behavioral.js +150 -0
- package/dist/scan.d.ts +166 -1
- package/dist/scan.js +622 -55
- package/dist/score-explainer.d.ts +69 -0
- package/dist/score-explainer.js +169 -0
- package/dist/test-coverage.d.ts +7 -0
- package/dist/test-coverage.js +39 -24
- package/package.json +2 -1
- package/skills/{migrate-to-spec → adopt-spec}/SKILL.md +4 -6
- 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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
71
|
-
* survives, so resolve
|
|
72
|
-
*
|
|
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
|
-
|
|
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(
|
|
78
|
-
.replaceAll(
|
|
79
|
-
if (
|
|
247
|
+
.replaceAll(pluginRootToken, root)
|
|
248
|
+
.replaceAll(unbraced, root);
|
|
249
|
+
if (cleaned.includes("$"))
|
|
80
250
|
return { script: token, status: "unresolved" };
|
|
81
|
-
|
|
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
|
|
110
|
-
const
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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`);
|