simt-dev 0.2.2 → 0.3.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 (96) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +129 -75
  3. package/bin/simt-dev.mjs +21 -2
  4. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  5. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  6. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  7. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  8. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  9. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  10. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  11. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  12. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  13. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  14. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  15. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  16. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  17. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  18. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  19. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  20. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  21. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  22. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  23. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  24. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  25. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  26. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  27. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  28. package/package.json +2 -1
  29. package/rules/core.md +1 -1
  30. package/src/agenthooks/decide.mjs +123 -0
  31. package/src/agenthooks/policy.mjs +78 -0
  32. package/src/agenthooks/render.mjs +240 -0
  33. package/src/argv.mjs +54 -1
  34. package/src/bench/ablate.mjs +630 -0
  35. package/src/bench/adapters.json +149 -0
  36. package/src/bench/grade.mjs +229 -0
  37. package/src/bench/mine.mjs +811 -0
  38. package/src/bench/probes.mjs +1350 -0
  39. package/src/bench/report.mjs +899 -0
  40. package/src/bench/run.mjs +1002 -0
  41. package/src/ci/workflow.mjs +11 -1
  42. package/src/commands/agenthook.mjs +282 -0
  43. package/src/commands/bench.mjs +371 -0
  44. package/src/commands/evidence.mjs +96 -0
  45. package/src/commands/gate.mjs +165 -0
  46. package/src/commands/harness.mjs +156 -5
  47. package/src/commands/help.mjs +89 -1
  48. package/src/commands/hooks.mjs +45 -5
  49. package/src/commands/impact.mjs +205 -0
  50. package/src/commands/index.mjs +60 -1
  51. package/src/commands/map.mjs +91 -0
  52. package/src/commands/plan.mjs +99 -0
  53. package/src/commands/repoindex.mjs +52 -0
  54. package/src/commands/shared.mjs +398 -0
  55. package/src/commands/sync.mjs +72 -3
  56. package/src/commands/tests.mjs +192 -0
  57. package/src/commands/tokens.mjs +61 -1
  58. package/src/commands/why.mjs +177 -0
  59. package/src/decisions/index.mjs +23 -5
  60. package/src/graph/spec.mjs +1 -1
  61. package/src/hooks/dispatcher.mjs +15 -0
  62. package/src/hooks/shim.mjs +31 -0
  63. package/src/index/compact.mjs +228 -0
  64. package/src/index/extract.mjs +331 -0
  65. package/src/index/gate.mjs +101 -0
  66. package/src/index/govern.mjs +222 -0
  67. package/src/index/impact.mjs +186 -0
  68. package/src/index/map.mjs +393 -0
  69. package/src/index/persist.mjs +172 -0
  70. package/src/index/store.mjs +301 -0
  71. package/src/index/tests.mjs +294 -0
  72. package/src/kernel/budget.mjs +100 -0
  73. package/src/kernel/card.mjs +254 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/ledger/index.mjs +112 -0
  77. package/src/mcp/resources.mjs +157 -0
  78. package/src/mcp/server.mjs +37 -6
  79. package/src/mcp/tools.mjs +84 -0
  80. package/src/measure/context.mjs +184 -11
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/plan/index.mjs +650 -0
  84. package/src/plan/template.mjs +331 -0
  85. package/src/projection/disclosure.mjs +92 -0
  86. package/src/projection/loaders.mjs +689 -0
  87. package/src/projection/lock.mjs +93 -0
  88. package/src/projection/manifest.mjs +78 -3
  89. package/src/projection/markers.mjs +56 -17
  90. package/src/projection/project.mjs +24 -17
  91. package/src/projection/rules.mjs +452 -43
  92. package/src/projection/shared.mjs +428 -0
  93. package/src/projection/surfaces.mjs +22 -8
  94. package/src/projection/targets.mjs +2 -1
  95. package/src/toolwords.mjs +71 -0
  96. package/templates/plan/tracker.json +36 -0
@@ -21,14 +21,21 @@
21
21
  // 4. Write anything. `compare` is read-only for the same reason - a measurement
22
22
  // that can change what it measures is not a measurement.
23
23
 
24
- import { readFile, readdir } from 'node:fs/promises';
24
+ import { lstat, readFile, readdir } from 'node:fs/promises';
25
25
  import { basename, join } from 'node:path';
26
26
 
27
27
  import { resolveSurfaces } from '../projection/surfaces.mjs';
28
28
  import { parse } from '../projection/markers.mjs';
29
- import { budgetFinding, DEFAULT_BUDGET_BYTES, readManifest } from '../projection/manifest.mjs';
29
+ import {
30
+ budgetFinding,
31
+ declaredBudget,
32
+ DEFAULT_BUDGET_BYTES,
33
+ readManifest,
34
+ } from '../projection/manifest.mjs';
30
35
  import { extractFrontmatter } from '../projection/targets.mjs';
31
36
  import { readLimitFindings } from '../projection/project.mjs';
37
+ import { loader } from '../projection/loaders.mjs';
38
+ import { expandClaudeImports, viewOf } from './loaderView.mjs';
32
39
  import { TOOLS } from '../mcp/tools.mjs';
33
40
 
34
41
  /**
@@ -98,7 +105,7 @@ async function readText(path) {
98
105
  * guess about nesting; it is the bound that keeps a stray `node_modules` inside a
99
106
  * tool-owned directory from turning a byte count into an unbounded walk.
100
107
  */
101
- async function filesUnder(root, dir, suffix, depth = 0) {
108
+ async function filesUnder(root, dir, suffix, depth = 0, maxDepth = 6) {
102
109
  let entries;
103
110
  try {
104
111
  entries = await readdir(join(root, dir), { withFileTypes: true });
@@ -112,8 +119,8 @@ async function filesUnder(root, dir, suffix, depth = 0) {
112
119
  const entry = entries.find((e) => e.name === name);
113
120
  const path = `${dir}/${name}`;
114
121
  if (entry.isDirectory()) {
115
- if (depth >= 6) continue;
116
- out.push(...(await filesUnder(root, `${path}`, suffix, depth + 1)));
122
+ if (depth >= maxDepth) continue;
123
+ out.push(...(await filesUnder(root, `${path}`, suffix, depth + 1, maxDepth)));
117
124
  } else if (entry.isFile() && (!suffix || name.endsWith(suffix))) {
118
125
  out.push(path);
119
126
  }
@@ -121,6 +128,46 @@ async function filesUnder(root, dir, suffix, depth = 0) {
121
128
  return out;
122
129
  }
123
130
 
131
+ /** True when `rel` under `root` is a regular file and not a symlink. */
132
+ async function isRegularFile(root, rel) {
133
+ try {
134
+ const st = await lstat(join(root, rel));
135
+ return st.isFile() && !st.isSymbolicLink();
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ /** Generated span bytes of a document (0 when the file carries no valid region). */
142
+ function generatedSpanBytes(text) {
143
+ const state = parse(text);
144
+ if (state.status !== 'ok') return 0;
145
+ const open = state.before.length === 0 ? 0 : state.before.length + 1;
146
+ const close = state.after.length === 0 ? text.length : text.length - state.after.length - 1;
147
+ return bytes(text.slice(open, close));
148
+ }
149
+
150
+ /**
151
+ * Whether a rule file loads unconditionally at session start according to the
152
+ * reader's loader row (R14).
153
+ *
154
+ * A file with no frontmatter block at all is treated as unconditional (e.g. a
155
+ * `.claude/rules/*.md` file or a bare `.mdc`); a file that declares frontmatter is
156
+ * filtered against the loader row's `frontmatter` requirement so scoped rules
157
+ * (`alwaysApply: false`, `trigger: glob`, `trigger: model_decision`, path-specific
158
+ * `applyTo`, or Claude `paths:`) are not billed to session start.
159
+ */
160
+ function isAlwaysOnRule(surfaceId, ruleEntry, text) {
161
+ const block = extractFrontmatter(text);
162
+ if (!block) return true;
163
+ const meta = frontmatter(text);
164
+ if (surfaceId === 'claude' && meta.paths) return false;
165
+ if (ruleEntry?.frontmatter) {
166
+ return Object.entries(ruleEntry.frontmatter).every(([k, v]) => meta[k] === v);
167
+ }
168
+ return true;
169
+ }
170
+
124
171
  /** Frontmatter keys, as an object. One block, no nesting - which is all a SKILL.md has. */
125
172
  function frontmatter(text) {
126
173
  const block = extractFrontmatter(text);
@@ -176,13 +223,68 @@ async function documentsFor(root, surface) {
176
223
  }
177
224
  }
178
225
 
226
+ if (surface.id === 'claude') {
227
+ const claudeLoader = loader('claude');
228
+ const seenImports = new Set();
229
+ for (const candidate of ['CLAUDE.md', 'CLAUDE.local.md']) {
230
+ if (!(await isRegularFile(root, candidate))) continue;
231
+ const text = await readText(join(root, candidate));
232
+ if (text === null) continue;
233
+ seenImports.add(candidate);
234
+ out.push({
235
+ channel: 'rules',
236
+ file: candidate,
237
+ text,
238
+ generatedBytes: generatedSpanBytes(text),
239
+ readerBytes: bytes(text),
240
+ });
241
+ const depth = claudeLoader?.imports?.depth ?? 4;
242
+ for (const imp of await expandClaudeImports(root, candidate, text, depth, seenImports)) {
243
+ out.push({
244
+ channel: 'imports',
245
+ file: imp.file,
246
+ text: imp.text,
247
+ generatedBytes: generatedSpanBytes(imp.text),
248
+ readerBytes: bytes(imp.text),
249
+ });
250
+ }
251
+ }
252
+ }
253
+
254
+ if (surface.id === 'copilot' && (await isRegularFile(root, '.github/copilot-instructions.md'))) {
255
+ const rel = '.github/copilot-instructions.md';
256
+ const text = await readText(join(root, rel));
257
+ if (text !== null) {
258
+ out.push({
259
+ channel: 'rules',
260
+ file: rel,
261
+ text,
262
+ generatedBytes: generatedSpanBytes(text),
263
+ readerBytes: bytes(text),
264
+ });
265
+ }
266
+ }
267
+
179
268
  if (surface.rule) {
180
269
  const dir = surface.rule.slice(0, surface.rule.lastIndexOf('/'));
181
270
  const suffix = surface.rule.slice(surface.rule.lastIndexOf('.') + 1);
182
- for (const file of await filesUnder(root, dir, `.${suffix}`)) {
271
+ const row = loader(surface.id);
272
+ const ruleEntry = row?.start.find((e) => e.dir === dir && e.atStart) ?? null;
273
+ // Antigravity scans `.agents/rules/` flat (`tree: 'flat'`) and ignores nested
274
+ // directories; Cursor, Claude and Copilot walk subdirectories (`tree: 'nested'`).
275
+ const maxDepth = ruleEntry?.tree === 'flat' ? 0 : 6;
276
+ for (const file of await filesUnder(root, dir, `.${suffix}`, 0, maxDepth)) {
183
277
  const text = await readText(join(root, file));
184
278
  if (text === null) continue;
185
- out.push({ channel: 'rules', file, text, generatedBytes: bytes(text), readerBytes: bytes(text) });
279
+ if (!isAlwaysOnRule(surface.id, ruleEntry, text)) continue;
280
+ const isGenerated = parse(text).status === 'ok';
281
+ out.push({
282
+ channel: 'rules',
283
+ file,
284
+ text,
285
+ generatedBytes: isGenerated ? bytes(text) : 0,
286
+ readerBytes: bytes(text),
287
+ });
186
288
  }
187
289
  }
188
290
 
@@ -231,10 +333,7 @@ export async function measureRoot(root) {
231
333
 
232
334
  const generatedBytes = documents.reduce((sum, d) => sum + d.generatedBytes, 0);
233
335
  const readerBytes = documents.reduce((sum, d) => sum + d.readerBytes, 0);
234
- const budgetBytes =
235
- manifest?.budget?.bytes !== undefined && Number.isFinite(manifest.budget.bytes)
236
- ? manifest.budget.bytes
237
- : ALWAYS_ON_BUDGET_BYTES;
336
+ const budgetBytes = declaredBudget(manifest);
238
337
 
239
338
  const byChannel = new Map();
240
339
  for (const doc of documents) {
@@ -268,6 +367,52 @@ export async function measureRoot(root) {
268
367
  }
269
368
  }
270
369
 
370
+ // R14 chain and per-file caps: Codex's 32 KiB applies to the merged root-to-cwd
371
+ // chain, so a repository where every individual AGENTS.md sits under 32 KiB can still
372
+ // drop its deepest file once merged; Antigravity's 24,000 B cap applies per rule file.
373
+ const codexLoader = loader('codex');
374
+ if (codexLoader?.cap?.scope === 'chain') {
375
+ for (const dir of await instructionDirs(root)) {
376
+ const view = await viewOf(root, codexLoader, { cwd: join(root, dir) });
377
+ if (!view.cap?.over) continue;
378
+ // If a single root AGENTS.md already tripped `readLimitFindings` above, do not
379
+ // report the same 1-file root overflow twice.
380
+ if (view.files.length === 1 && readLimits.some((r) => r.file === view.files[0].file && r.reader === 'Codex')) {
381
+ continue;
382
+ }
383
+ const chainLabel = view.files.map((f) => f.file).join(' -> ');
384
+ const overBy = view.cap.totalBytes - view.cap.bytes;
385
+ readLimits.push({
386
+ file: chainLabel,
387
+ reader: 'Codex',
388
+ limit: view.cap.bytes,
389
+ endByte: view.cap.totalBytes,
390
+ overBy,
391
+ });
392
+ findings.push(
393
+ `${chainLabel}: BEYOND-READ-LIMIT — merged chain is ${view.cap.totalBytes} B, ${overBy} B past Codex's ${view.cap.bytes} B (${view.cap.config}) chain limit`,
394
+ );
395
+ }
396
+ }
397
+
398
+ const antigravityCap = loader('antigravity')?.cap;
399
+ if (antigravityCap?.scope === 'file') {
400
+ for (const doc of documents) {
401
+ if (!doc.file.startsWith('.agents/rules/') || doc.readerBytes <= antigravityCap.bytes) continue;
402
+ const overBy = doc.readerBytes - antigravityCap.bytes;
403
+ readLimits.push({
404
+ file: doc.file,
405
+ reader: 'Antigravity',
406
+ limit: antigravityCap.bytes,
407
+ endByte: doc.readerBytes,
408
+ overBy,
409
+ });
410
+ findings.push(
411
+ `${doc.file}: BEYOND-READ-LIMIT — rule file is ${doc.readerBytes} B, ${overBy} B past Antigravity's ${antigravityCap.bytes} B per-file limit`,
412
+ );
413
+ }
414
+ }
415
+
271
416
  const name = root.split('/').filter(Boolean).pop() ?? root;
272
417
  return {
273
418
  name,
@@ -368,4 +513,32 @@ export function totals(rows) {
368
513
  repeatBytes,
369
514
  overBudget: rows.filter((r) => r.budget.over).map((r) => r.name),
370
515
  };
516
+ }
517
+
518
+ /**
519
+ * Directories under `root` that carry an `AGENTS.md` or `AGENTS.override.md` beyond
520
+ * a lone root `AGENTS.md`, sorted for determinism.
521
+ */
522
+ async function instructionDirs(root, rel = '', depth = 0, out = []) {
523
+ let entries;
524
+ try {
525
+ entries = await readdir(join(root, rel), { withFileTypes: true });
526
+ } catch {
527
+ return out;
528
+ }
529
+ const names = entries.map((e) => e.name).sort();
530
+ const hasOverride = names.includes('AGENTS.override.md');
531
+ const hasAgents = names.includes('AGENTS.md');
532
+ if ((rel !== '' && (hasAgents || hasOverride)) || (rel === '' && hasOverride)) {
533
+ out.push(rel);
534
+ }
535
+ if (depth >= 4) return out;
536
+ for (const name of names) {
537
+ if (name.startsWith('.') || name === 'node_modules' || name === 'dist' || name === 'build') continue;
538
+ const entry = entries.find((e) => e.name === name);
539
+ if (entry?.isDirectory()) {
540
+ await instructionDirs(root, rel === '' ? name : `${rel}/${name}`, depth + 1, out);
541
+ }
542
+ }
543
+ return out;
371
544
  }
@@ -0,0 +1,435 @@
1
+ // One reader's view of one repository: which files it loads before the first message,
2
+ // in what order, and what the cap leaves out.
3
+ //
4
+ // This is the consumer R14 was built for. Before the loader table, `simt tokens` counted
5
+ // the files a surface WRITES — which is why it recursed six levels into `.agents/rules/`
6
+ // and reported bytes Antigravity never reads, and why it could not see a repository that
7
+ // is inside 32 KiB in every file and past it once Codex merges the chain.
8
+ //
9
+ // What it deliberately does not do:
10
+ //
11
+ // 1. Walk the user's home. A reader's global instructions are the reader's, not the
12
+ // repository's, and a per-repository report that carried them would change because
13
+ // somebody installed something. `scopes` reports that a user scope exists; the bytes
14
+ // are the repository's.
15
+ // 2. Decide whether a file matches. `frontmatter` in the table is an equality filter
16
+ // on parsed frontmatter, which is all any of these vendors document. Cursor's
17
+ // `alwaysApply: true` and Antigravity's `trigger: always_on` are exactly that.
18
+ // 3. Expand imports. Claude's `@path` and Antigravity's `@[label](path)` are recorded
19
+ // with their depth, and `imports` reports what the reader would pull in — a
20
+ // measurement of the syntax, not a recursive parse of the file that would need a
21
+ // frontmatter-aware Markdown parser this package does not have (ADR-012).
22
+
23
+ import { readFile, readdir, stat } from 'node:fs/promises';
24
+ import { dirname, join, relative, resolve, sep } from 'node:path';
25
+
26
+ import { LOADERS, caps } from '../projection/loaders.mjs';
27
+ import { extractFrontmatter } from '../projection/targets.mjs';
28
+ import { BYTES_PER_TOKEN } from './context.mjs';
29
+
30
+ /**
31
+ * The longest directory walk any of these readers documents.
32
+ *
33
+ * A bound, not a guess about nesting: a stray `node_modules` inside a tool-owned
34
+ * directory must not turn a byte count into an unbounded walk.
35
+ */
36
+ const MAX_DEPTH = 8;
37
+
38
+ /** UTF-8 byte length. `String.length` counts UTF-16 units and undercounts every
39
+ * non-ASCII character, and a rule file with an em dash in it is ordinary. */
40
+ function bytes(text) {
41
+ return Buffer.byteLength(text);
42
+ }
43
+
44
+ /** Frontmatter as a flat object. One block, no nesting — all a rule file carries. */
45
+ function frontmatter(text) {
46
+ const block = extractFrontmatter(text);
47
+ const out = {};
48
+ if (!block) return out;
49
+ for (const line of block.split('\n').slice(1)) {
50
+ if (line.trim() === '---') break;
51
+ const at = line.indexOf(':');
52
+ if (at < 1) continue;
53
+ out[line.slice(0, at).trim()] = line
54
+ .slice(at + 1)
55
+ .trim()
56
+ .replace(/^["']|["']$/g, '');
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Files under a directory in a stable order.
63
+ *
64
+ * `depth` bounds the recursion. Nothing here filters dotfiles: a reader that documented
65
+ * ignoring them is not one whose docs were read, and a `.md` inside `.agents/rules/` is
66
+ * a rule.
67
+ */
68
+ async function filesUnder(dir, depth = 0, prefix = '') {
69
+ let entries;
70
+ try {
71
+ entries = await readdir(dir, { withFileTypes: true });
72
+ } catch {
73
+ return [];
74
+ }
75
+ const out = [];
76
+ for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
77
+ const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
78
+ if (entry.isDirectory()) {
79
+ if (depth >= MAX_DEPTH) continue;
80
+ out.push(...(await filesUnder(join(dir, entry.name), depth + 1, rel)));
81
+ } else if (entry.isFile()) {
82
+ out.push(rel);
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ async function readText(path) {
89
+ try {
90
+ return await readFile(path, 'utf8');
91
+ } catch {
92
+ // Absence is the expected answer for a reader configured in a repository that has
93
+ // not written the file yet, and a permission error is not a byte count either.
94
+ return null;
95
+ }
96
+ }
97
+
98
+ async function exists(root, rel) {
99
+ try {
100
+ return (await stat(join(root, rel))).isFile();
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Resolve one `start` entry into the repository files it loads.
108
+ *
109
+ * `chain` is the case that needs care and gets it: Codex takes at most one file per
110
+ * directory in precedence order, and the entries are the precedence list. Each entry is
111
+ * resolved separately and the first one present wins per directory, which is the rule as
112
+ * documented rather than as inferred — but the entries carry the same root, so the merge
113
+ * is over the same directory set and the winner is the entry order in the table.
114
+ *
115
+ * Returns `[]` for an entry whose documented precondition fails: `absent` names paths
116
+ * that must not exist, so `CLAUDE.md` beside `AGENTS.md` is exactly what makes Claude
117
+ * Code skip the latter.
118
+ */
119
+ async function resolveEntry(root, entry) {
120
+ if (entry.scope !== 'project' || !entry.atStart) return [];
121
+ if (entry.absent && (await Promise.all(entry.absent.map((p) => exists(root, p)))).some(Boolean)) {
122
+ return [];
123
+ }
124
+
125
+ const hit = async (rel, note) => {
126
+ const text = await readText(join(root, rel));
127
+ if (text === null) return [];
128
+ if (entry.frontmatter) {
129
+ const meta = frontmatter(text);
130
+ const matches = Object.entries(entry.frontmatter).every(([k, v]) => meta[k] === v);
131
+ if (!matches) return [];
132
+ }
133
+ return [{ file: rel, text, note: note ?? null }];
134
+ };
135
+
136
+ switch (entry.tree) {
137
+ case 'single':
138
+ return hit(entry.path);
139
+ case 'chain': {
140
+ // The repository root is the only directory measured: these readers walk between
141
+ // the project root and the working directory, and a repository's own files are
142
+ // what it controls. A nested AGENTS.md is picked up by the nested reads below.
143
+ return hit(entry.path);
144
+ }
145
+ case 'flat': {
146
+ const out = [];
147
+ for (const rel of await filesUnder(join(root, entry.dir))) {
148
+ if (rel.includes('/')) continue;
149
+ if (entry.suffix && !rel.endsWith(entry.suffix)) continue;
150
+ out.push(...(await hit(`${entry.dir}/${rel}`)));
151
+ }
152
+ return out;
153
+ }
154
+ case 'nested': {
155
+ const out = [];
156
+ for (const rel of await filesUnder(join(root, entry.dir))) {
157
+ if (entry.suffix && !rel.endsWith(entry.suffix)) continue;
158
+ out.push(...(await hit(`${entry.dir}/${rel}`)));
159
+ }
160
+ return out;
161
+ }
162
+ default:
163
+ return [];
164
+ }
165
+ }
166
+
167
+ /** One measured file: what it weighs, what it costs a turn, and what the caps did to it. */
168
+ function rowFor(found, entry, channel) {
169
+ return {
170
+ file: found.file,
171
+ scope: entry.scope,
172
+ channel,
173
+ bytes: bytes(found.text),
174
+ // A skill's frontmatter is what costs a turn; its body is opened later. Charging the
175
+ // body here is the error the `metadata` flag exists to prevent.
176
+ countedBytes: entry.metadata ? bytes(skillHead(found.text)) : bytes(found.text),
177
+ overCap: false,
178
+ beyondCap: false,
179
+ why: entry.why,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * What one reader loads from one repository, at session start.
185
+ *
186
+ * `chainBytes` is the sum in load order, and `cap` is what that reader does to it: a
187
+ * chain-scoped cap truncates the chain, a file-scoped cap truncates each file. Both
188
+ * report `droppedBytes`, because "over the limit" without "by how much and which file"
189
+ * is the number that started this slice.
190
+ *
191
+ * `overCap` on a row means THIS FILE is truncated; `beyondCap` means the chain stopped
192
+ * before reaching it. They are different failures with the same visible symptom, and the
193
+ * one that costs a repository its deepest instructions is the second.
194
+ */
195
+ export async function viewOf(root, row, options = {}) {
196
+ const cwd = options.cwd ? relative(root, options.cwd).split(sep).join('/') : '';
197
+ const files = [];
198
+
199
+ // The chain is walked directory by directory — project root down to the working
200
+ // directory, which is the order Codex and Antigravity document and the reverse of what
201
+ // a reader that walks *up* from the working directory sees. Inside one directory the
202
+ // entries are the precedence list and the first one present wins: two AGENTS.md files
203
+ // in the same directory are one of them not loaded, not both concatenated.
204
+ //
205
+ // With no `cwd` this is the root alone, which is the honest default: a repository's
206
+ // own root document is what the tool can measure without knowing where the agent is
207
+ // standing, and `--at` is how a reader asks about a subdirectory.
208
+ const chainEntries = row.start.filter((e) => e.tree === 'chain');
209
+ const parts = cwd === '' || cwd.startsWith('..') ? [] : cwd.split('/').filter(Boolean);
210
+ const dirs = ['', ...parts.map((_, i) => parts.slice(0, i + 1).join('/'))];
211
+ for (const dir of dirs) {
212
+ // `first` is Codex and Claude: one file per directory, precedence order.
213
+ // `all` is DeepSeek: every candidate loads, and a later file whose trimmed
214
+ // text matches an earlier one in the SAME directory is the duplicate the
215
+ // vendor collapses. The set resets per directory because a nested file that
216
+ // happens to match the root is a different scope, not a duplicate.
217
+ const mergeAll = row.chainMerge === 'all';
218
+ const trimmed = new Set();
219
+ let taken = false;
220
+ for (const entry of chainEntries) {
221
+ if (taken && !mergeAll) break;
222
+ const scoped = {
223
+ ...entry,
224
+ path: dir === '' ? entry.path : `${dir}/${entry.path}`,
225
+ absent: entry.absent?.map((p) => join(dir, p)),
226
+ };
227
+ const found = await resolveEntry(root, scoped);
228
+ if (found.length === 0) continue;
229
+ if (row.dedupe === 'trim') {
230
+ const key = found[0].text.trim();
231
+ if (trimmed.has(key)) continue;
232
+ trimmed.add(key);
233
+ }
234
+ if (!mergeAll) taken = true;
235
+ files.push(rowFor(found[0], entry, 'documents'));
236
+ if (row.imports?.syntax === '@path' && row.imports.depth > 0) {
237
+ for (const imp of await expandClaudeImports(root, found[0].file, found[0].text, row.imports.depth)) {
238
+ files.push(rowFor(imp, { scope: 'project', why: row.imports.why }, 'imports'));
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ for (const entry of row.start) {
245
+ if (entry.tree === 'chain') continue;
246
+ for (const found of await resolveEntry(root, entry)) {
247
+ // Claude Code treats `paths:` in `.claude/rules/*.md` frontmatter as path-scoped,
248
+ // not session-start.
249
+ if (row.id === 'claude' && entry.dir === '.claude/rules' && frontmatter(found.text).paths) continue;
250
+ files.push(
251
+ rowFor(found, entry, entry.metadata ? 'skills' : entry.tree === 'single' ? 'documents' : 'rules'),
252
+ );
253
+ }
254
+ }
255
+
256
+ // One skill id is one cost. OpenCode v2 registers .claude, then .agents, then
257
+ // .opencode, and the later source supplies the skill. Counting every copy would
258
+ // bill a turn for a body the reader replaced.
259
+ const skillsById = new Map();
260
+ const measured = [];
261
+ for (const file of files) {
262
+ if (file.channel !== 'skills') {
263
+ measured.push(file);
264
+ continue;
265
+ }
266
+ const id = file.file.split('/').at(-2) ?? file.file;
267
+ skillsById.set(id, file);
268
+ }
269
+ measured.push(...skillsById.values());
270
+ files.length = 0;
271
+ files.push(...measured);
272
+
273
+ const limits = caps(row);
274
+ const chainCap = limits.find((c) => c.scope === 'chain') ?? null;
275
+ // A token budget is a budget, but it is not a byte count: 20,000 tokens is 80,000
276
+ // bytes at the four-bytes-per-token approximation, and comparing raw bytes to it
277
+ // would trip on a repository that has not come close. So the token figure is
278
+ // converted here and reported in the unit it was documented in.
279
+ const tokenCap = limits.find((c) => c.scope === 'tokens') ?? null;
280
+ const tokenCapBytes = tokenCap ? tokenCap.bytes * BYTES_PER_TOKEN : null;
281
+ const fileCap = limits.find((c) => c.scope === 'file') ?? null;
282
+
283
+ // Both caps are checked, not either-or: Antigravity has a per-file byte cap and an
284
+ // aggregate token budget, and a rule can trip the first without tripping the second.
285
+ // Which one fired is reported separately, because the remedy differs.
286
+ let running = 0;
287
+ for (const file of files) {
288
+ running += file.countedBytes;
289
+ if (fileCap && file.countedBytes > fileCap.bytes) file.overCap = true;
290
+ }
291
+ if (chainCap?.drop === 'broader') {
292
+ // DeepSeek keeps the most specific files. Broader files are omitted whole;
293
+ // only the most specific file may be cut, and the rendered total stays inside
294
+ // the cap. A source already over the per-file cap is ignored before this.
295
+ const eligible = files.filter((f) => f.channel === 'documents' && !f.overCap);
296
+ let sum = eligible.reduce((n, f) => n + f.countedBytes, 0);
297
+ for (let i = 0; i < eligible.length && sum > chainCap.bytes; i += 1) {
298
+ const file = eligible[i];
299
+ const last = i === eligible.length - 1;
300
+ if (!last) {
301
+ file.beyondCap = true;
302
+ sum -= file.countedBytes;
303
+ continue;
304
+ }
305
+ file.truncated = true;
306
+ file.keptBytes = chainCap.bytes - (sum - file.countedBytes);
307
+ }
308
+ } else if (chainCap) {
309
+ let chainRunning = 0;
310
+ for (const file of files) {
311
+ if (file.overCap) continue;
312
+ chainRunning += file.countedBytes;
313
+ if (chainRunning > chainCap.bytes) file.beyondCap = true;
314
+ }
315
+ }
316
+ const contributed = (file) => {
317
+ if (file.beyondCap) return 0;
318
+ if (file.truncated) return file.keptBytes ?? 0;
319
+ if (file.overCap) return file.keptBytes ?? 0;
320
+ return file.countedBytes;
321
+ };
322
+ const totalBytes = files.reduce((n, file) => n + contributed(file), 0);
323
+
324
+ return {
325
+ reader: row.id,
326
+ label: row.label,
327
+ root,
328
+ order: row.order,
329
+ nested: row.nested,
330
+ imports: row.imports,
331
+ files,
332
+ totalBytes,
333
+ droppedBytes: files.reduce((n, f) => {
334
+ if (!f.beyondCap && !f.overCap && !f.truncated) return n;
335
+ return n + f.countedBytes - (f.truncated || f.overCap ? (f.keptBytes ?? 0) : 0);
336
+ }, 0),
337
+ // Reported, not measured: the user's global instructions are the reader's bytes.
338
+ // A per-repository total that included them would change when the reader installed
339
+ // something, which is the reason `--user` is a flag on `harness detect` and not a
340
+ // default anywhere in this tool.
341
+ userScope: row.start.filter((e) => e.scope === 'user').map((e) => e.path),
342
+ cap: chainCap
343
+ ? {
344
+ bytes: chainCap.bytes,
345
+ scope: chainCap.scope,
346
+ appliesTo: chainCap.appliesTo,
347
+ config: chainCap.config,
348
+ why: chainCap.why,
349
+ totalBytes: running,
350
+ over: running > chainCap.bytes,
351
+ }
352
+ : null,
353
+ budget: tokenCap
354
+ ? {
355
+ tokens: tokenCap.bytes,
356
+ bytes: tokenCapBytes,
357
+ appliesTo: tokenCap.appliesTo,
358
+ config: tokenCap.config ?? null,
359
+ why: tokenCap.why,
360
+ over: running > tokenCapBytes,
361
+ }
362
+ : null,
363
+ perFileCap: fileCap
364
+ ? {
365
+ bytes: fileCap.bytes,
366
+ appliesTo: fileCap.appliesTo,
367
+ config: fileCap.config,
368
+ why: fileCap.why,
369
+ over: files.some((f) => f.overCap),
370
+ }
371
+ : null,
372
+ skills: row.skills,
373
+ hooks: row.hooks,
374
+ mcp: row.mcp,
375
+ verified: row.verified,
376
+ note: row.note ?? null,
377
+ };
378
+ }
379
+
380
+ /** Name and description, which is what a reader loads before it opens a skill body. */
381
+ function skillHead(text) {
382
+ const meta = frontmatter(text);
383
+ const name = meta.name ?? '';
384
+ const description = meta.description ?? '';
385
+ if (description) return `${name}\n${description}`;
386
+ return name;
387
+ }
388
+
389
+ /**
390
+ * Expand Claude Code `@path/to/import` directives up to `maxDepth` hops.
391
+ *
392
+ * Follows the documented parser rules on `claude.imports`:
393
+ * - skips fenced code blocks and inline code spans;
394
+ * - skips quoted `@path` tokens and `@[label](path)` links;
395
+ * - resolves relative to the importing file's directory;
396
+ * - refuses paths outside the repository root (Claude Code prompts for approval
397
+ * before reading outside the working directory, so those bytes are not automatic).
398
+ */
399
+ export async function expandClaudeImports(
400
+ root,
401
+ fromRel,
402
+ text,
403
+ maxDepth = 4,
404
+ seen = new Set([fromRel]),
405
+ depth = 0,
406
+ ) {
407
+ if (depth >= maxDepth) return [];
408
+ const absRoot = resolve(root);
409
+ const stripped = text
410
+ .replace(/```[\s\S]*?```/g, '')
411
+ .replace(/~~~[\s\S]*?~~~/g, '')
412
+ .replace(/`[^`\n]*`/g, '');
413
+
414
+ const out = [];
415
+ const baseDir = dirname(join(absRoot, fromRel));
416
+ for (const match of stripped.matchAll(/(?:^|(?<=[\s(]))@([A-Za-z0-9_./-]+\.[A-Za-z0-9]+)(?=$|[\s),.;:!?\])])/gm)) {
417
+ const rawTarget = match[1];
418
+ const absTarget = resolve(baseDir, rawTarget);
419
+ const rel = relative(absRoot, absTarget).split(sep).join('/');
420
+ if (rel === '' || rel.startsWith('..') || seen.has(rel)) continue;
421
+ const body = await readText(absTarget);
422
+ if (body === null) continue;
423
+ seen.add(rel);
424
+ out.push({ file: rel, text: body });
425
+ out.push(...(await expandClaudeImports(absRoot, rel, body, maxDepth, seen, depth + 1)));
426
+ }
427
+ return out;
428
+ }
429
+
430
+ /** Every reader's view of one repository, in table order. */
431
+ export async function viewAll(root, rows = LOADERS, options = {}) {
432
+ const out = [];
433
+ for (const row of rows) out.push(await viewOf(root, row, options));
434
+ return out;
435
+ }