vigiles 29.0.0 → 30.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 (106) hide show
  1. package/dist/adapter-conformance.d.ts +1 -1
  2. package/dist/adapter-conformance.js +106 -25
  3. package/dist/adapter-registry.d.ts +61 -14
  4. package/dist/adapter-registry.js +78 -10
  5. package/dist/adapter.d.ts +23 -2
  6. package/dist/adapter.js +13 -1
  7. package/dist/adapters/claude-code/adapter.d.ts +32 -2
  8. package/dist/adapters/claude-code/adapter.js +44 -23
  9. package/dist/adapters/claude-code/dialect.js +87 -21
  10. package/dist/adapters/claude-code/hook-protocol.js +16 -0
  11. package/dist/adapters/claude-code/instruction-chain.d.ts +25 -0
  12. package/dist/adapters/claude-code/instruction-chain.js +626 -0
  13. package/dist/adapters/claude-code/layout.d.ts +2 -2
  14. package/dist/adapters/claude-code/layout.js +42 -8
  15. package/dist/adapters/claude-code/model-access.d.ts +41 -0
  16. package/dist/adapters/claude-code/model-access.js +46 -0
  17. package/dist/adapters/claude-code/skill-reachability.d.ts +125 -0
  18. package/dist/adapters/claude-code/skill-reachability.js +111 -0
  19. package/dist/adapters/codex/adapter.d.ts +39 -2
  20. package/dist/adapters/codex/adapter.js +29 -29
  21. package/dist/adapters/codex/dialect.js +11 -6
  22. package/dist/adapters/codex/eval.d.ts +10 -0
  23. package/dist/adapters/codex/eval.js +48 -1
  24. package/dist/adapters/codex/hook-protocol.d.ts +2 -1
  25. package/dist/adapters/codex/hook-protocol.js +10 -0
  26. package/dist/adapters/codex/instruction-chain.d.ts +40 -0
  27. package/dist/adapters/codex/instruction-chain.js +105 -0
  28. package/dist/adapters/codex/layout.d.ts +1 -1
  29. package/dist/adapters/codex/layout.js +41 -14
  30. package/dist/adapters/opencode/adapter.d.ts +33 -2
  31. package/dist/adapters/opencode/adapter.js +36 -36
  32. package/dist/adapters/opencode/dialect.js +2 -2
  33. package/dist/adapters/opencode/instruction-chain.d.ts +37 -0
  34. package/dist/adapters/opencode/instruction-chain.js +70 -0
  35. package/dist/adapters/opencode/layout.d.ts +19 -0
  36. package/dist/adapters/opencode/layout.js +34 -15
  37. package/dist/adoptability.d.ts +31 -1
  38. package/dist/adoptability.js +57 -0
  39. package/dist/cli-main.js +180 -102
  40. package/dist/core/adapter.d.ts +213 -61
  41. package/dist/core/compile.d.ts +2 -2
  42. package/dist/core/compile.js +57 -38
  43. package/dist/core/compose.d.ts +5 -3
  44. package/dist/core/compose.js +5 -3
  45. package/dist/core/config-schema.d.ts +14 -2
  46. package/dist/core/config-schema.js +24 -3
  47. package/dist/core/dialect.d.ts +54 -12
  48. package/dist/core/dialect.js +56 -0
  49. package/dist/core/eval-driver.d.ts +194 -0
  50. package/dist/core/eval-driver.js +3 -0
  51. package/dist/core/frontmatter-read.d.ts +10 -0
  52. package/dist/core/frontmatter-read.js +30 -3
  53. package/dist/core/hook-program.d.ts +27 -2
  54. package/dist/core/hook-program.js +29 -24
  55. package/dist/core/hook-protocol.d.ts +54 -0
  56. package/dist/core/install-reader.d.ts +18 -0
  57. package/dist/core/install-reader.js +88 -0
  58. package/dist/core/instruction-chain.d.ts +444 -0
  59. package/dist/core/instruction-chain.js +292 -0
  60. package/dist/core/instruction-weight.d.ts +96 -14
  61. package/dist/core/instruction-weight.js +65 -30
  62. package/dist/core/layout.d.ts +220 -33
  63. package/dist/core/layout.js +115 -1
  64. package/dist/core/lethal-trifecta.d.ts +12 -7
  65. package/dist/core/lethal-trifecta.js +13 -8
  66. package/dist/core/live-driver.d.ts +137 -0
  67. package/dist/core/live-driver.js +14 -0
  68. package/dist/core/markdown.d.ts +23 -0
  69. package/dist/core/markdown.js +77 -28
  70. package/dist/core/orphans.js +9 -7
  71. package/dist/core/settings-codec.d.ts +17 -0
  72. package/dist/core/settings-codec.js +56 -0
  73. package/dist/core/surface-discovery.d.ts +2 -2
  74. package/dist/core/surface-discovery.js +24 -8
  75. package/dist/core/surface-scopes.d.ts +26 -6
  76. package/dist/core/surface-scopes.js +52 -11
  77. package/dist/core/validate.js +16 -3
  78. package/dist/eval.d.ts +16 -108
  79. package/dist/eval.js +34 -1
  80. package/dist/harness-test.d.ts +3 -63
  81. package/dist/hook-install.d.ts +12 -1
  82. package/dist/hook-install.js +12 -1
  83. package/dist/plugin-loader.d.ts +1 -1
  84. package/dist/plugin-loader.js +43 -36
  85. package/dist/scan-behavioral.d.ts +34 -25
  86. package/dist/scan-behavioral.js +122 -58
  87. package/dist/scan-core.js +37 -18
  88. package/dist/scan-files.d.ts +1 -1
  89. package/dist/scan-files.js +53 -33
  90. package/dist/scan-trigger-suggest.d.ts +0 -21
  91. package/dist/scan-trigger-suggest.js +0 -23
  92. package/dist/scan.d.ts +4 -4
  93. package/dist/scan.js +120 -73
  94. package/dist/skill-harness.d.ts +21 -5
  95. package/dist/skill-harness.js +29 -11
  96. package/dist/surface-discovery-fs.d.ts +2 -0
  97. package/dist/surface-discovery-fs.js +108 -6
  98. package/dist/test-coverage-files.js +24 -17
  99. package/dist/test-coverage.d.ts +9 -3
  100. package/dist/test-coverage.js +32 -17
  101. package/dist/verify-plugin-guards.js +1 -1
  102. package/package.json +1 -1
  103. package/dist/skill-reachability.d.ts +0 -68
  104. package/dist/skill-reachability.js +0 -205
  105. /package/dist/{dialect-drift.d.ts → adapters/claude-code/dialect-drift.d.ts} +0 -0
  106. /package/dist/{dialect-drift.js → adapters/claude-code/dialect-drift.js} +0 -0
@@ -40,38 +40,72 @@ const scan_js_1 = require("./scan.js");
40
40
  const audit_prompts_js_1 = require("./audit-prompts.js");
41
41
  const judge_js_1 = require("./judge.js");
42
42
  const eval_js_1 = require("./eval.js");
43
- const harness_assert_js_1 = require("./harness-assert.js");
44
- const harness_test_js_1 = require("./harness-test.js");
43
+ // eslint-disable-next-line local/no-harness-names -- IMPORT PATH, not a decision: this module is the application layer that drives a real harness binary, and Claude Code is the documented DEFAULT for candidate discovery. The rule flags the module path the way it flags `scan.ts`'s (rule header, "three are the import specifiers"); what it is guarding against — a name-driven BRANCH — is gone from this file.
45
44
  const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
46
- const eval_js_2 = require("./adapters/codex/eval.js");
47
- const driver_js_1 = require("./adapters/codex/driver.js");
45
+ // The Claude Code default for candidate DISCOVERY. This module is the
46
+ // application layer (it drives a real harness binary and already imports the
47
+ // Claude Code loader above), which is where a default belongs — `src/scan.ts`
48
+ // and `src/test-coverage.ts` are the harness-agnostic detectors, and they no
49
+ // longer carry one.
50
+ // eslint-disable-next-line local/no-harness-names -- IMPORT PATH, not a decision: this module is the application layer that drives a real harness binary, and Claude Code is the documented DEFAULT for candidate discovery. The rule flags the module path the way it flags `scan.ts`'s (rule header, "three are the import specifiers"); what it is guarding against — a name-driven BRANCH — is gone from this file.
51
+ const layout_js_1 = require("./adapters/claude-code/layout.js");
52
+ // eslint-disable-next-line local/no-harness-names -- IMPORT PATH, not a decision: this module is the application layer that drives a real harness binary, and Claude Code is the documented DEFAULT for candidate discovery. The rule flags the module path the way it flags `scan.ts`'s (rule header, "three are the import specifiers"); what it is guarding against — a name-driven BRANCH — is gone from this file.
53
+ const dialect_js_1 = require("./adapters/claude-code/dialect.js");
54
+ const live_driver_js_1 = require("./core/live-driver.js");
55
+ const adapter_registry_js_1 = require("./adapter-registry.js");
48
56
  const tmp_root_js_1 = require("./core/tmp-root.js");
49
- function buildProbe(dir, harness) {
50
- if (harness === "codex") {
51
- return {
52
- evalDriver: eval_js_2.codexEvalDriver,
53
- firedFor: (name) => (t) => (0, eval_js_2.codexSkillFired)(t, name),
54
- // Codex stubbing of a non-Claude plugin isn't validated; install the real
55
- // skills (firing is the SKILL.md read, detected regardless of body).
56
- stub: false,
57
- available: () => driver_js_1.codexDriver.available(),
58
- };
59
- }
60
- const ns = pluginName(dir);
61
- return {
62
- evalDriver: eval_js_1.claudeEvalDriver,
63
- firedFor: (name) => (t) => (0, harness_assert_js_1.skillResolved)(t, ns ? `${ns}:${name}` : name),
64
- stub: true,
65
- available: harness_test_js_1.claudeAvailable,
66
- };
57
+ /**
58
+ * Resolve the adapter driving a behavioral measurement.
59
+ *
60
+ * 🔴 THE ONE SANCTIONED STRING→ADAPTER CONVERSION. `opts.adapter` is the way in;
61
+ * the deprecated `harness` NAME is accepted for one release because
62
+ * `SelectionOptions` is public API (`vigiles/claude-code`), and it is resolved
63
+ * HERE, through the registry, rather than compared to a literal anywhere.
64
+ */
65
+ function adapterFor(opts) {
66
+ return (opts.adapter ??
67
+ (opts.harness ? (0, adapter_registry_js_1.getAdapter)(opts.harness) : undefined) ??
68
+ adapter_registry_js_1.defaultAdapter);
69
+ }
70
+ /**
71
+ * The executing-tier wiring for one adapter: its live driver plus the CLI-presence
72
+ * gate. This is what `buildProbe(dir, harness)` used to switch on a NAME to build;
73
+ * both halves now come off the adapter, so adding a harness adds no branch here.
74
+ *
75
+ * ⚠️ `available` IS THE BINARY PROBE, NOT `liveDriver.access(env)`, and the
76
+ * difference is load-bearing. `access` answers "is a model reachable and on whose
77
+ * bill" — for Claude Code that is an env-only read by design (deciding whether to
78
+ * OFFER a measurement must not spend a token), which is NOT the same question as
79
+ * "is the CLI installed". Routing this gate through `access` would make the
80
+ * behavioral column go dark for anyone driving a logged-in `claude` from a plain
81
+ * shell with none of the env vars set, and light up for anyone with a key but no
82
+ * binary. The CLI-presence fact already has exactly one home — `available` on the
83
+ * MOCK-tier driver, which is the same binary and the same probe both tiers need
84
+ * (`claudeAvailable` / `codexDriver.available`, the two functions `buildProbe`
85
+ * itself called) — so it is read from there rather than copied onto a second port.
86
+ */
87
+ async function probeFor(adapter) {
88
+ if (!adapter.harnessTesting)
89
+ return null;
90
+ const [live, test] = await Promise.all([
91
+ adapter.liveDriver(),
92
+ adapter.harnessTestDriver(),
93
+ ]);
94
+ // Wrapped rather than handed over bare: an extracted method loses its `this`.
95
+ return { live, available: () => test.available() };
67
96
  }
68
- /** Plugin name from `.claude-plugin/plugin.json` (the skill id's namespace). */
69
- function pluginName(dir) {
70
- const p = (0, node_path_1.join)(dir, ".claude-plugin", "plugin.json");
97
+ /**
98
+ * The plugin manifest's `name` — the namespace a harness may prefix onto a skill
99
+ * id. Read through the LAYOUT's path and codec, never a `.claude-plugin` literal,
100
+ * so a TOML-manifest harness is read with its own parser.
101
+ */
102
+ function manifestName(dir, layout) {
103
+ const p = (0, node_path_1.join)(dir, layout.manifestPath);
71
104
  if (!(0, node_fs_1.existsSync)(p))
72
105
  return null;
73
106
  try {
74
- return (JSON.parse((0, node_fs_1.readFileSync)(p, "utf-8")).name ?? null);
107
+ const parsed = layout.settings.parse((0, node_fs_1.readFileSync)(p, "utf-8"));
108
+ return typeof parsed.name === "string" ? parsed.name : null;
75
109
  }
76
110
  catch {
77
111
  return null;
@@ -115,15 +149,15 @@ async function probeSkill(ctx, name, ps) {
115
149
  try {
116
150
  const r = await (0, eval_js_1.measureTriggerRate)({
117
151
  pluginDir: ctx.dir,
118
- stubSkillBodies: ctx.probe.stub,
152
+ stubSkillBodies: ctx.live.installsStubs,
119
153
  prompts: ps.prompts,
120
154
  irrelevantPrompts: ps.irrelevant,
121
155
  minPrompts: ctx.opts.minPrompts,
122
156
  minDistance: ctx.opts.minDistance,
123
157
  model: ctx.opts.model,
124
158
  concurrency: ctx.opts.concurrency,
125
- fired: ctx.probe.firedFor(name),
126
- }, { evalDriver: ctx.probe.evalDriver });
159
+ fired: ctx.live.firedFor(name, ctx.plugin),
160
+ }, { evalDriver: ctx.live.evalDriver });
127
161
  return {
128
162
  skill: name,
129
163
  measured: true,
@@ -144,13 +178,19 @@ async function probeSkill(ctx, name, ps) {
144
178
  }
145
179
  }
146
180
  /** The injectable core (for tests): probe every model-invocable skill that has prompts. */
147
- async function probePluginTriggersWith(dir, promptSet, probe, opts = {}) {
148
- const ctx = { dir, opts, probe };
181
+ async function probePluginTriggersWith(dir, promptSet, live, opts = {}) {
182
+ const layout = opts.layout ?? layout_js_1.claudeCodeLayout;
183
+ const ctx = {
184
+ dir,
185
+ opts,
186
+ live,
187
+ plugin: { name: manifestName(dir, layout) },
188
+ };
149
189
  // Only model-invocable, describable skills can auto-trigger; user-invoked and
150
190
  // description-less ones can't, so they're not behavioral candidates. Discover
151
191
  // them with the resolved layout/dialect (default CC) so a Codex repo's skills
152
192
  // aren't missed by the wrong layout.
153
- const candidates = (0, scan_js_1.scanPlugin)(dir, opts.layout, opts.dialect).skills.filter((s) => !s.userInvoked && s.hasDescription);
193
+ const candidates = (0, scan_js_1.scanPlugin)(dir, layout, opts.dialect ?? dialect_js_1.claudeCodeDialect).skills.filter((s) => !s.userInvoked && s.hasDescription);
154
194
  const results = [];
155
195
  for (const s of candidates) {
156
196
  const ps = promptSet[s.name];
@@ -166,14 +206,14 @@ async function probePluginTriggersWith(dir, promptSet, probe, opts = {}) {
166
206
  }
167
207
  return {
168
208
  available: true,
169
- results: relabelTriggerArtifact(dir, probe, results),
170
- experimental: probe.evalDriver.experimental,
209
+ results: relabelTriggerArtifact(dir, live, results),
210
+ experimental: live.evalDriver.experimental,
171
211
  };
172
212
  }
173
213
  /** Relabel an all-zero-recall stubbed run on a hooked plugin as unmeasured (Layer 1). */
174
- function relabelTriggerArtifact(dir, probe, results) {
214
+ function relabelTriggerArtifact(dir, live, results) {
175
215
  const recalls = results.filter((r) => r.measured).map((r) => r.recall ?? 0);
176
- if (!isStubbedHookArtifact(dir, probe.stub, recalls))
216
+ if (!isStubbedHookArtifact(dir, live.installsStubs, recalls))
177
217
  return [...results];
178
218
  return results.map((r) => r.measured && (r.recall ?? 0) === 0
179
219
  ? { skill: r.skill, measured: false, note: HOOK_PRIMED_NOTE }
@@ -185,10 +225,10 @@ function relabelTriggerArtifact(dir, probe, results) {
185
225
  * `available: false` otherwise.
186
226
  */
187
227
  async function probePluginTriggers(dir, promptSet, opts = {}) {
188
- const probe = buildProbe(dir, opts.harness ?? "claude-code");
189
- if (!probe.available())
228
+ const probe = await probeFor(adapterFor(opts));
229
+ if (!probe?.available())
190
230
  return { available: false, results: [] };
191
- return probePluginTriggersWith(dir, promptSet, probe, opts);
231
+ return probePluginTriggersWith(dir, promptSet, probe.live, opts);
192
232
  }
193
233
  const pct = (x) => `${(x * 100).toFixed(0)}%`;
194
234
  /** Format the behavioral column as a scan-report section. */
@@ -307,9 +347,17 @@ function selectionJobs(candidates, promptSet, trials) {
307
347
  return ps.prompts.flatMap((prompt) => Array.from({ length: trials }, () => ({ intended: c.name, prompt })));
308
348
  });
309
349
  }
310
- /** The injectable core (for tests): drive the matrix via a fake/real probe. */
350
+ /**
351
+ * The injectable core (for tests): drive the matrix via a fake/real driver.
352
+ *
353
+ * 🔴 IT TAKES AN {@link EventFiringDriver}, NOT ANY LIVE DRIVER, AND THAT IS THE
354
+ * RATCHET. The matrix asks WHICH of the plugin's skills fired on each prompt; a
355
+ * harness that only INFERS firing cannot answer that, and the old guard was a
356
+ * name check in the wrapper below — invisible to anyone calling this core
357
+ * directly. Now an inferred driver is a compile error at every call site.
358
+ */
311
359
  async function measurePluginSelectionWith(dir, promptSet, probe, opts = {}) {
312
- const candidates = (0, scan_js_1.scanPlugin)(dir).skills.filter((s) => !s.userInvoked && s.hasDescription);
360
+ const candidates = (0, scan_js_1.scanPlugin)(dir, opts.layout ?? layout_js_1.claudeCodeLayout, opts.dialect ?? dialect_js_1.claudeCodeDialect).skills.filter((s) => !s.userInvoked && s.hasDescription);
313
361
  const skills = candidates.map((c) => c.name);
314
362
  if (skills.length < 2) {
315
363
  return {
@@ -328,7 +376,7 @@ async function measurePluginSelectionWith(dir, promptSet, probe, opts = {}) {
328
376
  // Selection is decided at the frontmatter (the selector picks BEFORE the body
329
377
  // loads), so stub each body to a no-op: the run stops AT selection instead of
330
378
  // executing the whole workflow — the same affordability trick trigger-rate uses.
331
- const pluginDir = probe.stub ? (0, eval_js_1.stubbedPluginDir)(dir) : dir;
379
+ const pluginDir = probe.installsStubs ? (0, eval_js_1.stubbedPluginDir)(dir) : dir;
332
380
  try {
333
381
  const d = probe.evalDriver;
334
382
  const outcomes = await (0, eval_js_1.runPool)(jobs, Math.max(1, opts.concurrency ?? 1), (job) => (0, eval_js_1.runSkillSelectionTrial)({
@@ -356,38 +404,50 @@ async function measurePluginSelectionWith(dir, promptSet, probe, opts = {}) {
356
404
  // dropped-hook artifact — flag it instead of presenting a 0%-collision result
357
405
  // computed from a plugin that never fired.
358
406
  const recalls = report.perSkill.filter((s) => s.n > 0).map((s) => s.recall);
359
- return isStubbedHookArtifact(dir, probe.stub, recalls)
407
+ return isStubbedHookArtifact(dir, probe.installsStubs, recalls)
360
408
  ? { ...report, note: HOOK_PRIMED_NOTE }
361
409
  : report;
362
410
  }
363
411
  finally {
364
- if (probe.stub)
412
+ if (probe.installsStubs)
365
413
  (0, node_fs_1.rmSync)(pluginDir, { recursive: true, force: true });
366
414
  }
367
415
  }
416
+ /**
417
+ * Why the matrix cannot run on this harness — the DRIVER's own reason, carried
418
+ * verbatim, rather than a sentence written at the call site next to a name check.
419
+ */
420
+ function selectionUnavailableNote(name, live) {
421
+ if (!live)
422
+ return `selection-collision needs an executing-tier driver, which ${name} does not have`;
423
+ const why = live.firing.kind === "inferred" ? live.firing.caveat : "no firing event";
424
+ return `selection-collision needs a discrete skill-selection event, which ${name} does not emit (${why})`;
425
+ }
368
426
  /**
369
427
  * Measure a plugin's cross-skill selection-collision matrix against the real
370
428
  * harness (Claude Code only — Codex has no skill-selection event). Needs the
371
429
  * `claude` CLI + model auth; degrades to `available: false` otherwise.
372
430
  */
373
431
  async function measurePluginSelection(dir, promptSet, opts = {}) {
374
- const harness = opts.harness ?? "claude-code";
375
- if (harness !== "claude-code") {
432
+ const adapter = adapterFor(opts);
433
+ const probe = await probeFor(adapter);
434
+ // The n/a reason now comes from the DRIVER's own firing signal and carries the
435
+ // driver's own caveat, instead of being a name check with the reason inlined.
436
+ if (!probe || !(0, live_driver_js_1.isEventFiring)(probe.live)) {
376
437
  return {
377
438
  ...emptySelection([]),
378
439
  available: false,
379
- note: `selection-collision is Claude Code only (no skill-selection event on ${harness})`,
440
+ note: selectionUnavailableNote(adapter.name, probe?.live),
380
441
  };
381
442
  }
382
- const probe = buildProbe(dir, harness);
383
443
  if (!probe.available()) {
384
444
  return {
385
445
  ...emptySelection([]),
386
446
  available: false,
387
- note: "needs the claude CLI + model auth",
447
+ note: `needs the ${adapter.name} CLI + model auth`,
388
448
  };
389
449
  }
390
- return measurePluginSelectionWith(dir, promptSet, probe, opts);
450
+ return measurePluginSelectionWith(dir, promptSet, probe.live, opts);
391
451
  }
392
452
  /** Format the selection-collision matrix as a scan-report section. */
393
453
  function formatSelectionReport(r) {
@@ -437,7 +497,7 @@ async function measureSelectionMatrixWith(dir, probe, opts = {}) {
437
497
  * model-invocable skills' descriptions (the zero-setup path). */
438
498
  function resolveSelectionPrompts(dir, opts) {
439
499
  return (opts.prompts ??
440
- (0, audit_prompts_js_1.autoTriggerPrompts)((0, scan_js_1.scanPlugin)(dir)
500
+ (0, audit_prompts_js_1.autoTriggerPrompts)((0, scan_js_1.scanPlugin)(dir, opts.layout ?? layout_js_1.claudeCodeLayout, opts.dialect ?? dialect_js_1.claudeCodeDialect)
441
501
  .skills.filter((s) => !s.userInvoked && s.hasDescription)
442
502
  .map((s) => ({ name: s.name, description: s.description ?? "" }))));
443
503
  }
@@ -637,29 +697,33 @@ function judgeGateReal(a) {
637
697
  * hold vs cave. Claude Code only; degrades to `available: false` without the CLI/auth.
638
698
  */
639
699
  async function measureGateAdversarial(dir, opts = {}) {
640
- const harness = opts.harness ?? "claude-code";
641
- if (harness !== "claude-code") {
700
+ const adapter = adapterFor(opts);
701
+ const probe = await probeFor(adapter);
702
+ // Same gate as the collision matrix and for the same reason: the attack is only
703
+ // meaningful once the gate skill has actually been SELECTED, which an inferred
704
+ // firing signal cannot establish. Read off the driver, not off a name.
705
+ if (!probe || !(0, live_driver_js_1.isEventFiring)(probe.live)) {
642
706
  return {
643
707
  available: false,
644
708
  results: [],
645
- note: `adversarial-gate is Claude Code only (no Skill selection on ${harness})`,
709
+ note: `adversarial-gate needs a discrete skill-selection event, which ${adapter.name} does not emit`,
646
710
  };
647
711
  }
648
- const probe = buildProbe(dir, harness);
649
712
  if (!probe.available()) {
650
713
  return {
651
714
  available: false,
652
715
  results: [],
653
- note: "needs the claude CLI + model auth",
716
+ note: `needs the ${adapter.name} CLI + model auth`,
654
717
  };
655
718
  }
656
- const skills = (0, scan_js_1.scanPlugin)(dir, opts.layout, opts.dialect).skills;
719
+ const skills = (0, scan_js_1.scanPlugin)(dir, opts.layout ?? layout_js_1.claudeCodeLayout, opts.dialect ?? dialect_js_1.claudeCodeDialect).skills;
657
720
  const gateNames = new Set(detectGateSkills(skills));
658
721
  const gates = skills
659
722
  .filter((s) => gateNames.has(s.name))
660
723
  .map((s) => ({ name: s.name, description: s.description ?? "" }));
661
724
  const deps = {
662
- driver: eval_js_1.claudeEvalDriver,
725
+ // The driving adapter's own eval transport, not a hard-wired Claude one.
726
+ driver: probe.live.evalDriver,
663
727
  judge: judgeGateReal,
664
728
  derive: deriveAttackReal,
665
729
  };
package/dist/scan-core.js CHANGED
@@ -142,18 +142,35 @@ function escapeRe(s) {
142
142
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
143
143
  }
144
144
  function makeClassifier(layout) {
145
- // An empty dir means "this harness has no such surface" → never matches.
145
+ // An ABSENT dir means "this harness has no such surface" → never matches.
146
+ // (`""` used to mean the same thing; a `Partial<Record<SurfaceKind, string>>`
147
+ // has one spelling for it, so this takes `string | undefined` and the empty
148
+ // string is no longer a value any layout can supply.)
146
149
  const at = (dir) => dir ? `(?:^|/)${escapeRe(dir)}/` : null;
147
- const skill = at(layout.skillDir);
148
- const agent = at(layout.agentDir);
149
- const command = at(layout.commandDir);
150
- const rules = at(layout.rulesDir ?? "");
150
+ const skillDir = layout.surfaces.skill;
151
+ const agentDir = layout.surfaces.agent;
152
+ const commandDir = layout.surfaces.command;
153
+ const skill = at(skillDir);
154
+ const agent = at(agentDir);
155
+ const command = at(commandDir);
151
156
  const skillRe = skill ? new RegExp(`${skill}[^/]+/SKILL\\.md$`) : null;
152
157
  const agentRe = agent ? new RegExp(`${agent}${layout_js_1.AGENT_FILE_LEAF_RE}$`) : null;
153
158
  const commandRe = command ? new RegExp(`${command}.+\\.md$`) : null;
154
- // Flat `<rulesDir>/<name>.md`, like commands. A layout without a rules dir
155
- // yields null and every path below answers false — the additive default.
156
- const ruleRe = rules ? new RegExp(`${rules}[^/]+\\.md$`) : null;
159
+ // 🔴 RECURSIVE, AND IT USED TO BE FLAT (`[^/]+\\.md$`) WHILE THE LOADER WALKED
160
+ // THE DIRECTORY RECURSIVELY. A rule in a subdirectory was therefore READ and
161
+ // never CLASSIFIED: it never became a rule entry, so it was never
162
+ // frontmatter-checked, never counted and never weighed — zernie/vigiles#262
163
+ // §3. The depth rule is quoted from `RULE_FILE_LEAF_RE` rather than spelled
164
+ // here, because the instruction chain has to ask the same question and two
165
+ // readers that each spell it disagree silently (the agent constant beside it
166
+ // records the measurement where that happened).
167
+ // 🔴 THE PREFIX COMES FROM `ruleFileRe` NOW, not from `at()`. `at()` is
168
+ // deliberately loose — `(?:^|/)skills/` has to match a published plugin's
169
+ // root AND `.claude/skills` — and rules have exactly ONE home, so the same
170
+ // looseness let `.github/rules/policy.md` be classified as a Claude Code
171
+ // rule here just as it was weighed as one by the chain. Sharing the leaf and
172
+ // not the prefix is what let the two readers named above disagree.
173
+ const ruleRe = (0, layout_js_1.ruleFileRe)(layout);
157
174
  // A subagent lives under the plugin's `agents/` dir AT ANY DEPTH (the harness
158
175
  // reads it recursively — see AGENT_FILE_LEAF_RE for the vendor's wording and
159
176
  // the measurement), but never under ANOTHER surface dir. Two real-world
@@ -167,13 +184,13 @@ function makeClassifier(layout) {
167
184
  // (adapter-agnostic), and both patterns already tolerate depth on BOTH sides of
168
185
  // the `agents/` segment, so the recursion above does not leak through them.
169
186
  // See scan.test.ts for the regressions.
170
- const nestedUnder = [
171
- layout.skillDir &&
172
- `${escapeRe(layout.skillDir)}/.+/${escapeRe(layout.agentDir)}/`,
173
- layout.commandDir &&
174
- `${escapeRe(layout.commandDir)}/(?:.+/)?${escapeRe(layout.agentDir)}/`,
175
- ].filter((x) => Boolean(x));
176
- const nestedAgentRe = layout.agentDir && nestedUnder.length
187
+ const nestedUnder = agentDir
188
+ ? [
189
+ skillDir && `${escapeRe(skillDir)}/.+/${escapeRe(agentDir)}/`,
190
+ commandDir && `${escapeRe(commandDir)}/(?:.+/)?${escapeRe(agentDir)}/`,
191
+ ].filter((x) => Boolean(x))
192
+ : [];
193
+ const nestedAgentRe = agentDir && nestedUnder.length
177
194
  ? new RegExp(`(?:^|/)(?:${nestedUnder.join("|")})`)
178
195
  : null;
179
196
  const isAgent = (f) => (agentRe?.test(f) ?? false) &&
@@ -188,8 +205,8 @@ function makeClassifier(layout) {
188
205
  // `.md` under `agents/` recursively — so this file is a subagent, and is not a
189
206
  // skill. Excluding it here (rather than excluding it from agents) is what keeps
190
207
  // the two classifiers disjoint AND agreeing with the harness.
191
- const nestedSkillRe = layout.skillDir && layout.agentDir
192
- ? new RegExp(`(?:^|/)${escapeRe(layout.agentDir)}/(?:.+/)?${escapeRe(layout.skillDir)}/`)
208
+ const nestedSkillRe = skillDir && agentDir
209
+ ? new RegExp(`(?:^|/)${escapeRe(agentDir)}/(?:.+/)?${escapeRe(skillDir)}/`)
193
210
  : null;
194
211
  const isSkill = (f) => (skillRe?.test(f) ?? false) && !(nestedSkillRe?.test(f) ?? false);
195
212
  return {
@@ -197,7 +214,9 @@ function makeClassifier(layout) {
197
214
  isAgent,
198
215
  isCommand: (f) => commandRe?.test(f) ?? false,
199
216
  isRule: (f) => ruleRe?.test(f) ?? false,
200
- agentName: (f) => isAgent(f) ? (0, layout_js_1.agentSurfaceName)(f, layout.agentDir) : null,
217
+ agentName: (f) => isAgent(f) && agentDir !== undefined
218
+ ? (0, layout_js_1.agentSurfaceName)(f, agentDir)
219
+ : null,
201
220
  };
202
221
  }
203
222
  /**
@@ -1,4 +1,4 @@
1
- import type { PluginLayout } from "./core/layout.js";
1
+ import { type PluginLayout } from "./core/layout.js";
2
2
  import type { HarnessDialect } from "./core/dialect.js";
3
3
  import type { LoadedPlugin } from "./plugin-loader.js";
4
4
  import type { ScanReport } from "./scan.js";
@@ -39,10 +39,11 @@ exports.scanFiles = scanFiles;
39
39
  * is excluded. See research/report-view-and-browser-demo.md.
40
40
  */
41
41
  const posix_path_js_1 = require("./posix-path.js");
42
- const toml_1 = require("@iarna/toml");
43
42
  const layout_js_1 = require("./adapters/claude-code/layout.js");
44
43
  const dialect_js_1 = require("./adapters/claude-code/dialect.js");
44
+ const layout_js_2 = require("./core/layout.js");
45
45
  const instruction_weight_js_1 = require("./core/instruction-weight.js");
46
+ const instruction_chain_js_1 = require("./core/instruction-chain.js");
46
47
  const hook_normalize_js_1 = require("./core/hook-normalize.js");
47
48
  const hook_events_js_1 = require("./core/hook-events.js");
48
49
  const mcp_config_js_1 = require("./core/mcp-config.js");
@@ -159,31 +160,26 @@ function safeParseJson(text) {
159
160
  return null;
160
161
  }
161
162
  }
162
- /** Parse the layout's manifest (JSON or TOML) from the map, or null. */
163
+ /** Parse the layout's manifest from the map through its CODEC, or null. */
163
164
  function readManifest(files, layout) {
164
165
  const text = files[layout.manifestPath];
165
166
  if (text === undefined)
166
167
  return null;
167
- if (layout.settingsFormat === "toml") {
168
- try {
169
- return (0, toml_1.parse)(text);
170
- }
171
- catch {
172
- return null;
173
- }
168
+ try {
169
+ return layout.settings.parse(text);
170
+ }
171
+ catch {
172
+ return null;
174
173
  }
175
- return safeParseJson(text);
176
174
  }
177
175
  /** The `.hooks` field of a JSON file in the map, or undefined. */
178
176
  function readHooksJsonFile(files, rel) {
179
177
  return safeParseJson(files[rel])?.hooks;
180
178
  }
181
- /** The `.hooks` of a settings file in the map, in the layout's format. */
182
- function readSettingsHooks(text, format) {
183
- if (format === "json")
184
- return safeParseJson(text)?.hooks;
179
+ /** The `.hooks` of a settings file in the map, through the layout's codec. */
180
+ function readSettingsHooks(text, codec) {
185
181
  try {
186
- return (0, toml_1.parse)(text).hooks;
182
+ return codec.parse(text).hooks;
187
183
  }
188
184
  catch {
189
185
  return undefined;
@@ -199,12 +195,15 @@ function readHooks(files, layout) {
199
195
  if (m.hooks !== undefined)
200
196
  return m.hooks;
201
197
  }
202
- if (hasFile(files, layout.hooksConventionPath)) {
198
+ // Optional: a harness whose hooks are in-process code modules has no
199
+ // standalone hooks FILE. Mirrors the disk loader.
200
+ if (layout.hooksConventionPath !== undefined &&
201
+ hasFile(files, layout.hooksConventionPath)) {
203
202
  return readHooksJsonFile(files, layout.hooksConventionPath);
204
203
  }
205
204
  const settings = files[layout.settingsPath];
206
205
  if (settings !== undefined) {
207
- return readSettingsHooks(settings, layout.settingsFormat);
206
+ return readSettingsHooks(settings, layout.settings);
208
207
  }
209
208
  return undefined;
210
209
  }
@@ -225,7 +224,7 @@ function hasMcp(files, layout) {
225
224
  */
226
225
  function surfaceHasLoadable(layout, surface, tree) {
227
226
  const keys = Object.keys(tree);
228
- return surface === layout.skillDir
227
+ return surface === layout.surfaces.skill
229
228
  ? keys.some((k) => (0, posix_path_js_1.basename)(k) === "SKILL.md")
230
229
  : keys.some((k) => k.endsWith(".md"));
231
230
  }
@@ -236,13 +235,13 @@ function materializeSurfaces(files, layout, acc, repoName) {
236
235
  const harnessCounts = {};
237
236
  const scopeTrees = (base) => {
238
237
  const trees = new Map();
239
- for (const surface of layout.surfaceDirs) {
238
+ for (const surface of (0, layout_js_2.surfaceDirs)(layout)) {
240
239
  const dirRel = base === "" ? surface : `${base}/${surface}`;
241
240
  trees.set(surface, isDirRel(files, dirRel) ? readTreeUnder(files, dirRel, dirRel) : {});
242
241
  }
243
242
  return trees;
244
243
  };
245
- const hasLoadable = (trees) => layout.surfaceDirs.some((s) => surfaceHasLoadable(layout, s, trees.get(s) ?? {}));
244
+ const hasLoadable = (trees) => (0, layout_js_2.surfaceDirs)(layout).some((s) => surfaceHasLoadable(layout, s, trees.get(s) ?? {}));
246
245
  const add = (key, content, onDisk) => {
247
246
  out[key] = content;
248
247
  sources[key] = onDisk;
@@ -253,7 +252,7 @@ function materializeSurfaces(files, layout, acc, repoName) {
253
252
  : new Map();
254
253
  /** Mirror of the disk loader's `materializeScope`. */
255
254
  const materializeScope = (scope, trees) => {
256
- for (const surface of layout.surfaceDirs) {
255
+ for (const surface of (0, layout_js_2.surfaceDirs)(layout)) {
257
256
  const tree = trees.get(surface) ?? {};
258
257
  const dirRel = scope.base === "" ? surface : `${scope.base}/${surface}`;
259
258
  for (const [rel, content] of Object.entries(tree))
@@ -265,24 +264,29 @@ function materializeSurfaces(files, layout, acc, repoName) {
265
264
  }
266
265
  };
267
266
  const source = (0, surface_scopes_js_1.surfaceSource)(layout, {
268
- hasRootSkillFile: Boolean(layout.skillDir) && hasFile(files, "SKILL.md"),
267
+ hasRootSkillFile: layout.surfaces.skill !== undefined && hasFile(files, "SKILL.md"),
269
268
  // Disk mirrors the CLI: a nameless root SKILL.md takes the audited dir's
270
269
  // basename. In-browser there's no real dir, so use the repo name when the
271
270
  // caller (runAudit) supplies it, else the synthetic BROWSER_ROOT basename.
272
271
  skillName: repoName ?? (0, posix_path_js_1.basename)(exports.BROWSER_ROOT),
273
272
  rootHasLoadable: hasLoadable(rootTrees),
274
273
  isPluginShaped: hasFile(files, layout.manifestPath) ||
275
- hasFile(files, layout.hooksConventionPath),
274
+ (layout.hooksConventionPath !== undefined &&
275
+ hasFile(files, layout.hooksConventionPath)),
276
276
  userHasLoadable: hasLoadable(userTrees),
277
277
  });
278
278
  switch (source.kind) {
279
279
  case "single-skill": {
280
280
  const tree = readTreeUnder(files, "", "");
281
+ // Mirrors the disk loader: the skill dir is carried on the variant, so
282
+ // there is no `undefined` case to guard and no dead branch to keep in
283
+ // sync across the two engines.
284
+ const { skillDir } = source;
281
285
  for (const [rel, content] of Object.entries(tree)) {
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));
286
+ add((0, posix_path_js_1.join)((0, layout_js_2.materializePrefix)(layout), skillDir, source.skillName, rel), content, (0, posix_path_js_1.join)(exports.BROWSER_ROOT, rel));
283
287
  }
284
- counts[layout.skillDir] = Object.keys(tree).length;
285
- harnessCounts[layout.skillDir] = counts[layout.skillDir];
288
+ counts[skillDir] = Object.keys(tree).length;
289
+ harnessCounts[skillDir] = counts[skillDir];
286
290
  return { counts, harnessCounts, scopes: [] };
287
291
  }
288
292
  case "scopes": {
@@ -299,7 +303,7 @@ function materializeSurfaces(files, layout, acc, repoName) {
299
303
  // Extensions + BOTH token boundaries live in core/source-refs.ts, so this and
300
304
  // its disk twin (plugin-loader.ts) cannot disagree and neither can omit one.
301
305
  function intraRefRe(layout) {
302
- return (0, source_refs_js_1.intraRefPattern)(layout.intraRefDirs);
306
+ return (0, source_refs_js_1.intraRefPattern)((0, layout_js_2.executableSourceDirs)(layout));
303
307
  }
304
308
  const NON_PLUGIN_VARS = new Set([
305
309
  "CLAUDE_PROJECT_DIR",
@@ -326,7 +330,7 @@ const DOC_SOURCE_RE = /\.(?:md|markdown|mdx|txt|rst)$/i;
326
330
  /** The plugin's executable (non-prose) source-file CONTENTS under the surface dirs. */
327
331
  function executableContents(files, layout) {
328
332
  const out = [];
329
- for (const surface of layout.intraRefDirs) {
333
+ for (const surface of (0, layout_js_2.executableSourceDirs)(layout)) {
330
334
  if (!isDirRel(files, surface))
331
335
  continue;
332
336
  for (const [k, content] of Object.entries(files)) {
@@ -491,6 +495,14 @@ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = diale
491
495
  hasSpec: hasFile(files, `${lay.instructionFile}.spec.ts`),
492
496
  }
493
497
  : null;
498
+ // The BOUNDED candidate set over the map, then the same one-level import pass
499
+ // the disk walk runs — `resolveImports` lives in the core precisely so these
500
+ // two cannot drift. An import the fetcher never fetched simply stays unread,
501
+ // and the weight says so (`unreadImports`) instead of quietly omitting it.
502
+ const instructionFiles = (0, instruction_chain_js_1.resolveImports)(lay, Object.fromEntries((0, instruction_chain_js_1.instructionCandidatePaths)(Object.keys(files), lay).map((p) => [
503
+ p,
504
+ files[p] ?? "",
505
+ ])), (p) => files[p]);
494
506
  const mcpServers = collectMcpServers(files, lay);
495
507
  const declaredServers = Object.keys(mcpServers);
496
508
  // The repo's own test signal — same shared detector as the disk path, over the
@@ -505,7 +517,7 @@ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = diale
505
517
  });
506
518
  const skills = (0, scan_core_js_1.scanSkills)(loaded.files, cls, {
507
519
  root: exports.BROWSER_ROOT,
508
- materializeRoot: lay.materializeRoot,
520
+ materializeRoot: (0, layout_js_2.materializePrefix)(lay),
509
521
  dialect,
510
522
  sources: loaded.sources,
511
523
  existsSync: exists,
@@ -560,7 +572,10 @@ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = diale
560
572
  // The browser twin has the WHOLE fetched key set in hand, so discovery needs
561
573
  // no walk here — the pure classifier re-applies the same bounded-root rule.
562
574
  unclaimedSurfaces: (0, surface_discovery_js_1.unclaimedSurfaceFindings)(Object.keys(files), layout_registry_js_1.REGISTERED_LAYOUTS),
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) }),
575
+ 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)),
576
+ // See the note in scan.ts: the scripts dir is NAMED by the layout, not
577
+ // derived from the registration file's first segment.
578
+ (0, layout_js_2.executableSourceDirs)(lay), { existsSync: exists, isDirectory: mapIsDirectory(files) }),
564
579
  delegationTrifecta: (0, scan_core_js_1.collectDelegationTrifecta)(agents, dialect),
565
580
  hookBlockFindings: (0, event_capability_js_1.blockIneffectiveEventsOf)(dialect).length > 0
566
581
  ? (0, hook_block_ineffective_js_1.hookBlockIssues)((0, scan_core_js_1.collectHookBlockEntries)(hookRegs, exports.BROWSER_ROOT, lay.pluginRootToken, exists), {
@@ -578,10 +593,15 @@ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = diale
578
593
  ...loaded.warnings,
579
594
  ...(0, merge_conflict_js_1.conflictedHarnessConfigs)((f) => files[f]).map(merge_conflict_js_1.mergeConflictWarning),
580
595
  ],
581
- // The browser side needs no directory walk — the file map IS the repo, so
582
- // the glob filter inside weighInstructions does the whole job.
596
+ // 🔴 THE TWIN APPLIES THE SAME BOUND AS THE DISK WALK, and until now it
597
+ // applied none. The comment here used to read "the file map IS the repo, so
598
+ // the glob filter does the whole job" — which was true of a glob and is not
599
+ // true of a bound: `boundedInstructionFiles` enumerates the repo root plus
600
+ // depth-1 dot-directories and nothing else, so handing the whole fetched map
601
+ // over would make the browser weigh files the CLI never opens. Same filter,
602
+ // same import pass, same chain; only the storage differs.
583
603
  instructionWeight: dialect.instructionBudget
584
- ? (0, instruction_weight_js_1.weighInstructions)(files, dialect.instructionBudget)
604
+ ? (0, instruction_weight_js_1.weighInstructions)(lay.instructionChain(instructionFiles), instructionFiles, dialect.instructionBudget)
585
605
  : null,
586
606
  untested: coverage.untested.length,
587
607
  untestedHarness: coverage.harness.untested.length,