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
@@ -11,27 +11,6 @@
11
11
  * not through the report verb. The IO (prompt / run / remember) lives in the CLI;
12
12
  * this is the pure decision + helpers.
13
13
  */
14
- /** Only the env vars that signal a reachable model (parse, don't validate). */
15
- export interface ModelEnv {
16
- readonly ANTHROPIC_API_KEY?: string;
17
- readonly CLAUDECODE?: string;
18
- readonly CLAUDE_CODE_ENTRYPOINT?: string;
19
- }
20
- /**
21
- * Is a real model reachable for the trigger tier? Either a metered API key
22
- * (`ANTHROPIC_API_KEY`), OR an authenticated Claude Code session (`CLAUDECODE=1`
23
- * / `CLAUDE_CODE_ENTRYPOINT`, web/desktop/CLI) — the latter drives the `claude`
24
- * CLI on the user's subscription, no key needed and $0 metered. A tiny env-only
25
- * predicate (not a live probe), so it never spends a token just to decide.
26
- */
27
- export declare function hasModelAccess(env: ModelEnv): boolean;
28
- /**
29
- * Is the reachable model METERED (a paid API key) rather than a subscription?
30
- * Only affects the consent DISCLOSURE wording (a metered key bills per token; a
31
- * subscription is $0 metered) — the run/skip decision itself is consent-driven,
32
- * not metered-driven.
33
- */
34
- export declare function isMeteredAccess(env: ModelEnv): boolean;
35
14
  /** Why the executing checks were skipped (drives the "not run" nudge). */
36
15
  export type ExecuteSkipReason = "nothing" | "headless" | "remembered-no";
37
16
  /**
@@ -13,32 +13,9 @@
13
13
  * this is the pure decision + helpers.
14
14
  */
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
- exports.hasModelAccess = hasModelAccess;
17
- exports.isMeteredAccess = isMeteredAccess;
18
16
  exports.decideExecute = decideExecute;
19
17
  exports.formatExecuteSkip = formatExecuteSkip;
20
18
  exports.scaffoldTriggerPrompts = scaffoldTriggerPrompts;
21
- /**
22
- * Is a real model reachable for the trigger tier? Either a metered API key
23
- * (`ANTHROPIC_API_KEY`), OR an authenticated Claude Code session (`CLAUDECODE=1`
24
- * / `CLAUDE_CODE_ENTRYPOINT`, web/desktop/CLI) — the latter drives the `claude`
25
- * CLI on the user's subscription, no key needed and $0 metered. A tiny env-only
26
- * predicate (not a live probe), so it never spends a token just to decide.
27
- */
28
- function hasModelAccess(env) {
29
- return Boolean(env.ANTHROPIC_API_KEY ||
30
- env.CLAUDECODE === "1" ||
31
- env.CLAUDE_CODE_ENTRYPOINT);
32
- }
33
- /**
34
- * Is the reachable model METERED (a paid API key) rather than a subscription?
35
- * Only affects the consent DISCLOSURE wording (a metered key bills per token; a
36
- * subscription is $0 metered) — the run/skip decision itself is consent-driven,
37
- * not metered-driven.
38
- */
39
- function isMeteredAccess(env) {
40
- return Boolean(env.ANTHROPIC_API_KEY);
41
- }
42
19
  /**
43
20
  * Decide what `audit` does with the executing checks. Total + pure; the first
44
21
  * matching rule wins. There is NO execution flag — `audit` is a local report, so
package/dist/scan.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * need to RUN the plugin (observed egress under the sandbox, real trigger-rate)
12
12
  * stack on top later; this core stays pure so it runs anywhere in CI for free.
13
13
  */
14
- import type { PluginLayout } from "./core/layout.js";
14
+ import { type PluginLayout } from "./core/layout.js";
15
15
  import type { ExcludeSet } from "./exclude.js";
16
16
  import type { HarnessDialect } from "./core/dialect.js";
17
17
  import type { ToolIssue } from "./core/tool-contract.js";
@@ -412,7 +412,7 @@ export interface ScanHarness {
412
412
  readonly roots: readonly string[];
413
413
  }
414
414
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
415
- export declare function scanPlugin(dir: string, layout?: PluginLayout, dialect?: HarnessDialect, opts?: {
415
+ export declare function scanPlugin(dir: string, layout: PluginLayout, dialect: HarnessDialect, opts?: {
416
416
  sharedDirs?: readonly string[];
417
417
  sharedDirsRoot?: string;
418
418
  /**
@@ -492,7 +492,7 @@ export interface MarketplaceInfo {
492
492
  * marketplace. The source of truth behind {@link expandMarketplace} and the
493
493
  * curated-marketplace report in `vigiles audit`.
494
494
  */
495
- export declare function inspectMarketplace(dir: string, layout?: PluginLayout): MarketplaceInfo | null;
495
+ export declare function inspectMarketplace(dir: string, layout: PluginLayout): MarketplaceInfo | null;
496
496
  /**
497
497
  * If `dir` is a plugin MARKETPLACE (a `marketplace.json` beside the layout's
498
498
  * plugin manifest, e.g. `.claude-plugin/marketplace.json`), expand it into the
@@ -501,6 +501,6 @@ export declare function inspectMarketplace(dir: string, layout?: PluginLayout):
501
501
  * on disk). Used by `vigiles audit` to rank a whole marketplace — wshobson/agents
502
502
  * alone ships 80+ plugins under one `marketplace.json`. See {@link inspectMarketplace}.
503
503
  */
504
- export declare function expandMarketplace(dir: string, layout?: PluginLayout): string[] | null;
504
+ export declare function expandMarketplace(dir: string, layout: PluginLayout): string[] | null;
505
505
  export declare function formatScanReport(r: ScanReport): string;
506
506
  //# sourceMappingURL=scan.d.ts.map
package/dist/scan.js CHANGED
@@ -40,11 +40,20 @@ const node_path_1 = require("node:path");
40
40
  // Claude Code default), and only the generic one takes the `ExcludeSet` that
41
41
  // makes `.vigilesrc.json#exclude` reach surface discovery.
42
42
  const plugin_loader_js_1 = require("./plugin-loader.js");
43
- const layout_js_1 = require("./adapters/claude-code/layout.js");
44
- const dialect_js_1 = require("./adapters/claude-code/dialect.js");
43
+ // 🔴 THIS MODULE NO LONGER IMPORTS AN ADAPTER, and that is the point of the
44
+ // change rather than tidiness. It is listed as a harness-agnostic detector and
45
+ // it used to import `claudeCodeLayout` + `claudeCodeDialect` to DEFAULT five
46
+ // parameters with. Neither of the other two fences could see it —
47
+ // `boundaries/dependencies` deliberately leaves this file unclassified, and the
48
+ // CC-literal rule's `\.claude` needs the dot while this path spells
49
+ // `/claude-code/` — so it took a third rule to find, and two per-line disables
50
+ // to live with. The default belongs to the CALLER: the CLI already resolves an
51
+ // adapter before every one of these calls, and passing it is what removes the
52
+ // import rather than hiding it.
45
53
  const plugin_loader_js_2 = require("./plugin-loader.js");
46
54
  const score_core_js_1 = require("./score-core.js");
47
55
  const skill_refs_js_1 = require("./skill-refs.js");
56
+ const layout_js_1 = require("./core/layout.js");
48
57
  const hook_events_js_1 = require("./core/hook-events.js");
49
58
  const mcp_config_js_1 = require("./core/mcp-config.js");
50
59
  const agent_plugins_js_1 = require("./core/agent-plugins.js");
@@ -145,8 +154,8 @@ function ownTestSignalOnDisk(dir) {
145
154
  });
146
155
  }
147
156
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
148
- function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts = {}) {
149
- const lay = layout ?? layout_js_1.claudeCodeLayout;
157
+ function scanPlugin(dir, layout, dialect, opts = {}) {
158
+ const lay = layout;
150
159
  // The declared harnesses, or the single positional one — so every path below
151
160
  // is the multi-harness path and a one-entry list is not a second code path.
152
161
  const declared = opts.harnesses && opts.harnesses.length > 0
@@ -181,6 +190,12 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
181
190
  const allHookEventIssues = (0, hook_events_js_1.verifyHookEvents)(eventNames, dialect);
182
191
  const hookEventIssues = (0, hook_events_js_1.scoredIssues)(allHookEventIssues);
183
192
  const instructionFile = instructionHarness.layout.instructionFile;
193
+ // The BOUNDED candidate set, read once and handed to the harness that owns
194
+ // the instruction file this repo has. It replaced `readAlwaysLoaded`, which
195
+ // expanded an ADAPTER's globs by walking the whole tree; the bound and the
196
+ // classification are now separate jobs held by separate modules, and only the
197
+ // second is the adapter's. See `core/instruction-chain.ts`.
198
+ const instructionFiles = (0, surface_discovery_fs_js_1.boundedInstructionFiles)((0, node_path_1.resolve)(dir), instructionHarness.layout, opts.excludes);
184
199
  const instructions = loaded.files[instructionFile] !== undefined
185
200
  ? {
186
201
  file: instructionFile,
@@ -195,7 +210,7 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
195
210
  });
196
211
  const skills = (0, scan_core_js_1.scanSkills)(loaded.files, cls, {
197
212
  root: (0, node_path_1.resolve)(dir),
198
- materializeRoot: lay.materializeRoot,
213
+ materializeRoot: (0, layout_js_1.materializePrefix)(lay),
199
214
  dialect,
200
215
  sources: loaded.sources,
201
216
  sharedDirs: opts.sharedDirs,
@@ -269,10 +284,14 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
269
284
  roots: h.roots,
270
285
  }))),
271
286
  pluginLayoutIssues: (0, plugin_dir_layout_js_1.pluginDirLayoutIssues)((0, node_path_1.resolve)(dir, (0, node_path_1.dirname)(lay.manifestPath)),
272
- // The hooks dir is a misplaceable functional surface too, but it lives in
273
- // the layout as a convention PATH (`hooks/hooks.json`), not in surfaceDirs
274
- // — derive its first segment and dedupe so the detector watches it as well.
275
- [...new Set([...lay.surfaceDirs, lay.hooksConventionPath.split("/")[0]])], { existsSync: node_fs_1.existsSync, isDirectory: nodeIsDirectory }),
287
+ // 🔴 THE HOOK SCRIPTS DIR IS NAMED, NOT DERIVED FROM A FILE PATH. This
288
+ // used to be `lay.hooksConventionPath.split("/")[0]`, copied here and into
289
+ // the twin: it reads `hooks` from `hooks/hooks.json`, but `.codex` from
290
+ // `.codex/hooks.json` — a REGISTRATION directory, not a scripts one, so
291
+ // for Codex the detector was watching the wrong directory entirely. The
292
+ // layout names it (`hookScriptsDir`) and `executableSourceDirs` joins it
293
+ // to the surfaces.
294
+ (0, layout_js_1.executableSourceDirs)(lay), { existsSync: node_fs_1.existsSync, isDirectory: nodeIsDirectory }),
276
295
  delegationTrifecta: (0, scan_core_js_1.collectDelegationTrifecta)(agents, dialect),
277
296
  hookBlockFindings: (0, event_capability_js_1.blockIneffectiveEventsOf)(dialect).length > 0
278
297
  ? (0, hook_block_ineffective_js_1.hookBlockIssues)((0, scan_core_js_1.collectHookBlockEntries)(hookRegs, (0, node_path_1.resolve)(dir), lay.pluginRootToken, node_fs_1.existsSync), {
@@ -302,7 +321,7 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
302
321
  // meaningful sum across two; reporting the one that owns the file that
303
322
  // exists is the only reading that is true of something.
304
323
  instructionWeight: instructionHarness.dialect.instructionBudget
305
- ? (0, instruction_weight_js_1.weighInstructions)(readAlwaysLoaded(dir, instructionHarness.dialect.instructionBudget), instructionHarness.dialect.instructionBudget)
324
+ ? (0, instruction_weight_js_1.weighInstructions)(instructionHarness.layout.instructionChain(instructionFiles), instructionFiles, instructionHarness.dialect.instructionBudget)
306
325
  : null,
307
326
  untested: coverage.untested.length,
308
327
  untestedHarness: coverage.harness.untested.length,
@@ -351,7 +370,7 @@ function formatMcpContractReport(errors) {
351
370
  * marketplace. The source of truth behind {@link expandMarketplace} and the
352
371
  * curated-marketplace report in `vigiles audit`.
353
372
  */
354
- function inspectMarketplace(dir, layout = layout_js_1.claudeCodeLayout) {
373
+ function inspectMarketplace(dir, layout) {
355
374
  const mpPath = (0, node_path_1.join)(dir, (0, node_path_1.dirname)(layout.manifestPath), "marketplace.json");
356
375
  if (!(0, node_fs_1.existsSync)(mpPath))
357
376
  return null;
@@ -404,7 +423,7 @@ function inspectMarketplace(dir, layout = layout_js_1.claudeCodeLayout) {
404
423
  * on disk). Used by `vigiles audit` to rank a whole marketplace — wshobson/agents
405
424
  * alone ships 80+ plugins under one `marketplace.json`. See {@link inspectMarketplace}.
406
425
  */
407
- function expandMarketplace(dir, layout = layout_js_1.claudeCodeLayout) {
426
+ function expandMarketplace(dir, layout) {
408
427
  const mp = inspectMarketplace(dir, layout);
409
428
  return mp ? [...mp.onDisk] : null;
410
429
  }
@@ -497,63 +516,6 @@ function agentLines(a) {
497
516
  return lines;
498
517
  }
499
518
  /** Format a scan report as human-readable text. */
500
- /**
501
- * Read every unconditionally-loaded instruction file off disk.
502
- *
503
- * Separate from `loadPlugin` on purpose: that materializes the harness's
504
- * SURFACES (skills, agents, hooks), while this reads what the harness loads
505
- * before any surface is involved — including `.claude/rules/**`, which is
506
- * exactly the directory a repo relocates into when it wants the root file to
507
- * look smaller.
508
- */
509
- function readAlwaysLoaded(dir, budget) {
510
- const out = {};
511
- const walk = (rel) => {
512
- const abs = (0, node_path_1.join)(dir, rel);
513
- if (!(0, node_fs_1.existsSync)(abs) || !(0, node_fs_1.statSync)(abs).isDirectory())
514
- return;
515
- for (const entry of (0, node_fs_1.readdirSync)(abs)) {
516
- const child = `${rel}/${entry}`;
517
- if ((0, node_fs_1.statSync)((0, node_path_1.join)(dir, child)).isDirectory())
518
- walk(child);
519
- else
520
- out[child] = (0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, child), "utf-8");
521
- }
522
- };
523
- // `**/NAME` — Codex reads nested AGENTS.md root-to-leaf and they all pay into
524
- // the SAME budget, so leaving them out under-reports in the one direction that
525
- // matters: the harness truncates silently, and an under-report reads as "you
526
- // are fine". Skipped dirs are the ones that are never the user's instructions
527
- // and would dominate the walk.
528
- const SKIP = new Set(["node_modules", ".git", "dist", "build", "vendor"]);
529
- const findNested = (name, rel = "") => {
530
- const abs = rel === "" ? dir : (0, node_path_1.join)(dir, rel);
531
- if (!(0, node_fs_1.existsSync)(abs))
532
- return;
533
- for (const entry of (0, node_fs_1.readdirSync)(abs)) {
534
- if (SKIP.has(entry) || entry.startsWith("."))
535
- continue;
536
- const child = rel === "" ? entry : `${rel}/${entry}`;
537
- const childAbs = (0, node_path_1.join)(dir, child);
538
- if ((0, node_fs_1.statSync)(childAbs).isDirectory())
539
- findNested(name, child);
540
- else if (entry === name && out[child] === undefined)
541
- out[child] = (0, node_fs_1.readFileSync)(childAbs, "utf-8");
542
- }
543
- };
544
- for (const glob of budget.alwaysLoaded) {
545
- if (!glob.includes("*")) {
546
- const abs = (0, node_path_1.join)(dir, glob);
547
- if ((0, node_fs_1.existsSync)(abs) && (0, node_fs_1.statSync)(abs).isFile())
548
- out[glob] = (0, node_fs_1.readFileSync)(abs, "utf-8");
549
- }
550
- else if (glob.endsWith("/**"))
551
- walk(glob.slice(0, -3));
552
- else if (glob.startsWith("**/"))
553
- findNested(glob.slice(3));
554
- }
555
- return out;
556
- }
557
519
  /**
558
520
  * The weight report. States the SUM first and the per-file breakdown second,
559
521
  * because the sum is the number a reader can act on and the breakdown is only
@@ -565,20 +527,105 @@ function readAlwaysLoaded(dir, budget) {
565
527
  */
566
528
  function instructionWeightLines(w) {
567
529
  const g = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
568
- const head = `Always-loaded instructions: ${g(w.total)} ${w.unit} (budget ${g(w.limit)})`;
530
+ /** One breakdown row, with its provenance when it did not get in by location. */
531
+ const fileLine = (f) => ` ${g(f.size).padStart(9)} ${f.path}` +
532
+ (f.via === undefined ? "" : ` via ${f.via.token} in ${f.via.from}`);
533
+ const head = `Always-loaded instructions: ${g(w.committedTotal)} ${w.unit} (budget ${g(w.limit)})`;
534
+ // 🔴 TWO NUMBERS, AND ONLY THE FIRST IS JUDGED. The committed total is what a
535
+ // teammate or CI loads from this commit; the effective total is what THIS
536
+ // working copy loads, per-machine files included. Scoring the second would
537
+ // make a published grade depend on a gitignored file and put the CLI and the
538
+ // browser engine — which reads a GitHub tree and can never see one —
539
+ // permanently out of agreement. Printing only the first would hide bytes the
540
+ // author is really paying for. See `core/instruction-chain.ts`.
541
+ const local = w.effectiveTotal - w.committedTotal;
542
+ // 🔴 A REDIRECT IS A FINDING, NOT A SIZE. A `CLAUDE.md` holding nothing but
543
+ // `@AGENTS.md` is fourteen bytes, and the repository it describes loads tens
544
+ // of kilobytes; printing the fourteen would be a confident wrong answer. The
545
+ // line says what the file IS and what it points at, above the number.
546
+ const redirectLines = w.redirects.map((r) => ` ↪ ${r.path} is a REDIRECT — its entire content is import(s): ${r.to.join(", ")}`);
547
+ // 🔴 AN IMPORTED FILE IS NAMED WHETHER OR NOT WE ARE OVER BUDGET. The
548
+ // breakdown below only prints when over, and an import is exactly the entry a
549
+ // reader cannot account for — `AGENTS.md` in a Claude Code weight reads as a
550
+ // bug until the line says which file asked for it.
551
+ const importedLines = w.overBy === null
552
+ ? w.files.filter((f) => f.via !== undefined).map(fileLine)
553
+ : [];
554
+ // 🔴 THE DELTA HAS A SIGN NOW, AND SUPPRESSING THE NEGATIVE ONE WAS THE WORST
555
+ // OF THE THREE OPTIONS. `local > 0` was written when a per-machine file could
556
+ // only ADD bytes; the supersede rule makes it subtract, and the model already
557
+ // reports that (`supersededLocallyBy`, `effectiveTotal` below `committedTotal`
558
+ // — see `core/instruction-weight.ts`). The printer was the last reader still
559
+ // assuming one direction, so the header announced the larger COMMITTED number
560
+ // as what is always loaded and nothing said this working copy loads a
561
+ // different, smaller chain. That is a confident over-report — the same
562
+ // undecomposable total the breakdown exists to prevent, pointing the other
563
+ // way. Both signs print; only exactly zero stays silent, because there is
564
+ // nothing to decompose.
565
+ const removedHere = w.files.filter((f) => f.notLoadedHere !== undefined);
566
+ const deltaLine = () => {
567
+ const here = `${g(w.effectiveTotal)} in this working copy, not scored`;
568
+ if (local > 0) {
569
+ return ` + ${g(local)} ${w.unit} from per-machine file(s) — ${here}`;
570
+ }
571
+ // NAMED, not just signed. A reader meeting "−93" has to be told which
572
+ // committed file stopped being loaded and what silenced it, or the number
573
+ // is an accusation with no defendant.
574
+ // The VERB comes off the entry, because the two doors to this state read
575
+ // very differently to someone deciding what to do about it: a superseding
576
+ // file is one they wrote, an exclusion is a pattern they set.
577
+ const by = removedHere
578
+ .map((f) => `${f.path} (${f.notLoadedHere?.why === "excluded" ? "excluded" : "silenced"} by ${String(f.notLoadedHere?.by)})`)
579
+ .join(", ");
580
+ return (` − ${g(-local)} ${w.unit}: a per-machine file REMOVES committed instruction(s) — ${here}` +
581
+ (by === "" ? "" : `\n not loaded here: ${by}`));
582
+ };
583
+ const tail = [
584
+ ...(local === 0 ? [] : [deltaLine()]),
585
+ // Named rather than silently dropped: a number that omits a file it knows
586
+ // about is the under-report this whole report exists to prevent.
587
+ ...(w.unreadImports.length > 0
588
+ ? [
589
+ ` + ${String(w.unreadImports.length)} named import(s) not read: ${w.unreadImports.join(", ")}`,
590
+ ]
591
+ : []),
592
+ ...(w.unweighedPatterns.length > 0
593
+ ? [
594
+ ` + ${String(w.unweighedPatterns.length)} pattern(s) not weighed: ${w.unweighedPatterns.join(", ")}`,
595
+ ]
596
+ : []),
597
+ // 🔴 THE NUMBER IS A FLOOR, AND SAYING SO IS THE WHOLE FIX. Both vendors
598
+ // load instruction files from OUTSIDE the repository ALONGSIDE the ones
599
+ // counted here — a home-directory file and an organization's managed one.
600
+ // The Claude Code page is explicit that those "don't count, and keep
601
+ // loading alongside `AGENTS.md`": they are ADDED to this sum in a real
602
+ // session, they are never subtracted from it.
603
+ //
604
+ // READING THEM WOULD BE THE WRONG FIX, not a better one. A grade that
605
+ // reached into `~` would depend on whose machine ran it, and the browser
606
+ // twin — which reads a GitHub tree — could never reproduce it; that is the
607
+ // same argument `scope: "local"` rests on, one directory further out. So
608
+ // the fix is this line: a total that silently omits files it KNOWS exist is
609
+ // the undecomposable number this whole report exists to prevent, and one
610
+ // line costs nothing and cannot be wrong. Unconditional, because the
611
+ // omission does not depend on anything in the repository.
612
+ ` ⌊ a FLOOR: a home-directory or organization-managed instruction file loads on top of this and is outside a repository audit`,
613
+ ];
569
614
  if (w.overBy === null)
570
- return [head];
571
- const factor = (w.total / w.limit).toFixed(1);
615
+ return [head, ...redirectLines, ...importedLines, ...tail];
616
+ const factor = (w.committedTotal / w.limit).toFixed(1);
572
617
  const consequence = w.onExceed === "truncates"
573
618
  ? "past the budget is SILENTLY TRUNCATED — those rules never reach the model"
574
619
  : "the harness warns; the rules still reach the model, you pay for them every request";
575
620
  return [
576
621
  `${head} — ${factor}x OVER by ${g(w.overBy)} ${w.unit}`,
577
622
  ` ${consequence}`,
578
- ...w.files.slice(0, 5).map((f) => ` ${g(f.size).padStart(9)} ${f.path}`),
623
+ ...redirectLines,
624
+ ...w.files.slice(0, 5).map(fileLine),
579
625
  ...(w.files.length > 5
580
626
  ? [` …and ${String(w.files.length - 5)} more`]
581
627
  : []),
628
+ ...tail,
582
629
  ];
583
630
  }
584
631
  function formatScanReport(r) {
@@ -14,12 +14,28 @@
14
14
  * about another tool's parser tolerance.
15
15
  */
16
16
  import type { SkillSpec } from "./core/spec.js";
17
- /** The Claude-Code-only frontmatter keys a skill spec would emit. */
18
- export declare function claudeOnlyFrontmatterKeys(spec: SkillSpec): string[];
19
17
  /**
20
- * Warn for each declared harness whose `minimal` SKILL.md profile would DROP a
21
- * skill's Claude-Code-only frontmatter. Empty when the skill uses no such keys or
22
- * no declared harness is minimal-profile.
18
+ * The optional frontmatter keys a skill spec would emit that this check ranges
19
+ * over. `name` and `description` are excluded because conformance requires
20
+ * every dialect to read them, so they can never be dropped.
21
+ *
22
+ * ⚠️ NOT the whole optional set: `context`, `allowed-tools` and
23
+ * `disallowed-tools` are also droppable and are not listed here. That is the
24
+ * pre-existing scope of this warning, carried over unchanged when the profile
25
+ * enum became a key set — widening it is a behaviour change to argue
26
+ * separately, not a side effect of the rename.
27
+ */
28
+ export declare function optionalFrontmatterKeys(spec: SkillSpec): string[];
29
+ /**
30
+ * Warn for each declared harness that would DROP frontmatter keys this skill
31
+ * sets, because its dialect does not read them. Empty when the skill uses no
32
+ * optional keys, or when every declared harness reads the ones it uses.
33
+ *
34
+ * 🔴 THE KEYS THE WARNING NAMES ARE NOW THE KEYS ACTUALLY DROPPED. It used to
35
+ * test `dialect.skillFrontmatter === "minimal"` and then print every
36
+ * Claude-Code-only key the spec set — one bucket for every non-CC harness, and
37
+ * a message that could name a key the harness in fact reads. The set difference
38
+ * is both the condition and the message, so the two cannot disagree.
23
39
  */
24
40
  export declare function skillFrontmatterDropWarnings(spec: SkillSpec, harnessNames: readonly string[]): string[];
25
41
  //# sourceMappingURL=skill-harness.d.ts.map
@@ -1,10 +1,20 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.claudeOnlyFrontmatterKeys = claudeOnlyFrontmatterKeys;
3
+ exports.optionalFrontmatterKeys = optionalFrontmatterKeys;
4
4
  exports.skillFrontmatterDropWarnings = skillFrontmatterDropWarnings;
5
5
  const adapter_registry_js_1 = require("./adapter-registry.js");
6
- /** The Claude-Code-only frontmatter keys a skill spec would emit. */
7
- function claudeOnlyFrontmatterKeys(spec) {
6
+ /**
7
+ * The optional frontmatter keys a skill spec would emit that this check ranges
8
+ * over. `name` and `description` are excluded because conformance requires
9
+ * every dialect to read them, so they can never be dropped.
10
+ *
11
+ * ⚠️ NOT the whole optional set: `context`, `allowed-tools` and
12
+ * `disallowed-tools` are also droppable and are not listed here. That is the
13
+ * pre-existing scope of this warning, carried over unchanged when the profile
14
+ * enum became a key set — widening it is a behaviour change to argue
15
+ * separately, not a side effect of the rename.
16
+ */
17
+ function optionalFrontmatterKeys(spec) {
8
18
  const keys = [];
9
19
  if (spec.disableModelInvocation !== undefined) {
10
20
  keys.push("disable-model-invocation");
@@ -15,13 +25,19 @@ function claudeOnlyFrontmatterKeys(spec) {
15
25
  return keys;
16
26
  }
17
27
  /**
18
- * Warn for each declared harness whose `minimal` SKILL.md profile would DROP a
19
- * skill's Claude-Code-only frontmatter. Empty when the skill uses no such keys or
20
- * no declared harness is minimal-profile.
28
+ * Warn for each declared harness that would DROP frontmatter keys this skill
29
+ * sets, because its dialect does not read them. Empty when the skill uses no
30
+ * optional keys, or when every declared harness reads the ones it uses.
31
+ *
32
+ * 🔴 THE KEYS THE WARNING NAMES ARE NOW THE KEYS ACTUALLY DROPPED. It used to
33
+ * test `dialect.skillFrontmatter === "minimal"` and then print every
34
+ * Claude-Code-only key the spec set — one bucket for every non-CC harness, and
35
+ * a message that could name a key the harness in fact reads. The set difference
36
+ * is both the condition and the message, so the two cannot disagree.
21
37
  */
22
38
  function skillFrontmatterDropWarnings(spec, harnessNames) {
23
- const ccKeys = claudeOnlyFrontmatterKeys(spec);
24
- if (ccKeys.length === 0)
39
+ const optionalKeys = optionalFrontmatterKeys(spec);
40
+ if (optionalKeys.length === 0)
25
41
  return [];
26
42
  const warnings = [];
27
43
  const seen = new Set();
@@ -30,9 +46,11 @@ function skillFrontmatterDropWarnings(spec, harnessNames) {
30
46
  if (!adapter || seen.has(adapter.name))
31
47
  continue;
32
48
  seen.add(adapter.name);
33
- if (adapter.dialect.skillFrontmatter === "minimal") {
34
- const one = ccKeys.length === 1;
35
- warnings.push(`skill "${spec.name}": ${ccKeys.join(", ")} ${one ? "is" : "are"} Claude-Code-only — declared harness "${adapter.name}" drops ${one ? "it" : "them"}.`);
49
+ const read = adapter.dialect.skillFrontmatterKeys;
50
+ const dropped = optionalKeys.filter((key) => !read.includes(key));
51
+ if (dropped.length > 0) {
52
+ const one = dropped.length === 1;
53
+ warnings.push(`skill "${spec.name}": ${dropped.join(", ")} ${one ? "is" : "are"} not read by declared harness "${adapter.name}" — it drops ${one ? "it" : "them"}.`);
36
54
  }
37
55
  }
38
56
  return warnings;
@@ -1,3 +1,4 @@
1
+ import type { PluginLayout } from "./core/layout.js";
1
2
  import { type DiscoveredSurface } from "./core/surface-discovery.js";
2
3
  import { type ExcludeSet } from "./exclude.js";
3
4
  /**
@@ -9,4 +10,5 @@ import { type ExcludeSet } from "./exclude.js";
9
10
  export declare function boundedSurfacePaths(root: string, excludes?: ExcludeSet): readonly string[];
10
11
  /** Every surface the bounded walk finds on disk under `root`, by shape. */
11
12
  export declare function discoverSurfacesOnDisk(root: string, excludes?: ExcludeSet): readonly DiscoveredSurface[];
13
+ export declare function boundedInstructionFiles(root: string, layout: PluginLayout, excludes?: ExcludeSet): Record<string, string>;
12
14
  //# sourceMappingURL=surface-discovery-fs.d.ts.map
@@ -2,14 +2,21 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.boundedSurfacePaths = boundedSurfacePaths;
4
4
  exports.discoverSurfacesOnDisk = discoverSurfacesOnDisk;
5
+ exports.boundedInstructionFiles = boundedInstructionFiles;
5
6
  /**
6
- * The DISK half of surface discovery: enumerate the repo-relative paths inside
7
- * the bounded root set, so the pure classifier in `src/core/surface-discovery.ts`
8
- * can say which of them are surfaces.
7
+ * The DISK half of BOUNDED DISCOVERY: enumerate the repo-relative paths inside
8
+ * the bounded root set, so the pure classifiers in `src/core/surface-discovery.ts`
9
+ * and `src/core/instruction-chain.ts` can say what each of them is.
9
10
  *
10
- * Paths only — never contents. Discovery answers "is there a surface here that
11
- * nobody reads", which is a question about NAMES; reading a file nobody claims
12
- * would be doing the very work the finding says is not being done.
11
+ * Two enumerations, one bound. {@link boundedSurfacePaths} answers "is there a
12
+ * surface here that nobody reads" and returns PATHS ONLY — that is a question
13
+ * about NAMES, and reading a file nobody claims would be doing the very work the
14
+ * finding says is not being done. {@link boundedInstructionFiles} answers "what
15
+ * does this harness load without being asked" and must return CONTENTS, because
16
+ * the answer depends on them: a rule's own frontmatter decides whether it loads
17
+ * at launch, and the repository's settings decide which files are candidates at
18
+ * all. The header of `core/instruction-chain.ts` holds the reason that is a port
19
+ * method rather than a glob.
13
20
  *
14
21
  * 🔴 THE WALK IS BOUNDED BY CONSTRUCTION, NOT BY A DEPTH COUNTER. One `readdir`
15
22
  * of the repo root, one per DOT-DIRECTORY found there, and then descent ONLY
@@ -31,6 +38,8 @@ exports.discoverSurfacesOnDisk = discoverSurfacesOnDisk;
31
38
  */
32
39
  const node_fs_1 = require("node:fs");
33
40
  const node_path_1 = require("node:path");
41
+ const instruction_chain_js_1 = require("./core/instruction-chain.js");
42
+ const layout_js_1 = require("./core/layout.js");
34
43
  const surface_discovery_js_1 = require("./core/surface-discovery.js");
35
44
  const exclude_js_1 = require("./exclude.js");
36
45
  const fs_walk_js_1 = require("./fs-walk.js");
@@ -105,4 +114,97 @@ function openableSurfaceDir(root, rel, excluded) {
105
114
  function discoverSurfacesOnDisk(root, excludes) {
106
115
  return (0, surface_discovery_js_1.discoverSurfaces)(boundedSurfacePaths(root, excludes));
107
116
  }
117
+ /** Read one repo-relative file, or `undefined` for anything that is not one. */
118
+ function readIfFile(root, rel) {
119
+ const abs = (0, node_path_1.join)(root, rel);
120
+ if ((0, fs_walk_js_1.entryOf)(abs).kind !== "file")
121
+ return undefined;
122
+ try {
123
+ return (0, node_fs_1.readFileSync)(abs, "utf-8");
124
+ }
125
+ catch {
126
+ return undefined; // unreadable: the same silence the walk gives elsewhere
127
+ }
128
+ }
129
+ /** Repo-relative paths of the files directly inside one discovery root. */
130
+ function filesDirectlyIn(root, base) {
131
+ const baseAbs = base === "" ? root : (0, node_path_1.join)(root, base);
132
+ return namesIn(baseAbs).map((n) => (base === "" ? n : `${base}/${n}`));
133
+ }
134
+ /**
135
+ * Every instruction CANDIDATE on disk, with its contents — the input a harness's
136
+ * `instructionChain` is allowed to classify.
137
+ *
138
+ * 🔴 BOUNDED BY THE SAME CONSTRUCTION AS THE SURFACE WALK, and for the same
139
+ * reason. One `readdir` of the repo root, one per dot-directory found there, and
140
+ * a recursive descent ONLY into a `rules` directory inside one of those. `src/`,
141
+ * `packages/` and `node_modules/` are never entered — which is exactly what the
142
+ * thing this replaced did do: `scan.ts:readAlwaysLoaded` expanded an ADAPTER's
143
+ * `"**\/AGENTS.md"` by recursing through the whole tree, so registering an
144
+ * adapter widened what vigiles read in everyone's repository.
145
+ *
146
+ * The one read outside that bound is the IMPORT PASS below, and the difference
147
+ * is who chose the path.
148
+ */
149
+ /**
150
+ * The `rules` tree under one dot-directory, read RECURSIVELY.
151
+ *
152
+ * Recursive because the vendor documents it that way — and because the scan
153
+ * classifier now says the same through `RULE_FILE_LEAF_RE`; a rule the loader
154
+ * reads and the classifier ignores is a file that is never checked, counted or
155
+ * weighed. The entry point goes through the same symlink and exclude policy as
156
+ * a surface dir, `filesUnder` applies `exclude` per entry below it.
157
+ */
158
+ function addRulesTree(root, rulesRel, excluded, out) {
159
+ if (rulesRel === null)
160
+ return;
161
+ // The same entry question `openableSurfaceDir` answers for a surface dir, so
162
+ // it is asked through that function rather than spelled a second time. The
163
+ // second spelling was `kind !== "dir"`, and `entryOf` says "skip" for a
164
+ // symlinked directory on purpose — so a `.claude/rules` linked to a shared
165
+ // policy directory was dropped whole, while the same link as `.claude/skills`
166
+ // was walked.
167
+ if (!openableSurfaceDir(root, rulesRel, excluded))
168
+ return;
169
+ filesUnder(root, rulesRel, excluded, out);
170
+ }
171
+ function boundedInstructionFiles(root, layout, excludes) {
172
+ const excluded = (0, exclude_js_1.excludedBy)(excludes);
173
+ const roots = ["", ...namesIn(root).filter((n) => (0, surface_discovery_js_1.isDiscoveryRoot)(n))].filter((b) => b === "" || !excluded((0, node_path_1.join)(root, b)));
174
+ // Every file directly inside a discovery root is a candidate PATH; the pure
175
+ // filter keeps the instruction-shaped ones plus the layout's own named files
176
+ // (`.codex/config.toml` is neither markdown nor an instruction — it is the
177
+ // settings source that decides WHICH files load, and is never weighed).
178
+ const candidates = [];
179
+ for (const base of roots) {
180
+ candidates.push(...filesDirectlyIn(root, base));
181
+ }
182
+ // 🔴 THE RULES TREE HAS ONE HOME, AND THE LAYOUT NAMES IT (#271).
183
+ //
184
+ // This used to run INSIDE the loop above, joining `<base>/<rulesDir>` for
185
+ // every discovery root and guarding the repository root out with
186
+ // `if (base !== "")`. Two wrong answers fell out of that, measured on layouts
187
+ // built from the type: a layout declaring `rulesDir` with no
188
+ // `userSurfaceRoot` had its tree skipped entirely, while `.github/rules` was
189
+ // walked for every layout, declared or not. `rulesHome` is the same answer
190
+ // `ruleFileRe` gives the classifier, so the walk and the filter can no longer
191
+ // disagree about which tree belongs to this harness.
192
+ addRulesTree(root, (0, layout_js_1.rulesHome)(layout), excluded, candidates);
193
+ const candidateFiles = {};
194
+ for (const rel of (0, instruction_chain_js_1.instructionCandidatePaths)(candidates, layout)) {
195
+ if (excluded((0, node_path_1.join)(root, rel)))
196
+ continue;
197
+ const text = readIfFile(root, rel);
198
+ if (text !== undefined)
199
+ candidateFiles[rel] = text;
200
+ }
201
+ // The import pass is the ONE read outside the bound, and the pure half of it
202
+ // lives in the core so the browser twin runs the identical loop over its file
203
+ // map. It is ONE concrete path per token, one level deep — the corpus
204
+ // measurement behind that is in `resolveImports`. `exclude` is deliberately
205
+ // NOT applied to it: the path was written by the repository owner in their own
206
+ // instruction file, and the harness really does load it, so hiding its size
207
+ // would under-report the one number this report exists to give.
208
+ return (0, instruction_chain_js_1.resolveImports)(layout, candidateFiles, (rel) => readIfFile(root, rel));
209
+ }
108
210
  //# sourceMappingURL=surface-discovery-fs.js.map