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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/dist/adapter-registry.d.ts +39 -0
  3. package/dist/adapter-registry.js +45 -0
  4. package/dist/adapter.d.ts +8 -0
  5. package/dist/adapter.js +10 -1
  6. package/dist/adapters/claude-code/adapter.js +9 -2
  7. package/dist/adapters/claude-code/layout.d.ts +5 -0
  8. package/dist/adapters/claude-code/plugin-loader.d.ts +10 -1
  9. package/dist/adapters/claude-code/plugin-loader.js +10 -1
  10. package/dist/adapters/codex/adapter.js +7 -2
  11. package/dist/adapters/codex/layout.d.ts +51 -5
  12. package/dist/adapters/codex/layout.js +13 -4
  13. package/dist/adapters/opencode/adapter.js +6 -0
  14. package/dist/audit-report.template.html +1 -1
  15. package/dist/audit-score.d.ts +7 -0
  16. package/dist/audit-score.js +49 -2
  17. package/dist/cli-main.js +162 -39
  18. package/dist/core/adapter.d.ts +45 -1
  19. package/dist/core/compile.js +11 -1
  20. package/dist/core/config-schema.d.ts +244 -0
  21. package/dist/core/config-schema.js +452 -0
  22. package/dist/core/hook-program.d.ts +43 -0
  23. package/dist/core/hook-program.js +32 -0
  24. package/dist/core/refs.js +10 -1
  25. package/dist/core/surface-discovery.d.ts +270 -0
  26. package/dist/core/surface-discovery.js +425 -0
  27. package/dist/core/surface-scopes.d.ts +38 -1
  28. package/dist/core/surface-scopes.js +73 -1
  29. package/dist/core/symbols.d.ts +24 -2
  30. package/dist/core/symbols.js +66 -18
  31. package/dist/core/types.d.ts +36 -107
  32. package/dist/core/validate.d.ts +46 -18
  33. package/dist/core/validate.js +98 -172
  34. package/dist/exclude.d.ts +20 -0
  35. package/dist/exclude.js +11 -1
  36. package/dist/harness-test.js +3 -3
  37. package/dist/hook-install.d.ts +53 -0
  38. package/dist/hook-install.js +60 -0
  39. package/dist/hook-runtime.d.ts +2 -2
  40. package/dist/hook-runtime.js +82 -63
  41. package/dist/layout-registry.d.ts +14 -0
  42. package/dist/layout-registry.js +40 -0
  43. package/dist/load-hook.d.ts +1 -1
  44. package/dist/load-hook.js +2 -2
  45. package/dist/plugin-loader.d.ts +49 -1
  46. package/dist/plugin-loader.js +120 -14
  47. package/dist/run-hook.js +17 -1
  48. package/dist/scan-core.d.ts +19 -0
  49. package/dist/scan-core.js +30 -0
  50. package/dist/scan-files.js +15 -5
  51. package/dist/scan.d.ts +63 -0
  52. package/dist/scan.js +68 -12
  53. package/dist/score-core.js +8 -0
  54. package/dist/setup-plan.d.ts +2 -1
  55. package/dist/setup-plan.js +7 -2
  56. package/dist/surface-discovery-fs.d.ts +12 -0
  57. package/dist/surface-discovery-fs.js +108 -0
  58. package/dist/vigilesrc.schema.json +1689 -0
  59. package/package.json +10 -6
@@ -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) ? readTree(dir, dir) : {};
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
- counts[surface] = (counts[surface] ?? 0) + Object.keys(tree).length;
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
- return { counts, scopes: [] };
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.base === "" ? rootTrees : userTrees);
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, counts);
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
- const res = (0, run_script_js_1.runScriptWith)(command, JSON.stringify(input), opts, deps);
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 {
@@ -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$/, "")
@@ -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
- counts[surface] = (counts[surface] ?? 0) + Object.keys(tree).length;
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
- return { counts, scopes: [] };
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, counts);
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