vigiles 27.3.0 → 29.0.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 +1 -1
- package/dist/adapter-registry.d.ts +39 -0
- package/dist/adapter-registry.js +45 -0
- package/dist/adapter.d.ts +8 -0
- package/dist/adapter.js +10 -1
- package/dist/adapters/claude-code/adapter.js +9 -2
- package/dist/adapters/claude-code/layout.d.ts +5 -0
- package/dist/adapters/claude-code/plugin-loader.d.ts +10 -1
- package/dist/adapters/claude-code/plugin-loader.js +10 -1
- package/dist/adapters/codex/adapter.js +7 -2
- package/dist/adapters/codex/layout.d.ts +51 -5
- package/dist/adapters/codex/layout.js +13 -4
- package/dist/adapters/opencode/adapter.js +6 -0
- package/dist/audit-report.template.html +1 -1
- package/dist/audit-score.d.ts +7 -0
- package/dist/audit-score.js +49 -2
- package/dist/cli-main.js +162 -39
- package/dist/core/adapter.d.ts +45 -1
- package/dist/core/compile.js +11 -1
- package/dist/core/config-schema.d.ts +244 -0
- package/dist/core/config-schema.js +452 -0
- package/dist/core/hook-program.d.ts +43 -0
- package/dist/core/hook-program.js +32 -0
- package/dist/core/refs.js +10 -1
- package/dist/core/surface-discovery.d.ts +270 -0
- package/dist/core/surface-discovery.js +425 -0
- package/dist/core/surface-scopes.d.ts +38 -1
- package/dist/core/surface-scopes.js +73 -1
- package/dist/core/symbols.d.ts +24 -2
- package/dist/core/symbols.js +66 -18
- package/dist/core/types.d.ts +36 -107
- package/dist/core/validate.d.ts +46 -18
- package/dist/core/validate.js +98 -172
- package/dist/exclude.d.ts +20 -0
- package/dist/exclude.js +11 -1
- package/dist/harness-test.js +3 -3
- package/dist/hook-install.d.ts +53 -0
- package/dist/hook-install.js +60 -0
- package/dist/hook-runtime.d.ts +2 -2
- package/dist/hook-runtime.js +82 -63
- package/dist/layout-registry.d.ts +14 -0
- package/dist/layout-registry.js +40 -0
- package/dist/load-hook.d.ts +1 -1
- package/dist/load-hook.js +2 -2
- package/dist/plugin-loader.d.ts +49 -1
- package/dist/plugin-loader.js +120 -14
- package/dist/run-hook.js +17 -1
- package/dist/scan-core.d.ts +19 -0
- package/dist/scan-core.js +30 -0
- package/dist/scan-files.js +15 -5
- package/dist/scan.d.ts +63 -0
- package/dist/scan.js +68 -12
- package/dist/score-core.js +8 -0
- package/dist/setup-plan.d.ts +2 -1
- package/dist/setup-plan.js +7 -2
- package/dist/surface-discovery-fs.d.ts +12 -0
- package/dist/surface-discovery-fs.js +108 -0
- package/dist/vigilesrc.schema.json +1689 -0
- package/package.json +10 -6
package/dist/plugin-loader.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.loadPlugin = loadPlugin;
|
|
4
|
+
exports.loadPlugins = loadPlugins;
|
|
4
5
|
exports.danglingRefs = danglingRefs;
|
|
5
6
|
exports.resolveHarness = resolveHarness;
|
|
6
7
|
/**
|
|
@@ -37,6 +38,7 @@ const node_fs_1 = require("node:fs");
|
|
|
37
38
|
const node_path_1 = require("node:path");
|
|
38
39
|
const toml_1 = require("@iarna/toml");
|
|
39
40
|
const hash_js_1 = require("./core/hash.js");
|
|
41
|
+
const exclude_js_1 = require("./exclude.js");
|
|
40
42
|
const fs_walk_js_1 = require("./fs-walk.js");
|
|
41
43
|
const source_refs_js_1 = require("./core/source-refs.js");
|
|
42
44
|
const surface_scopes_js_1 = require("./core/surface-scopes.js");
|
|
@@ -140,13 +142,20 @@ function readHooks(root, layout) {
|
|
|
140
142
|
* `entryOf` has already cleared would cost a syscall per directory to answer a
|
|
141
143
|
* question that cannot come out differently.
|
|
142
144
|
*/
|
|
143
|
-
function readTree(dir, base) {
|
|
145
|
+
function readTree(dir, base, excluded = exclude_js_1.excludesNothing) {
|
|
144
146
|
const out = {};
|
|
145
147
|
for (const entry of (0, node_fs_1.readdirSync)(dir)) {
|
|
146
148
|
const full = (0, node_path_1.join)(dir, entry);
|
|
149
|
+
// `.vigilesrc.json#exclude`, applied HERE — per entry, inside the recursion —
|
|
150
|
+
// and not merely at the surface-dir entry point. A vendored tree is usually
|
|
151
|
+
// excluded at its own root (`bench`, `vendor/corpus`), which is a descendant
|
|
152
|
+
// of a surface dir, so a check that only guarded the walk's start would let
|
|
153
|
+
// every one of its files into the file map the whole report is computed from.
|
|
154
|
+
if (excluded(full))
|
|
155
|
+
continue;
|
|
147
156
|
const { kind, size } = (0, fs_walk_js_1.entryOf)(full);
|
|
148
157
|
if (kind === "dir")
|
|
149
|
-
Object.assign(out, readTree(full, base));
|
|
158
|
+
Object.assign(out, readTree(full, base, excluded));
|
|
150
159
|
// The cap keeps a stray binary out of the in-memory file map, and the size
|
|
151
160
|
// comes from the SAME stat that classified the entry. Asking a second time
|
|
152
161
|
// needed its own `catch` — reachable only if the file vanished between the two
|
|
@@ -162,9 +171,21 @@ function readTree(dir, base) {
|
|
|
162
171
|
* the files (CLAUDE.md + skills + agents + commands) to write into the test
|
|
163
172
|
* sandbox, and `warnings` for surfaces the deterministic tier can't drive. Merge
|
|
164
173
|
* `settings` with any inline settings and spread `files` into the fixture.
|
|
174
|
+
*
|
|
175
|
+
* `surfaceRoots` is this function's parameter name for what the repo owner
|
|
176
|
+
* writes as `.vigilesrc.json#harnesses["<name>"].roots` — extra repo-relative
|
|
177
|
+
* bases to read `<base>/<surfaceDir>/…` from, for a repo that keeps its skills
|
|
178
|
+
* somewhere no harness reads by default. The config key is nested UNDER a
|
|
179
|
+
* harness name precisely so a root cannot be declared without saying whose
|
|
180
|
+
* layout reads it; this parameter receives one harness's slice of that, which
|
|
181
|
+
* is why it is still a bare list here. `excludes` still wins over it:
|
|
182
|
+
* both the per-tree check in {@link materializeSurfaces} and `readTree` drop an
|
|
183
|
+
* excluded path whatever declared it, so a root that is declared AND excluded is
|
|
184
|
+
* read exactly as if it had never been declared.
|
|
165
185
|
*/
|
|
166
|
-
function loadPlugin(pluginPath, layout) {
|
|
186
|
+
function loadPlugin(pluginPath, layout, excludes, surfaceRoots) {
|
|
167
187
|
const root = (0, node_path_1.resolve)(pluginPath);
|
|
188
|
+
const excluded = (0, exclude_js_1.excludedBy)(excludes);
|
|
168
189
|
const hooks = readHooks(root, layout);
|
|
169
190
|
// Expand the plugin-root token to the real absolute path so the actual hook
|
|
170
191
|
// scripts execute — we test the shipped wiring, not a reimplementation.
|
|
@@ -174,11 +195,11 @@ function loadPlugin(pluginPath, layout) {
|
|
|
174
195
|
const files = {};
|
|
175
196
|
const sources = {};
|
|
176
197
|
const instructions = (0, node_path_1.join)(root, layout.instructionFile);
|
|
177
|
-
if ((0, node_fs_1.existsSync)(instructions)) {
|
|
198
|
+
if ((0, node_fs_1.existsSync)(instructions) && !excluded(instructions)) {
|
|
178
199
|
files[layout.instructionFile] = (0, node_fs_1.readFileSync)(instructions, "utf-8");
|
|
179
200
|
sources[layout.instructionFile] = instructions;
|
|
180
201
|
}
|
|
181
|
-
const surfaces = materializeSurfaces(root, layout, files, sources);
|
|
202
|
+
const surfaces = materializeSurfaces(root, layout, files, sources, excluded, surfaceRoots);
|
|
182
203
|
return {
|
|
183
204
|
settings: resolvedHooks ? { hooks: resolvedHooks } : {},
|
|
184
205
|
files,
|
|
@@ -186,6 +207,69 @@ function loadPlugin(pluginPath, layout) {
|
|
|
186
207
|
warnings: pluginWarnings(root, surfaces, resolvedHooks, files, layout),
|
|
187
208
|
};
|
|
188
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Load the repo once PER DECLARED HARNESS and merge the results into one
|
|
212
|
+
* `LoadedPlugin` (#240).
|
|
213
|
+
*
|
|
214
|
+
* 🔴 THE MERGE IS WHY THIS EXISTS, AND THE DEDUPLICATION IS ITS WHOLE CONTRACT.
|
|
215
|
+
* A repo that declares two harnesses is a repo whose grade must cover both — the
|
|
216
|
+
* flat `harness` array could not do that, because whichever name sat first
|
|
217
|
+
* decided the ONE layout everything was read under: measured on a repo with
|
|
218
|
+
* `AGENTS.md` + `.ai/skills/alpha/SKILL.md`, Claude-Code-first graded the skill
|
|
219
|
+
* and reported 0 chars of always-loaded instructions, Codex-first read
|
|
220
|
+
* `AGENTS.md` and reported no skill at all. Neither order produced both halves.
|
|
221
|
+
*
|
|
222
|
+
* ⚠️ AND THE OBVIOUS FIX HAS AN OBVIOUS SECOND BUG: a repo honest enough to say
|
|
223
|
+
* one tree serves both tools would then have that tree read twice and every
|
|
224
|
+
* skill in it counted twice, so declaring the truth would lower the grade. So the
|
|
225
|
+
* merge keys on the REAL ON-DISK PATH (`sources`), not on the materialized key:
|
|
226
|
+
* the first harness to claim a file keeps it, later ones skip it, and the counts
|
|
227
|
+
* are of files rather than of claims. Keying on the materialized key would not
|
|
228
|
+
* do — two layouts can reach one file under two different keys (`.ai/.agents`
|
|
229
|
+
* + `skills` and `.ai` + `.agents/skills` are the same directory), and that is
|
|
230
|
+
* exactly the case an honest dual declaration produces.
|
|
231
|
+
*
|
|
232
|
+
* Settings come from the FIRST harness that yields any, and the file-map merge
|
|
233
|
+
* is first-wins for the same reason: the primary harness is the one whose
|
|
234
|
+
* dialect the report is rendered in, so its reading of a shared path is the one
|
|
235
|
+
* the rest of the report is consistent with.
|
|
236
|
+
*/
|
|
237
|
+
function loadPlugins(pluginPath, harnesses, excludes) {
|
|
238
|
+
const loads = harnesses.map((h) => loadPlugin(pluginPath, h.layout, excludes, h.roots));
|
|
239
|
+
/* v8 ignore next -- callers always pass >=1; the guard keeps the type honest */
|
|
240
|
+
if (loads.length <= 1)
|
|
241
|
+
return loads[0] ?? EMPTY_LOAD;
|
|
242
|
+
const files = {};
|
|
243
|
+
const sources = {};
|
|
244
|
+
const seenOnDisk = new Set();
|
|
245
|
+
const warnings = [];
|
|
246
|
+
let settings = {};
|
|
247
|
+
for (const load of loads) {
|
|
248
|
+
for (const [key, content] of Object.entries(load.files)) {
|
|
249
|
+
// A file with no recorded source is one the loader synthesized rather than
|
|
250
|
+
// read (there are none today); key it by its own key so it still dedupes.
|
|
251
|
+
const onDisk = load.sources[key] ?? key;
|
|
252
|
+
if (seenOnDisk.has(onDisk))
|
|
253
|
+
continue;
|
|
254
|
+
seenOnDisk.add(onDisk);
|
|
255
|
+
files[key] = content;
|
|
256
|
+
sources[key] = onDisk;
|
|
257
|
+
}
|
|
258
|
+
for (const w of load.warnings)
|
|
259
|
+
if (!warnings.includes(w))
|
|
260
|
+
warnings.push(w);
|
|
261
|
+
if (settings.hooks === undefined && load.settings.hooks !== undefined)
|
|
262
|
+
settings = load.settings;
|
|
263
|
+
}
|
|
264
|
+
return { settings, files, sources, warnings };
|
|
265
|
+
}
|
|
266
|
+
/** The shape `loadPlugins` returns for an empty harness list. */
|
|
267
|
+
const EMPTY_LOAD = {
|
|
268
|
+
settings: {},
|
|
269
|
+
files: {},
|
|
270
|
+
sources: {},
|
|
271
|
+
warnings: [],
|
|
272
|
+
};
|
|
189
273
|
/**
|
|
190
274
|
* Materialize every model surface (skills/agents/commands) into `files`, and
|
|
191
275
|
* record each file's real on-disk path in `sources`. Best-effort (headless
|
|
@@ -214,8 +298,9 @@ function surfaceHasLoadable(layout, surface, tree) {
|
|
|
214
298
|
? keys.some((k) => (0, node_path_1.basename)(k) === "SKILL.md")
|
|
215
299
|
: keys.some((k) => k.endsWith(".md"));
|
|
216
300
|
}
|
|
217
|
-
function materializeSurfaces(root, layout, files, sources) {
|
|
301
|
+
function materializeSurfaces(root, layout, files, sources, excluded = exclude_js_1.excludesNothing, surfaceRoots) {
|
|
218
302
|
const counts = {};
|
|
303
|
+
const harnessCounts = {};
|
|
219
304
|
const isDir = (p) => (0, node_fs_1.existsSync)(p) && (0, node_fs_1.statSync)(p).isDirectory();
|
|
220
305
|
/**
|
|
221
306
|
* One surface dir's tree, or `{}` when there is nothing to read.
|
|
@@ -226,7 +311,9 @@ function materializeSurfaces(root, layout, files, sources) {
|
|
|
226
311
|
* per-entry one and that function says why; a shared skills dir linked in from
|
|
227
312
|
* outside is still read.
|
|
228
313
|
*/
|
|
229
|
-
const surfaceTree = (dir) => isDir(dir) && (0, fs_walk_js_1.walkableRoot)(dir, root)
|
|
314
|
+
const surfaceTree = (dir) => isDir(dir) && (0, fs_walk_js_1.walkableRoot)(dir, root) && !excluded(dir)
|
|
315
|
+
? readTree(dir, dir, excluded)
|
|
316
|
+
: {};
|
|
230
317
|
/** Every surface tree of one scope, read once, keyed by surface dir. */
|
|
231
318
|
const scopeTrees = (base) => {
|
|
232
319
|
const trees = new Map();
|
|
@@ -245,13 +332,24 @@ function materializeSurfaces(root, layout, files, sources) {
|
|
|
245
332
|
const userTrees = layout.userSurfaceRoot !== undefined
|
|
246
333
|
? scopeTrees(layout.userSurfaceRoot)
|
|
247
334
|
: new Map();
|
|
335
|
+
// Declared roots are read HERE, up front and once, for the same reason the two
|
|
336
|
+
// above are: `surfaceSource` needs to know which of them hold anything before
|
|
337
|
+
// it can decide the scope list, and re-reading them afterwards would be a
|
|
338
|
+
// second walk that could disagree with the first.
|
|
339
|
+
const declaredRoots = (0, surface_scopes_js_1.normalizeSurfaceRoots)(surfaceRoots);
|
|
340
|
+
const declaredTrees = new Map();
|
|
341
|
+
for (const base of declaredRoots)
|
|
342
|
+
declaredTrees.set(base, scopeTrees(base));
|
|
248
343
|
/** Copy one scope's already-read trees into `files`, keyed by that scope. */
|
|
249
344
|
const materializeScope = (scope, trees) => {
|
|
250
345
|
for (const surface of layout.surfaceDirs) {
|
|
251
346
|
const tree = trees.get(surface) ?? {};
|
|
252
347
|
for (const [rel, content] of Object.entries(tree))
|
|
253
348
|
add((0, surface_scopes_js_1.scopeKey)(scope, surface, rel), content, (0, node_path_1.join)(root, scope.base, surface, rel));
|
|
254
|
-
|
|
349
|
+
const n = Object.keys(tree).length;
|
|
350
|
+
counts[surface] = (counts[surface] ?? 0) + n;
|
|
351
|
+
if (scope.declared !== true)
|
|
352
|
+
harnessCounts[surface] = (harnessCounts[surface] ?? 0) + n;
|
|
255
353
|
}
|
|
256
354
|
};
|
|
257
355
|
/**
|
|
@@ -288,6 +386,12 @@ function materializeSurfaces(root, layout, files, sources) {
|
|
|
288
386
|
add((0, node_path_1.join)(base, dir, rel), content, (0, node_path_1.join)(root, base, dir, rel));
|
|
289
387
|
}
|
|
290
388
|
};
|
|
389
|
+
/** The already-read trees a scope materializes from — never a second walk. */
|
|
390
|
+
const treesOf = (scope) => scope.declared === true
|
|
391
|
+
? (declaredTrees.get(scope.base) ?? new Map())
|
|
392
|
+
: scope.base === ""
|
|
393
|
+
? rootTrees
|
|
394
|
+
: userTrees;
|
|
291
395
|
const source = (0, surface_scopes_js_1.surfaceSource)(layout, {
|
|
292
396
|
hasRootSkillFile: (0, node_fs_1.existsSync)((0, node_path_1.join)(root, "SKILL.md")),
|
|
293
397
|
skillName: (0, node_path_1.basename)(root),
|
|
@@ -295,6 +399,7 @@ function materializeSurfaces(root, layout, files, sources) {
|
|
|
295
399
|
isPluginShaped: (0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.manifestPath)) ||
|
|
296
400
|
(0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.hooksConventionPath)),
|
|
297
401
|
userHasLoadable: hasLoadable(userTrees),
|
|
402
|
+
declaredRoots,
|
|
298
403
|
});
|
|
299
404
|
switch (source.kind) {
|
|
300
405
|
case "single-skill": {
|
|
@@ -303,19 +408,20 @@ function materializeSurfaces(root, layout, files, sources) {
|
|
|
303
408
|
// No `walkableRoot` here on purpose: `root` is the directory the CALLER
|
|
304
409
|
// named (`vigiles audit <dir>`), not one this walk discovered, and refusing
|
|
305
410
|
// to read the path someone explicitly pointed at is not a containment rule.
|
|
306
|
-
const tree = readTree(root, root);
|
|
411
|
+
const tree = readTree(root, root, excluded);
|
|
307
412
|
for (const [rel, content] of Object.entries(tree)) {
|
|
308
413
|
add((0, node_path_1.join)(layout.materializeRoot, layout.skillDir, source.skillName, rel), content, (0, node_path_1.join)(root, rel));
|
|
309
414
|
}
|
|
310
415
|
counts[layout.skillDir] = Object.keys(tree).length;
|
|
311
|
-
|
|
416
|
+
harnessCounts[layout.skillDir] = counts[layout.skillDir];
|
|
417
|
+
return { counts, harnessCounts, scopes: [] };
|
|
312
418
|
}
|
|
313
419
|
case "scopes": {
|
|
314
420
|
(0, surface_scopes_js_1.assertDistinctScopeKeys)(source.scopes, layout.name);
|
|
315
421
|
for (const scope of source.scopes)
|
|
316
|
-
materializeScope(scope, scope
|
|
422
|
+
materializeScope(scope, treesOf(scope));
|
|
317
423
|
materializeRules();
|
|
318
|
-
return { counts, scopes: source.scopes };
|
|
424
|
+
return { counts, harnessCounts, scopes: source.scopes };
|
|
319
425
|
}
|
|
320
426
|
/* v8 ignore next 2 -- exhaustiveness guard, unreachable given SurfaceSource */
|
|
321
427
|
default:
|
|
@@ -329,9 +435,9 @@ function materializeSurfaces(root, layout, files, sources) {
|
|
|
329
435
|
* the eval tier. MCP servers aren't wired by the loader at all. And a plugin
|
|
330
436
|
* that yields neither hooks nor files would otherwise be a silent empty machine.
|
|
331
437
|
*/
|
|
332
|
-
function pluginWarnings(root, { counts, scopes }, hooks, files, layout) {
|
|
438
|
+
function pluginWarnings(root, { counts, harnessCounts, scopes }, hooks, files, layout) {
|
|
333
439
|
const warnings = [];
|
|
334
|
-
const multiScope = (0, surface_scopes_js_1.multiScopeWarning)(scopes,
|
|
440
|
+
const multiScope = (0, surface_scopes_js_1.multiScopeWarning)(scopes, harnessCounts);
|
|
335
441
|
if (multiScope !== undefined)
|
|
336
442
|
warnings.push(multiScope);
|
|
337
443
|
if (counts.agents) {
|
package/dist/run-hook.js
CHANGED
|
@@ -189,7 +189,23 @@ function runHookWith(command, input, opts, deps) {
|
|
|
189
189
|
ran: false,
|
|
190
190
|
conditionReason: verdict.why,
|
|
191
191
|
};
|
|
192
|
-
|
|
192
|
+
// 🔴 A SPAWN DIRECTORY WITH NO DECLARED ROOT MEANS "THIS DIRECTORY IS THE
|
|
193
|
+
// PROJECT". Without this, a test that says `{ cwd: dir }` and nothing else
|
|
194
|
+
// inherits whatever `$CLAUDE_PROJECT_DIR` the AMBIENT shell exports, and since
|
|
195
|
+
// the runtime prefers the env over the payload (see `projectRootOf`), the hook
|
|
196
|
+
// resolves a root the test never named. Measured 2026-09-19: five rail suites
|
|
197
|
+
// pass with the variable unset — which is CI, and this container — and fail
|
|
198
|
+
// when it is set, which is any run inside a live Claude Code session. That is
|
|
199
|
+
// a one-sided failure, invisible exactly where it would be caught.
|
|
200
|
+
//
|
|
201
|
+
// An EXPLICIT value always wins, including the empty string: a test that pins
|
|
202
|
+
// `CLAUDE_PROJECT_DIR: ""` is saying "no env root, resolve from the payload",
|
|
203
|
+
// and that case has its own coverage.
|
|
204
|
+
const rooted = opts.cwd !== undefined &&
|
|
205
|
+
!Object.prototype.hasOwnProperty.call(opts.env ?? {}, "CLAUDE_PROJECT_DIR")
|
|
206
|
+
? { ...opts, env: { ...opts.env, CLAUDE_PROJECT_DIR: opts.cwd } }
|
|
207
|
+
: opts;
|
|
208
|
+
const res = (0, run_script_js_1.runScriptWith)(command, JSON.stringify(input), rooted, deps);
|
|
193
209
|
const json = parseHookOutput(res.stdout);
|
|
194
210
|
const { blocked, decision, haltsTurn, blockedBy } = decideHook(res.exitCode, json, protocol);
|
|
195
211
|
return {
|
package/dist/scan-core.d.ts
CHANGED
|
@@ -37,6 +37,25 @@ export interface SurfaceClassifier {
|
|
|
37
37
|
readonly isRule: (f: string) => boolean;
|
|
38
38
|
}
|
|
39
39
|
export declare function makeClassifier(layout: PluginLayout): SurfaceClassifier;
|
|
40
|
+
/**
|
|
41
|
+
* ONE classifier over SEVERAL layouts — a path is a skill if ANY declared
|
|
42
|
+
* harness would read it as one.
|
|
43
|
+
*
|
|
44
|
+
* 🔴 IT EXISTS BECAUSE A MERGED FILE MAP HAS NO SINGLE LAYOUT. A repo declaring
|
|
45
|
+
* `{"harnesses": {"claude-code": {…}, "codex": {}}}` is read under BOTH layouts
|
|
46
|
+
* and the two file maps merge into one, at which point classifying with one
|
|
47
|
+
* layout drops the other's surfaces on the floor — Codex's `prompts/x.md` is not
|
|
48
|
+
* a Claude Code command, and silently counting it as nothing is the same shape of
|
|
49
|
+
* bug as reading no surfaces and grading A (100).
|
|
50
|
+
*
|
|
51
|
+
* `agentName` answers from the FIRST layout that calls the path an agent, so the
|
|
52
|
+
* name is always the one the layout that claimed it would give — never a name
|
|
53
|
+
* derived under a layout that would not have read the file at all.
|
|
54
|
+
*
|
|
55
|
+
* A single-layout list behaves byte-identically to {@link makeClassifier}, which
|
|
56
|
+
* is what lets every existing caller keep passing one layout.
|
|
57
|
+
*/
|
|
58
|
+
export declare function makeUnionClassifier(layouts: readonly PluginLayout[]): SurfaceClassifier;
|
|
40
59
|
/** The plugin-root + materialize-root + dialect context skill scanning needs. */
|
|
41
60
|
export interface SkillScanContext {
|
|
42
61
|
readonly root: string;
|
package/dist/scan-core.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.makeClassifier = makeClassifier;
|
|
4
|
+
exports.makeUnionClassifier = makeUnionClassifier;
|
|
4
5
|
exports.remapFindingPaths = remapFindingPaths;
|
|
5
6
|
exports.detectOwnTestSignal = detectOwnTestSignal;
|
|
6
7
|
exports.scanSkills = scanSkills;
|
|
@@ -199,6 +200,35 @@ function makeClassifier(layout) {
|
|
|
199
200
|
agentName: (f) => isAgent(f) ? (0, layout_js_1.agentSurfaceName)(f, layout.agentDir) : null,
|
|
200
201
|
};
|
|
201
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* ONE classifier over SEVERAL layouts — a path is a skill if ANY declared
|
|
205
|
+
* harness would read it as one.
|
|
206
|
+
*
|
|
207
|
+
* 🔴 IT EXISTS BECAUSE A MERGED FILE MAP HAS NO SINGLE LAYOUT. A repo declaring
|
|
208
|
+
* `{"harnesses": {"claude-code": {…}, "codex": {}}}` is read under BOTH layouts
|
|
209
|
+
* and the two file maps merge into one, at which point classifying with one
|
|
210
|
+
* layout drops the other's surfaces on the floor — Codex's `prompts/x.md` is not
|
|
211
|
+
* a Claude Code command, and silently counting it as nothing is the same shape of
|
|
212
|
+
* bug as reading no surfaces and grading A (100).
|
|
213
|
+
*
|
|
214
|
+
* `agentName` answers from the FIRST layout that calls the path an agent, so the
|
|
215
|
+
* name is always the one the layout that claimed it would give — never a name
|
|
216
|
+
* derived under a layout that would not have read the file at all.
|
|
217
|
+
*
|
|
218
|
+
* A single-layout list behaves byte-identically to {@link makeClassifier}, which
|
|
219
|
+
* is what lets every existing caller keep passing one layout.
|
|
220
|
+
*/
|
|
221
|
+
function makeUnionClassifier(layouts) {
|
|
222
|
+
const cs = layouts.map(makeClassifier);
|
|
223
|
+
const any = (pick) => (f) => cs.some((c) => pick(c)(f));
|
|
224
|
+
return {
|
|
225
|
+
isSkill: any((c) => c.isSkill),
|
|
226
|
+
isAgent: any((c) => c.isAgent),
|
|
227
|
+
isCommand: any((c) => c.isCommand),
|
|
228
|
+
isRule: any((c) => c.isRule),
|
|
229
|
+
agentName: (f) => cs.find((c) => c.isAgent(f))?.agentName(f) ?? null,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
202
232
|
function skillName(path) {
|
|
203
233
|
return (path
|
|
204
234
|
.replace(/\/SKILL\.md$/, "")
|
package/dist/scan-files.js
CHANGED
|
@@ -49,6 +49,8 @@ const mcp_config_js_1 = require("./core/mcp-config.js");
|
|
|
49
49
|
const agent_plugins_js_1 = require("./core/agent-plugins.js");
|
|
50
50
|
const mcp_hook_js_1 = require("./core/mcp-hook.js");
|
|
51
51
|
const plugin_dir_layout_js_1 = require("./core/plugin-dir-layout.js");
|
|
52
|
+
const surface_discovery_js_1 = require("./core/surface-discovery.js");
|
|
53
|
+
const layout_registry_js_1 = require("./layout-registry.js");
|
|
52
54
|
const surface_scopes_js_1 = require("./core/surface-scopes.js");
|
|
53
55
|
const hook_block_ineffective_js_1 = require("./core/hook-block-ineffective.js");
|
|
54
56
|
const hook_matcher_js_1 = require("./core/hook-matcher.js");
|
|
@@ -231,6 +233,7 @@ function surfaceHasLoadable(layout, surface, tree) {
|
|
|
231
233
|
function materializeSurfaces(files, layout, acc, repoName) {
|
|
232
234
|
const { out, sources } = acc;
|
|
233
235
|
const counts = {};
|
|
236
|
+
const harnessCounts = {};
|
|
234
237
|
const scopeTrees = (base) => {
|
|
235
238
|
const trees = new Map();
|
|
236
239
|
for (const surface of layout.surfaceDirs) {
|
|
@@ -255,7 +258,10 @@ function materializeSurfaces(files, layout, acc, repoName) {
|
|
|
255
258
|
const dirRel = scope.base === "" ? surface : `${scope.base}/${surface}`;
|
|
256
259
|
for (const [rel, content] of Object.entries(tree))
|
|
257
260
|
add((0, surface_scopes_js_1.scopeKey)(scope, surface, rel), content, (0, posix_path_js_1.join)(exports.BROWSER_ROOT, dirRel, rel));
|
|
258
|
-
|
|
261
|
+
const n = Object.keys(tree).length;
|
|
262
|
+
counts[surface] = (counts[surface] ?? 0) + n;
|
|
263
|
+
if (scope.declared !== true)
|
|
264
|
+
harnessCounts[surface] = (harnessCounts[surface] ?? 0) + n;
|
|
259
265
|
}
|
|
260
266
|
};
|
|
261
267
|
const source = (0, surface_scopes_js_1.surfaceSource)(layout, {
|
|
@@ -276,13 +282,14 @@ function materializeSurfaces(files, layout, acc, repoName) {
|
|
|
276
282
|
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));
|
|
277
283
|
}
|
|
278
284
|
counts[layout.skillDir] = Object.keys(tree).length;
|
|
279
|
-
|
|
285
|
+
harnessCounts[layout.skillDir] = counts[layout.skillDir];
|
|
286
|
+
return { counts, harnessCounts, scopes: [] };
|
|
280
287
|
}
|
|
281
288
|
case "scopes": {
|
|
282
289
|
(0, surface_scopes_js_1.assertDistinctScopeKeys)(source.scopes, layout.name);
|
|
283
290
|
for (const scope of source.scopes)
|
|
284
291
|
materializeScope(scope, scope.base === "" ? rootTrees : userTrees);
|
|
285
|
-
return { counts, scopes: source.scopes };
|
|
292
|
+
return { counts, harnessCounts, scopes: source.scopes };
|
|
286
293
|
}
|
|
287
294
|
}
|
|
288
295
|
}
|
|
@@ -371,9 +378,9 @@ function danglingRefs(files, layout, rootName) {
|
|
|
371
378
|
// ---------------------------------------------------------------------------
|
|
372
379
|
// Warnings (mirrors plugin-loader.ts pluginWarnings)
|
|
373
380
|
// ---------------------------------------------------------------------------
|
|
374
|
-
function pluginWarnings(files, layout, { counts, scopes }, hooks, materialized, rootName) {
|
|
381
|
+
function pluginWarnings(files, layout, { counts, harnessCounts, scopes }, hooks, materialized, rootName) {
|
|
375
382
|
const warnings = [];
|
|
376
|
-
const multiScope = (0, surface_scopes_js_1.multiScopeWarning)(scopes,
|
|
383
|
+
const multiScope = (0, surface_scopes_js_1.multiScopeWarning)(scopes, harnessCounts);
|
|
377
384
|
if (multiScope !== undefined)
|
|
378
385
|
warnings.push(multiScope);
|
|
379
386
|
if (counts.agents) {
|
|
@@ -550,6 +557,9 @@ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = diale
|
|
|
550
557
|
trifectaFindings,
|
|
551
558
|
skillResourceIssues: skillResourceFindings,
|
|
552
559
|
skillFenceIssues: skillFenceFindings,
|
|
560
|
+
// The browser twin has the WHOLE fetched key set in hand, so discovery needs
|
|
561
|
+
// no walk here — the pure classifier re-applies the same bounded-root rule.
|
|
562
|
+
unclaimedSurfaces: (0, surface_discovery_js_1.unclaimedSurfaceFindings)(Object.keys(files), layout_registry_js_1.REGISTERED_LAYOUTS),
|
|
553
563
|
pluginLayoutIssues: (0, plugin_dir_layout_js_1.pluginDirLayoutIssues)((0, posix_path_js_1.join)(exports.BROWSER_ROOT, (0, posix_path_js_1.dirname)(lay.manifestPath)), [...new Set([...lay.surfaceDirs, lay.hooksConventionPath.split("/")[0]])], { existsSync: exists, isDirectory: mapIsDirectory(files) }),
|
|
554
564
|
delegationTrifecta: (0, scan_core_js_1.collectDelegationTrifecta)(agents, dialect),
|
|
555
565
|
hookBlockFindings: (0, event_capability_js_1.blockIneffectiveEventsOf)(dialect).length > 0
|
package/dist/scan.d.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* stack on top later; this core stays pure so it runs anywhere in CI for free.
|
|
13
13
|
*/
|
|
14
14
|
import type { PluginLayout } from "./core/layout.js";
|
|
15
|
+
import type { ExcludeSet } from "./exclude.js";
|
|
15
16
|
import type { HarnessDialect } from "./core/dialect.js";
|
|
16
17
|
import type { ToolIssue } from "./core/tool-contract.js";
|
|
17
18
|
import { type HookEventIssue } from "./core/hook-events.js";
|
|
@@ -25,6 +26,7 @@ import type { TrifectaFinding } from "./core/lethal-trifecta.js";
|
|
|
25
26
|
import type { SkillResourceFinding } from "./core/skill-resources.js";
|
|
26
27
|
import type { SkillFenceFinding } from "./core/skill-missing-fence.js";
|
|
27
28
|
import { type PluginLayoutFinding } from "./core/plugin-dir-layout.js";
|
|
29
|
+
import { type UnclaimedSurfaceFinding } from "./core/surface-discovery.js";
|
|
28
30
|
import type { DelegationTrifectaFinding } from "./core/delegation-trifecta.js";
|
|
29
31
|
import { type HookBlockFinding } from "./core/hook-block-ineffective.js";
|
|
30
32
|
import { type HookMatcherFinding } from "./core/hook-matcher.js";
|
|
@@ -283,6 +285,14 @@ export interface ScanReport {
|
|
|
283
285
|
* and the `plugin-dir-layout` lint rule (one detector, no drift).
|
|
284
286
|
*/
|
|
285
287
|
readonly pluginLayoutIssues: readonly PluginLayoutFinding[];
|
|
288
|
+
/**
|
|
289
|
+
* Surface directories found by SHAPE in the bounded root set that NO
|
|
290
|
+
* registered harness claims — a harness sitting somewhere vigiles does not
|
|
291
|
+
* read, reported instead of silently graded around (#240). Computed by
|
|
292
|
+
* `core/surface-discovery.ts` from paths alone; shared with the browser twin
|
|
293
|
+
* (one detector, no drift).
|
|
294
|
+
*/
|
|
295
|
+
readonly unclaimedSurfaces: readonly UnclaimedSurfaceFinding[];
|
|
286
296
|
/**
|
|
287
297
|
* Lethal trifectas that EMERGE across a delegation edge — a subagent whose
|
|
288
298
|
* effective (own ∪ delegated-to) capability holds all three legs though no
|
|
@@ -386,10 +396,63 @@ export interface ScanReport {
|
|
|
386
396
|
unrestricted: number;
|
|
387
397
|
};
|
|
388
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* ONE harness a scan reads the repo under — the resolved form of a
|
|
401
|
+
* `.vigilesrc.json#harnesses` entry (#240).
|
|
402
|
+
*
|
|
403
|
+
* All three parts travel together on purpose. The LAYOUT says where this harness
|
|
404
|
+
* reads and what a surface is there; the DIALECT says what it understands (and
|
|
405
|
+
* carries its own instruction budget, in its own unit); the ROOTS are the extra
|
|
406
|
+
* places this repo's owner says that harness's surfaces also live. Splitting
|
|
407
|
+
* them is how a root came to be read under a layout nobody chose for it.
|
|
408
|
+
*/
|
|
409
|
+
export interface ScanHarness {
|
|
410
|
+
readonly layout: PluginLayout;
|
|
411
|
+
readonly dialect: HarnessDialect;
|
|
412
|
+
readonly roots: readonly string[];
|
|
413
|
+
}
|
|
389
414
|
/** Scan a plugin/repo directory and report its surfaces + structural issues. */
|
|
390
415
|
export declare function scanPlugin(dir: string, layout?: PluginLayout, dialect?: HarnessDialect, opts?: {
|
|
391
416
|
sharedDirs?: readonly string[];
|
|
392
417
|
sharedDirsRoot?: string;
|
|
418
|
+
/**
|
|
419
|
+
* The repo's `.vigilesrc.json#exclude`, so surface DISCOVERY honours it.
|
|
420
|
+
*
|
|
421
|
+
* It rides in `opts` rather than as a fifth positional parameter because ~25
|
|
422
|
+
* call sites pass the first three and nothing else; a required parameter here
|
|
423
|
+
* would be twenty-odd mechanical edits for one behavioural change.
|
|
424
|
+
*
|
|
425
|
+
* ⚠️ Omitting it is NOT "the repo excludes nothing" — it is "this caller has
|
|
426
|
+
* no ExcludeSet to give", and the walk then reads everything. Today only
|
|
427
|
+
* `audit` supplies one; the `lint` rule checkers below still do not (they
|
|
428
|
+
* share a `(config, silent, adapter, root)` signature through `overBundles`).
|
|
429
|
+
*/
|
|
430
|
+
excludes?: ExcludeSet;
|
|
431
|
+
/**
|
|
432
|
+
* The repo's `.vigilesrc.json#harnesses`, resolved — every declared harness
|
|
433
|
+
* with its own layout, dialect and roots, IN DECLARATION ORDER.
|
|
434
|
+
*
|
|
435
|
+
* 🔴 THE LIST IS READ WHOLE, AND THAT IS THE FIX (#240). The repo is loaded
|
|
436
|
+
* once per entry, each under its OWN layout, and the file maps merge
|
|
437
|
+
* (deduplicated by on-disk path, so one tree serving two harnesses is
|
|
438
|
+
* counted once). Under the flat `harness` array only the first entry's
|
|
439
|
+
* layout was ever used, so a repo declaring both got its skills OR its
|
|
440
|
+
* instruction file depending on which name it happened to list first, and no
|
|
441
|
+
* order gave it both.
|
|
442
|
+
*
|
|
443
|
+
* The first entry is the PRIMARY: it supplies the dialect every
|
|
444
|
+
* dialect-shaped check runs under and the `harness` the report is labelled
|
|
445
|
+
* with. The INSTRUCTION FILE is the deliberate exception — it comes from the
|
|
446
|
+
* first declared harness whose instruction file is actually present, because
|
|
447
|
+
* reporting "no instruction file" for a repo holding the other declared
|
|
448
|
+
* harness's one is the half of the bug that has nothing to do with skills.
|
|
449
|
+
*
|
|
450
|
+
* Rides in `opts` for the same reason `excludes` does: ~25 call sites pass
|
|
451
|
+
* the first three positionals and nothing else. Omitting it means "this
|
|
452
|
+
* caller has no declaration to pass" and the scan runs single-harness on the
|
|
453
|
+
* positional `layout`/`dialect`, exactly as before.
|
|
454
|
+
*/
|
|
455
|
+
harnesses?: readonly ScanHarness[];
|
|
393
456
|
}): ScanReport;
|
|
394
457
|
/**
|
|
395
458
|
* LIVE MCP tool resolution for a scanned plugin — the dynamic check no static
|