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
@@ -0,0 +1,292 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.INSTRUCTION_SHAPES = exports.EMPTY_CHAIN = void 0;
4
+ exports.siblingNamed = siblingNamed;
5
+ exports.settingsSources = settingsSources;
6
+ exports.settingsSourcePaths = settingsSourcePaths;
7
+ exports.isInstructionShaped = isInstructionShaped;
8
+ exports.instructionCandidatePaths = instructionCandidatePaths;
9
+ exports.isRepoRootedImport = isRepoRootedImport;
10
+ exports.resolveImportPath = resolveImportPath;
11
+ exports.resolveImports = resolveImports;
12
+ const layout_js_1 = require("./layout.js");
13
+ /** An empty chain — the answer for a harness with no instruction surface. */
14
+ exports.EMPTY_CHAIN = {
15
+ loaded: [],
16
+ unloaded: [],
17
+ imports: [],
18
+ patterns: [],
19
+ redirects: [],
20
+ };
21
+ /**
22
+ * The SHAPE of an instruction candidate — the domain's bound, stated the same
23
+ * way `SURFACE_SHAPES` states the surface one, and for the same reason: if an
24
+ * adapter could add a root we would be back to "registering an adapter widens
25
+ * the read in everyone's repository".
26
+ *
27
+ * These are CROSS-VENDOR shapes, not one harness's paths. `rules` is the name
28
+ * Claude Code (`.claude/rules`), Cursor (`.cursor/rules`) and Windsurf
29
+ * (`.windsurf/rules`) all use; the dot-directory is the variable, the shape name
30
+ * is not. Nothing here spells a harness's own directory.
31
+ *
32
+ * ⚠️ WHAT THIS CANNOT SEE, stated rather than assumed: a nested
33
+ * `packages/x/AGENTS.md` (neither vendor loads it at a root session — the chain
34
+ * classifies one as on-demand if it is handed one, and never goes looking);
35
+ * `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` and every other home-directory
36
+ * file (reading `~` for a grade is wrong on its face); and a repo-configured
37
+ * fallback instruction name that is not markdown, because the root entry below
38
+ * is bounded to `.md` rather than to "every file in the repo root". That last
39
+ * one under-reports, which is the wrong direction, and it is the price of not
40
+ * reading a lockfile to grade a harness.
41
+ */
42
+ exports.INSTRUCTION_SHAPES = [
43
+ { what: "root markdown", re: /^[^/]+\.md$/ },
44
+ { what: "dot-directory markdown", re: /^\.[^/]+\/[^/]+\.md$/ },
45
+ ];
46
+ /**
47
+ * 🔴 THE RULES TREE IS NOT IN THE TABLE ABOVE, and that is the fix for #271.
48
+ *
49
+ * It used to be, as `/^\.[^/]+\/rules\/…/` — a shape that spelled the word
50
+ * `rules` itself and demanded a leading dot. Measured against three layouts
51
+ * built from the TYPE rather than taken from the registry, it answered the same
52
+ * thing for all of them:
53
+ *
54
+ * rulesDir "rules", no userSurfaceRoot -> `rules/a.md` NOT a candidate
55
+ * rulesDir "guidelines" under `.x` -> `.x/guidelines/a.md` NOT a candidate
56
+ * no rulesDir at all -> `.github/rules/a.md` IS a candidate
57
+ *
58
+ * Three wrong answers of two kinds: a home the layout DECLARED going unread,
59
+ * and somebody else's tree being read for a layout that declared none. The
60
+ * second is the same defect `ruleFileRe` was created for one commit earlier —
61
+ * fixed there for the CLASSIFIER and left standing here in the BOUND, which is
62
+ * exactly the "two readers, one fact" split this port exists to remove.
63
+ *
64
+ * So the bound asks the layout, through the same function the classifier uses.
65
+ * This does not let an adapter widen the bound: `rulesDir` is a directory NAME,
66
+ * the regexp is anchored at the repository root, and a layout that declares no
67
+ * rules home gets `null` and matches nothing.
68
+ */
69
+ function isDeclaredRuleFile(path, layout) {
70
+ const re = (0, layout_js_1.ruleFileRe)(layout);
71
+ return re !== null && re.test(path);
72
+ }
73
+ /**
74
+ * The files that carry SETTINGS for one layout — the parse target and the
75
+ * per-machine override beside it.
76
+ *
77
+ * 🔴 A SETTINGS SOURCE IS NOT AN INSTRUCTION, and keeping the two roles apart
78
+ * is the whole point of this function having its own name. These files are
79
+ * handed to `instructionChain` and are never weighed, never appear in `loaded`
80
+ * and never get a `role`: they are not read TO the model, they decide WHICH
81
+ * files are. Claude Code's `claudeMdExcludes` and Codex's
82
+ * `project_doc_fallback_filenames` both live in one, which is why they are in
83
+ * the bound at all.
84
+ *
85
+ * The `.local` sibling is DERIVED from `settingsPath` rather than listed,
86
+ * because a second list is the defect this whole redesign removes. It is
87
+ * advisory for the same reason `scope: "local"` is: it is gitignored by
88
+ * convention, so the browser twin can never see it, and anything that DEPENDED
89
+ * on it would make the two engines disagree. It can only narrow what loads.
90
+ */
91
+ /**
92
+ * `AGENTS.md` + `override` → `AGENTS.override.md`; `settings.json` + `local` →
93
+ * `settings.local.json`. The ONE place the "sibling file" spelling lives.
94
+ *
95
+ * Both vendors name a per-machine file by inserting a word before the
96
+ * extension, and they choose DIFFERENT words — Claude Code `local`, Codex
97
+ * `override` — so the word is the argument and the spelling is not. A file with
98
+ * no extension gets the word appended, which is the only reading that does not
99
+ * invent a dot.
100
+ */
101
+ function siblingNamed(path, infix) {
102
+ const dot = path.lastIndexOf(".");
103
+ const slash = path.lastIndexOf("/");
104
+ return dot > slash && dot > slash + 1
105
+ ? `${path.slice(0, dot)}.${infix}${path.slice(dot)}`
106
+ : `${path}.${infix}`;
107
+ }
108
+ /**
109
+ * The settings files in precedence order, each carrying its SCOPE.
110
+ *
111
+ * The scope is the whole point: a pattern read out of a gitignored sibling may
112
+ * not change what a teammate on this commit is scored for. Callers that only
113
+ * need the paths use {@link settingsSourcePaths}, which is this list flattened.
114
+ */
115
+ function settingsSources(layout) {
116
+ const infix = layout.settingsLocalInfix;
117
+ const committed = {
118
+ path: layout.settingsPath,
119
+ scope: "repo",
120
+ };
121
+ return infix === undefined
122
+ ? [committed]
123
+ : [
124
+ committed,
125
+ { path: siblingNamed(layout.settingsPath, infix), scope: "local" },
126
+ ];
127
+ }
128
+ function settingsSourcePaths(layout) {
129
+ // DECLARED, not derived — see `PluginLayout.settingsLocalInfix` for the
130
+ // measurement. A harness that names no infix has no per-machine settings
131
+ // layer, and inventing one for it manufactures a file to read.
132
+ return settingsSources(layout).map((s) => s.path);
133
+ }
134
+ /** Does this repo-relative path match one of the {@link INSTRUCTION_SHAPES}? */
135
+ function isInstructionShaped(path) {
136
+ return exports.INSTRUCTION_SHAPES.some((s) => s.re.test(path));
137
+ }
138
+ /**
139
+ * The bounded candidate set: every path a harness may be ASKED about.
140
+ *
141
+ * Pure and storage-blind on purpose, exactly as `discoverSurfaces` is — the
142
+ * disk walk (`src/surface-discovery-fs.ts`) and the browser file-map twin
143
+ * (`src/scan-files.ts`) each enumerate from their own storage and call THIS for
144
+ * the decision, so the pair cannot disagree about what is a candidate.
145
+ */
146
+ function instructionCandidatePaths(paths, layout) {
147
+ const named = new Set([
148
+ layout.instructionFile,
149
+ ...settingsSourcePaths(layout),
150
+ ]);
151
+ return paths.filter((p) => named.has(p) || isInstructionShaped(p) || isDeclaredRuleFile(p, layout));
152
+ }
153
+ /**
154
+ * Is `token` a path this repo could hold, and safe to resolve against its root?
155
+ *
156
+ * Refuses an absolute path, a `~` home reference, and anything with a `..`
157
+ * segment — an instruction file that points outside the repository is not a
158
+ * fact about the repository, and following it would let a file decide what
159
+ * vigiles opens on the machine running it.
160
+ */
161
+ function isRepoRootedImport(token) {
162
+ if (token === "" || token.startsWith("/") || token.startsWith("~")) {
163
+ return false;
164
+ }
165
+ if (/^[A-Za-z]:/.test(token) || token.includes("\\"))
166
+ return false;
167
+ return !token.split("/").includes("..");
168
+ }
169
+ /**
170
+ * Where an `@import` token inside `from` actually points.
171
+ *
172
+ * 🔴 RELATIVE TO THE IMPORTING FILE, NOT TO THE REPOSITORY ROOT, and that is a
173
+ * MEASUREMENT rather than a reading of the docs — Claude Code 2.1.278, fixture
174
+ * `q3-relative` in `test/fixtures/instruction-chain-vendor/`. The case is built
175
+ * so the answer cannot be "it found nothing": `.claude/CLAUDE.md` holds
176
+ * `@notes.md` and BOTH candidates exist, each with its own codeword.
177
+ *
178
+ * recited: SAIGA-8181 (`.claude/notes.md`) not TAPIR-6262 (`notes.md`)
179
+ * hook: .claude/notes.md load_reason: include parent: .claude/CLAUDE.md
180
+ *
181
+ * Resolving against the root instead reports that file unread and charges the
182
+ * weight of a DIFFERENT file that happens to share its name — wrong in both
183
+ * directions at once, and silent.
184
+ *
185
+ * ⚠️ A TOKEN WITH `..` STAYS REFUSED even though this resolution would make
186
+ * some of them land inside the repository (`@../notes.md` from `.claude/`).
187
+ * {@link isRepoRootedImport} rejects them before this is called, and lifting
188
+ * that is a separate decision needing its own fixture: the refusal is what
189
+ * stops an instruction file deciding what vigiles opens on the machine running
190
+ * it, and "it happens to stay inside" is a property of one path, not a rule.
191
+ */
192
+ function resolveImportPath(from, token) {
193
+ const slash = from.lastIndexOf("/");
194
+ const dir = slash === -1 ? "" : from.slice(0, slash + 1);
195
+ // 🔴 EVERY `.` SEGMENT, NOT JUST THE LEADING ONE. A first fix stripped
196
+ // `^(\./)+` only, which made `@./notes.md` and `@notes.md` one key and left
197
+ // `@docs/./style.md` as `docs/./style.md`. That is invisible on disk — `join`
198
+ // normalises it for the reader — and wrong in the browser, whose file map is
199
+ // keyed `docs/style.md`: the file is reported unread and its bytes vanish
200
+ // from the weight. Two engines, one path, and only one of them normalising is
201
+ // the disagreement the shared candidate set exists to prevent.
202
+ //
203
+ // `..` is NOT handled here and must not be: `isRepoRootedImport` refuses those
204
+ // tokens before this is called, and collapsing one would quietly turn a
205
+ // refused path into an accepted one.
206
+ return `${dir}${token}`
207
+ .split("/")
208
+ .filter((seg, i, all) => seg !== "." && (seg !== "" || i === all.length - 1))
209
+ .join("/");
210
+ }
211
+ /**
212
+ * Read the `@import` paths the loaded files NAME — ONE LEVEL, no recursion.
213
+ * Returns a NEW map: the candidates handed in, plus whatever they named.
214
+ *
215
+ * 🔴 THIS READS OUTSIDE THE DOT-DIRECTORY BOUND, DELIBERATELY, AND THE REASON IS
216
+ * WHO CHOSE THE PATH. The bound exists so that REGISTERING AN ADAPTER cannot
217
+ * widen what vigiles reads in someone else's repository. An `@import` token is a
218
+ * concrete path written by the REPOSITORY OWNER in their own instruction file;
219
+ * the adapter only finds it, and `adapter-properties.test.ts` asserts exactly
220
+ * that — every reported import literally occurs in the file that reports it.
221
+ *
222
+ * 🔴 ONE LEVEL IS A MEASUREMENT, NOT A SHORTCUT — DO NOT "IMPROVE" IT INTO A
223
+ * RECURSIVE PASS. Across a corpus of 198 real `CLAUDE.md` files scraped from
224
+ * public repositories (July sample; the grep finds `@name.md` shapes only),
225
+ * exactly SIX files carried an import at all — 3%:
226
+ *
227
+ * 4 @AGENTS.md
228
+ * 1 @docs/architecture.md
229
+ * 1 @.maister/docs/INDEX.md
230
+ *
231
+ * Every one is a single concrete path at depth 1. Nothing in that corpus needs
232
+ * recursion, a depth budget or an exclude pass, and a recursive walk driven by
233
+ * strings found in files is the exact defect zernie/vigiles#262 is about.
234
+ *
235
+ * 🔴 AND THE SAME IS NOW MEASURED FOR `AGENTS.md`, WHICH USED TO BE THE HOLE IN
236
+ * THIS BOUND. The corpus above is `CLAUDE.md` BY CONSTRUCTION, so it said
237
+ * nothing about the family Claude Code reads natively since v2.1.277 — and a
238
+ * one-level bound justified by a corpus that could not contain the file is not
239
+ * justified, it is extrapolated. Measured over the same sample, by the same
240
+ * method, carrying the same two caveats (July sample of public repositories;
241
+ * the grep finds `@name.md` shapes only): 214 real `AGENTS.md` files, THREE
242
+ * carry an import at all — 1.4%:
243
+ *
244
+ * 1 @tasks/BASED.md
245
+ * 1 @ai-rules/rule-loading.md
246
+ * 1 @AGENTS.local.md
247
+ *
248
+ * Every one is a single concrete path at depth 1 — the same SHAPE and the same
249
+ * RARITY as the six on the `CLAUDE.md` side (3%). So one level is measured on
250
+ * both families rather than assumed to carry over from one.
251
+ *
252
+ * ⏳ THE THIRD OF THOSE THREE IS NOT AN ORDINARY IMPORT, and it is an OPEN
253
+ * QUESTION rather than a decided one: `AGENTS.local.md` is a name Claude Code
254
+ * lists under "Not read", so an explicit `@` token names a file the loader may
255
+ * never open. Both readings and the observation that settles them are at
256
+ * `isNeverRead` in `adapters/claude-code/instruction-chain.ts` — one harness's
257
+ * list belongs in one harness's adapter, not in the domain.
258
+ *
259
+ * ⚠️ AND THE VENDOR PUTS A NUMBER ON THE THING THIS BOUND APPROXIMATES, which
260
+ * the measurement above does not repeal: "Imported files can recursively import
261
+ * other files, with a maximum depth of FOUR HOPS" (same page, read 2026-09-21).
262
+ * So one level is a bound on what this reads, chosen because neither corpus has
263
+ * a second hop — not a claim that a second hop cannot exist. A repository that
264
+ * uses them is under-reported by the nested size, and the honest form of that
265
+ * is the sentence below rather than a depth counter nothing exercises.
266
+ *
267
+ * 🔴 AND THE SIX ARE WHERE THE NUMBER IS MOST WRONG WITHOUT THIS PASS. Four of
268
+ * them are `@AGENTS.md` — the workaround for Claude Code not yet reading
269
+ * `AGENTS.md` natively (anthropics/claude-code#34235; reversed in v2.1.277, see
270
+ * `adapters/claude-code/dialect.ts`). Skipping imports would still miss that
271
+ * file's whole size in exactly those repositories, because the vendor's rule is
272
+ * that a `CLAUDE.md` SUPPRESSES `AGENTS.md` — so the import is the only way in,
273
+ * and dropping it is an under-report, which reads as "you are fine".
274
+ *
275
+ * ⚠️ WHAT ONE LEVEL COSTS, stated rather than implied: a transitive import (an
276
+ * imported file that imports again) is a real Claude Code feature, and its
277
+ * nested size is NOT counted. NEITHER corpus — 198 `CLAUDE.md`, 214
278
+ * `AGENTS.md`, 412 files, nine imports between them — holds one; if a real case
279
+ * shows up, those measurements are the thing to redo, not this loop.
280
+ */
281
+ function resolveImports(layout, files, read) {
282
+ const out = { ...files };
283
+ for (const { path } of layout.instructionChain(files).imports) {
284
+ if (out[path] !== undefined || !isRepoRootedImport(path))
285
+ continue;
286
+ const text = read(path);
287
+ if (text !== undefined)
288
+ out[path] = text;
289
+ }
290
+ return out;
291
+ }
292
+ //# sourceMappingURL=instruction-chain.js.map
@@ -35,6 +35,7 @@
35
35
  * first consumer is `audit`, as a REPORT. It earns a severity when a corpus
36
36
  * exists that it would not immediately fail.
37
37
  */
38
+ import type { InstructionChain, InstructionRole, InstructionScope } from "./instruction-chain.js";
38
39
  /** What the harness counts, and what it does when the count is exceeded. */
39
40
  export interface InstructionBudget {
40
41
  /** Claude Code counts characters; Codex counts bytes. Never tokens. */
@@ -50,17 +51,53 @@ export interface InstructionBudget {
50
51
  readonly onExceed: "warns" | "truncates";
51
52
  /** The vendor artifact this was read from, version included. */
52
53
  readonly capturedFrom: string;
53
- /**
54
- * Globs the harness loads WITHOUT the user asking — the set the SUM is taken
55
- * over. A file reachable only by an explicit read does not belong here; that
56
- * is exactly the distinction the relocation trick exploits.
57
- */
58
- readonly alwaysLoaded: readonly string[];
59
54
  }
60
55
  /** One file's contribution, so a report can say WHERE the weight is. */
61
56
  export interface WeighedFile {
62
57
  readonly path: string;
63
58
  readonly size: number;
59
+ /** What it is to the harness — a root file, a rule, an import. */
60
+ readonly role: InstructionRole;
61
+ /** `"local"` files are shown and never scored; see {@link InstructionScope}. */
62
+ readonly scope: InstructionScope;
63
+ /**
64
+ * Set when this file got into the count through an IMPORT — who named it, and
65
+ * with what text. The report prints it on the file's own line, because
66
+ * `AGENTS.md` appearing in a Claude Code weight reads as a bug until the line
67
+ * says `via @AGENTS.md in CLAUDE.md`. A total a reader cannot decompose is the
68
+ * failure this report exists to prevent.
69
+ */
70
+ readonly via?: {
71
+ readonly from: string;
72
+ readonly token: string;
73
+ };
74
+ /**
75
+ * Set when this file pays into {@link InstructionWeight.committedTotal} but
76
+ * NOT into {@link InstructionWeight.effectiveTotal}: a PER-MACHINE file in
77
+ * this working copy supersedes it, so a teammate on the same commit loads it
78
+ * and you do not. Names the file that did it.
79
+ *
80
+ * 🔴 THE ONE CASE WHERE THE TWO TOTALS MOVE IN OPPOSITE DIRECTIONS, and the
81
+ * reason this is a field rather than a filter at the print site. Every other
82
+ * per-machine effect is ADDITIVE — a `CLAUDE.local.md` appends its own bytes,
83
+ * so `effective = committed + locals` — which is why it was safe for
84
+ * `effectiveTotal` to be "the sum of everything loaded". Claude Code's
85
+ * supersede rule breaks that: "Because `CLAUDE.local.md` counts, adding one
86
+ * to keep your own uncommitted instructions in a project that relies on
87
+ * `AGENTS.md` stops Claude from reading `AGENTS.md` for you." The gitignored
88
+ * file changes the MEMBERSHIP of the load, not its size, and the committed
89
+ * number has to keep a file this working copy never opens.
90
+ *
91
+ * A reader who could not see this on the file's own line would meet a
92
+ * `committedTotal` larger than the `effectiveTotal` beside it with nothing
93
+ * accounting for the gap — the undecomposable total this whole report exists
94
+ * to prevent.
95
+ */
96
+ readonly notLoadedHere?: {
97
+ /** The per-machine file that did it — a superseder, or a settings file. */
98
+ readonly by: string;
99
+ readonly why: "superseded" | "excluded";
100
+ };
64
101
  }
65
102
  export interface InstructionWeight {
66
103
  readonly unit: "chars" | "bytes";
@@ -68,19 +105,64 @@ export interface InstructionWeight {
68
105
  readonly onExceed: "warns" | "truncates";
69
106
  /** Heaviest first — a report's first line should name the biggest payer. */
70
107
  readonly files: readonly WeighedFile[];
71
- /** The number that matters: everything loaded without a decision. */
72
- readonly total: number;
73
- /** `null` when within budget; otherwise how far over, in `unit`. */
108
+ /**
109
+ * The SCORED number: everything loaded without a decision that a TEAMMATE or
110
+ * CI would also load. Per-machine files are excluded, which is what makes the
111
+ * figure reproducible from a commit alone.
112
+ */
113
+ readonly committedTotal: number;
114
+ /**
115
+ * What THIS working copy actually loads. Never compared against the budget;
116
+ * printed beside it so the difference is visible rather than silently either
117
+ * counted or dropped.
118
+ *
119
+ * ⚠️ IT IS NOT "`committedTotal` PLUS THE PER-MACHINE FILES", and it used to
120
+ * say so. A per-machine file can also SUBTRACT: Claude Code stops reading
121
+ * `AGENTS.md` at all once a `CLAUDE.local.md` exists, so this number can come
122
+ * out BELOW `committedTotal`. See {@link WeighedFile.supersededLocallyBy}.
123
+ */
124
+ readonly effectiveTotal: number;
125
+ /** `null` when {@link committedTotal} is within budget; else how far over. */
74
126
  readonly overBy: number | null;
127
+ /**
128
+ * Files the chain NAMED and this run did not read — an import that does not
129
+ * exist, points outside the repo, or sits past the import depth. Printed, not
130
+ * dropped: a number missing a file it knows about would be the under-report
131
+ * this whole module exists to prevent.
132
+ */
133
+ readonly unreadImports: readonly string[];
134
+ /**
135
+ * Globs and URLs the harness would expand at launch and vigiles will not walk
136
+ * (OpenCode `instructions`). Reported for the same reason.
137
+ */
138
+ readonly unweighedPatterns: readonly string[];
139
+ /**
140
+ * A loaded file whose ENTIRE content is import tokens. Printed as a FINDING,
141
+ * not as a size: such a `CLAUDE.md` is fourteen bytes and the repository it
142
+ * describes loads tens of kilobytes, so the number on its own is a confident
143
+ * wrong answer. See `InstructionChain.redirects` for why the shape is common.
144
+ */
145
+ readonly redirects: readonly {
146
+ readonly path: string;
147
+ readonly to: readonly string[];
148
+ }[];
75
149
  }
76
150
  /** Size in the harness's own unit. Bytes and chars differ on any non-ASCII text. */
77
151
  export declare function sizeIn(text: string, unit: "chars" | "bytes"): number;
78
152
  /**
79
- * Weigh every unconditionally-loaded file in a file map.
153
+ * Weigh a harness's LOADED chain against its own budget.
154
+ *
155
+ * 🔴 IT TAKES A CHAIN, NOT A GLOB LIST, AND THAT IS THE WHOLE FIX. This used to
156
+ * filter the file map with `matchesGlob` over `budget.alwaysLoaded` — an adapter
157
+ * string the core interpreted — and so it counted files the harness does not
158
+ * load at launch (`paths:`-scoped rules, a sibling package's instruction file)
159
+ * and a file no teammate has (`CLAUDE.local.md`). Which files load is the
160
+ * harness's answer now (`PluginLayout.instructionChain`); this function only
161
+ * adds up what it was told and says what it could not weigh.
80
162
  *
81
- * Takes a MAP rather than a directory so the same function serves the CLI and
82
- * the browser engine (the `scan-files.ts` split), and so a test states its
83
- * input instead of building a tree.
163
+ * Takes the map as well as the chain because a chain is a CLASSIFICATION, not
164
+ * a measurement: the sizes live in the bytes, and the same map serves the CLI
165
+ * and the browser engine.
84
166
  */
85
- export declare function weighInstructions(files: Readonly<Record<string, string>>, budget: InstructionBudget): InstructionWeight;
167
+ export declare function weighInstructions(chain: InstructionChain, files: Readonly<Record<string, string>>, budget: InstructionBudget): InstructionWeight;
86
168
  //# sourceMappingURL=instruction-weight.d.ts.map
@@ -44,43 +44,78 @@ function sizeIn(text, unit) {
44
44
  return unit === "chars" ? text.length : Buffer.byteLength(text, "utf8");
45
45
  }
46
46
  /**
47
- * Match a path against one glob. Deliberately tiny: the patterns here are
48
- * `alwaysLoaded` entries an ADAPTER writes, not user input — `CLAUDE.md`,
49
- * `.claude/rules/**`. `*` stops at a separator, `**` crosses them.
50
- */
51
- function matchesGlob(path, glob) {
52
- const rx = glob
53
- .split(/(\*\*\/|\*\*|\*)/)
54
- .map((part) => part === "**/"
55
- ? "(?:.*/)?"
56
- : part === "**"
57
- ? ".*"
58
- : part === "*"
59
- ? "[^/]*"
60
- : part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
61
- .join("");
62
- return new RegExp(`^${rx}$`).test(path);
63
- }
64
- /**
65
- * Weigh every unconditionally-loaded file in a file map.
47
+ * Weigh a harness's LOADED chain against its own budget.
66
48
  *
67
- * Takes a MAP rather than a directory so the same function serves the CLI and
68
- * the browser engine (the `scan-files.ts` split), and so a test states its
69
- * input instead of building a tree.
49
+ * 🔴 IT TAKES A CHAIN, NOT A GLOB LIST, AND THAT IS THE WHOLE FIX. This used to
50
+ * filter the file map with `matchesGlob` over `budget.alwaysLoaded` — an adapter
51
+ * string the core interpreted — and so it counted files the harness does not
52
+ * load at launch (`paths:`-scoped rules, a sibling package's instruction file)
53
+ * and a file no teammate has (`CLAUDE.local.md`). Which files load is the
54
+ * harness's answer now (`PluginLayout.instructionChain`); this function only
55
+ * adds up what it was told and says what it could not weigh.
56
+ *
57
+ * Takes the map as well as the chain because a chain is a CLASSIFICATION, not
58
+ * a measurement: the sizes live in the bytes, and the same map serves the CLI
59
+ * and the browser engine.
70
60
  */
71
- function weighInstructions(files, budget) {
72
- const weighed = Object.entries(files)
73
- .filter(([path]) => budget.alwaysLoaded.some((g) => matchesGlob(path, g)))
74
- .map(([path, text]) => ({ path, size: sizeIn(text, budget.unit) }))
75
- .sort((a, b) => b.size - a.size || a.path.localeCompare(b.path));
76
- const total = weighed.reduce((sum, f) => sum + f.size, 0);
61
+ function weighInstructions(chain, files, budget) {
62
+ const notLoadedHere = chain.unloaded.flatMap((e) => {
63
+ if (e.scope !== "repo")
64
+ return [];
65
+ if (e.reason.kind === "superseded" && e.reason.byScope === "local") {
66
+ return [{ entry: e, by: e.reason.by, why: "superseded" }];
67
+ }
68
+ if (e.reason.kind === "excluded-by-settings" &&
69
+ e.reason.byScope === "local") {
70
+ return [{ entry: e, by: e.reason.by, why: "excluded" }];
71
+ }
72
+ return [];
73
+ });
74
+ const weighOne = (entry, notLoadedHereBy) => {
75
+ const text = files[entry.path];
76
+ return text === undefined
77
+ ? []
78
+ : [
79
+ {
80
+ path: entry.path,
81
+ size: sizeIn(text, budget.unit),
82
+ role: entry.role,
83
+ scope: entry.scope,
84
+ ...(entry.via === undefined ? {} : { via: entry.via }),
85
+ ...(notLoadedHereBy === undefined
86
+ ? {}
87
+ : { notLoadedHere: notLoadedHereBy }),
88
+ },
89
+ ];
90
+ };
91
+ const weighed = [
92
+ ...chain.loaded.flatMap((e) => weighOne(e)),
93
+ ...notLoadedHere.flatMap((s) => weighOne(s.entry, { by: s.by, why: s.why })),
94
+ ].sort((a, b) => b.size - a.size || a.path.localeCompare(b.path));
95
+ const sum = (of) => of.reduce((total, f) => total + f.size, 0);
96
+ // COMMITTED is still "every `repo`-scoped file in the list", unchanged — the
97
+ // superseded entry is a committed file and joins the list with `scope:
98
+ // "repo"`, so the formula did not have to learn a second rule. EFFECTIVE is
99
+ // the one that changed: it was `sum(weighed)`, which was only ever right
100
+ // while the list held nothing this working copy fails to load.
101
+ const committedTotal = sum(weighed.filter((f) => f.scope === "repo"));
77
102
  return {
78
103
  unit: budget.unit,
79
104
  limit: budget.limit,
80
105
  onExceed: budget.onExceed,
81
106
  files: weighed,
82
- total,
83
- overBy: total > budget.limit ? total - budget.limit : null,
107
+ committedTotal,
108
+ effectiveTotal: sum(weighed.filter((f) => f.notLoadedHere === undefined)),
109
+ overBy: committedTotal > budget.limit ? committedTotal - budget.limit : null,
110
+ unreadImports: [
111
+ ...new Set(chain.imports
112
+ .map((i) => i.path)
113
+ .filter((path) => files[path] === undefined)),
114
+ ].sort(),
115
+ unweighedPatterns: [
116
+ ...new Set(chain.patterns.map((p) => p.pattern)),
117
+ ].sort(),
118
+ redirects: chain.redirects,
84
119
  };
85
120
  }
86
121
  //# sourceMappingURL=instruction-weight.js.map