simt-dev 0.2.1 → 0.2.2

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 (61) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  3. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  4. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  5. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  6. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  7. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  8. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  9. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  10. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  11. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  12. package/package.json +1 -1
  13. package/rules/core.md +1 -0
  14. package/rules/stacks/android.md +2 -0
  15. package/rules/stacks/cloudflare-workers.md +2 -0
  16. package/rules/stacks/connectiq.md +1 -0
  17. package/rules/stacks/container.md +2 -0
  18. package/rules/stacks/dart.md +2 -0
  19. package/rules/stacks/deno.md +2 -0
  20. package/rules/stacks/dotnet.md +2 -0
  21. package/rules/stacks/elixir.md +2 -0
  22. package/rules/stacks/go.md +2 -0
  23. package/rules/stacks/jvm.md +2 -0
  24. package/rules/stacks/node.md +2 -0
  25. package/rules/stacks/python.md +2 -0
  26. package/rules/stacks/react-native.md +2 -0
  27. package/rules/stacks/react-web.md +2 -0
  28. package/rules/stacks/ruby.md +2 -0
  29. package/rules/stacks/rust.md +2 -0
  30. package/rules/stacks/swift.md +2 -0
  31. package/rules/stacks/typescript.md +2 -0
  32. package/rules/stacks/uv.md +2 -0
  33. package/src/argv.mjs +19 -0
  34. package/src/commands/compare.mjs +15 -0
  35. package/src/commands/graph.mjs +45 -21
  36. package/src/commands/harness.mjs +150 -0
  37. package/src/commands/help.mjs +15 -1
  38. package/src/commands/index.mjs +43 -25
  39. package/src/commands/init.mjs +12 -8
  40. package/src/commands/mcp.mjs +20 -5
  41. package/src/commands/rules.mjs +42 -57
  42. package/src/commands/skills.mjs +67 -92
  43. package/src/commands/skillsinstall.mjs +17 -2
  44. package/src/commands/sync.mjs +62 -6
  45. package/src/commands/tokens.mjs +138 -0
  46. package/src/compare/row.mjs +73 -2
  47. package/src/graph/spec.mjs +227 -0
  48. package/src/graph/speccheck.mjs +68 -0
  49. package/src/hooks/dispatcher.mjs +35 -3
  50. package/src/mcp/schema.mjs +139 -0
  51. package/src/mcp/server.mjs +197 -20
  52. package/src/mcp/tools.mjs +288 -1
  53. package/src/measure/context.mjs +371 -0
  54. package/src/monorepo.mjs +339 -0
  55. package/src/output/index.mjs +78 -2
  56. package/src/packs.mjs +182 -0
  57. package/src/projection/manifest.mjs +88 -0
  58. package/src/projection/project.mjs +140 -9
  59. package/src/projection/rules.mjs +288 -20
  60. package/src/projection/surfaces.mjs +133 -15
  61. package/src/projection/targets.mjs +37 -0
@@ -0,0 +1,371 @@
1
+ // What a reader loads at session start, in bytes, counted rather than estimated.
2
+ //
3
+ // Review 0002 S38 measured this by hand - 90,042 B per turn across seven roots,
4
+ // 64,738 B of it a paragraph an earlier root had already loaded - and a number that
5
+ // costs a session to produce is a number nobody re-measures. This module is that
6
+ // measurement as code.
7
+ //
8
+ // What it will not do:
9
+ //
10
+ // 1. Estimate. Tokens are bytes divided by four and are labelled approximate: a
11
+ // tokenizer is a dependency this package does not have (ADR-012), and an
12
+ // exact-looking wrong number is worse than a labelled approximation.
13
+ // 2. Charge hand-written bytes to the tool. `generatedBytes` is what the
14
+ // projection wrote; `readerBytes` is what the reader reads past the marker. A
15
+ // repository with a 40 KB hand-written prefix has a context problem this tool
16
+ // does not have, and billing it to the budget would put the number on the
17
+ // wrong head.
18
+ // 3. Keep a second opinion about any harness. Paths, read limits and pointer
19
+ // behaviour all come from the surface table, which the writer already has to
20
+ // agree with; a loader list living here would drift from it.
21
+ // 4. Write anything. `compare` is read-only for the same reason - a measurement
22
+ // that can change what it measures is not a measurement.
23
+
24
+ import { readFile, readdir } from 'node:fs/promises';
25
+ import { basename, join } from 'node:path';
26
+
27
+ import { resolveSurfaces } from '../projection/surfaces.mjs';
28
+ import { parse } from '../projection/markers.mjs';
29
+ import { budgetFinding, DEFAULT_BUDGET_BYTES, readManifest } from '../projection/manifest.mjs';
30
+ import { extractFrontmatter } from '../projection/targets.mjs';
31
+ import { readLimitFindings } from '../projection/project.mjs';
32
+ import { TOOLS } from '../mcp/tools.mjs';
33
+
34
+ /**
35
+ * The ceiling review 0002 S38 set for one repository's always-on generated bytes.
36
+ *
37
+ * Read as 30,000 rather than 30 * 1024: it is a ceiling, and the conservative reading
38
+ * of "at most 30 KB" is the one that trips first. Plan 0003 R08 makes the budget a
39
+ * manifest field, and the manifest's default is the same number - so a repo that
40
+ * declares nothing is measured against the line the review drew, and a repo that
41
+ * declares something is measured against its own (ADR-072).
42
+ */
43
+ export const ALWAYS_ON_BUDGET_BYTES = DEFAULT_BUDGET_BYTES;
44
+
45
+ /** Bytes per token for the approximation. Labelled approximate wherever it is printed. */
46
+ export const BYTES_PER_TOKEN = 4;
47
+
48
+ /** Tokens for a byte count. Rounded, and never presented as exact. */
49
+ export function tokens(bytes) {
50
+ return Math.round(bytes / BYTES_PER_TOKEN);
51
+ }
52
+
53
+ /**
54
+ * Bytes of `tools/list` the MCP surface answers with - the same for every repository,
55
+ * so it is reported beside the roots and never inside a root's total. Counting it per
56
+ * root would make each repository look over budget for a workspace-level cost.
57
+ */
58
+ export function mcpTools() {
59
+ const list = TOOLS.map((t) => ({ name: t.name, description: t.description ?? '' }));
60
+ return { tools: TOOLS.length, bytes: Buffer.byteLength(JSON.stringify({ tools: list })) };
61
+ }
62
+
63
+ /**
64
+ * Paragraphs, as the redundancy figure in review 0002 counted them: blank-line
65
+ * separated blocks, byte-exact after trimming. A different definition would produce a
66
+ * different number that could not be compared with the one this replaces.
67
+ */
68
+ export function paragraphs(text) {
69
+ return text
70
+ .split(/\n[ \t]*\n/)
71
+ .map((block) => block.trim())
72
+ .filter((block) => block.length > 0);
73
+ }
74
+
75
+ /** UTF-8 byte length. `text.length` counts UTF-16 units, which undercounts every
76
+ * non-ASCII character - and a rule file with an em dash in it is ordinary. */
77
+ function bytes(text) {
78
+ return Buffer.byteLength(text);
79
+ }
80
+
81
+ async function readText(path) {
82
+ try {
83
+ return await readFile(path, 'utf8');
84
+ } catch {
85
+ // ENOENT is the expected answer for a surface a repo selected but has not written
86
+ // yet, and a permission error is not a byte count either. Both mean "nothing to
87
+ // measure here", which the caller states as zero rather than as a failure.
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Files under a directory a surface declares, in a stable order.
94
+ *
95
+ * Recursive because Claude Code documents recursive discovery for `.claude/rules/`: a
96
+ * nested rule file loads for that reader, and a meter that did not walk into it would
97
+ * report a cost the reader does not pay - or hide one it does. The depth cap is not a
98
+ * guess about nesting; it is the bound that keeps a stray `node_modules` inside a
99
+ * tool-owned directory from turning a byte count into an unbounded walk.
100
+ */
101
+ async function filesUnder(root, dir, suffix, depth = 0) {
102
+ let entries;
103
+ try {
104
+ entries = await readdir(join(root, dir), { withFileTypes: true });
105
+ } catch {
106
+ return [];
107
+ }
108
+ const names = entries.map((e) => e.name).sort();
109
+ const out = [];
110
+ for (const name of names) {
111
+ if (name.startsWith('.')) continue;
112
+ const entry = entries.find((e) => e.name === name);
113
+ const path = `${dir}/${name}`;
114
+ if (entry.isDirectory()) {
115
+ if (depth >= 6) continue;
116
+ out.push(...(await filesUnder(root, `${path}`, suffix, depth + 1)));
117
+ } else if (entry.isFile() && (!suffix || name.endsWith(suffix))) {
118
+ out.push(path);
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** Frontmatter keys, as an object. One block, no nesting - which is all a SKILL.md has. */
125
+ function frontmatter(text) {
126
+ const block = extractFrontmatter(text);
127
+ const out = {};
128
+ if (!block) return out;
129
+ for (const line of block.split('\n').slice(1)) {
130
+ if (line === '---') break;
131
+ const at = line.indexOf(':');
132
+ if (at < 1) continue;
133
+ out[line.slice(0, at).trim()] = line
134
+ .slice(at + 1)
135
+ .trim()
136
+ .replace(/^["']|["']$/g, '');
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /** Every document a selected surface puts in front of a reader, with what it costs.
142
+ *
143
+ * `channel` which kind of channel the bytes arrive through
144
+ * `file` the path, relative to the repository root, for the reader to check
145
+ * `text` the bytes a reader loads - the generated span for a region, the whole
146
+ * file for a rule file, only the frontmatter for a skill
147
+ * `readerBytes` what the reader reads, which for the primary file includes the
148
+ * hand-written bytes around the region */
149
+ async function documentsFor(root, surface) {
150
+ const out = [];
151
+
152
+ if (surface.primary) {
153
+ const text = await readText(join(root, surface.primary));
154
+ if (text !== null) {
155
+ const state = parse(text);
156
+ // A malformed region is reported as a finding with no byte count: the span
157
+ // between two markers that do not pair is not a region, and attributing the
158
+ // whole file to the projection would overstate what the tool wrote.
159
+ // `parse` hands back `before` and `after` as lines re-joined with `\n`, which is
160
+ // one byte shorter than the text they were cut from: the newline that joined
161
+ // them to the region belongs to neither. So `text - before - after` bills the
162
+ // projection for the blank line a person left above its own marker - in the one
163
+ // command whose entire claim is that it counts exactly. The span is the two
164
+ // markers and what is between them, and the byte count is that span's.
165
+ const open = state.before.length === 0 ? 0 : state.before.length + 1;
166
+ const close = state.after.length === 0 ? text.length : text.length - state.after.length - 1;
167
+ const span = state.status === 'ok' ? text.slice(open, close) : '';
168
+ const generated = bytes(span);
169
+ out.push({
170
+ channel: 'region',
171
+ file: surface.primary,
172
+ text: span,
173
+ generatedBytes: generated,
174
+ readerBytes: bytes(text),
175
+ });
176
+ }
177
+ }
178
+
179
+ if (surface.rule) {
180
+ const dir = surface.rule.slice(0, surface.rule.lastIndexOf('/'));
181
+ const suffix = surface.rule.slice(surface.rule.lastIndexOf('.') + 1);
182
+ for (const file of await filesUnder(root, dir, `.${suffix}`)) {
183
+ const text = await readText(join(root, file));
184
+ if (text === null) continue;
185
+ out.push({ channel: 'rules', file, text, generatedBytes: bytes(text), readerBytes: bytes(text) });
186
+ }
187
+ }
188
+
189
+ if (surface.skills) {
190
+ // `skills` is the directory this tool writes; the documented paths are the
191
+ // alternatives a reader also accepts. A skill present in two of them loads once,
192
+ // so the first path in the table keeps the bytes - the same rule a pointer
193
+ // follows, and for the same reason: one reader, one copy of the text.
194
+ const dirs = [...new Set([surface.skills, ...(surface.documentedSkillsPaths ?? [])])];
195
+ const seen = new Set();
196
+ for (const dir of dirs) {
197
+ for (const file of await filesUnder(root, dir, '.md')) {
198
+ if (!basename(file).startsWith('SKILL')) continue;
199
+ const text = await readText(join(root, file));
200
+ if (text === null) continue;
201
+ const meta = frontmatter(text);
202
+ const name = meta.name ?? basename(file);
203
+ const description = meta.description ?? '';
204
+ if (seen.has(name)) continue;
205
+ seen.add(name);
206
+ // Name and description only: that is what the Agent Skills spec has a reader
207
+ // load before it decides to open the body, so that is what costs a turn.
208
+ const head = description ? `${name}\n${description}` : name;
209
+ out.push({
210
+ channel: 'skills',
211
+ file,
212
+ text: head,
213
+ generatedBytes: bytes(head),
214
+ readerBytes: bytes(head),
215
+ });
216
+ }
217
+ }
218
+ }
219
+
220
+ return out;
221
+ }
222
+
223
+ /** One repository: what its readers load, and what of it this tool wrote. */
224
+ export async function measureRoot(root) {
225
+ const { manifest, exists, error } = await readManifest(root);
226
+ const resolved = resolveSurfaces(manifest, {});
227
+ const documents = [];
228
+ for (const surface of resolved.surfaces) {
229
+ documents.push(...(await documentsFor(root, surface)));
230
+ }
231
+
232
+ const generatedBytes = documents.reduce((sum, d) => sum + d.generatedBytes, 0);
233
+ 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;
238
+
239
+ const byChannel = new Map();
240
+ for (const doc of documents) {
241
+ const row = byChannel.get(doc.channel) ?? { channel: doc.channel, files: 0, generatedBytes: 0, readerBytes: 0 };
242
+ row.files += 1;
243
+ row.generatedBytes += doc.generatedBytes;
244
+ row.readerBytes += doc.readerBytes;
245
+ byChannel.set(doc.channel, row);
246
+ }
247
+
248
+ const findings = [];
249
+ const readLimits = [];
250
+ if (manifest?.error) findings.push(`manifest: ${manifest.error}`);
251
+ const regionDoc = documents.find((d) => d.channel === 'region');
252
+ if (regionDoc) {
253
+ const text = await readText(join(root, regionDoc.file));
254
+ const state = parse(text ?? '');
255
+ if (state.status === 'malformed') findings.push(`${regionDoc.file}: generated region is malformed`);
256
+ // The gate's own finding, for the same reason `check` uses it: one answer to
257
+ // "does this reader reach the end of the region", so a meter that disagreed with
258
+ // the gate would be a second bug to find.
259
+ for (const finding of readLimitFindings(text ?? '', regionDoc.file)) {
260
+ findings.push(finding.detail);
261
+ readLimits.push({
262
+ file: finding.file,
263
+ reader: finding.reader,
264
+ limit: finding.limitBytes,
265
+ endByte: finding.endByte,
266
+ overBy: finding.overBy,
267
+ });
268
+ }
269
+ }
270
+
271
+ const name = root.split('/').filter(Boolean).pop() ?? root;
272
+ return {
273
+ name,
274
+ path: root,
275
+ adoption: !exists ? 'unmanaged' : error ? 'broken' : 'adopted',
276
+ surfaces: resolved.ids,
277
+ surfaceProblems: resolved.problems,
278
+ generatedBytes,
279
+ readerBytes,
280
+ // `readerBytes` is what a reader pays for this repository's file; `tokens` is an
281
+ // approximation of it and is reported as such wherever it is printed.
282
+ tokens: tokens(readerBytes),
283
+ generatedTokens: tokens(generatedBytes),
284
+ // The repository's own number, not the constant. The constant is the default a repo
285
+ // gets if it never declares one, which is the same line the review drew — but a repo
286
+ // that measures its rules differently has said so, and a measurement that overrode
287
+ // that would be reporting a violation of a line the repository never agreed to
288
+ // (ADR-072).
289
+ budget: {
290
+ bytes: budgetBytes,
291
+ declared: manifest?.budget?.bytes !== undefined,
292
+ over: generatedBytes > budgetBytes,
293
+ overBy: Math.max(0, generatedBytes - budgetBytes),
294
+ // The finding the gate would raise, produced by the same function, so the report
295
+ // and the gate cannot word the same fact two ways. It is carried rather than
296
+ // raised: R08 calls the cut non-blocking, and this command never fails on a
297
+ // measurement (ADR-064).
298
+ finding: budgetFinding(generatedBytes, manifest?.budget, manifest?.mode),
299
+ },
300
+ channels: [...byChannel.values()],
301
+ readLimits,
302
+ findings,
303
+ documents,
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Fill in the redundancy numbers for a set of roots, in the order given.
309
+ *
310
+ * Three questions, one walk: bytes that repeat a paragraph an earlier ROOT loaded
311
+ * (`duplicateBytes` - the figure review 0002 quoted), bytes that repeat a paragraph
312
+ * an earlier DOCUMENT in the same root loaded (`repeatBytes` - what a per-rule file
313
+ * repeating the region costs), and the bytes neither does (`uniqueBytes`).
314
+ *
315
+ * Order decides ownership, so it is the caller's: `discover` returns repositories
316
+ * sorted by path, and the first root to carry a paragraph keeps it. Two runs over the
317
+ * same workspace therefore produce the same two numbers, which is the only reason the
318
+ * first of them is worth keeping.
319
+ */
320
+ export function annotate(rows) {
321
+ const seenAcrossRoots = new Set();
322
+ for (const row of rows) {
323
+ const seenInRoot = new Set();
324
+ let uniqueBytes = 0;
325
+ let duplicateBytes = 0;
326
+ let repeatBytes = 0;
327
+ for (const doc of row.documents) {
328
+ for (const block of paragraphs(doc.text)) {
329
+ const size = bytes(block);
330
+ if (seenInRoot.has(block)) {
331
+ repeatBytes += size;
332
+ continue;
333
+ }
334
+ seenInRoot.add(block);
335
+ if (seenAcrossRoots.has(block)) {
336
+ duplicateBytes += size;
337
+ continue;
338
+ }
339
+ seenAcrossRoots.add(block);
340
+ uniqueBytes += size;
341
+ }
342
+ }
343
+ row.redundancy = {
344
+ blockBytes: uniqueBytes + duplicateBytes + repeatBytes,
345
+ uniqueBytes,
346
+ duplicateBytes,
347
+ repeatBytes,
348
+ };
349
+ }
350
+ return rows;
351
+ }
352
+
353
+ /** The workspace totals. `generatedBytes` is the denominator the review used, so the
354
+ * percentage beside it means what the 72% meant. */
355
+ export function totals(rows) {
356
+ const sum = (pick) => rows.reduce((n, r) => n + pick(r), 0);
357
+ const generatedBytes = sum((r) => r.generatedBytes);
358
+ const duplicateBytes = sum((r) => r.redundancy?.duplicateBytes ?? 0);
359
+ const repeatBytes = sum((r) => r.redundancy?.repeatBytes ?? 0);
360
+ return {
361
+ roots: rows.length,
362
+ generatedBytes,
363
+ readerBytes: sum((r) => r.readerBytes),
364
+ tokens: sum((r) => r.generatedTokens),
365
+ blockBytes: sum((r) => r.redundancy?.blockBytes ?? 0),
366
+ uniqueBytes: sum((r) => r.redundancy?.uniqueBytes ?? 0),
367
+ duplicateBytes,
368
+ repeatBytes,
369
+ overBudget: rows.filter((r) => r.budget.over).map((r) => r.name),
370
+ };
371
+ }