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.
- package/CHANGELOG.md +239 -0
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
- package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
- package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
- package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
- package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
- package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
- package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
- package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
- package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
- package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/package.json +2 -1
- package/rules/core.md +1 -0
- package/rules/stacks/android.md +2 -0
- package/rules/stacks/cloudflare-workers.md +2 -0
- package/rules/stacks/connectiq.md +1 -0
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +2 -0
- package/rules/stacks/deno.md +2 -0
- package/rules/stacks/dotnet.md +2 -0
- package/rules/stacks/elixir.md +2 -0
- package/rules/stacks/go.md +2 -0
- package/rules/stacks/jvm.md +2 -0
- package/rules/stacks/node.md +2 -0
- package/rules/stacks/python.md +2 -0
- package/rules/stacks/react-native.md +2 -0
- package/rules/stacks/react-web.md +2 -0
- package/rules/stacks/ruby.md +2 -0
- package/rules/stacks/rust.md +2 -0
- package/rules/stacks/swift.md +2 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +55 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +359 -0
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +45 -21
- package/src/commands/harness.mjs +301 -0
- package/src/commands/help.mjs +73 -2
- package/src/commands/index.mjs +67 -25
- package/src/commands/init.mjs +12 -8
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/plan.mjs +99 -0
- package/src/commands/rules.mjs +42 -57
- package/src/commands/skills.mjs +67 -92
- package/src/commands/skillsinstall.mjs +17 -2
- package/src/commands/sync.mjs +62 -6
- package/src/commands/tokens.mjs +169 -0
- package/src/compare/row.mjs +73 -2
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/dispatcher.mjs +35 -3
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +228 -20
- package/src/mcp/tools.mjs +372 -1
- package/src/measure/context.mjs +542 -0
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +78 -2
- package/src/packs.mjs +182 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/manifest.mjs +114 -3
- package/src/projection/project.mjs +140 -9
- package/src/projection/rules.mjs +365 -22
- package/src/projection/surfaces.mjs +155 -23
- package/src/projection/targets.mjs +37 -0
- package/src/toolwords.mjs +69 -0
- 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
|
+
}
|