vigiles 18.1.0 → 18.1.2
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/dist/adapters/claude-code/run-scripts.d.ts +6 -1
- package/dist/adapters/claude-code/run-scripts.js +78 -24
- package/dist/cli.js +18 -2
- package/dist/core/compile.js +52 -5
- package/dist/core/description-overlap.d.ts +12 -6
- package/dist/core/description-overlap.js +7 -3
- package/dist/core/doc-refs.js +66 -11
- package/dist/core/surface-scopes.d.ts +110 -0
- package/dist/core/surface-scopes.js +100 -0
- package/dist/plugin-loader.js +63 -54
- package/dist/scan-core.d.ts +6 -1
- package/dist/scan-core.js +40 -6
- package/dist/scan-files.js +59 -52
- package/dist/scan.js +4 -1
- package/dist/score-explainer.js +4 -1
- package/package.json +1 -1
- package/skills/linter-docs/pylint.md +2 -0
- package/skills/linter-docs/rubocop.md +2 -0
|
@@ -93,6 +93,11 @@ export declare function interpreterArgs(file: string, caps: NodeCaps, entry?: st
|
|
|
93
93
|
export declare function discoverScripts(patterns: readonly string[], defaultGlob: string, cwd: string): string[];
|
|
94
94
|
/** Extra wiring for {@link runScripts}. */
|
|
95
95
|
export interface RunScriptsOptions {
|
|
96
|
+
/**
|
|
97
|
+
* How many scripts may run at once. Omitted → decided from `entry`: `test`
|
|
98
|
+
* fans out across the cores, `eval` stays at 1. See {@link runScripts}.
|
|
99
|
+
*/
|
|
100
|
+
readonly concurrency?: number;
|
|
96
101
|
/**
|
|
97
102
|
* A program to run INSTEAD of each script, with the script's path as its one
|
|
98
103
|
* argument. `vigiles eval` passes `dist/eval-entry.js`; `vigiles test` passes
|
|
@@ -116,7 +121,7 @@ export interface RunScriptsOptions {
|
|
|
116
121
|
* record them (`.vigiles/coverage.json`) and coverage can answer "tested?" from
|
|
117
122
|
* execution rather than from a matching file name.
|
|
118
123
|
*/
|
|
119
|
-
export declare function runScripts(files: readonly string[], cwd: string, env?: NodeJS.ProcessEnv, opts?: RunScriptsOptions): ScriptRunResult[]
|
|
124
|
+
export declare function runScripts(files: readonly string[], cwd: string, env?: NodeJS.ProcessEnv, opts?: RunScriptsOptions): Promise<ScriptRunResult[]>;
|
|
120
125
|
/**
|
|
121
126
|
* Whether any script FAILED. Neither a skip nor a vacuous run counts: the first
|
|
122
127
|
* declined to run, the second ran and verified nothing, and neither is evidence
|
|
@@ -22,9 +22,10 @@ exports.formatScriptSummary = formatScriptSummary;
|
|
|
22
22
|
* also run standalone — the CLI just discovers, runs, and aggregates exit codes.
|
|
23
23
|
*/
|
|
24
24
|
const node_child_process_1 = require("node:child_process");
|
|
25
|
+
const node_os_1 = require("node:os");
|
|
25
26
|
const node_path_1 = require("node:path");
|
|
26
27
|
const node_fs_1 = require("node:fs");
|
|
27
|
-
const
|
|
28
|
+
const node_os_2 = require("node:os");
|
|
28
29
|
const glob_1 = require("glob");
|
|
29
30
|
const check_count_js_1 = require("../../check-count.js");
|
|
30
31
|
/**
|
|
@@ -178,42 +179,95 @@ function readCheckReport(path) {
|
|
|
178
179
|
* record them (`.vigiles/coverage.json`) and coverage can answer "tested?" from
|
|
179
180
|
* execution rather than from a matching file name.
|
|
180
181
|
*/
|
|
181
|
-
function runScripts(files, cwd, env = {}, opts = {}) {
|
|
182
|
+
async function runScripts(files, cwd, env = {}, opts = {}) {
|
|
182
183
|
const caps = (0, ts_runner_caps_js_2.detectNodeCaps)(cwd);
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
184
|
+
const countDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_2.tmpdir)(), "vigiles-checks-"));
|
|
185
|
+
// 🔴 THE DEFAULT IS DECIDED BY `entry`, NOT BY A FLAG, because the two commands
|
|
186
|
+
// that share this runner have OPPOSITE right answers and the caller already
|
|
187
|
+
// distinguishes them:
|
|
188
|
+
//
|
|
189
|
+
// `test` passes no entry — every script is a `*.harness.*` file, which is free
|
|
190
|
+
// and deterministic BY CONSTRUCTION (the harness tier drives the agent CLI
|
|
191
|
+
// against a mock model with no API key, and anything that spends money lives
|
|
192
|
+
// behind `vigiles/eval` with a `paid_` prefix). Nothing here can bill, so the
|
|
193
|
+
// only reason to serialize was that we always had.
|
|
194
|
+
//
|
|
195
|
+
// `eval` passes an entry — every script spends real model quota. Running those
|
|
196
|
+
// N-at-a-time multiplies spend and collides with rate limits, so it stays at 1.
|
|
197
|
+
//
|
|
198
|
+
// Measured motivation: 48 harness files in one consumer repo took 1m48s in CI as
|
|
199
|
+
// a strict queue, on a runner with cores sitting idle.
|
|
200
|
+
const parallel = Math.max(1, opts.concurrency ?? (opts.entry ? 1 : Math.min(8, (0, node_os_1.availableParallelism)())));
|
|
201
|
+
// Output is BUFFERED per child and printed when that child exits, rather than
|
|
202
|
+
// inherited. This is the real cost of concurrency and the reason it was not
|
|
203
|
+
// free: with `stdio: "inherit"` two children write to the same terminal at once
|
|
204
|
+
// and 48 reports shred into each other. Buffering keeps each report whole and
|
|
205
|
+
// attributable; what it gives up is live streaming, which only matters when one
|
|
206
|
+
// script is slow AND alone — i.e. exactly the `eval` case, where parallel is 1
|
|
207
|
+
// and the buffer is flushed as soon as the single child ends anyway.
|
|
208
|
+
const runOne = (file, i) => new Promise((resolveRun) => {
|
|
209
|
+
let argv;
|
|
210
|
+
try {
|
|
211
|
+
argv = interpreterArgs(file, caps, opts.entry);
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
console.error(`✗ ${file}: ${e.message}`);
|
|
215
|
+
resolveRun({ file, code: 1, status: "fail" });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const countFile = (0, node_path_1.join)(countDir, `${String(i)}.count`);
|
|
219
|
+
const child = (0, node_child_process_1.spawn)("node", argv, {
|
|
220
|
+
cwd,
|
|
221
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
222
|
+
env: { ...process.env, ...env, [check_count_js_1.CHECK_COUNT_ENV]: countFile },
|
|
223
|
+
});
|
|
224
|
+
const chunks = [];
|
|
225
|
+
child.stdout.on("data", (c) => chunks.push(c));
|
|
226
|
+
child.stderr.on("data", (c) => chunks.push(c));
|
|
227
|
+
const finish = (code) => {
|
|
228
|
+
process.stdout.write(Buffer.concat(chunks));
|
|
203
229
|
const report = readCheckReport(countFile);
|
|
204
|
-
|
|
230
|
+
resolveRun({
|
|
205
231
|
file,
|
|
206
232
|
code,
|
|
207
233
|
status: statusFor(code, report?.checks),
|
|
208
234
|
checks: report?.checks,
|
|
209
235
|
...(report ? { surfaces: report.surfaces } : {}),
|
|
210
236
|
});
|
|
237
|
+
};
|
|
238
|
+
// `error` fires when the process could not be spawned at all; without this
|
|
239
|
+
// the promise would never settle and the whole run would hang silently —
|
|
240
|
+
// which is worse than any failure it could report.
|
|
241
|
+
child.on("error", (e) => {
|
|
242
|
+
chunks.push(Buffer.from(`✗ ${file}: ${e.message}\n`));
|
|
243
|
+
finish(1);
|
|
244
|
+
});
|
|
245
|
+
child.on("close", (code) => {
|
|
246
|
+
finish(code ?? 1);
|
|
211
247
|
});
|
|
248
|
+
});
|
|
249
|
+
try {
|
|
250
|
+
// Results are stored BY INDEX so the reported order is the discovery order,
|
|
251
|
+
// whatever order the children happen to finish in. A run whose output reorders
|
|
252
|
+
// itself between invocations reads as flaky even when every result is stable.
|
|
253
|
+
const results = new Array(files.length);
|
|
254
|
+
let next = 0;
|
|
255
|
+
const worker = async () => {
|
|
256
|
+
for (;;) {
|
|
257
|
+
const i = next++;
|
|
258
|
+
if (i >= files.length)
|
|
259
|
+
return;
|
|
260
|
+
results[i] = await runOne(files[i], i);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
await Promise.all(Array.from({ length: Math.min(parallel, files.length) }, () => {
|
|
264
|
+
return worker();
|
|
265
|
+
}));
|
|
266
|
+
return results;
|
|
212
267
|
}
|
|
213
268
|
finally {
|
|
214
269
|
(0, node_fs_1.rmSync)(countDir, { recursive: true, force: true });
|
|
215
270
|
}
|
|
216
|
-
return results;
|
|
217
271
|
}
|
|
218
272
|
/**
|
|
219
273
|
* Whether any script FAILED. Neither a skip nor a vacuous run counts: the first
|
package/dist/cli.js
CHANGED
|
@@ -1141,7 +1141,23 @@ async function runLint(restArgs, flags, config) {
|
|
|
1141
1141
|
// `<!-- vigiles:ignore-file -->` (whole file). Same engine as spec.ts.
|
|
1142
1142
|
if (!silent)
|
|
1143
1143
|
console.log("\nMarkdown code block refs:\n");
|
|
1144
|
-
|
|
1144
|
+
// 🔴 `config.exclude` REACHES THIS PASS. It did not, and that single omission is
|
|
1145
|
+
// why `lint` could not exit 0 on a repository that vendors other people's
|
|
1146
|
+
// markdown: this walker globs `**/*.md` from the repo root, so a third-party
|
|
1147
|
+
// `CLAUDE.md` captured verbatim as benchmark data was held to the same ref
|
|
1148
|
+
// validation as the repo's own docs — and the one config field documented to
|
|
1149
|
+
// stop exactly that ("vendored or benchmark fixtures the repo's own lint
|
|
1150
|
+
// shouldn't police") was never handed over.
|
|
1151
|
+
//
|
|
1152
|
+
// Measured on a consumer repo 2026-08-19: 9 broken refs, 8 of them inside a
|
|
1153
|
+
// directory the user had explicitly listed in `exclude`, all 9 still reported.
|
|
1154
|
+
// With no way to reach 0 the step was made `continue-on-error: true`, and a lint
|
|
1155
|
+
// whose exit code is discarded gates nothing — after which hand-written CI steps
|
|
1156
|
+
// grew to do the gating instead. One unpassed argument, that whole chain.
|
|
1157
|
+
const docRefReport = (0, doc_refs_js_1.findDocRefs)({
|
|
1158
|
+
basePath: process.cwd(),
|
|
1159
|
+
ignore: config?.exclude,
|
|
1160
|
+
});
|
|
1145
1161
|
if (!silent) {
|
|
1146
1162
|
for (const line of (0, doc_refs_js_1.formatDocRefReport)(docRefReport).split("\n")) {
|
|
1147
1163
|
console.log(` ${line}`);
|
|
@@ -4366,7 +4382,7 @@ async function handleRunScripts(kind, args, restArgs) {
|
|
|
4366
4382
|
// An eval file DESCRIBES its eval; `dist/eval-entry.js` is what imports the
|
|
4367
4383
|
// description and runs what it declares. A harness script is still its own
|
|
4368
4384
|
// program (it is free, so "import spends money" never applied to it).
|
|
4369
|
-
const results = (0, run_scripts_js_1.runScripts)(files, cwd, env, {
|
|
4385
|
+
const results = await (0, run_scripts_js_1.runScripts)(files, cwd, env, {
|
|
4370
4386
|
...(kind === "eval" ? { entry: (0, node_path_1.resolve)(__dirname, "eval-entry.js") } : {}),
|
|
4371
4387
|
});
|
|
4372
4388
|
// Write down WHAT the run exercised, so `lint`/`audit` can answer "tested?"
|
package/dist/core/compile.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* Reads .spec.ts files, validates references, and produces
|
|
6
6
|
* markdown instruction files with integrity hashes.
|
|
7
7
|
*/
|
|
8
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
|
+
};
|
|
8
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
12
|
exports.computeHash = computeHash;
|
|
10
13
|
exports.addHash = addHash;
|
|
@@ -25,6 +28,7 @@ exports.checkFileHash = checkFileHash;
|
|
|
25
28
|
exports.adoptDiff = adoptDiff;
|
|
26
29
|
const node_fs_1 = require("node:fs");
|
|
27
30
|
const glob_1 = require("glob");
|
|
31
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
28
32
|
const node_path_1 = require("node:path");
|
|
29
33
|
const hash_js_1 = require("./hash.js");
|
|
30
34
|
const integrity_js_1 = require("./integrity.js");
|
|
@@ -626,12 +630,52 @@ function collectSkillRefs(spec) {
|
|
|
626
630
|
* argument-hint, tools). Default is `"claude-code"` so callers that pass no
|
|
627
631
|
* dialect get byte-identical output to before.
|
|
628
632
|
*/
|
|
633
|
+
/**
|
|
634
|
+
* A frontmatter scalar, quoted only when leaving it bare would not survive YAML.
|
|
635
|
+
*
|
|
636
|
+
* 🔴 WHY THIS EXISTS. `description: ${spec.description}` interpolated the value
|
|
637
|
+
* raw, so a description containing a colon-space — `"…измеряет, где замер врёт:
|
|
638
|
+
* неаналоги в выборке"` — emitted frontmatter that is not valid YAML. Measured
|
|
639
|
+
* 2026-08-19 on two real skills: right after `vigiles compile`, `skillContract()`
|
|
640
|
+
* reported `malformed: true` and `declared: []`. The file LOOKS like it declares
|
|
641
|
+
* `allowed-tools`; a strict parser reads nothing, so the skill silently inherits
|
|
642
|
+
* every tool the session grants.
|
|
643
|
+
*
|
|
644
|
+
* That is the exact defect `frontmatter-valid` exists to report — i.e. the
|
|
645
|
+
* blessed path produced the thing the product hunts for. Worse, it PUNISHED the
|
|
646
|
+
* fix: a human who quoted the value by hand got a hash mismatch on the next lint
|
|
647
|
+
* ("manually edited after compilation"), and recompiling silently reverted them.
|
|
648
|
+
*
|
|
649
|
+
* The test is a ROUND TRIP rather than a list of dangerous characters: emit it,
|
|
650
|
+
* read it back with the same loader the linter uses, and quote only if what comes
|
|
651
|
+
* back is not the string that went in. Consequences of that choice:
|
|
652
|
+
* - a value that was already safe is emitted byte-identically, so no existing
|
|
653
|
+
* compiled file churns and no integrity hash moves;
|
|
654
|
+
* - the set of "dangerous" inputs never has to be enumerated or maintained —
|
|
655
|
+
* YAML itself decides, so `#`, `[`, `&`, `*`, leading/trailing space, `yes`,
|
|
656
|
+
* `null` and everything else are covered without being listed.
|
|
657
|
+
*/
|
|
658
|
+
function yamlScalar(value) {
|
|
659
|
+
try {
|
|
660
|
+
const parsed = js_yaml_1.default.load(`v: ${value}`);
|
|
661
|
+
if (parsed !== null &&
|
|
662
|
+
typeof parsed === "object" &&
|
|
663
|
+
parsed.v === value) {
|
|
664
|
+
return value;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
// Did not parse at all — definitively needs quoting.
|
|
669
|
+
}
|
|
670
|
+
// A JSON string IS a YAML double-quoted scalar, escaping included.
|
|
671
|
+
return JSON.stringify(value);
|
|
672
|
+
}
|
|
629
673
|
function renderSkillFrontmatter(spec, profile = "claude-code") {
|
|
630
674
|
const fm = [
|
|
631
675
|
"---",
|
|
632
676
|
"",
|
|
633
|
-
`name: ${spec.name}`,
|
|
634
|
-
`description: ${spec.description}`,
|
|
677
|
+
`name: ${yamlScalar(spec.name)}`,
|
|
678
|
+
`description: ${yamlScalar(spec.description)}`,
|
|
635
679
|
];
|
|
636
680
|
// The CC-only keys below are inert in a minimal (Codex/OpenCode) SKILL.md, so
|
|
637
681
|
// they're omitted entirely under that profile.
|
|
@@ -645,7 +689,7 @@ function renderSkillFrontmatter(spec, profile = "claude-code") {
|
|
|
645
689
|
? renderArgumentHint(spec.inputs)
|
|
646
690
|
: spec.argumentHint;
|
|
647
691
|
if (argHint)
|
|
648
|
-
fm.push(`argument-hint: ${argHint}`);
|
|
692
|
+
fm.push(`argument-hint: ${yamlScalar(argHint)}`);
|
|
649
693
|
if (spec.tools && spec.tools.length > 0) {
|
|
650
694
|
// A Claude Code SKILL declares its tool contract under `allowed-tools`
|
|
651
695
|
// (NOT `tools:` — that's the SUBAGENT key), as a real YAML sequence. Flow
|
|
@@ -839,11 +883,14 @@ function validateAgentTools(tools, dialect) {
|
|
|
839
883
|
}
|
|
840
884
|
/** Build the subagent YAML frontmatter (name / description / model / tools). */
|
|
841
885
|
function renderAgentFrontmatter(spec) {
|
|
886
|
+
// Same round-trip quoting as the skill renderer — a subagent description is
|
|
887
|
+
// written just as freely and breaks its frontmatter the same way. See
|
|
888
|
+
// {@link yamlScalar}.
|
|
842
889
|
const fm = [
|
|
843
890
|
"---",
|
|
844
891
|
"",
|
|
845
|
-
`name: ${spec.name}`,
|
|
846
|
-
`description: ${spec.description}`,
|
|
892
|
+
`name: ${yamlScalar(spec.name)}`,
|
|
893
|
+
`description: ${yamlScalar(spec.description)}`,
|
|
847
894
|
];
|
|
848
895
|
if (spec.model !== undefined)
|
|
849
896
|
fm.push(`model: ${spec.model}`);
|
|
@@ -2,9 +2,21 @@
|
|
|
2
2
|
export interface DescribedSurface {
|
|
3
3
|
readonly name: string;
|
|
4
4
|
readonly description: string;
|
|
5
|
+
/**
|
|
6
|
+
* Repo-relative path to report the surface at. Optional for callers that have
|
|
7
|
+
* no path, but REQUIRED in practice to keep the message actionable: since the
|
|
8
|
+
* loader started reading BOTH discovery levels, a repo that keeps a copy of a
|
|
9
|
+
* skill in `skills/` and `.claude/skills/` yields two surfaces with the SAME
|
|
10
|
+
* name, and "skills \"dup\" and \"dup\" have near-identical descriptions" names
|
|
11
|
+
* neither file. Measured on `nyldn/claude-octopus`: 50 such pairs. The path is
|
|
12
|
+
* the only thing that distinguishes them.
|
|
13
|
+
*/
|
|
14
|
+
readonly where?: string;
|
|
5
15
|
}
|
|
6
16
|
export interface DescriptionOverlap {
|
|
17
|
+
/** Display label for the first skill — `"name"`, or `"name" (path)`. */
|
|
7
18
|
readonly a: string;
|
|
19
|
+
/** Display label for the second skill — `"name"`, or `"name" (path)`. */
|
|
8
20
|
readonly b: string;
|
|
9
21
|
/** 0–1, higher = more alike (1 − NCD), rounded to 2 dp. */
|
|
10
22
|
readonly similarity: number;
|
|
@@ -17,11 +29,5 @@ export interface DescriptionOverlap {
|
|
|
17
29
|
* the calibrated value.
|
|
18
30
|
*/
|
|
19
31
|
export declare const OVERLAP_NCD_CUTOFF = 0.2;
|
|
20
|
-
/**
|
|
21
|
-
* Find near-duplicate description pairs among `surfaces`. Returns one
|
|
22
|
-
* {@link DescriptionOverlap} per pair whose NCD is below `cutoff`, most-similar
|
|
23
|
-
* first. Pure; pass only the surfaces that actually compete for auto-selection
|
|
24
|
-
* (model-invocable, described) so a user-invoked pair isn't a false alarm.
|
|
25
|
-
*/
|
|
26
32
|
export declare function findDescriptionOverlaps(surfaces: readonly DescribedSurface[], cutoff?: number): DescriptionOverlap[];
|
|
27
33
|
//# sourceMappingURL=description-overlap.d.ts.map
|
|
@@ -31,6 +31,10 @@ exports.OVERLAP_NCD_CUTOFF = 0.2;
|
|
|
31
31
|
* first. Pure; pass only the surfaces that actually compete for auto-selection
|
|
32
32
|
* (model-invocable, described) so a user-invoked pair isn't a false alarm.
|
|
33
33
|
*/
|
|
34
|
+
/** `"name"`, or `"name" (path)` when the caller supplied one. */
|
|
35
|
+
function label(s) {
|
|
36
|
+
return s.where === undefined ? `"${s.name}"` : `"${s.name}" (${s.where})`;
|
|
37
|
+
}
|
|
34
38
|
function findDescriptionOverlaps(surfaces, cutoff = exports.OVERLAP_NCD_CUTOFF) {
|
|
35
39
|
const overlaps = [];
|
|
36
40
|
for (let i = 0; i < surfaces.length; i++) {
|
|
@@ -38,13 +42,13 @@ function findDescriptionOverlaps(surfaces, cutoff = exports.OVERLAP_NCD_CUTOFF)
|
|
|
38
42
|
const d = (0, ncd_js_1.ncd)(surfaces[i].description, surfaces[j].description);
|
|
39
43
|
if (d >= cutoff)
|
|
40
44
|
continue;
|
|
41
|
-
const a = surfaces[i]
|
|
42
|
-
const b = surfaces[j]
|
|
45
|
+
const a = label(surfaces[i]);
|
|
46
|
+
const b = label(surfaces[j]);
|
|
43
47
|
overlaps.push({
|
|
44
48
|
a,
|
|
45
49
|
b,
|
|
46
50
|
similarity: Math.round((1 - d) * 100) / 100,
|
|
47
|
-
message: `skills
|
|
51
|
+
message: `skills ${a} and ${b} have near-identical descriptions (${String(Math.round((1 - d) * 100))}% alike) — the model can't reliably tell them apart, so the wrong one may fire. Differentiate their descriptions.`,
|
|
48
52
|
});
|
|
49
53
|
}
|
|
50
54
|
}
|
package/dist/core/doc-refs.js
CHANGED
|
@@ -18,11 +18,15 @@
|
|
|
18
18
|
* in markdown is explicitly out of scope — use eslint-plugin-markdown or
|
|
19
19
|
* twoslash for that.
|
|
20
20
|
*/
|
|
21
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
22
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
23
|
+
};
|
|
21
24
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
25
|
exports.extractDocRefs = extractDocRefs;
|
|
23
26
|
exports.findDocRefs = findDocRefs;
|
|
24
27
|
exports.formatDocRefReport = formatDocRefReport;
|
|
25
28
|
const node_fs_1 = require("node:fs");
|
|
29
|
+
const typescript_1 = __importDefault(require("typescript"));
|
|
26
30
|
const node_path_1 = require("node:path");
|
|
27
31
|
const glob_1 = require("glob");
|
|
28
32
|
const linters_js_1 = require("./linters.js");
|
|
@@ -44,7 +48,7 @@ const TS_LANGS = new Set(["ts", "typescript", "js", "javascript"]);
|
|
|
44
48
|
// doesn't accidentally disable validation.
|
|
45
49
|
const IGNORE_BLOCK_RE = /^\s{0,3}<!--\s*vigiles:ignore\s*-->\s*$/;
|
|
46
50
|
const IGNORE_FILE_RE = /^\s{0,3}<!--\s*vigiles:ignore-file\s*-->\s*$/m;
|
|
47
|
-
const
|
|
51
|
+
const KINDS = new Set(["enforce", "file", "cmd", "ref"]);
|
|
48
52
|
const PLACEHOLDER_RE = /[<>]/;
|
|
49
53
|
/**
|
|
50
54
|
* Error-message patterns that mean "tool not available in this env" rather
|
|
@@ -57,6 +61,66 @@ const UNVERIFIABLE_PATTERNS = [
|
|
|
57
61
|
/not found on PATH/i,
|
|
58
62
|
/No Cedar policies found/i,
|
|
59
63
|
];
|
|
64
|
+
/**
|
|
65
|
+
* The builder calls in one fenced block, found by PARSING it rather than by
|
|
66
|
+
* matching text.
|
|
67
|
+
*
|
|
68
|
+
* 🔴 WHY THIS IS NOT A REGEX ANY MORE. The previous form was
|
|
69
|
+
* `/\b(enforce|file|cmd|ref)\(\s*["']([^"'\n]+)["']/g`, and four of these six
|
|
70
|
+
* inputs were classified wrongly (measured 2026-08-19):
|
|
71
|
+
*
|
|
72
|
+
* ctx.file("OUT") → matched, though it is a method on some other
|
|
73
|
+
* object. A live instance of this sat in a real
|
|
74
|
+
* repository's notes and was reported as a broken
|
|
75
|
+
* ref for weeks.
|
|
76
|
+
* obj.cmd("npm test") → matched, same reason
|
|
77
|
+
* // cmd("npm test") → matched, though it is a comment
|
|
78
|
+
* const s = 'cmd("x")' → matched, though it is a string
|
|
79
|
+
* myFile("x") → correctly skipped
|
|
80
|
+
* cmd("npm test") → correctly matched
|
|
81
|
+
*
|
|
82
|
+
* `\b` sits happily after a `.`, so every member call read as a builder call,
|
|
83
|
+
* and a regex has no notion of comments or string literals at all. Parsing makes
|
|
84
|
+
* all four INEXPRESSIBLE rather than individually patched: the AST only offers a
|
|
85
|
+
* call whose callee is a bare identifier, and comments and string bodies are not
|
|
86
|
+
* call expressions in the first place.
|
|
87
|
+
*
|
|
88
|
+
* `typescript` is already a runtime dependency of this package, so this costs no
|
|
89
|
+
* new install — the parser was in the box the whole time.
|
|
90
|
+
*/
|
|
91
|
+
function callsIn(blockLines, file) {
|
|
92
|
+
if (blockLines.length === 0)
|
|
93
|
+
return [];
|
|
94
|
+
const src = blockLines.map((b) => b.text).join("\n");
|
|
95
|
+
const firstLine = blockLines[0].lineNo;
|
|
96
|
+
const sf = typescript_1.default.createSourceFile("block.ts", src, typescript_1.default.ScriptTarget.Latest,
|
|
97
|
+
/* setParentNodes */ true, typescript_1.default.ScriptKind.TS);
|
|
98
|
+
const out = [];
|
|
99
|
+
const visit = (node) => {
|
|
100
|
+
if (typescript_1.default.isCallExpression(node) && typescript_1.default.isIdentifier(node.expression)) {
|
|
101
|
+
const kind = node.expression.text;
|
|
102
|
+
if (KINDS.has(kind)) {
|
|
103
|
+
const arg = node.arguments[0];
|
|
104
|
+
// Only a plain string literal is a ref we can resolve. A template with
|
|
105
|
+
// substitutions, a variable, or a computed value is not something this
|
|
106
|
+
// pass can check, and guessing at it is how false reports start.
|
|
107
|
+
if (arg &&
|
|
108
|
+
(typescript_1.default.isStringLiteral(arg) || typescript_1.default.isNoSubstitutionTemplateLiteral(arg))) {
|
|
109
|
+
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
110
|
+
out.push({
|
|
111
|
+
file,
|
|
112
|
+
line: firstLine + line,
|
|
113
|
+
kind: kind,
|
|
114
|
+
value: arg.text,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
typescript_1.default.forEachChild(node, visit);
|
|
120
|
+
};
|
|
121
|
+
visit(sf);
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
60
124
|
/** @internal */ function extractDocRefs(content, file) {
|
|
61
125
|
const lines = content.split("\n");
|
|
62
126
|
const refs = [];
|
|
@@ -88,16 +152,7 @@ const UNVERIFIABLE_PATTERNS = [
|
|
|
88
152
|
blocksIgnored++;
|
|
89
153
|
}
|
|
90
154
|
else {
|
|
91
|
-
|
|
92
|
-
for (const m of text.matchAll(CALL_RE)) {
|
|
93
|
-
refs.push({
|
|
94
|
-
file,
|
|
95
|
-
line: lineNo,
|
|
96
|
-
kind: m[1],
|
|
97
|
-
value: m[2],
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
}
|
|
155
|
+
refs.push(...callsIn(blockLines, file));
|
|
101
156
|
}
|
|
102
157
|
}
|
|
103
158
|
fenceChar = null;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE a repo's model surfaces (skills/agents/commands) live — and the key each
|
|
3
|
+
* one is materialized under.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 THIS EXISTS BECAUSE THE LOADER USED TO CHOOSE. It read the repo-root
|
|
6
|
+
* `skills/` **or** the project-level `.claude/skills/` — never both — and
|
|
7
|
+
* materialized whichever it picked under the SAME canonical
|
|
8
|
+
* `<materializeRoot>/<surface>/…` key. Two real files, one key: the loser was
|
|
9
|
+
* never read, and the winner's content sat under the loser's name. Measured
|
|
10
|
+
* 2026-08-18 on `nyldn/claude-octopus` (pinned corpus): **50 skill names exist in
|
|
11
|
+
* both `skills/` and `.claude/skills/`, and all 50 pairs differ** — the `.claude/`
|
|
12
|
+
* copies carry multi-line unquoted `description:` blocks that a strict YAML
|
|
13
|
+
* loader rejects. vigiles reported those fifty skills as clean without ever
|
|
14
|
+
* having opened the files it named.
|
|
15
|
+
*
|
|
16
|
+
* The vendor settles it — Claude Code loads BOTH, in two namespaces:
|
|
17
|
+
*
|
|
18
|
+
* > Plugin skills use a `plugin-name:skill-name` namespace, so they can't
|
|
19
|
+
* > conflict with other levels.
|
|
20
|
+
* > For example, `my-plugin/skills/deploy/SKILL.md` becomes `/my-plugin:deploy`
|
|
21
|
+
* > and loads alongside a `deploy` skill in your project's `.claude/skills/`.
|
|
22
|
+
* > — https://code.claude.com/docs/en/skills § "Where skills live"
|
|
23
|
+
*
|
|
24
|
+
* So "pick one" was never a tie-break to get right; it was a question that has no
|
|
25
|
+
* answer, asked because the key shape forced one. The fix removes the question:
|
|
26
|
+
* every scope present is read, and **a scope's key prefix is derived from the
|
|
27
|
+
* scope, not from a winner**, so two files can no longer claim one key.
|
|
28
|
+
*
|
|
29
|
+
* Node-free and IO-free on purpose: the disk loader (`src/plugin-loader.ts`) and
|
|
30
|
+
* the browser file-map twin (`src/scan-files.ts`) each probe their own storage
|
|
31
|
+
* and call THIS for the decision, so the pair that this repo has repeatedly been
|
|
32
|
+
* bitten by fixing on one side only cannot disagree about scoping.
|
|
33
|
+
*/
|
|
34
|
+
import type { PluginLayout } from "./layout.js";
|
|
35
|
+
/**
|
|
36
|
+
* One discovery level, and the prefix its files are materialized under.
|
|
37
|
+
*
|
|
38
|
+
* `base` is the repo-relative dir the surfaces really live under (`""` = the repo
|
|
39
|
+
* root, the published-plugin shape; `.claude` = the plain-user project shape).
|
|
40
|
+
* `materializeUnder` is the prefix prepended to `<surface>/<rel>` to form the
|
|
41
|
+
* `LoadedPlugin.files` key.
|
|
42
|
+
*/
|
|
43
|
+
export interface SurfaceScope {
|
|
44
|
+
/** Repo-relative dir holding `<surface>/…`; `""` for the repo root. */
|
|
45
|
+
readonly base: string;
|
|
46
|
+
/** Key prefix for this scope's files; `""` for none. */
|
|
47
|
+
readonly materializeUnder: string;
|
|
48
|
+
/** Human label for warnings — `plugin` (root) or `project` (`.claude/`). */
|
|
49
|
+
readonly label: string;
|
|
50
|
+
}
|
|
51
|
+
/** Which shape the audited target is, and every scope to read from it. */
|
|
52
|
+
export type SurfaceSource = {
|
|
53
|
+
readonly kind: "single-skill";
|
|
54
|
+
readonly skillName: string;
|
|
55
|
+
} | {
|
|
56
|
+
readonly kind: "scopes";
|
|
57
|
+
readonly scopes: readonly SurfaceScope[];
|
|
58
|
+
};
|
|
59
|
+
/** What the caller must probe on its own storage for {@link surfaceSource}. */
|
|
60
|
+
export interface SurfaceProbe {
|
|
61
|
+
/** A `<root>/SKILL.md` exists — the target IS one skill dir. */
|
|
62
|
+
readonly hasRootSkillFile: boolean;
|
|
63
|
+
/** Name to give that single skill (the target dir's basename). */
|
|
64
|
+
readonly skillName: string;
|
|
65
|
+
/** Some `<root>/<surface>/` holds a loadable file. */
|
|
66
|
+
readonly rootHasLoadable: boolean;
|
|
67
|
+
/** A plugin manifest or the hooks convention path exists. */
|
|
68
|
+
readonly isPluginShaped: boolean;
|
|
69
|
+
/** Some `<root>/<userSurfaceRoot>/<surface>/` holds a loadable file. */
|
|
70
|
+
readonly userHasLoadable: boolean;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Classify the target and list every scope to read, HIGHEST-PRECEDENCE FIRST.
|
|
74
|
+
*
|
|
75
|
+
* Precedence decides only one thing: which scope keeps the canonical
|
|
76
|
+
* `<materializeRoot>/…` key. The project scope takes it, because that key IS
|
|
77
|
+
* where a project skill lives — `.claude/skills/deploy/SKILL.md` is loaded from
|
|
78
|
+
* exactly that path and answers to `/deploy`. A plugin scope keeps its own real
|
|
79
|
+
* location (`skills/deploy/SKILL.md`), which is likewise where the harness reads
|
|
80
|
+
* it from, under `/plugin:deploy`. Nothing is relocated on top of something else.
|
|
81
|
+
*
|
|
82
|
+
* 🔴 THE COLLISION IS STRUCTURAL, NOT CHECKED. Only the FIRST scope is relocated;
|
|
83
|
+
* every later one keeps `base` as its prefix. Since the first scope is the only
|
|
84
|
+
* one that can produce a `<materializeRoot>/…` key, and every other prefix is a
|
|
85
|
+
* distinct real directory, two scopes cannot mint the same key — there is no
|
|
86
|
+
* ordering, no "if already taken", and no last-write-wins to get wrong.
|
|
87
|
+
* {@link assertDistinctScopeKeys} is the LOUD backstop for a future
|
|
88
|
+
* `PluginLayout` that breaks the premise (e.g. one naming `.claude` as BOTH its
|
|
89
|
+
* `materializeRoot` and a second scope's base).
|
|
90
|
+
*/
|
|
91
|
+
export declare function surfaceSource(layout: PluginLayout, probe: SurfaceProbe): SurfaceSource;
|
|
92
|
+
/** The `LoadedPlugin.files` key for one file under one scope. */
|
|
93
|
+
export declare function scopeKey(scope: SurfaceScope, surface: string, rel: string): string;
|
|
94
|
+
/**
|
|
95
|
+
* Throw when two scopes would mint the same key prefix. Unreachable for every
|
|
96
|
+
* shipped layout (see {@link surfaceSource}) — it exists so a NEW layout that
|
|
97
|
+
* breaks the premise fails loudly at load, rather than silently dropping a
|
|
98
|
+
* surface file the way the shadowing bug did for a year.
|
|
99
|
+
*/
|
|
100
|
+
export declare function assertDistinctScopeKeys(scopes: readonly SurfaceScope[], layoutName: string): void;
|
|
101
|
+
/**
|
|
102
|
+
* The warning to emit when more than one scope is present. Both scopes load in a
|
|
103
|
+
* real session under DIFFERENT names, but the deterministic sandbox is a project
|
|
104
|
+
* dir — it registers the project scope only, so a plugin-scope skill sitting at
|
|
105
|
+
* `skills/…` in the fixture never activates there (the footgun
|
|
106
|
+
* `unregisteredSkillFiles` already warns about for inline arm files). Say so,
|
|
107
|
+
* rather than quietly relocating one on top of the other.
|
|
108
|
+
*/
|
|
109
|
+
export declare function multiScopeWarning(scopes: readonly SurfaceScope[], counts: Record<string, number>): string | undefined;
|
|
110
|
+
//# sourceMappingURL=surface-scopes.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.surfaceSource = surfaceSource;
|
|
4
|
+
exports.scopeKey = scopeKey;
|
|
5
|
+
exports.assertDistinctScopeKeys = assertDistinctScopeKeys;
|
|
6
|
+
exports.multiScopeWarning = multiScopeWarning;
|
|
7
|
+
/**
|
|
8
|
+
* Classify the target and list every scope to read, HIGHEST-PRECEDENCE FIRST.
|
|
9
|
+
*
|
|
10
|
+
* Precedence decides only one thing: which scope keeps the canonical
|
|
11
|
+
* `<materializeRoot>/…` key. The project scope takes it, because that key IS
|
|
12
|
+
* where a project skill lives — `.claude/skills/deploy/SKILL.md` is loaded from
|
|
13
|
+
* exactly that path and answers to `/deploy`. A plugin scope keeps its own real
|
|
14
|
+
* location (`skills/deploy/SKILL.md`), which is likewise where the harness reads
|
|
15
|
+
* it from, under `/plugin:deploy`. Nothing is relocated on top of something else.
|
|
16
|
+
*
|
|
17
|
+
* 🔴 THE COLLISION IS STRUCTURAL, NOT CHECKED. Only the FIRST scope is relocated;
|
|
18
|
+
* every later one keeps `base` as its prefix. Since the first scope is the only
|
|
19
|
+
* one that can produce a `<materializeRoot>/…` key, and every other prefix is a
|
|
20
|
+
* distinct real directory, two scopes cannot mint the same key — there is no
|
|
21
|
+
* ordering, no "if already taken", and no last-write-wins to get wrong.
|
|
22
|
+
* {@link assertDistinctScopeKeys} is the LOUD backstop for a future
|
|
23
|
+
* `PluginLayout` that breaks the premise (e.g. one naming `.claude` as BOTH its
|
|
24
|
+
* `materializeRoot` and a second scope's base).
|
|
25
|
+
*/
|
|
26
|
+
function surfaceSource(layout, probe) {
|
|
27
|
+
if (layout.skillDir && probe.hasRootSkillFile) {
|
|
28
|
+
return { kind: "single-skill", skillName: probe.skillName };
|
|
29
|
+
}
|
|
30
|
+
const scopes = [];
|
|
31
|
+
if (layout.userSurfaceRoot !== undefined && probe.userHasLoadable) {
|
|
32
|
+
scopes.push({
|
|
33
|
+
base: layout.userSurfaceRoot,
|
|
34
|
+
materializeUnder: layout.materializeRoot,
|
|
35
|
+
label: "project",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (probe.rootHasLoadable || probe.isPluginShaped) {
|
|
39
|
+
scopes.push({
|
|
40
|
+
base: "",
|
|
41
|
+
materializeUnder: scopes.length === 0 ? layout.materializeRoot : "",
|
|
42
|
+
label: "plugin",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// A layout with no user root and nothing at the root still has to read the
|
|
46
|
+
// project shape, or a plain repo would load as an empty machine — the reason
|
|
47
|
+
// `userSurfaceRoot` exists. An empty list means genuinely nothing loadable.
|
|
48
|
+
if (scopes.length === 0 && layout.userSurfaceRoot !== undefined) {
|
|
49
|
+
scopes.push({
|
|
50
|
+
base: layout.userSurfaceRoot,
|
|
51
|
+
materializeUnder: layout.materializeRoot,
|
|
52
|
+
label: "project",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return { kind: "scopes", scopes };
|
|
56
|
+
}
|
|
57
|
+
/** The `LoadedPlugin.files` key for one file under one scope. */
|
|
58
|
+
function scopeKey(scope, surface, rel) {
|
|
59
|
+
return [scope.materializeUnder, surface, rel]
|
|
60
|
+
.filter((s) => s !== "")
|
|
61
|
+
.join("/");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Throw when two scopes would mint the same key prefix. Unreachable for every
|
|
65
|
+
* shipped layout (see {@link surfaceSource}) — it exists so a NEW layout that
|
|
66
|
+
* breaks the premise fails loudly at load, rather than silently dropping a
|
|
67
|
+
* surface file the way the shadowing bug did for a year.
|
|
68
|
+
*/
|
|
69
|
+
function assertDistinctScopeKeys(scopes, layoutName) {
|
|
70
|
+
const seen = new Map();
|
|
71
|
+
for (const s of scopes) {
|
|
72
|
+
const prev = seen.get(s.materializeUnder);
|
|
73
|
+
if (prev !== undefined) {
|
|
74
|
+
throw new Error(`layout "${layoutName}": surface scopes "${prev}" and "${s.base}" both materialize under ` +
|
|
75
|
+
`"${s.materializeUnder || "<repo root>"}" — one would silently shadow the other. ` +
|
|
76
|
+
`Give each scope a distinct materialize prefix (see src/core/surface-scopes.ts).`);
|
|
77
|
+
}
|
|
78
|
+
seen.set(s.materializeUnder, s.base);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The warning to emit when more than one scope is present. Both scopes load in a
|
|
83
|
+
* real session under DIFFERENT names, but the deterministic sandbox is a project
|
|
84
|
+
* dir — it registers the project scope only, so a plugin-scope skill sitting at
|
|
85
|
+
* `skills/…` in the fixture never activates there (the footgun
|
|
86
|
+
* `unregisteredSkillFiles` already warns about for inline arm files). Say so,
|
|
87
|
+
* rather than quietly relocating one on top of the other.
|
|
88
|
+
*/
|
|
89
|
+
function multiScopeWarning(scopes, counts) {
|
|
90
|
+
if (scopes.length < 2)
|
|
91
|
+
return undefined;
|
|
92
|
+
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
93
|
+
return (`repo carries surfaces at TWO discovery levels (${scopes
|
|
94
|
+
.map((s) => `${s.label} → ${s.base === "" ? "<repo root>" : s.base}/`)
|
|
95
|
+
.join(", ")}); ${String(total)} file(s) were read from both. Claude Code loads both — ` +
|
|
96
|
+
`a plugin skill as \`/<plugin>:<name>\`, a project skill as \`/<name>\` — so a name in both ` +
|
|
97
|
+
`places is TWO surfaces, not one. The deterministic sandbox is a project dir and registers ` +
|
|
98
|
+
`the project scope only; install the plugin scope with \`pluginDir\` to exercise it.`);
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=surface-scopes.js.map
|
package/dist/plugin-loader.js
CHANGED
|
@@ -39,6 +39,7 @@ const toml_1 = require("@iarna/toml");
|
|
|
39
39
|
const hash_js_1 = require("./core/hash.js");
|
|
40
40
|
const fs_walk_js_1 = require("./fs-walk.js");
|
|
41
41
|
const source_refs_js_1 = require("./core/source-refs.js");
|
|
42
|
+
const surface_scopes_js_1 = require("./core/surface-scopes.js");
|
|
42
43
|
const MAX_SKILL_FILE_BYTES = 256 * 1024;
|
|
43
44
|
/** Parse a JSON file, or null on any error (missing / malformed). */
|
|
44
45
|
function safeReadJson(path) {
|
|
@@ -177,19 +178,35 @@ function loadPlugin(pluginPath, layout) {
|
|
|
177
178
|
files[layout.instructionFile] = (0, node_fs_1.readFileSync)(instructions, "utf-8");
|
|
178
179
|
sources[layout.instructionFile] = instructions;
|
|
179
180
|
}
|
|
180
|
-
const
|
|
181
|
+
const surfaces = materializeSurfaces(root, layout, files, sources);
|
|
181
182
|
return {
|
|
182
183
|
settings: resolvedHooks ? { hooks: resolvedHooks } : {},
|
|
183
184
|
files,
|
|
184
185
|
sources,
|
|
185
|
-
warnings: pluginWarnings(root,
|
|
186
|
+
warnings: pluginWarnings(root, surfaces, resolvedHooks, files, layout),
|
|
186
187
|
};
|
|
187
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Materialize every model surface (skills/agents/commands) into `files`, and
|
|
191
|
+
* record each file's real on-disk path in `sources`. Best-effort (headless
|
|
192
|
+
* activation of plugin skills/subagents/commands is not guaranteed; the body is
|
|
193
|
+
* present to read).
|
|
194
|
+
*
|
|
195
|
+
* EVERY discovery level present is read — the repo-root `<surface>` (the
|
|
196
|
+
* published-plugin / skills-library shape) AND the project-level
|
|
197
|
+
* `<userSurfaceRoot>/<surface>` (the shape a plain Claude Code user has). The
|
|
198
|
+
* loader used to read one OR the other and materialize the winner under the
|
|
199
|
+
* loser's canonical key; `src/core/surface-scopes.ts` carries the measurement
|
|
200
|
+
* that killed that, and the vendor quote that settles which one the harness
|
|
201
|
+
* loads (both, in different namespaces). The KEY now comes from the scope, so
|
|
202
|
+
* two files can no longer claim one. Plus the single-skill-directory case
|
|
203
|
+
* (`<root>/SKILL.md`), so pointing at one skill dir works. Returns the
|
|
204
|
+
* per-surface counts (drives the surface warnings).
|
|
205
|
+
*/
|
|
188
206
|
/**
|
|
189
207
|
* A surface holds a LOADABLE file — a `<name>/SKILL.md` for skills, a `.md` for
|
|
190
208
|
* agents/commands. A stray non-surface file (`skills/README.md`, `.gitkeep`) does
|
|
191
|
-
* NOT count, else
|
|
192
|
-
* `.claude/skills`.
|
|
209
|
+
* NOT count, else an empty-but-present dir would mark a scope populated.
|
|
193
210
|
*/
|
|
194
211
|
function surfaceHasLoadable(layout, surface, tree) {
|
|
195
212
|
const keys = Object.keys(tree);
|
|
@@ -197,30 +214,6 @@ function surfaceHasLoadable(layout, surface, tree) {
|
|
|
197
214
|
? keys.some((k) => (0, node_path_1.basename)(k) === "SKILL.md")
|
|
198
215
|
: keys.some((k) => k.endsWith(".md"));
|
|
199
216
|
}
|
|
200
|
-
/**
|
|
201
|
-
* Classify the repo shape from disk, with EXPLICIT precedence:
|
|
202
|
-
* 1. a `<root>/SKILL.md` → the target IS one skill dir (single-skill).
|
|
203
|
-
* 2. any root-surface with LOADABLE content, OR a plugin manifest / hooks
|
|
204
|
-
* convention → read the ROOT surfaces. A plugin ships from its manifest even
|
|
205
|
-
* with no root surface dirs, so its dev `.claude/…` is never a fallback.
|
|
206
|
-
* 3. else, if the layout declares a user-surface root → a plain user repo.
|
|
207
|
-
* 4. else → nothing loadable.
|
|
208
|
-
* Pure over the pre-read `rootTrees` + a few existence checks — one place to test.
|
|
209
|
-
*/
|
|
210
|
-
function classifySurfaceSource(root, layout, rootTrees) {
|
|
211
|
-
if (layout.skillDir && (0, node_fs_1.existsSync)((0, node_path_1.join)(root, "SKILL.md"))) {
|
|
212
|
-
return { kind: "single-skill", skillName: (0, node_path_1.basename)(root) };
|
|
213
|
-
}
|
|
214
|
-
const rootHasLoadable = layout.surfaceDirs.some((s) => surfaceHasLoadable(layout, s, rootTrees.get(s) ?? {}));
|
|
215
|
-
const isPluginShaped = (0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.manifestPath)) ||
|
|
216
|
-
(0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.hooksConventionPath));
|
|
217
|
-
if (rootHasLoadable || isPluginShaped)
|
|
218
|
-
return { kind: "root" };
|
|
219
|
-
if (layout.userSurfaceRoot !== undefined) {
|
|
220
|
-
return { kind: "user", sub: layout.userSurfaceRoot };
|
|
221
|
-
}
|
|
222
|
-
return { kind: "none" };
|
|
223
|
-
}
|
|
224
217
|
function materializeSurfaces(root, layout, files, sources) {
|
|
225
218
|
const counts = {};
|
|
226
219
|
const isDir = (p) => (0, node_fs_1.existsSync)(p) && (0, node_fs_1.statSync)(p).isDirectory();
|
|
@@ -234,15 +227,41 @@ function materializeSurfaces(root, layout, files, sources) {
|
|
|
234
227
|
* outside is still read.
|
|
235
228
|
*/
|
|
236
229
|
const surfaceTree = (dir) => isDir(dir) && (0, fs_walk_js_1.walkableRoot)(dir, root) ? readTree(dir, dir) : {};
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
230
|
+
/** Every surface tree of one scope, read once, keyed by surface dir. */
|
|
231
|
+
const scopeTrees = (base) => {
|
|
232
|
+
const trees = new Map();
|
|
233
|
+
for (const surface of layout.surfaceDirs)
|
|
234
|
+
trees.set(surface, surfaceTree((0, node_path_1.join)(root, base, surface)));
|
|
235
|
+
return trees;
|
|
236
|
+
};
|
|
237
|
+
const hasLoadable = (trees) => layout.surfaceDirs.some((s) => surfaceHasLoadable(layout, s, trees.get(s) ?? {}));
|
|
241
238
|
const add = (key, content, onDisk) => {
|
|
242
239
|
files[key] = content;
|
|
243
240
|
sources[key] = onDisk;
|
|
244
241
|
};
|
|
245
|
-
|
|
242
|
+
// Both candidate scopes are read ONCE, up front, because the decision needs to
|
|
243
|
+
// know whether each holds anything — and then the same trees are materialized.
|
|
244
|
+
const rootTrees = scopeTrees("");
|
|
245
|
+
const userTrees = layout.userSurfaceRoot !== undefined
|
|
246
|
+
? scopeTrees(layout.userSurfaceRoot)
|
|
247
|
+
: new Map();
|
|
248
|
+
/** Copy one scope's already-read trees into `files`, keyed by that scope. */
|
|
249
|
+
const materializeScope = (scope, trees) => {
|
|
250
|
+
for (const surface of layout.surfaceDirs) {
|
|
251
|
+
const tree = trees.get(surface) ?? {};
|
|
252
|
+
for (const [rel, content] of Object.entries(tree))
|
|
253
|
+
add((0, surface_scopes_js_1.scopeKey)(scope, surface, rel), content, (0, node_path_1.join)(root, scope.base, surface, rel));
|
|
254
|
+
counts[surface] = (counts[surface] ?? 0) + Object.keys(tree).length;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
const source = (0, surface_scopes_js_1.surfaceSource)(layout, {
|
|
258
|
+
hasRootSkillFile: (0, node_fs_1.existsSync)((0, node_path_1.join)(root, "SKILL.md")),
|
|
259
|
+
skillName: (0, node_path_1.basename)(root),
|
|
260
|
+
rootHasLoadable: hasLoadable(rootTrees),
|
|
261
|
+
isPluginShaped: (0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.manifestPath)) ||
|
|
262
|
+
(0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.hooksConventionPath)),
|
|
263
|
+
userHasLoadable: hasLoadable(userTrees),
|
|
264
|
+
});
|
|
246
265
|
switch (source.kind) {
|
|
247
266
|
case "single-skill": {
|
|
248
267
|
// Materialize the WHOLE skill dir under the canonical skills key, so its
|
|
@@ -255,31 +274,18 @@ function materializeSurfaces(root, layout, files, sources) {
|
|
|
255
274
|
add((0, node_path_1.join)(layout.materializeRoot, layout.skillDir, source.skillName, rel), content, (0, node_path_1.join)(root, rel));
|
|
256
275
|
}
|
|
257
276
|
counts[layout.skillDir] = Object.keys(tree).length;
|
|
258
|
-
|
|
277
|
+
return { counts, scopes: [] };
|
|
259
278
|
}
|
|
260
|
-
case "
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// Root surfaces were pre-read; user surfaces are read fresh here.
|
|
266
|
-
const tree = source.kind === "root"
|
|
267
|
-
? (rootTrees.get(surface) ?? {})
|
|
268
|
-
: surfaceTree(dir);
|
|
269
|
-
for (const [rel, content] of Object.entries(tree)) {
|
|
270
|
-
add((0, node_path_1.join)(layout.materializeRoot, surface, rel), content, (0, node_path_1.join)(dir, rel));
|
|
271
|
-
}
|
|
272
|
-
counts[surface] = Object.keys(tree).length;
|
|
273
|
-
}
|
|
274
|
-
break;
|
|
279
|
+
case "scopes": {
|
|
280
|
+
(0, surface_scopes_js_1.assertDistinctScopeKeys)(source.scopes, layout.name);
|
|
281
|
+
for (const scope of source.scopes)
|
|
282
|
+
materializeScope(scope, scope.base === "" ? rootTrees : userTrees);
|
|
283
|
+
return { counts, scopes: source.scopes };
|
|
275
284
|
}
|
|
276
|
-
case "none":
|
|
277
|
-
break;
|
|
278
285
|
/* v8 ignore next 2 -- exhaustiveness guard, unreachable given SurfaceSource */
|
|
279
286
|
default:
|
|
280
|
-
(0, hash_js_1.assertNever)(source);
|
|
287
|
+
return (0, hash_js_1.assertNever)(source);
|
|
281
288
|
}
|
|
282
|
-
return counts;
|
|
283
289
|
}
|
|
284
290
|
/**
|
|
285
291
|
* Flag surfaces present-but-not-deterministically-exercisable. Subagents
|
|
@@ -288,8 +294,11 @@ function materializeSurfaces(root, layout, files, sources) {
|
|
|
288
294
|
* the eval tier. MCP servers aren't wired by the loader at all. And a plugin
|
|
289
295
|
* that yields neither hooks nor files would otherwise be a silent empty machine.
|
|
290
296
|
*/
|
|
291
|
-
function pluginWarnings(root, counts, hooks, files, layout) {
|
|
297
|
+
function pluginWarnings(root, { counts, scopes }, hooks, files, layout) {
|
|
292
298
|
const warnings = [];
|
|
299
|
+
const multiScope = (0, surface_scopes_js_1.multiScopeWarning)(scopes, counts);
|
|
300
|
+
if (multiScope !== undefined)
|
|
301
|
+
warnings.push(multiScope);
|
|
293
302
|
if (counts.agents) {
|
|
294
303
|
warnings.push(`plugin defines ${String(counts.agents)} subagent file(s) under agents/ — these run only under a real model; test them at the eval tier (runEval), not the deterministic mock.`);
|
|
295
304
|
}
|
package/dist/scan-core.d.ts
CHANGED
|
@@ -109,7 +109,12 @@ export declare function skillRefSources(files: Record<string, string>, cls: Surf
|
|
|
109
109
|
readonly root: string;
|
|
110
110
|
readonly sources?: Record<string, string>;
|
|
111
111
|
}): SkillRefSource[];
|
|
112
|
-
|
|
112
|
+
/** Where a materialized key really lives — for reporting a surface by path. */
|
|
113
|
+
export interface SurfacePathContext {
|
|
114
|
+
readonly root: string;
|
|
115
|
+
readonly sources?: Record<string, string>;
|
|
116
|
+
}
|
|
117
|
+
export declare function descriptionOverlapsFor(files: Record<string, string>, cls: SurfaceClassifier, where?: SurfacePathContext): DescriptionOverlap[];
|
|
113
118
|
/**
|
|
114
119
|
* Model-invocable skills whose description is so long the trigger signal is
|
|
115
120
|
* buried (heuristic proxy; degrades recall + precision). Same surfaces as the
|
package/dist/scan-core.js
CHANGED
|
@@ -408,7 +408,7 @@ function skillRefSources(files, cls, ctx) {
|
|
|
408
408
|
* logic as `scanSkills` (frontmatter `description` ← first body paragraph), then
|
|
409
409
|
* the NCD precision-proxy. See description-overlap.ts.
|
|
410
410
|
*/
|
|
411
|
-
function modelInvocableSkillSurfaces(files, cls) {
|
|
411
|
+
function modelInvocableSkillSurfaces(files, cls, where) {
|
|
412
412
|
const surfaces = [];
|
|
413
413
|
for (const [path, md] of Object.entries(files)) {
|
|
414
414
|
if (!cls.isSkill(path))
|
|
@@ -419,12 +419,24 @@ function modelInvocableSkillSurfaces(files, cls) {
|
|
|
419
419
|
const description = fm.description ?? firstBodyParagraph(md);
|
|
420
420
|
if (!description || description.length < 20)
|
|
421
421
|
continue;
|
|
422
|
-
surfaces.push({
|
|
422
|
+
surfaces.push({
|
|
423
|
+
name: fm.name ?? skillName(path),
|
|
424
|
+
description,
|
|
425
|
+
// The NAME alone stopped identifying a surface once the loader began
|
|
426
|
+
// reading both discovery levels: a repo carrying `skills/x` AND
|
|
427
|
+
// `.claude/skills/x` has two skills called `x`. Report the real path
|
|
428
|
+
// (never the synthetic key) so the pair is actionable.
|
|
429
|
+
...(where
|
|
430
|
+
? {
|
|
431
|
+
where: reportedSurfacePath(path, where.sources?.[path], where.root),
|
|
432
|
+
}
|
|
433
|
+
: {}),
|
|
434
|
+
});
|
|
423
435
|
}
|
|
424
436
|
return surfaces;
|
|
425
437
|
}
|
|
426
|
-
function descriptionOverlapsFor(files, cls) {
|
|
427
|
-
return (0, description_overlap_js_1.findDescriptionOverlaps)(modelInvocableSkillSurfaces(files, cls));
|
|
438
|
+
function descriptionOverlapsFor(files, cls, where) {
|
|
439
|
+
return (0, description_overlap_js_1.findDescriptionOverlaps)(modelInvocableSkillSurfaces(files, cls, where));
|
|
428
440
|
}
|
|
429
441
|
/**
|
|
430
442
|
* Model-invocable skills whose description is so long the trigger signal is
|
|
@@ -834,9 +846,31 @@ function collectDelegationTrifecta(agents, dialect) {
|
|
|
834
846
|
delegatesTo: canDispatch ? allNames.filter((n) => n !== a.name) : [],
|
|
835
847
|
};
|
|
836
848
|
});
|
|
837
|
-
|
|
849
|
+
// 🔴 A NAME IS NOT AN IDENTITY HERE, and this map used to assume it was.
|
|
850
|
+
// `new Map(agents.map((a) => [a.name, a.path]))` keeps the LAST entry for a
|
|
851
|
+
// repeated key, so when the same agent name exists in two discovery scopes —
|
|
852
|
+
// `agents/foo.md` and `.claude/agents/foo.md`, which Claude registers in
|
|
853
|
+
// DIFFERENT namespaces — a finding about the first was reported against the
|
|
854
|
+
// second's file. A lethal-trifecta finding that names the wrong file is worse
|
|
855
|
+
// than one that names none: it sends the reader to audit innocent code and
|
|
856
|
+
// leaves the real surface unexamined.
|
|
857
|
+
//
|
|
858
|
+
// Until identity is scope-qualified through the whole delegation pipeline (the
|
|
859
|
+
// proper fix, and a larger one — `finding.name` itself carries only the bare
|
|
860
|
+
// name), an ambiguous name resolves to NO path rather than to an arbitrary one.
|
|
861
|
+
// Nothing changes for the overwhelmingly common case of distinct names.
|
|
862
|
+
const pathByName = new Map();
|
|
863
|
+
const ambiguous = new Set();
|
|
864
|
+
for (const a of agents) {
|
|
865
|
+
if (pathByName.has(a.name))
|
|
866
|
+
ambiguous.add(a.name);
|
|
867
|
+
else
|
|
868
|
+
pathByName.set(a.name, a.path);
|
|
869
|
+
}
|
|
838
870
|
return (0, delegation_trifecta_js_1.delegationTrifectaIssues)(nodes, dialect).map((finding) => ({
|
|
839
|
-
path:
|
|
871
|
+
path: ambiguous.has(finding.name)
|
|
872
|
+
? ""
|
|
873
|
+
: (pathByName.get(finding.name) ?? ""),
|
|
840
874
|
finding,
|
|
841
875
|
}));
|
|
842
876
|
}
|
package/dist/scan-files.js
CHANGED
|
@@ -48,6 +48,7 @@ const mcp_config_js_1 = require("./core/mcp-config.js");
|
|
|
48
48
|
const agent_plugins_js_1 = require("./core/agent-plugins.js");
|
|
49
49
|
const mcp_hook_js_1 = require("./core/mcp-hook.js");
|
|
50
50
|
const plugin_dir_layout_js_1 = require("./core/plugin-dir-layout.js");
|
|
51
|
+
const surface_scopes_js_1 = require("./core/surface-scopes.js");
|
|
51
52
|
const hook_block_ineffective_js_1 = require("./core/hook-block-ineffective.js");
|
|
52
53
|
const hook_matcher_js_1 = require("./core/hook-matcher.js");
|
|
53
54
|
const test_coverage_files_js_1 = require("./test-coverage-files.js");
|
|
@@ -209,49 +210,63 @@ function hasMcp(files, layout) {
|
|
|
209
210
|
return true;
|
|
210
211
|
return readManifest(files, layout)?.[layout.mcpManifestKey] !== undefined;
|
|
211
212
|
}
|
|
212
|
-
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Surface materialization (mirrors plugin-loader.ts materializeSurfaces)
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
/**
|
|
217
|
+
* Mirror of plugin-loader.ts `surfaceHasLoadable`. The SCOPING DECISION itself is
|
|
218
|
+
* not mirrored — it lives once, IO-free, in `src/core/surface-scopes.ts`, and both
|
|
219
|
+
* engines call it. That pair used to be two copies of the same precedence rules,
|
|
220
|
+
* which is exactly the shape this repo keeps getting bitten by fixing on one side.
|
|
221
|
+
*/
|
|
213
222
|
function surfaceHasLoadable(layout, surface, tree) {
|
|
214
223
|
const keys = Object.keys(tree);
|
|
215
224
|
return surface === layout.skillDir
|
|
216
225
|
? keys.some((k) => (0, posix_path_js_1.basename)(k) === "SKILL.md")
|
|
217
226
|
: keys.some((k) => k.endsWith(".md"));
|
|
218
227
|
}
|
|
219
|
-
/** Mirror of plugin-loader.ts `classifySurfaceSource`, over the file map. */
|
|
220
|
-
function classifySurfaceSource(files, layout, rootTrees, repoName) {
|
|
221
|
-
if (layout.skillDir && hasFile(files, "SKILL.md")) {
|
|
222
|
-
// Disk mirrors the CLI: a nameless root SKILL.md takes the audited dir's
|
|
223
|
-
// basename. In-browser there's no real dir, so use the repo name when the
|
|
224
|
-
// caller (runAudit) supplies it, else the synthetic BROWSER_ROOT basename.
|
|
225
|
-
return {
|
|
226
|
-
kind: "single-skill",
|
|
227
|
-
skillName: repoName ?? (0, posix_path_js_1.basename)(exports.BROWSER_ROOT),
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
const rootHasLoadable = layout.surfaceDirs.some((s) => surfaceHasLoadable(layout, s, rootTrees.get(s) ?? {}));
|
|
231
|
-
const isPluginShaped = hasFile(files, layout.manifestPath) ||
|
|
232
|
-
hasFile(files, layout.hooksConventionPath);
|
|
233
|
-
if (rootHasLoadable || isPluginShaped)
|
|
234
|
-
return { kind: "root" };
|
|
235
|
-
if (layout.userSurfaceRoot !== undefined) {
|
|
236
|
-
return { kind: "user", sub: layout.userSurfaceRoot };
|
|
237
|
-
}
|
|
238
|
-
return { kind: "none" };
|
|
239
|
-
}
|
|
240
228
|
/** Mirror of plugin-loader.ts `materializeSurfaces`, over the file map. */
|
|
241
229
|
function materializeSurfaces(files, layout, acc, repoName) {
|
|
242
230
|
const { out, sources } = acc;
|
|
243
231
|
const counts = {};
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
232
|
+
const scopeTrees = (base) => {
|
|
233
|
+
const trees = new Map();
|
|
234
|
+
for (const surface of layout.surfaceDirs) {
|
|
235
|
+
const dirRel = base === "" ? surface : `${base}/${surface}`;
|
|
236
|
+
trees.set(surface, isDirRel(files, dirRel) ? readTreeUnder(files, dirRel, dirRel) : {});
|
|
248
237
|
}
|
|
249
|
-
|
|
238
|
+
return trees;
|
|
239
|
+
};
|
|
240
|
+
const hasLoadable = (trees) => layout.surfaceDirs.some((s) => surfaceHasLoadable(layout, s, trees.get(s) ?? {}));
|
|
250
241
|
const add = (key, content, onDisk) => {
|
|
251
242
|
out[key] = content;
|
|
252
243
|
sources[key] = onDisk;
|
|
253
244
|
};
|
|
254
|
-
const
|
|
245
|
+
const rootTrees = scopeTrees("");
|
|
246
|
+
const userTrees = layout.userSurfaceRoot !== undefined
|
|
247
|
+
? scopeTrees(layout.userSurfaceRoot)
|
|
248
|
+
: new Map();
|
|
249
|
+
/** Mirror of the disk loader's `materializeScope`. */
|
|
250
|
+
const materializeScope = (scope, trees) => {
|
|
251
|
+
for (const surface of layout.surfaceDirs) {
|
|
252
|
+
const tree = trees.get(surface) ?? {};
|
|
253
|
+
const dirRel = scope.base === "" ? surface : `${scope.base}/${surface}`;
|
|
254
|
+
for (const [rel, content] of Object.entries(tree))
|
|
255
|
+
add((0, surface_scopes_js_1.scopeKey)(scope, surface, rel), content, (0, posix_path_js_1.join)(exports.BROWSER_ROOT, dirRel, rel));
|
|
256
|
+
counts[surface] = (counts[surface] ?? 0) + Object.keys(tree).length;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
const source = (0, surface_scopes_js_1.surfaceSource)(layout, {
|
|
260
|
+
hasRootSkillFile: Boolean(layout.skillDir) && hasFile(files, "SKILL.md"),
|
|
261
|
+
// Disk mirrors the CLI: a nameless root SKILL.md takes the audited dir's
|
|
262
|
+
// basename. In-browser there's no real dir, so use the repo name when the
|
|
263
|
+
// caller (runAudit) supplies it, else the synthetic BROWSER_ROOT basename.
|
|
264
|
+
skillName: repoName ?? (0, posix_path_js_1.basename)(exports.BROWSER_ROOT),
|
|
265
|
+
rootHasLoadable: hasLoadable(rootTrees),
|
|
266
|
+
isPluginShaped: hasFile(files, layout.manifestPath) ||
|
|
267
|
+
hasFile(files, layout.hooksConventionPath),
|
|
268
|
+
userHasLoadable: hasLoadable(userTrees),
|
|
269
|
+
});
|
|
255
270
|
switch (source.kind) {
|
|
256
271
|
case "single-skill": {
|
|
257
272
|
const tree = readTreeUnder(files, "", "");
|
|
@@ -259,29 +274,15 @@ function materializeSurfaces(files, layout, acc, repoName) {
|
|
|
259
274
|
add((0, posix_path_js_1.join)(layout.materializeRoot, layout.skillDir, source.skillName, rel), content, (0, posix_path_js_1.join)(exports.BROWSER_ROOT, rel));
|
|
260
275
|
}
|
|
261
276
|
counts[layout.skillDir] = Object.keys(tree).length;
|
|
262
|
-
|
|
277
|
+
return { counts, scopes: [] };
|
|
263
278
|
}
|
|
264
|
-
case "
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const tree = source.kind === "root"
|
|
270
|
-
? (rootTrees.get(surface) ?? {})
|
|
271
|
-
: isDirRel(files, dirRel)
|
|
272
|
-
? readTreeUnder(files, dirRel, dirRel)
|
|
273
|
-
: {};
|
|
274
|
-
for (const [rel, content] of Object.entries(tree)) {
|
|
275
|
-
add((0, posix_path_js_1.join)(layout.materializeRoot, surface, rel), content, (0, posix_path_js_1.join)(exports.BROWSER_ROOT, dirRel, rel));
|
|
276
|
-
}
|
|
277
|
-
counts[surface] = Object.keys(tree).length;
|
|
278
|
-
}
|
|
279
|
-
break;
|
|
279
|
+
case "scopes": {
|
|
280
|
+
(0, surface_scopes_js_1.assertDistinctScopeKeys)(source.scopes, layout.name);
|
|
281
|
+
for (const scope of source.scopes)
|
|
282
|
+
materializeScope(scope, scope.base === "" ? rootTrees : userTrees);
|
|
283
|
+
return { counts, scopes: source.scopes };
|
|
280
284
|
}
|
|
281
|
-
case "none":
|
|
282
|
-
break;
|
|
283
285
|
}
|
|
284
|
-
return counts;
|
|
285
286
|
}
|
|
286
287
|
// ---------------------------------------------------------------------------
|
|
287
288
|
// Dangling intra-plugin refs (mirrors plugin-loader.ts danglingRefs)
|
|
@@ -368,8 +369,11 @@ function danglingRefs(files, layout, rootName) {
|
|
|
368
369
|
// ---------------------------------------------------------------------------
|
|
369
370
|
// Warnings (mirrors plugin-loader.ts pluginWarnings)
|
|
370
371
|
// ---------------------------------------------------------------------------
|
|
371
|
-
function pluginWarnings(files, layout, counts, hooks, materialized, rootName) {
|
|
372
|
+
function pluginWarnings(files, layout, { counts, scopes }, hooks, materialized, rootName) {
|
|
372
373
|
const warnings = [];
|
|
374
|
+
const multiScope = (0, surface_scopes_js_1.multiScopeWarning)(scopes, counts);
|
|
375
|
+
if (multiScope !== undefined)
|
|
376
|
+
warnings.push(multiScope);
|
|
373
377
|
if (counts.agents) {
|
|
374
378
|
warnings.push(`plugin defines ${String(counts.agents)} subagent file(s) under agents/ — these run only under a real model; test them at the eval tier (runEval), not the deterministic mock.`);
|
|
375
379
|
}
|
|
@@ -411,12 +415,12 @@ function loadPluginFromFiles(files, layout, repoName) {
|
|
|
411
415
|
out[layout.instructionFile] = instructionText;
|
|
412
416
|
sources[layout.instructionFile] = (0, posix_path_js_1.join)(exports.BROWSER_ROOT, layout.instructionFile);
|
|
413
417
|
}
|
|
414
|
-
const
|
|
418
|
+
const surfaces = materializeSurfaces(files, layout, { out, sources }, repoName);
|
|
415
419
|
return {
|
|
416
420
|
settings: resolvedHooks ? { hooks: resolvedHooks } : {},
|
|
417
421
|
files: out,
|
|
418
422
|
sources,
|
|
419
|
-
warnings: pluginWarnings(files, layout,
|
|
423
|
+
warnings: pluginWarnings(files, layout, surfaces, resolvedHooks, out, repoName ?? (0, posix_path_js_1.basename)(exports.BROWSER_ROOT)),
|
|
420
424
|
};
|
|
421
425
|
}
|
|
422
426
|
// ---------------------------------------------------------------------------
|
|
@@ -536,7 +540,10 @@ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = diale
|
|
|
536
540
|
skillMetaIssues: remap((0, scan_core_js_1.skillMetaIssuesFor)(loaded.files, cls)),
|
|
537
541
|
mcpIssues: (0, mcp_config_js_1.verifyMcpServers)(mcpServers),
|
|
538
542
|
mcpHookIssues: (0, mcp_hook_js_1.verifyMcpHookTargets)(loaded.settings.hooks, declaredServers, dialect),
|
|
539
|
-
descriptionOverlaps: (0, scan_core_js_1.descriptionOverlapsFor)(loaded.files, cls
|
|
543
|
+
descriptionOverlaps: (0, scan_core_js_1.descriptionOverlapsFor)(loaded.files, cls, {
|
|
544
|
+
root: exports.BROWSER_ROOT,
|
|
545
|
+
sources: loaded.sources,
|
|
546
|
+
}),
|
|
540
547
|
descriptionBudgetIssues: (0, scan_core_js_1.descriptionBudgetFor)(loaded.files, cls),
|
|
541
548
|
trifectaFindings,
|
|
542
549
|
skillResourceIssues: skillResourceFindings,
|
package/dist/scan.js
CHANGED
|
@@ -219,7 +219,10 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
|
|
|
219
219
|
skillMetaIssues: remap((0, scan_core_js_1.skillMetaIssuesFor)(loaded.files, cls)),
|
|
220
220
|
mcpIssues: (0, mcp_config_js_1.verifyMcpServers)(mcpServers),
|
|
221
221
|
mcpHookIssues: (0, mcp_hook_js_1.verifyMcpHookTargets)(loaded.settings.hooks, declaredServers, dialect),
|
|
222
|
-
descriptionOverlaps: (0, scan_core_js_1.descriptionOverlapsFor)(loaded.files, cls
|
|
222
|
+
descriptionOverlaps: (0, scan_core_js_1.descriptionOverlapsFor)(loaded.files, cls, {
|
|
223
|
+
root: (0, node_path_1.resolve)(dir),
|
|
224
|
+
sources: loaded.sources,
|
|
225
|
+
}),
|
|
223
226
|
descriptionBudgetIssues: (0, scan_core_js_1.descriptionBudgetFor)(loaded.files, cls),
|
|
224
227
|
trifectaFindings,
|
|
225
228
|
skillResourceIssues: skillResourceFindings,
|
package/dist/score-explainer.js
CHANGED
|
@@ -41,7 +41,10 @@ function overlapExplanations(report) {
|
|
|
41
41
|
symptom: "wrong-skill-fires",
|
|
42
42
|
cause: o.message,
|
|
43
43
|
detector: "description-overlap",
|
|
44
|
-
|
|
44
|
+
// `o.a`/`o.b` are already display LABELS (quoted name, plus the file path
|
|
45
|
+
// when the caller had one) — a bare name is ambiguous now that both
|
|
46
|
+
// discovery levels are read and the same name can appear twice.
|
|
47
|
+
fix: `Differentiate the descriptions of ${o.a} and ${o.b} (${o.similarity} similar) — the selector picks by description, so near-identical text makes it fire the wrong one.`,
|
|
45
48
|
confidence: "possible",
|
|
46
49
|
}));
|
|
47
50
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "18.1.
|
|
3
|
+
"version": "18.1.2",
|
|
4
4
|
"description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|