simt-dev 0.2.1 → 0.3.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 (95) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/bin/simt-dev.mjs +21 -2
  3. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  4. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  5. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  6. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  7. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  8. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  9. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  10. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  11. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  12. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  13. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  14. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  15. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  16. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  17. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  18. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  19. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  20. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  21. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  22. package/package.json +2 -1
  23. package/rules/core.md +1 -0
  24. package/rules/stacks/android.md +2 -0
  25. package/rules/stacks/cloudflare-workers.md +2 -0
  26. package/rules/stacks/connectiq.md +1 -0
  27. package/rules/stacks/container.md +2 -0
  28. package/rules/stacks/dart.md +2 -0
  29. package/rules/stacks/deno.md +2 -0
  30. package/rules/stacks/dotnet.md +2 -0
  31. package/rules/stacks/elixir.md +2 -0
  32. package/rules/stacks/go.md +2 -0
  33. package/rules/stacks/jvm.md +2 -0
  34. package/rules/stacks/node.md +2 -0
  35. package/rules/stacks/python.md +2 -0
  36. package/rules/stacks/react-native.md +2 -0
  37. package/rules/stacks/react-web.md +2 -0
  38. package/rules/stacks/ruby.md +2 -0
  39. package/rules/stacks/rust.md +2 -0
  40. package/rules/stacks/swift.md +2 -0
  41. package/rules/stacks/typescript.md +2 -0
  42. package/rules/stacks/uv.md +2 -0
  43. package/src/agenthooks/decide.mjs +123 -0
  44. package/src/agenthooks/policy.mjs +78 -0
  45. package/src/agenthooks/render.mjs +240 -0
  46. package/src/argv.mjs +55 -1
  47. package/src/bench/ablate.mjs +630 -0
  48. package/src/bench/adapters.json +149 -0
  49. package/src/bench/grade.mjs +229 -0
  50. package/src/bench/mine.mjs +811 -0
  51. package/src/bench/probes.mjs +1350 -0
  52. package/src/bench/report.mjs +899 -0
  53. package/src/bench/run.mjs +1002 -0
  54. package/src/commands/agenthook.mjs +282 -0
  55. package/src/commands/bench.mjs +359 -0
  56. package/src/commands/compare.mjs +15 -0
  57. package/src/commands/graph.mjs +45 -21
  58. package/src/commands/harness.mjs +301 -0
  59. package/src/commands/help.mjs +73 -2
  60. package/src/commands/index.mjs +67 -25
  61. package/src/commands/init.mjs +12 -8
  62. package/src/commands/mcp.mjs +20 -5
  63. package/src/commands/plan.mjs +99 -0
  64. package/src/commands/rules.mjs +42 -57
  65. package/src/commands/skills.mjs +67 -92
  66. package/src/commands/skillsinstall.mjs +17 -2
  67. package/src/commands/sync.mjs +62 -6
  68. package/src/commands/tokens.mjs +169 -0
  69. package/src/compare/row.mjs +73 -2
  70. package/src/decisions/index.mjs +23 -5
  71. package/src/graph/spec.mjs +227 -0
  72. package/src/graph/speccheck.mjs +68 -0
  73. package/src/hooks/dispatcher.mjs +35 -3
  74. package/src/kernel/card.mjs +254 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/mcp/resources.mjs +157 -0
  77. package/src/mcp/schema.mjs +139 -0
  78. package/src/mcp/server.mjs +228 -20
  79. package/src/mcp/tools.mjs +372 -1
  80. package/src/measure/context.mjs +542 -0
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/monorepo.mjs +339 -0
  84. package/src/output/index.mjs +78 -2
  85. package/src/packs.mjs +182 -0
  86. package/src/plan/index.mjs +650 -0
  87. package/src/plan/template.mjs +331 -0
  88. package/src/projection/loaders.mjs +689 -0
  89. package/src/projection/manifest.mjs +114 -3
  90. package/src/projection/project.mjs +140 -9
  91. package/src/projection/rules.mjs +365 -22
  92. package/src/projection/surfaces.mjs +155 -23
  93. package/src/projection/targets.mjs +37 -0
  94. package/src/toolwords.mjs +69 -0
  95. package/templates/plan/tracker.json +36 -0
@@ -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
+ }
@@ -0,0 +1,267 @@
1
+ // Local session transcript attribution for `simt tokens --session <path>` (R06, ADR-075).
2
+ //
3
+ // Splits a recorded Claude Code or Codex JSONL/JSON session transcript into `rules`,
4
+ // `fileReads` and `toolOutput` using only the local file on disk. Remote URLs are
5
+ // refused before any socket or filesystem call so session contents never leave the
6
+ // machine.
7
+
8
+ import { readFile } from 'node:fs/promises';
9
+ import { resolve } from 'node:path';
10
+
11
+ import { tokens } from './context.mjs';
12
+
13
+ const RULE_PATH_RE =
14
+ /(?:^|\/)(?:AGENTS(?:\.override)?\.md|CLAUDE(?:\.local)?\.md|GEMINI\.md|SKILL\.md|\.cursor\/rules\/|\.claude\/rules\/|\.agents\/rules\/|\.github\/(?:copilot-instructions\.md|instructions\/)|\.simt-dev\/profiles\/)/;
15
+
16
+ const FILE_READ_TOOLS = new Set(['Read', 'read_file', 'NotebookRead', 'view_file', 'read']);
17
+
18
+ function bytes(text) {
19
+ return Buffer.byteLength(text);
20
+ }
21
+
22
+ function isRulePath(path) {
23
+ return typeof path === 'string' && RULE_PATH_RE.test(path.replace(/\\/g, '/'));
24
+ }
25
+
26
+ function textOfContent(content) {
27
+ if (typeof content === 'string') return content;
28
+ if (Array.isArray(content)) {
29
+ return content
30
+ .map((item) => {
31
+ if (typeof item === 'string') return item;
32
+ if (typeof item?.text === 'string') return item.text;
33
+ if (typeof item?.content === 'string') return item.content;
34
+ return '';
35
+ })
36
+ .filter(Boolean)
37
+ .join('\n');
38
+ }
39
+ if (content && typeof content === 'object') {
40
+ if (typeof content.text === 'string') return content.text;
41
+ if (typeof content.output === 'string') return content.output;
42
+ if (typeof content.content === 'string') return textOfContent(content.content);
43
+ }
44
+ return '';
45
+ }
46
+
47
+ /**
48
+ * Split a local Claude Code or Codex session transcript into `rules`, `fileReads` and
49
+ * `toolOutput` (R06, ADR-075).
50
+ */
51
+ export async function measureSession(transcriptPath) {
52
+ if (typeof transcriptPath !== 'string' || transcriptPath.trim() === '') {
53
+ return {
54
+ ok: false,
55
+ error: '--session requires a local transcript path',
56
+ remedy: 'pass a local JSONL or JSON transcript path: simt tokens --session <path>',
57
+ };
58
+ }
59
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(transcriptPath.trim())) {
60
+ return {
61
+ ok: false,
62
+ error: `${transcriptPath}: remote URLs are refused; session transcripts are read locally only (ADR-075)`,
63
+ remedy: 'pass a local file path on this machine: simt tokens --session ./session.jsonl',
64
+ };
65
+ }
66
+
67
+ const resolvedPath = resolve(transcriptPath);
68
+ let raw;
69
+ try {
70
+ raw = await readFile(resolvedPath, 'utf8');
71
+ } catch (err) {
72
+ return {
73
+ ok: false,
74
+ error: `${resolvedPath}: cannot read session transcript (${err.code ?? err.message})`,
75
+ remedy: 'pass a readable local JSONL or JSON transcript file to --session <path>',
76
+ };
77
+ }
78
+
79
+ let records = [];
80
+ const trimmed = raw.trim();
81
+ if (trimmed.length > 0) {
82
+ try {
83
+ if (trimmed.startsWith('[')) {
84
+ records = JSON.parse(trimmed);
85
+ } else {
86
+ records = trimmed
87
+ .split('\n')
88
+ .map((line) => line.trim())
89
+ .filter(Boolean)
90
+ .map((line) => JSON.parse(line));
91
+ }
92
+ } catch (err) {
93
+ return {
94
+ ok: false,
95
+ error: `${resolvedPath}: invalid JSON/JSONL session transcript (${err.message})`,
96
+ remedy: 'pass a valid Claude Code or Codex JSONL transcript file to --session <path>',
97
+ };
98
+ }
99
+ }
100
+
101
+ const callsById = new Map();
102
+ for (const rec of records) {
103
+ const msg = rec?.message ?? rec?.payload ?? rec;
104
+ const content = msg?.content;
105
+ if (Array.isArray(content)) {
106
+ for (const block of content) {
107
+ if (block?.type === 'tool_use' && block.id) {
108
+ const input = block.input ?? {};
109
+ const path = input.file_path ?? input.path ?? input.notebook_path ?? null;
110
+ callsById.set(block.id, { name: block.name, path });
111
+ }
112
+ }
113
+ }
114
+ if ((rec?.type === 'function_call' || msg?.type === 'function_call') && (rec.call_id ?? msg.call_id)) {
115
+ const id = rec.call_id ?? msg.call_id;
116
+ const name = rec.name ?? msg.name ?? '';
117
+ let args = {};
118
+ try {
119
+ args =
120
+ typeof (rec.arguments ?? msg.arguments) === 'string'
121
+ ? JSON.parse(rec.arguments ?? msg.arguments)
122
+ : (rec.arguments ?? msg.arguments ?? {});
123
+ } catch {
124
+ args = {};
125
+ }
126
+ const path = args.file_path ?? args.path ?? args.file ?? null;
127
+ callsById.set(id, { name, path });
128
+ }
129
+ }
130
+
131
+ const buckets = {
132
+ rules: { bytes: 0, items: 0 },
133
+ fileReads: { bytes: 0, items: 0 },
134
+ toolOutput: { bytes: 0, items: 0 },
135
+ };
136
+
137
+ const credit = (bucket, text) => {
138
+ const n = bytes(text);
139
+ if (n <= 0) return;
140
+ buckets[bucket].bytes += n;
141
+ buckets[bucket].items += 1;
142
+ };
143
+
144
+ for (const rec of records) {
145
+ if (!rec || typeof rec !== 'object') continue;
146
+
147
+ const explicitKind = rec.category ?? rec.kind;
148
+ if (explicitKind === 'rules' || explicitKind === 'rule') {
149
+ credit('rules', textOfContent(rec.text ?? rec.content ?? rec.output ?? ''));
150
+ continue;
151
+ }
152
+ if (explicitKind === 'file_read' || explicitKind === 'fileRead') {
153
+ const target = rec.path ?? rec.file ?? null;
154
+ credit(isRulePath(target) ? 'rules' : 'fileReads', textOfContent(rec.text ?? rec.content ?? rec.output ?? ''));
155
+ continue;
156
+ }
157
+ if (explicitKind === 'tool_output' || explicitKind === 'toolOutput') {
158
+ credit('toolOutput', textOfContent(rec.text ?? rec.content ?? rec.output ?? ''));
159
+ continue;
160
+ }
161
+
162
+ const msg = rec.message ?? rec.payload ?? rec;
163
+ const role = msg.role ?? rec.role;
164
+ const type = msg.type ?? rec.type;
165
+
166
+ if (
167
+ role === 'system' ||
168
+ role === 'developer' ||
169
+ type === 'system' ||
170
+ type === 'user_instructions' ||
171
+ type === 'instructions'
172
+ ) {
173
+ credit('rules', textOfContent(msg.content ?? msg.text ?? msg.instructions ?? ''));
174
+ continue;
175
+ }
176
+
177
+ if (type === 'session_meta' && (msg.instructions || msg.base_instructions)) {
178
+ credit('rules', textOfContent(msg.instructions ?? msg.base_instructions));
179
+ continue;
180
+ }
181
+
182
+ if (type === 'function_call_output' || type === 'custom_tool_call_output' || type === 'local_shell_call_output') {
183
+ const call = callsById.get(msg.call_id ?? rec.call_id);
184
+ const outText = textOfContent(msg.output ?? rec.output ?? '');
185
+ if (call && isRulePath(call.path)) credit('rules', outText);
186
+ else if (call && FILE_READ_TOOLS.has(call.name)) credit('fileReads', outText);
187
+ else credit('toolOutput', outText);
188
+ continue;
189
+ }
190
+
191
+ if (role === 'tool' || type === 'tool_result') {
192
+ const call = callsById.get(msg.tool_use_id ?? rec.tool_use_id ?? msg.call_id);
193
+ const target = msg.path ?? rec.path ?? call?.path ?? null;
194
+ const outText = textOfContent(msg.content ?? msg.output ?? '');
195
+ if (isRulePath(target)) credit('rules', outText);
196
+ else if ((call && FILE_READ_TOOLS.has(call.name)) || FILE_READ_TOOLS.has(msg.name ?? rec.name)) {
197
+ credit('fileReads', outText);
198
+ } else {
199
+ credit('toolOutput', outText);
200
+ }
201
+ continue;
202
+ }
203
+
204
+ const content = msg.content;
205
+ if (typeof content === 'string') {
206
+ for (const m of content.matchAll(
207
+ /<(?:user_instructions|instructions|system-reminder)>([\s\S]*?)<\/(?:user_instructions|instructions|system-reminder)>/g,
208
+ )) {
209
+ credit('rules', m[1]);
210
+ }
211
+ continue;
212
+ }
213
+
214
+ if (Array.isArray(content)) {
215
+ for (const block of content) {
216
+ if (!block || typeof block !== 'object') continue;
217
+ if (block.type === 'tool_result') {
218
+ const call = callsById.get(block.tool_use_id);
219
+ const target = block.path ?? call?.path ?? null;
220
+ const outText = textOfContent(block.content ?? block.output ?? '');
221
+ if (isRulePath(target)) credit('rules', outText);
222
+ else if ((call && FILE_READ_TOOLS.has(call.name)) || FILE_READ_TOOLS.has(block.name)) {
223
+ credit('fileReads', outText);
224
+ } else {
225
+ credit('toolOutput', outText);
226
+ }
227
+ } else if (typeof block.text === 'string') {
228
+ for (const m of block.text.matchAll(
229
+ /<(?:user_instructions|instructions|system-reminder)>([\s\S]*?)<\/(?:user_instructions|instructions|system-reminder)>/g,
230
+ )) {
231
+ credit('rules', m[1]);
232
+ }
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ const totalBytes = buckets.rules.bytes + buckets.fileReads.bytes + buckets.toolOutput.bytes;
239
+ const share = (n) => (totalBytes === 0 ? 0 : Math.round((n / totalBytes) * 100));
240
+
241
+ return {
242
+ ok: true,
243
+ path: resolvedPath,
244
+ rules: {
245
+ bytes: buckets.rules.bytes,
246
+ tokens: tokens(buckets.rules.bytes),
247
+ share: share(buckets.rules.bytes),
248
+ items: buckets.rules.items,
249
+ },
250
+ fileReads: {
251
+ bytes: buckets.fileReads.bytes,
252
+ tokens: tokens(buckets.fileReads.bytes),
253
+ share: share(buckets.fileReads.bytes),
254
+ items: buckets.fileReads.items,
255
+ },
256
+ toolOutput: {
257
+ bytes: buckets.toolOutput.bytes,
258
+ tokens: tokens(buckets.toolOutput.bytes),
259
+ share: share(buckets.toolOutput.bytes),
260
+ items: buckets.toolOutput.items,
261
+ },
262
+ total: {
263
+ bytes: totalBytes,
264
+ tokens: tokens(totalBytes),
265
+ },
266
+ };
267
+ }