vigiles 29.1.0 → 30.0.1

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 (122) 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/agent-runtime.js +3 -1
  10. package/dist/adapters/claude-code/dialect.js +87 -21
  11. package/dist/adapters/claude-code/effect-region.js +3 -1
  12. package/dist/adapters/claude-code/hook-protocol.js +16 -0
  13. package/dist/adapters/claude-code/instruction-chain.d.ts +25 -0
  14. package/dist/adapters/claude-code/instruction-chain.js +626 -0
  15. package/dist/adapters/claude-code/layout.d.ts +2 -2
  16. package/dist/adapters/claude-code/layout.js +42 -8
  17. package/dist/adapters/claude-code/model-access.d.ts +41 -0
  18. package/dist/adapters/claude-code/model-access.js +46 -0
  19. package/dist/adapters/claude-code/skill-reachability.d.ts +125 -0
  20. package/dist/adapters/claude-code/skill-reachability.js +111 -0
  21. package/dist/adapters/claude-code/skill-runtime.js +3 -1
  22. package/dist/adapters/codex/adapter.d.ts +39 -2
  23. package/dist/adapters/codex/adapter.js +29 -29
  24. package/dist/adapters/codex/dialect.js +11 -6
  25. package/dist/adapters/codex/eval.d.ts +10 -0
  26. package/dist/adapters/codex/eval.js +48 -1
  27. package/dist/adapters/codex/hook-protocol.d.ts +2 -1
  28. package/dist/adapters/codex/hook-protocol.js +10 -0
  29. package/dist/adapters/codex/instruction-chain.d.ts +40 -0
  30. package/dist/adapters/codex/instruction-chain.js +105 -0
  31. package/dist/adapters/codex/layout.d.ts +1 -1
  32. package/dist/adapters/codex/layout.js +41 -14
  33. package/dist/adapters/opencode/adapter.d.ts +33 -2
  34. package/dist/adapters/opencode/adapter.js +36 -36
  35. package/dist/adapters/opencode/dialect.js +2 -2
  36. package/dist/adapters/opencode/instruction-chain.d.ts +37 -0
  37. package/dist/adapters/opencode/instruction-chain.js +70 -0
  38. package/dist/adapters/opencode/layout.d.ts +19 -0
  39. package/dist/adapters/opencode/layout.js +34 -15
  40. package/dist/adoptability.d.ts +31 -1
  41. package/dist/adoptability.js +57 -0
  42. package/dist/cli-main.js +185 -102
  43. package/dist/core/adapter.d.ts +213 -61
  44. package/dist/core/compile.d.ts +2 -2
  45. package/dist/core/compile.js +57 -46
  46. package/dist/core/compose.d.ts +5 -3
  47. package/dist/core/compose.js +5 -3
  48. package/dist/core/config-schema.d.ts +14 -2
  49. package/dist/core/config-schema.js +20 -7
  50. package/dist/core/dialect.d.ts +54 -12
  51. package/dist/core/dialect.js +56 -0
  52. package/dist/core/eval-driver.d.ts +194 -0
  53. package/dist/core/eval-driver.js +3 -0
  54. package/dist/core/frontmatter-read.d.ts +10 -0
  55. package/dist/core/frontmatter-read.js +30 -3
  56. package/dist/core/guards.js +3 -1
  57. package/dist/core/hook-program.d.ts +27 -2
  58. package/dist/core/hook-program.js +29 -24
  59. package/dist/core/hook-protocol.d.ts +54 -0
  60. package/dist/core/install-reader.d.ts +18 -0
  61. package/dist/core/install-reader.js +88 -0
  62. package/dist/core/instruction-chain.d.ts +444 -0
  63. package/dist/core/instruction-chain.js +292 -0
  64. package/dist/core/instruction-weight.d.ts +96 -14
  65. package/dist/core/instruction-weight.js +65 -30
  66. package/dist/core/layout.d.ts +220 -33
  67. package/dist/core/layout.js +115 -1
  68. package/dist/core/lethal-trifecta.d.ts +12 -7
  69. package/dist/core/lethal-trifecta.js +13 -13
  70. package/dist/core/live-driver.d.ts +137 -0
  71. package/dist/core/live-driver.js +14 -0
  72. package/dist/core/markdown.d.ts +23 -0
  73. package/dist/core/markdown.js +77 -28
  74. package/dist/core/orphans.js +9 -7
  75. package/dist/core/settings-codec.d.ts +17 -0
  76. package/dist/core/settings-codec.js +56 -0
  77. package/dist/core/surface-discovery.d.ts +2 -2
  78. package/dist/core/surface-discovery.js +24 -12
  79. package/dist/core/surface-scopes.d.ts +26 -6
  80. package/dist/core/surface-scopes.js +52 -11
  81. package/dist/core/validate.js +16 -3
  82. package/dist/coverage-artifact.d.ts +3 -2
  83. package/dist/coverage-artifact.js +6 -5
  84. package/dist/eval-cache.d.ts +6 -1
  85. package/dist/eval-cache.js +11 -1
  86. package/dist/eval.d.ts +16 -108
  87. package/dist/eval.js +36 -2
  88. package/dist/harness-test.d.ts +3 -63
  89. package/dist/hook-install.d.ts +12 -1
  90. package/dist/hook-install.js +12 -1
  91. package/dist/hook-runtime.js +4 -2
  92. package/dist/hook-state-store.js +3 -1
  93. package/dist/local-files-tracked.d.ts +17 -0
  94. package/dist/local-files-tracked.js +70 -0
  95. package/dist/local-files.d.ts +62 -0
  96. package/dist/local-files.js +183 -0
  97. package/dist/observe.d.ts +3 -2
  98. package/dist/observe.js +7 -6
  99. package/dist/plugin-loader.d.ts +1 -1
  100. package/dist/plugin-loader.js +43 -36
  101. package/dist/scan-behavioral.d.ts +34 -25
  102. package/dist/scan-behavioral.js +122 -58
  103. package/dist/scan-core.js +37 -18
  104. package/dist/scan-files.d.ts +1 -1
  105. package/dist/scan-files.js +53 -33
  106. package/dist/scan-trigger-suggest.d.ts +0 -21
  107. package/dist/scan-trigger-suggest.js +0 -23
  108. package/dist/scan.d.ts +4 -4
  109. package/dist/scan.js +120 -84
  110. package/dist/skill-harness.d.ts +21 -5
  111. package/dist/skill-harness.js +29 -11
  112. package/dist/surface-discovery-fs.d.ts +2 -0
  113. package/dist/surface-discovery-fs.js +108 -6
  114. package/dist/test-coverage-files.js +24 -17
  115. package/dist/test-coverage.d.ts +9 -3
  116. package/dist/test-coverage.js +32 -22
  117. package/dist/verify-plugin-guards.js +1 -1
  118. package/package.json +1 -1
  119. package/dist/skill-reachability.d.ts +0 -68
  120. package/dist/skill-reachability.js +0 -205
  121. /package/dist/{dialect-drift.d.ts → adapters/claude-code/dialect-drift.d.ts} +0 -0
  122. /package/dist/{dialect-drift.js → adapters/claude-code/dialect-drift.js} +0 -0
@@ -0,0 +1,626 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.localSiblingOf = localSiblingOf;
4
+ exports.claudeCodeInstructionChain = claudeCodeInstructionChain;
5
+ const Minimatch = () => require("minimatch").Minimatch;
6
+ const instruction_chain_js_1 = require("../../core/instruction-chain.js");
7
+ const frontmatter_read_js_1 = require("../../core/frontmatter-read.js");
8
+ const markdown_js_1 = require("../../core/markdown.js");
9
+ /**
10
+ * The settings key that removes files from the chain, and the frontmatter key
11
+ * that makes a rule load ON DEMAND instead of at launch. Both are Claude Code's
12
+ * own words; they live here and nowhere in the core, which is the boundary the
13
+ * `no-harness-names` lint polices.
14
+ */
15
+ const EXCLUDES_KEY = "claudeMdExcludes";
16
+ const PATH_SCOPE_KEY = "paths";
17
+ /**
18
+ * The cross-tool instruction filename Claude Code now reads natively — "Claude
19
+ * Code can read `AGENTS.md` as your project instructions".
20
+ *
21
+ * 🔴 IT IS A LITERAL HERE, AND DELIBERATELY NOT A FIELD ON `PluginLayout`.
22
+ * `layout.instructionFile` answers "which file does this harness WRITE and
23
+ * own"; the answer is still `CLAUDE.md` — that is what `vigiles init` compiles
24
+ * into and what `detect` scores on. `AGENTS.md` is a file this harness READS
25
+ * and another harness owns, which is a different question, and putting it in
26
+ * the layout would make `layoutClaims` say Claude Code claims `AGENTS.md` —
27
+ * i.e. two registered adapters claiming the same path, which is the collision
28
+ * `claims` exists to prevent. So it lives beside {@link EXCLUDES_KEY} and
29
+ * {@link PATH_SCOPE_KEY}: Claude Code's own words, in Claude Code's adapter,
30
+ * and nowhere in the core.
31
+ */
32
+ const AGENTS_FILE = "AGENTS.md";
33
+ /** The directory the vendor names in its "Not read" list. */
34
+ const AGENTS_DIR = ".agents";
35
+ /**
36
+ * The two cross-tool per-machine siblings, DERIVED through the one
37
+ * `siblingNamed` spelling rather than written out: Claude Code names its
38
+ * per-machine file with `local`, Codex with `override`, and both spellings of
39
+ * the `AGENTS.md` family are gitignored by convention.
40
+ *
41
+ * 🔴 USED FOR TWO DIFFERENT THINGS, and that is the point of it being a set
42
+ * rather than two comparisons inside {@link isNeverRead}. It answers "does this
43
+ * harness ever go looking for this name" (no), and it answers "is this file one
44
+ * machine's" — which is what {@link takeImportsOf} needs, because an imported
45
+ * file inherits the IMPORTER's scope, and a committed `AGENTS.md` importing
46
+ * `@AGENTS.local.md` would otherwise put a gitignored file into a published
47
+ * `committedTotal`. That is the very defect {@link InstructionScope} exists to
48
+ * prevent: the browser twin reads a GitHub tree and can never see the file.
49
+ *
50
+ * ⚠️ THE SECOND USE IS A CONVENTION, NOT A VENDOR RULE, and it is the SAME
51
+ * convention this module already applies to `CLAUDE.local.md` by name — the
52
+ * `.local.` / `.override.` infix means "one machine's", for both vendors. So
53
+ * this is the cross-tool family catching up with the rule its twin already
54
+ * follows, not a new one. What it costs if the convention is wrong in some
55
+ * repository — a committed `AGENTS.local.md` — is that the file leaves
56
+ * `committedTotal`; it stays in `effectiveTotal` and on its own breakdown row,
57
+ * so it is moved rather than dropped. Identical to the cost already accepted
58
+ * for `CLAUDE.local.md`.
59
+ */
60
+ const CROSS_TOOL_LOCAL_LEAVES = new Set([
61
+ (0, instruction_chain_js_1.siblingNamed)(AGENTS_FILE, "local"),
62
+ (0, instruction_chain_js_1.siblingNamed)(AGENTS_FILE, "override"),
63
+ ]);
64
+ /** Is this path one machine's file by NAME — `X.local.md` / `X.override.md`? */
65
+ function isPerMachineName(path) {
66
+ return CROSS_TOOL_LOCAL_LEAVES.has(path.slice(path.lastIndexOf("/") + 1));
67
+ }
68
+ /**
69
+ * Cross-tool instruction files Claude Code NEVER reads, at any depth. Vendor:
70
+ *
71
+ * > **Not read**: `AGENTS.local.md`, `AGENTS.override.md`, or anything under a
72
+ * > `.agents/` directory.
73
+ *
74
+ * 🔴 A NAMED PREDICATE BECAUSE THE DEFAULT IS WRONG FOR THIS FAMILY, and it is
75
+ * wrong QUIETLY. {@link takeSubdirectories} recognises an instruction file by
76
+ * its LEAF NAME, so `.agents/AGENTS.md` — which the domain's bound really does
77
+ * enumerate, it is dot-directory markdown — would come out classified
78
+ * "on-demand". The number is the same either way (an on-demand file weighs
79
+ * nothing), so no total would move and nothing would go red; only the printed
80
+ * REASON would be a confident wrong answer about a file the harness will never
81
+ * open. That is the class this whole module exists to remove.
82
+ *
83
+ * The two sibling names come from {@link CROSS_TOOL_LOCAL_LEAVES}, which
84
+ * derives them through the one `siblingNamed` spelling rather than writing them
85
+ * out, for the reason {@link localSiblingOf} is derived: a second literal is a
86
+ * second thing to keep in step.
87
+ *
88
+ * ⚠️ WHAT THIS DOES NOT DO, stated rather than discovered later: a file it
89
+ * refuses is named by NOTHING — it appears in neither `loaded` nor `unloaded`.
90
+ * `NotLoadedReason` has no member for "this harness never reads this name", and
91
+ * inventing one for a single vendor sentence would put a branch into every
92
+ * consumer of the union for a file that weighs nothing. Silence here is the
93
+ * same answer the bound already gives every non-instruction file.
94
+ *
95
+ * ⏳ AND AN EXPLICIT `@` TOKEN NAMING ONE OF THESE FILES IS AN OPEN QUESTION,
96
+ * raised by the corpus rather than imagined: of the three real imports in the
97
+ * 214-file `AGENTS.md` sample (`core/instruction-chain.ts#resolveImports`), ONE
98
+ * is `@AGENTS.local.md` — a name this predicate refuses, written on purpose by
99
+ * the file beside it. The vendor's two sentences are peers in one bulleted
100
+ * list and neither qualifies the other:
101
+ *
102
+ * > **Inside each `AGENTS.md`**: `@path` imports are expanded …
103
+ * > **Not read**: `AGENTS.local.md`, `AGENTS.override.md`, or anything under a
104
+ * > `.agents/` directory
105
+ *
106
+ * Read as DISCOVERY, the second says where the loader goes looking and the
107
+ * token still loads the file. Read FLATLY, the name is never opened at all and
108
+ * the token resolves to nothing. {@link takeImportsOf} does not consult this
109
+ * predicate, so today the token IS honoured — but that is the behaviour that
110
+ * was already there, not a verdict reached here, and it is left alone rather
111
+ * than changed on a coin-flip. Settled by the same instrument as the exclusion
112
+ * question at {@link supersederOf}: one session, the documented `AGENTS.md
113
+ * loaded` line, or an `InstructionsLoaded` hook transcript.
114
+ *
115
+ * 🔴 WHAT IS DECIDED, BECAUSE IT IS RIGHT UNDER BOTH READINGS, is the SCOPE
116
+ * such a file gets if it is counted at all — see {@link isPerMachineName} and
117
+ * {@link takeImportsOf}. Under the flat reading the file is never taken and
118
+ * that code is simply unreachable; under the discovery reading it is taken, and
119
+ * scoring a `.local.` file as `repo` would put a gitignored file into a
120
+ * published `committedTotal`. Neither answer to the open question makes `repo`
121
+ * correct, which is what makes this one safe to take now.
122
+ */
123
+ function isNeverRead(path) {
124
+ const leaf = path.slice(path.lastIndexOf("/") + 1);
125
+ return path.startsWith(`${AGENTS_DIR}/`) || CROSS_TOOL_LOCAL_LEAVES.has(leaf);
126
+ }
127
+ /**
128
+ * `@path/to/file.md` — how Claude Code names another file from inside an
129
+ * instruction file.
130
+ *
131
+ * 🔴 THIS IS A MODEL OF A LOADER, NOT A PARSE OF A FORMAT, and a reader who
132
+ * misses that will "fix" it in the wrong direction. `@path` is not in CommonMark
133
+ * or in any other specification, so there is no authority to parse against:
134
+ * markdown-it hands back `@AGENTS.md` as ordinary TEXT, correctly, because to
135
+ * markdown it is ordinary text. What we assume, stated so it can be argued with:
136
+ * a leading `@` followed by a path, written in PROSE (never inside a code fence
137
+ * or a code span), naming a markdown file. Anything beyond that — how the real
138
+ * loader treats a path relative to the importing file, a `~` reference, a
139
+ * recursive import — is inferred from the vendor documentation and from a
140
+ * 198-file corpus of public `CLAUDE.md`s, and a divergence from the real loader
141
+ * is discoverable only by OBSERVING it, never by reading a grammar.
142
+ *
143
+ * The narrowness is measured rather than cautious. A loose `@` pattern over that
144
+ * corpus matches Python decorators, Blade templates, npm scopes and CSS at-rules
145
+ * — all of them inside fenced code blocks, which {@link proseLines} removes
146
+ * before this pattern ever runs. Requiring a `.md` tail on top keeps
147
+ * `email me @ foo` and a prose `@dataclass` out. A token this refuses is simply
148
+ * not reported, which under-reports by that file's size; a token it wrongly
149
+ * accepted would make vigiles open a path the repo did not mean to name, and
150
+ * only one of those two is a safety question.
151
+ */
152
+ const IMPORT_TOKEN = /(?:^|\s)(@[A-Za-z0-9_.][^\s]*\.md)\b/g;
153
+ /** A prose line that is NOTHING BUT one import token — the redirect shape. */
154
+ const ONLY_IMPORT = /^@[A-Za-z0-9_.][^\s]*\.md$/;
155
+ /**
156
+ * The author's own prose, with the frontmatter, the code and the comments gone.
157
+ *
158
+ * Both halves come from the modules that own them — `frontmatterBody` from the
159
+ * frontmatter reader, {@link proseLines} from the ONE markdown-structure helper
160
+ * — rather than from a line-splitting loop here. The helper's own header records
161
+ * why: the hand-rolled fence toggle it replaced had been copy-pasted into five
162
+ * detectors and is wrong on nested and unbalanced fences, which is exactly the
163
+ * `@dataclass`-inside-a-code-block case this has to get right.
164
+ *
165
+ * Indentation, CRLF, trailing whitespace, trailing blank lines and a UTF-8 BOM
166
+ * are all handled THERE, measured; `instruction-chain.test.ts` names each of
167
+ * those spellings as its own case anyway, because they are the ratchet that
168
+ * proves this path still goes through the parser after the next edit.
169
+ */
170
+ function contentLines(text) {
171
+ return (0, markdown_js_1.proseLines)((0, frontmatter_read_js_1.frontmatterBody)(text));
172
+ }
173
+ /** Every `@import` token in one file, deduplicated, in first-seen order. */
174
+ function importTokens(text) {
175
+ const out = [];
176
+ for (const line of contentLines(text)) {
177
+ for (const m of line.matchAll(IMPORT_TOKEN)) {
178
+ const token = m[1];
179
+ if (token !== undefined && !out.includes(token))
180
+ out.push(token);
181
+ }
182
+ }
183
+ return out;
184
+ }
185
+ /**
186
+ * Is this file NOTHING BUT imports — a redirect rather than instructions?
187
+ *
188
+ * Binary, never a threshold: every prose line is a bare import token, and there
189
+ * is at least one. "Mostly imports" would be a number nobody can defend, and an
190
+ * empty file is empty rather than a redirect.
191
+ */
192
+ function isPureRedirect(text) {
193
+ const lines = contentLines(text);
194
+ return lines.length > 0 && lines.every((line) => ONLY_IMPORT.test(line));
195
+ }
196
+ /**
197
+ * The `claudeMdExcludes` patterns this chain can APPLY, compiled.
198
+ *
199
+ * ⚠️ THE VENDOR MATCHES THESE AGAINST ABSOLUTE PATHS and the chain is handed
200
+ * repo-relative keys, so a pattern anchored at the filesystem root cannot be
201
+ * applied here at all. Rather than guess a root, only the `**\/`-prefixed form
202
+ * is applied — that leading segment matches zero or more directories, so such a
203
+ * pattern means the same thing against an absolute path and against a
204
+ * repo-relative one, which is exactly the subset that needs no root. Every other
205
+ * pattern is left UNAPPLIED, which counts a file that would not have loaded.
206
+ * That over-reports, and over-reporting is the safe direction: an under-report
207
+ * reads as "you are fine", which is the failure this feature exists to prevent.
208
+ *
209
+ * Matching is `minimatch`, the parser the rest of this repo's exclusion policy
210
+ * already uses (`src/exclude.ts`) — a glob is a LANGUAGE, and hand-rolling a
211
+ * second interpreter for it is precisely the defect being removed here.
212
+ */
213
+ function compileExcludes(settings) {
214
+ if (settings.length === 0)
215
+ return [];
216
+ const ctor = Minimatch();
217
+ return settings
218
+ .filter((p) => p.startsWith("**/"))
219
+ .map((p) => new ctor(p, { dot: true }));
220
+ }
221
+ /** `claudeMdExcludes` from one settings file's text, or `[]` if it says nothing. */
222
+ function excludesIn(text, parse) {
223
+ if (text === undefined)
224
+ return [];
225
+ let value;
226
+ try {
227
+ value = parse(text);
228
+ }
229
+ catch {
230
+ // A settings file mid-merge or mid-edit must not decide which instructions
231
+ // load. Reporting "nothing is excluded" over-reports, the safe direction.
232
+ return [];
233
+ }
234
+ const raw = value[EXCLUDES_KEY];
235
+ return Array.isArray(raw)
236
+ ? raw.filter((p) => typeof p === "string")
237
+ : [];
238
+ }
239
+ /** Does this rule file declare a `paths:` scope — i.e. load only on demand? */
240
+ function isPathScoped(text) {
241
+ const { data } = (0, frontmatter_read_js_1.readFrontmatter)(text);
242
+ return data !== null && Object.hasOwn(data, PATH_SCOPE_KEY);
243
+ }
244
+ /**
245
+ * `CLAUDE.md` → `CLAUDE.local.md`: the per-machine sibling, DERIVED so the two
246
+ * names cannot drift apart the way a second constant would.
247
+ */
248
+ function localSiblingOf(instructionFile) {
249
+ return (0, instruction_chain_js_1.siblingNamed)(instructionFile, "local");
250
+ }
251
+ /** Into `loaded`, or into `unloaded` with the settings reason; absent → nothing. */
252
+ function take(b, path, entry) {
253
+ if (b.files[path] === undefined)
254
+ return;
255
+ const excluder = b.excluderOf(path);
256
+ if (excluder !== undefined) {
257
+ b.unloaded.push({
258
+ path,
259
+ ...entry,
260
+ reason: {
261
+ kind: "excluded-by-settings",
262
+ key: EXCLUDES_KEY,
263
+ by: excluder.path,
264
+ byScope: excluder.scope,
265
+ },
266
+ });
267
+ return;
268
+ }
269
+ b.loaded.push({ path, ...entry });
270
+ }
271
+ /**
272
+ * Every file under the rules dir: loaded, unless it declares a `paths:` scope.
273
+ *
274
+ * 🔴 THE CLAUDE CODE OVER-REPORT, FIXED HERE. A `paths:`-scoped rule is included
275
+ * when Claude reads a matching file, not at launch, so counting it as
276
+ * always-loaded inflated the one number this whole feature reports. Sorted by
277
+ * path for determinism; the vendor states that the files are concatenated, not
278
+ * in which order the project-scope ones arrive.
279
+ */
280
+ function takeRules(b, ruleRe) {
281
+ for (const path of Object.keys(b.files).sort()) {
282
+ if (!ruleRe.test(path))
283
+ continue;
284
+ const text = b.files[path];
285
+ if (text === undefined)
286
+ continue;
287
+ if (isPathScoped(text)) {
288
+ b.unloaded.push({
289
+ path,
290
+ role: "rule",
291
+ scope: "repo",
292
+ reason: { kind: "on-demand", when: "path-scoped" },
293
+ });
294
+ continue;
295
+ }
296
+ take(b, path, { role: "rule", scope: "repo" });
297
+ }
298
+ }
299
+ /**
300
+ * A subdirectory's own instruction file, IF the caller handed one over.
301
+ *
302
+ * The domain's bound never enumerates one ({@link INSTRUCTION_SHAPES}), so this
303
+ * is reached only by a caller holding a wider map — and then the honest answer
304
+ * is "included when Claude reads files in those subdirectories", not "always".
305
+ *
306
+ * 🔴 `AGENTS.md` GETS THE SAME TREATMENT AS A `paths:`-SCOPED RULE, and the
307
+ * vendor puts the two in the same list for the same reason — neither is read at
308
+ * launch: "As Claude works in subdirectories: a subdirectory's `AGENTS.md`,
309
+ * when Claude opens a file there with the Read tool". Counting one would inflate
310
+ * the single number this feature exists to report, which is the over-report
311
+ * `alwaysLoaded` shipped.
312
+ *
313
+ * 🔴 THE VENDOR'S EXTRA CONDITION IS NOT MODELLED, AND THE REASON IS A
314
+ * MEASUREMENT RATHER THAN A PREFERENCE. "…when Claude opens a file there with
315
+ * the Read tool AND THAT SUBDIRECTORY HAS NONE OF THE THREE `CLAUDE.md` FILES
316
+ * OF ITS OWN": with one of them beside it, a nested `AGENTS.md` is never read
317
+ * at all rather than read on demand. Both answers weigh zero, so the only thing
318
+ * at stake is the printed REASON — which is worth getting right, since "same
319
+ * number, confident wrong reason" is the class {@link isNeverRead} exists for.
320
+ *
321
+ * ⚠️ IT CANNOT BE ANSWERED FROM THE MAP THIS METHOD IS HANDED. Measured over
322
+ * both engines, which are the only two callers: `boundedInstructionFiles`
323
+ * (`surface-discovery-fs.ts`) and the browser twin (`scan-files.ts:700`) each
324
+ * build the map as `instructionCandidatePaths(...)` UNION the paths the imports
325
+ * pass resolved — nothing else. `instructionCandidatePaths` keeps
326
+ * `^[^/]+\\.md$` and `^\\.[^/]+\\/[^/]+\\.md$`, so NO `<dir>/…` path is ever a
327
+ * candidate. A subdirectory `AGENTS.md` therefore reaches this loop only
328
+ * because something IMPORTED it, and its sibling `<dir>/CLAUDE.md` is in the
329
+ * map only if something imported that too. Absence of the sibling carries no
330
+ * information at all, so a supersede verdict built on it would be a guess
331
+ * wearing a vendor quote.
332
+ *
333
+ * 🔴 AND WIDENING THE BOUND TO GET IT WOULD BE THE WRONG TRADE. The bound is
334
+ * what stops registering an adapter from widening the read in someone else's
335
+ * repository — the construction this whole port rests on. Enumerating every
336
+ * directory's `CLAUDE.md` family to decide a printed reason on a file that
337
+ * weighs nothing spends the load-bearing thing on a cosmetic one. So the
338
+ * condition is declared here, unmodelled, and the file keeps the honest
339
+ * "on-demand": it says what this chain knows, not what the loader would do.
340
+ *
341
+ * ⏳ WHAT WOULD CLOSE IT: a caller that legitimately holds a per-directory map —
342
+ * a future `vigiles audit <subpackage>` run, where that directory IS the
343
+ * working directory and its files are in the bound by construction. Then the
344
+ * question is the ROOT question, already answered by {@link supersederOf}, and
345
+ * no widening is needed.
346
+ *
347
+ * ⚠️ AND THE CONDITION IS MOOT FOR AN IMPORTED FILE, WHICH IS WHY THIS PASS NOW
348
+ * RUNS LAST. A `<dir>/CLAUDE.md` that something `@`-imports is taken by the
349
+ * imports pass before this loop sees it, so the only paths reaching here are
350
+ * the ones nothing imported — the ones the condition was always about. The
351
+ * ordering, and the bug that came from the other order, are stated at the call.
352
+ *
353
+ * `reserved` holds the paths this chain owns at the root level. Without it, a
354
+ * `.claude/AGENTS.md` that the supersede pass has not yet classified would be
355
+ * read as a SUBDIRECTORY file, because it has a slash and the right basename —
356
+ * and the dot-directory is not a subdirectory of the project in the vendor's
357
+ * sense. It is empty-set-safe: every root candidate is already in `named` on
358
+ * the paths that reach this today.
359
+ */
360
+ function takeSubdirectories(b, leafNames, reserved) {
361
+ const named = new Set([...b.loaded, ...b.unloaded].map((e) => e.path));
362
+ for (const path of Object.keys(b.files).sort()) {
363
+ if (named.has(path) || reserved.has(path) || !path.includes("/"))
364
+ continue;
365
+ if (isNeverRead(path))
366
+ continue;
367
+ if (!leafNames.has(path.slice(path.lastIndexOf("/") + 1)))
368
+ continue;
369
+ b.unloaded.push({
370
+ path,
371
+ role: "root",
372
+ scope: "repo",
373
+ reason: { kind: "on-demand", when: "subdirectory" },
374
+ });
375
+ }
376
+ }
377
+ /**
378
+ * Which file, if any, stops this repository's `AGENTS.md` being read — vendor:
379
+ * "a `CLAUDE.md`, `.claude/CLAUDE.md`, or `CLAUDE.local.md` in your working
380
+ * directory or any directory above it".
381
+ *
382
+ * 🔴 AN EXCLUDED `CLAUDE.md` DOES NOT SUPERSEDE, AND THAT IS A MEASUREMENT.
383
+ * Two vendor rules meet here and the vendor composes neither: the supersede
384
+ * rule is phrased about files you HAVE ("look for a `CLAUDE.md`… if you find
385
+ * one"), while `claudeMdExcludes` takes a file out of the chain without taking
386
+ * it off the disk. The page answers neither way, so this was run rather than
387
+ * argued — Claude Code **2.1.278**, fixtures and runner under
388
+ * `test/fixtures/instruction-chain-vendor/`, each file holding a codeword the
389
+ * model is then asked to recite with the file-reading tools denied:
390
+ *
391
+ * c0 both files, no settings → ALPHA only. Supersede happens at all.
392
+ * c1 `claudeMdExcludes` hides the CLAUDE.md → **BETA, and no ALPHA**. The AGENTS.md LOADS.
393
+ * c3 root hidden, `.claude/CLAUDE.md` kept → GAMMA only. A SURVIVING candidate still supersedes.
394
+ *
395
+ * c0 is the control: without it, c1 is a reading of an instrument nobody
396
+ * checked. c3 is what fixes the SHAPE of the fix — the answer is not "ignore
397
+ * exclusions", it is "the first candidate that is present AND not excluded",
398
+ * and the survivor is what gets named in `by`. Hence the predicate below
399
+ * rather than a boolean bypass.
400
+ *
401
+ * 🔴 AND THE OBVIOUS INSTRUMENT IS THE WRONG ONE, which is worth a line here
402
+ * because it nearly reversed this conclusion. An `InstructionsLoaded` hook
403
+ * looks like the exact tool for the job and is BLIND TO `AGENTS.md`: case c2 —
404
+ * a repository holding only an `AGENTS.md` — recites BETA (so the file
405
+ * plainly loaded) while the hook logs NOTHING. Read on the hook alone, c1's
406
+ * silence says "nothing loaded" and this whole function stays as it was. The
407
+ * silence is a fact about the hook. Both instruments are in the runner, with
408
+ * c2 as the case that tells them apart.
409
+ *
410
+ * ⚠️ WHAT THE MEASUREMENT DOES NOT COVER: it was taken on ONE build, and the
411
+ * vendor has already reversed a neighbouring fact once (`AGENTS.md` auto-load,
412
+ * v2.1.277). So the build number is part of the claim, not decoration. Re-run
413
+ * the fixtures before trusting this on a much later version.
414
+ *
415
+ * 🔴 A COMMITTED SUPERSEDER WINS OVER THE PER-MACHINE ONE, and the order of
416
+ * this array is the whole of that rule. Both can be present; picking the local
417
+ * file then would put `AGENTS.md` into `committedTotal`, claiming a teammate
418
+ * loads it — but that teammate has the `CLAUDE.md`, so they do not. The
419
+ * per-machine file is therefore last, and only answers when nothing committed
420
+ * did.
421
+ *
422
+ * ⚠️ THIS ASSUMES THE DEFAULT MODE, AND THE REPOSITORY CANNOT CONFIRM IT. The
423
+ * vendor's `claude-md-or-agents-md` (default) is what supersedes at all;
424
+ * `claude-md-and-agents-md` loads both families and supersedes nothing, and
425
+ * `managed-only` loads neither. The setting is read only from user-level, a
426
+ * `--settings` file or managed settings — "Claude Code ignores it in project
427
+ * and local settings files" — so a repo scan cannot see it, and the same
428
+ * commit therefore loads a different instruction set for two different people
429
+ * with no file in the tree saying which. Nothing in this function can close
430
+ * that; it is the module header's limit 1, restated at the line that assumes.
431
+ *
432
+ * ⚠️ AND THE THREE NON-COUNTING FILES ARE ABSENT BY CONSTRUCTION, not by an
433
+ * omission: `~/.claude/CLAUDE.md` and a managed `CLAUDE.md` are outside a
434
+ * repository audit entirely (reading `~` for a grade is wrong on its face), and
435
+ * `.claude/rules/` files "keep loading alongside `AGENTS.md`" — they are taken
436
+ * by {@link takeRules} in both modes and are not consulted here. Stated because
437
+ * a reader who adds the rules dir to this array would turn `AGENTS.md` off in
438
+ * every repository that has one.
439
+ */
440
+ function supersederOf(files, input, isExcluded) {
441
+ const candidates = [
442
+ { path: input.instructionFile, scope: "repo" },
443
+ {
444
+ path: `${input.userSurfaceRoot}/${input.instructionFile}`,
445
+ scope: "repo",
446
+ },
447
+ { path: localSiblingOf(input.instructionFile), scope: "local" },
448
+ ];
449
+ // PRESENT AND NOT EXCLUDED — c3 is why both halves are here. A bypass that
450
+ // just skipped the check when anything was excluded would name the wrong
451
+ // file in `by`, or none at all, in a repo that excludes one candidate and
452
+ // keeps another.
453
+ return candidates.find((c) => files[c.path] !== undefined && !isExcluded(c.path));
454
+ }
455
+ /** One loaded file's `@import` tokens: reported, and TAKEN when already present. */
456
+ function takeImportsOf(b, entry) {
457
+ const text = b.files[entry.path];
458
+ if (text === undefined)
459
+ return;
460
+ const known = new Set([...b.loaded, ...b.unloaded].map((e) => e.path));
461
+ for (const token of importTokens(text)) {
462
+ // The token as WRITTEN carries the `@`; the path is what it resolves to —
463
+ // against the IMPORTING FILE's directory, measured, see `resolveImportPath`.
464
+ const written = token.slice(1);
465
+ if (!(0, instruction_chain_js_1.isRepoRootedImport)(written))
466
+ continue;
467
+ const path = (0, instruction_chain_js_1.resolveImportPath)(entry.path, written);
468
+ if (!b.imports.some((n) => n.path === path && n.from === entry.path)) {
469
+ b.imports.push({ path, token, from: entry.path });
470
+ }
471
+ if (b.files[path] === undefined || known.has(path))
472
+ continue;
473
+ // The importer's scope is INHERITED: a committed file pulled in only by
474
+ // `CLAUDE.local.md` does not load for a teammate, so it must not be in the
475
+ // committed total either. And `via` travels with it, so the report can say
476
+ // WHY a file nobody expected is in the count.
477
+ //
478
+ // 🔴 EXCEPT WHEN THE IMPORTED FILE IS ONE MACHINE'S BY NAME, which is not a
479
+ // hypothetical: `@AGENTS.local.md` is one of the three real imports in the
480
+ // measured 214-file corpus. Inheriting `repo` there would put a gitignored
481
+ // file into a published `committedTotal` — the browser twin reads a GitHub
482
+ // tree and can never see it, so the CLI and the browser would disagree
483
+ // about the same commit. That is the defect `InstructionScope` exists to
484
+ // prevent, and a token in a committed file does not make the target
485
+ // committed. Inheritance still applies in the other direction: a `local`
486
+ // importer keeps `local`, because `"local"` is the narrower answer.
487
+ //
488
+ // ⏳ THIS LINE DOES NOT DECIDE WHETHER THE TOKEN SHOULD LOAD AT ALL — that
489
+ // is the open question at `isNeverRead`, and this predicate is right under
490
+ // either of its answers. See the note there before "simplifying" this.
491
+ take(b, path, {
492
+ role: "import",
493
+ scope: isPerMachineName(path) ? "local" : entry.scope,
494
+ via: { from: entry.path, token },
495
+ });
496
+ }
497
+ }
498
+ function claudeCodeInstructionChain(files, input) {
499
+ // PER SOURCE, not flattened. Flattening lost which file a pattern came from,
500
+ // and that is the whole fact `byScope` needs — see the measurement on
501
+ // `excluded-by-settings` in core.
502
+ const excluders = input.settingsSources.map((source) => ({
503
+ source,
504
+ matchers: compileExcludes(excludesIn(files[source.path], input.parseSettings)),
505
+ }));
506
+ const b = {
507
+ files,
508
+ loaded: [],
509
+ unloaded: [],
510
+ imports: [],
511
+ excluderOf: (path) => excluders.find((e) => e.matchers.some((m) => m.match(path)))?.source,
512
+ };
513
+ // ORDER. The one ordering fact the vendor states is that the local file is
514
+ // LAST — "the last thing Claude reads at that level" — so it is taken after
515
+ // the rules rather than beside the root file it is named for.
516
+ take(b, input.instructionFile, { role: "root", scope: "repo" });
517
+ take(b, `${input.userSurfaceRoot}/${input.instructionFile}`, {
518
+ role: "root",
519
+ scope: "repo",
520
+ });
521
+ // THE CROSS-FAMILY SWITCH. "At session start: every `AGENTS.md` and
522
+ // `.claude/AGENTS.md` in your working directory" — but only "when you have no
523
+ // CLAUDE.md in your working directory or above it". Both spellings, in the
524
+ // same root-then-dot-directory order as the two takes above; the vendor states
525
+ // no order BETWEEN them, and it cannot matter to a sum.
526
+ const superseder = supersederOf(files, input, (p) => b.excluderOf(p) !== undefined);
527
+ const crossToolPaths = [
528
+ AGENTS_FILE,
529
+ `${input.userSurfaceRoot}/${AGENTS_FILE}`,
530
+ ];
531
+ if (superseder === undefined) {
532
+ for (const path of crossToolPaths) {
533
+ take(b, path, { role: "root", scope: "repo" });
534
+ }
535
+ }
536
+ takeRules(b, input.ruleRe);
537
+ take(b, localSiblingOf(input.instructionFile), {
538
+ role: "root-local",
539
+ scope: "local",
540
+ });
541
+ // THE IMPORTS PASS, ONE LEVEL. It reads a SNAPSHOT of what is loaded so far,
542
+ // so a file pulled in by an import is not itself scanned for imports — see
543
+ // `resolveImports` in the core for the corpus measurement behind that, and for
544
+ // what it costs. Every path reported literally occurs in the file that names
545
+ // it, which is the property that keeps this from being a widening the ADAPTER
546
+ // chose rather than one the repo owner wrote.
547
+ //
548
+ // 🔴 IT RUNS BEFORE THE SUPERSEDE VERDICT, AND THAT ORDER IS A VENDOR ROW
549
+ // RATHER THAN A CONVENIENCE. The third row of the vendor's own table reads:
550
+ // "A `CLAUDE.md` that already imports `AGENTS.md`" → "Your `CLAUDE.md`, with
551
+ // `AGENTS.md` included through the import". So the idiom four of the six real
552
+ // imports in the measured corpus use — a `CLAUDE.md` holding `@AGENTS.md` —
553
+ // still LOADS that file, and marking it superseded first would have deleted
554
+ // the whole redirect finding. Both passes see `known`, so whichever gets there
555
+ // first owns the entry; this one is meant to.
556
+ //
557
+ // It also settles the two vendor sentences about what happens INSIDE an
558
+ // `AGENTS.md` at no extra cost, because both passes are role-blind: "`@path`
559
+ // imports are expanded" is this loop over any loaded entry, and
560
+ // "`claudeMdExcludes` patterns apply" is `take` above. Asserted rather than
561
+ // assumed — `instruction-chain.test.ts` runs each against an `AGENTS.md` that
562
+ // got in as a ROOT file, since a role-keyed version of either would pass every
563
+ // `CLAUDE.md` case and fail exactly those two.
564
+ for (const entry of [...b.loaded])
565
+ takeImportsOf(b, entry);
566
+ // THE SUBDIRECTORY PASS, AFTER THE IMPORTS AND NOT BEFORE THEM. Order is the
567
+ // whole of a fixed bug: this pass claims ANY slash path whose leaf is an
568
+ // instruction name, and `takeImportsOf` skips a path already `known`. Running
569
+ // it first therefore swallowed `@pkg/CLAUDE.md` and `@pkg/AGENTS.md` — a
570
+ // file the root instruction file literally imports, which the loader expands
571
+ // at session start (measured on 2.1.278, case `q2-import`: an `InstructionsLoaded` entry reading
572
+ // `pkg/CLAUDE.md | load_reason: include | parent_file_path: CLAUDE.md`) — and
573
+ // filed it `on-demand/subdirectory`, dropping its bytes from BOTH totals. Only
574
+ // the leaf name decided it, so `@pkg/style.md` was counted and `@pkg/CLAUDE.md`
575
+ // was not: the same import, reported two ways.
576
+ //
577
+ // 🔴 THE FIX IS THE ORDER, NOT A CONDITION, and that is why it is cheap. A
578
+ // sibling test — "is there a `pkg/CLAUDE.md` next to this `pkg/AGENTS.md`" —
579
+ // is the shape this pass CANNOT answer (see its own header: no `<dir>/…` path
580
+ // is ever a candidate, so absence carries no information). Letting the imports
581
+ // pass go first makes the question moot BY CONSTRUCTION: an imported file is
582
+ // taken because a token names it, and what this pass then sees is exactly the
583
+ // set nothing imported. The bound is untouched — no new path is read.
584
+ takeSubdirectories(b, new Set([input.instructionFile, AGENTS_FILE]), new Set(crossToolPaths));
585
+ // THE VERDICT, LAST: anything cross-tool that the passes above did not claim
586
+ // is present, unread, and the reason is a file rather than a setting. `scope`
587
+ // stays `"repo"` — this IS a committed file — and `byScope` carries whether
588
+ // the thing that silenced it is committed too, which is what decides whether a
589
+ // teammate loads it. See `weighInstructions`.
590
+ if (superseder !== undefined) {
591
+ const named = new Set([...b.loaded, ...b.unloaded].map((e) => e.path));
592
+ for (const path of crossToolPaths) {
593
+ if (b.files[path] === undefined || named.has(path))
594
+ continue;
595
+ b.unloaded.push({
596
+ path,
597
+ role: "root",
598
+ scope: "repo",
599
+ reason: {
600
+ kind: "superseded",
601
+ by: superseder.path,
602
+ byScope: superseder.scope,
603
+ },
604
+ });
605
+ }
606
+ }
607
+ return {
608
+ loaded: b.loaded,
609
+ unloaded: b.unloaded,
610
+ imports: b.imports,
611
+ patterns: [],
612
+ // A loaded file that is NOTHING BUT imports is a redirect, not instructions
613
+ // — reported as a shape so the report can say so instead of printing the
614
+ // reassuring size of a fourteen-byte pointer.
615
+ redirects: b.loaded.flatMap((entry) => {
616
+ const text = files[entry.path];
617
+ if (text === undefined || !isPureRedirect(text))
618
+ return [];
619
+ const to = b.imports
620
+ .filter((i) => i.from === entry.path)
621
+ .map((i) => i.path);
622
+ return to.length === 0 ? [] : [{ path: entry.path, to }];
623
+ }),
624
+ };
625
+ }
626
+ //# sourceMappingURL=instruction-chain.js.map
@@ -3,11 +3,11 @@
3
3
  * port's reference implementation). `loadPlugin` defaults to it; a Codex adapter
4
4
  * defines a sibling `codexLayout` and passes it to the same loader.
5
5
  * 🔴 THE PATHS BELOW ARE DOCUMENTED IN `docs/configuration.md`. Change any of
6
- * them — `instructionFile`, `surfaceDirs`, `userSurfaceRoot`, `rulesDir` — and
6
+ * them — `instructionFile`, `surfaces`, `userSurfaceRoot`, `rulesDir` — and
7
7
  * that page is wrong until you edit it too. The page marks this symbol with
8
8
  * `vigiles:symbol`, so RENAMING it turns `vigiles lint` red and forces the
9
9
  * edit; changing a VALUE in place does not, and nothing today catches that.
10
10
  */
11
- import type { PluginLayout } from "../../core/layout.js";
11
+ import { type PluginLayout } from "../../core/layout.js";
12
12
  export declare const claudeCodeLayout: PluginLayout;
13
13
  //# sourceMappingURL=layout.d.ts.map