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
@@ -0,0 +1,428 @@
1
+ // The shared half of a workspace's rules, and the user-level files that carry it.
2
+ //
3
+ // Review 0002 S38 measured seven repositories carrying 90 KB of always-on rules of
4
+ // which 64 KB — 72% — was a paragraph an earlier root had already loaded. The fix is not
5
+ // a smaller region; it is carrying the repeated half once, in a file every reader in the
6
+ // estate loads before they open a repository.
7
+ //
8
+ // FOUR DECISIONS, each with a cost. All four are settled in ADR-084; they are written out
9
+ // here because the code is where the next change will look.
10
+ //
11
+ // 1. THE SHARED UNIT IS A RULE, NOT A PARAGRAPH. `simt rules show <id>` prints one
12
+ // rule; a rule split across a user file and a region cannot be read by one command,
13
+ // and the review's own accept criterion says every rule stays reachable. The meter's
14
+ // `paragraphs()` is the unit that REPORTS redundancy, not the unit that stores it.
15
+ //
16
+ // 2. A RULE IS SHAREABLE ONLY IF EVERY ROOT IN THE WORKSPACE SHIPS THE SAME BYTES.
17
+ // Union would put a python rule in front of a java repository; the intersection is
18
+ // the only set that cannot. Bytes, not ids: a rule two roots render differently —
19
+ // inferability having dropped a line in one of them — is not shareable, and comparing
20
+ // the rendered frame is how that is found rather than assumed.
21
+ //
22
+ // 3. THE IDS LIVE IN EACH REPOSITORY'S MANIFEST, not in the shared file and not in the
23
+ // workspace config. A repository's region must be a pure function of that
24
+ // repository, or `check` in CI — where nobody has installed anything — disagrees
25
+ // with the reader's own session. That also means `sync` never reads `$HOME`, which
26
+ // `harness.mjs`'s comment forbids for anything a projection depends on.
27
+ //
28
+ // 4. THE TARGET LIST IS THE LOADER TABLE'S, not a second list of paths. Plan 0003 R11
29
+ // named `~/.codex/AGENTS.md`, `~/.claude/CLAUDE.md` and `~/.gemini/GEMINI.md`; the
30
+ // table (`src/projection/loaders.mjs`, every row citing a vendor document) records
31
+ // `~/.claude/rules/*.md` and `~/.gemini/config/rules/*.md` instead, and records no
32
+ // user-level file at all for Cursor or Copilot. A path this tool writes to and a
33
+ // reader's harness never loads buys nothing and costs a directory.
34
+
35
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
36
+ import { homedir } from 'node:os';
37
+ import { dirname, join } from 'node:path';
38
+
39
+ import { CLI } from '../name.mjs';
40
+ import { caps, loader } from './loaders.mjs';
41
+ import { composeShared, diff as diffRegion, inject, parse } from './markers.mjs';
42
+ import { byPriorityThenId, desiredRegion, provenanceDigest } from './rules.mjs';
43
+ import { discover } from '../compare/discover.mjs';
44
+
45
+ /**
46
+ * The filename this tool claims inside a reader's flat user rules directory.
47
+ *
48
+ * `simt` rather than `simt-dev`, for the reason in `src/name.mjs`: a command name appears
49
+ * in text this tool writes and nowhere else, while the on-disk names stay locked. Naming
50
+ * the file for the command that wrote it is what lets a reader who finds it in their home
51
+ * directory tell which tool put it there.
52
+ */
53
+ const SHARED_BASENAME = 'simt.md';
54
+
55
+ /**
56
+ * A path from the loader table, under the reader's home directory.
57
+ *
58
+ * The table spells these two ways — `~/.config/opencode/AGENTS.md` and the bare
59
+ * `.claude/rules` directory — so both are joined onto `homedir()` rather than one being
60
+ * special-cased. A relative path left alone would resolve against the process's working
61
+ * directory, which is a repository, and a user-level file written there is a file in
62
+ * nobody's home directory at all.
63
+ */
64
+ function inHome(path) {
65
+ if (process.env.CODEX_HOME !== undefined && process.env.CODEX_HOME !== '' && path.startsWith('~/.codex/')) {
66
+ return join(process.env.CODEX_HOME, path.slice('~/.codex/'.length));
67
+ }
68
+ return path.startsWith('~/') ? join(homedir(), path.slice(2)) : join(homedir(), path);
69
+ }
70
+
71
+ /**
72
+ * Is this the file the reader's harness reads in preference to the ones below it?
73
+ *
74
+ * A fact about the reader's machine rather than about the table, which is why it is asked
75
+ * here and nowhere else: Codex reads `AGENTS.override.md` when it is non-empty and
76
+ * `AGENTS.md` otherwise, and writing an override where the reader has none would shadow
77
+ * the global file they do have — silently, and for every repository in the estate.
78
+ */
79
+ async function nonEmptyFile(path) {
80
+ try {
81
+ const info = await stat(path);
82
+ return info.isFile() && info.size > 0;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The smallest byte cap that applies to the file this tool would write.
90
+ *
91
+ * Token budgets are deliberately not compared against bytes: Antigravity's 20,000-token
92
+ * cap for global plus always-on rules is a real limit the table records, and 20,000
93
+ * compared against a byte count would be a number with no meaning.
94
+ */
95
+ function fileCap(row) {
96
+ return (
97
+ caps(row)
98
+ .filter((c) => c.appliesTo === 'a single file' || c.appliesTo === 'the merged chain')
99
+ .sort((a, b) => a.bytes - b.bytes)[0] ?? null
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Where a surface's rules can be carried once per reader, or `null` when they cannot be.
105
+ *
106
+ * Read from the loader table rather than written here, because that is the one place a
107
+ * path comes with the document that says a harness loads it. Two answers are deliberate:
108
+ *
109
+ * - a surface with no `scope: 'user'` row (Cursor, Copilot) yields `null`. Those
110
+ * harnesses keep user rules in their own settings, which is not a file this tool can
111
+ * project, so `shared` prints the block for a reader to paste instead of writing
112
+ * somewhere that would do nothing.
113
+ * - the `pointer` surfaces (`claude-md`, `gemini-md`) have no loader row at all, so they
114
+ * yield `null` too. The plan named `~/.claude/CLAUDE.md` and `~/.gemini/GEMINI.md`;
115
+ * the table records no such user-level file for either harness, and a guessed path is
116
+ * worse than a printed block — the file would be written, and never read.
117
+ *
118
+ * Where a harness records more than one global file, the one that is already there wins:
119
+ * which of them is read is a fact about the reader's machine, and the fallback is written
120
+ * only when none of them exists.
121
+ *
122
+ * @param {string[]} surfaces the surface ids selected across the workspace
123
+ * @returns {Promise<{surface: string, label: string, path: string|null, why: string,
124
+ * cap: {bytes: number, appliesTo: string, why: string}|null}[]>}
125
+ */
126
+ export async function sharedTargets(surfaces) {
127
+ const out = [];
128
+ for (const id of [...surfaces].sort()) {
129
+ const row = loader(id);
130
+ if (row === null) {
131
+ out.push({
132
+ surface: id,
133
+ label: id,
134
+ path: null,
135
+ cap: null,
136
+ why: `no user-level instruction file is recorded for ${id}; its user rules are configured in the app, not in a file`,
137
+ });
138
+ continue;
139
+ }
140
+ const users = row.start.filter((s) => s.scope === 'user' && s.atStart === true);
141
+ const flat = users.find((u) => u.tree === 'flat');
142
+ if (flat !== undefined) {
143
+ out.push({
144
+ surface: id,
145
+ label: row.label,
146
+ // A flat tree names a directory and a glob; the filename inside it is this tool's
147
+ // to choose, which is why this path is the only one in the module that is not in
148
+ // the table verbatim. Nothing to choose between here: the reader's own files in
149
+ // that directory load alongside ours rather than instead of them.
150
+ path: join(inHome(flat.dir ?? flat.path), SHARED_BASENAME),
151
+ why: flat.why,
152
+ cap: fileCap(row),
153
+ });
154
+ continue;
155
+ }
156
+ const singles = users
157
+ .filter((u) => u.tree === 'single')
158
+ .map((u) => ({ ...u, path: inHome(u.path) }));
159
+ const present = [];
160
+ for (const candidate of singles) {
161
+ if (await nonEmptyFile(candidate.path)) present.push(candidate);
162
+ }
163
+ const chosen = present[0] ?? singles[singles.length - 1];
164
+ if (chosen === undefined) {
165
+ out.push({
166
+ surface: id,
167
+ label: id,
168
+ path: null,
169
+ cap: fileCap(row),
170
+ why: `${row.label} keeps user rules in its own settings, not in a file this tool can write`,
171
+ });
172
+ continue;
173
+ }
174
+ const alternates = singles.filter((u) => u.path !== chosen.path).map((u) => u.path);
175
+ out.push({
176
+ surface: id,
177
+ label: row.label,
178
+ path: chosen.path,
179
+ why:
180
+ alternates.length === 0 || chosen === singles[0]
181
+ ? chosen.why
182
+ : `${chosen.why}; written here rather than ${alternates.join(' or ')} because it is the one ${row.label} reads on this machine`,
183
+ cap: fileCap(row),
184
+ });
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /**
190
+ * What every root in a workspace would carry, as `{id: rule}` per root.
191
+ *
192
+ * Each root is asked the question it already answers for `sync`, and nothing is read out
193
+ * of a sibling's tree beyond its own projection: the only reason this module exists is to
194
+ * compare roots, and it does that by asking each one rather than by reconstructing what
195
+ * each one would say.
196
+ *
197
+ * @returns {Promise<{path: string, desired: object, shareable: Map<string, object>}[]>}
198
+ */
199
+ export async function sharedCandidates(roots, flags) {
200
+ const rows = [];
201
+ for (const path of roots) {
202
+ const desired = await desiredRegion(path, flags ?? {});
203
+ rows.push({ path, desired, shareable: new Map(desired.shareable.map((r) => [r.id, r])) });
204
+ }
205
+ return rows;
206
+ }
207
+
208
+ /**
209
+ * The rules every root carries with identical bytes, plus the ones that nearly qualify.
210
+ *
211
+ * `divergent` is not a diagnostic nobody reads: a rule two roots carry differently is
212
+ * almost always inferability having dropped a line in one of them, and the answer is to
213
+ * leave it in both regions rather than to teach this module which root was right. The two
214
+ * reasons are kept apart because they are different facts: a rule one root does not carry
215
+ * at all, and a rule two roots render differently, need different words in the report —
216
+ * "differs across 1 repositories" describes neither.
217
+ *
218
+ * @param {{shareable: Map<string, object>}[]} rows
219
+ * @returns {{shared: object[], divergent: {id: string, roots: string[], reason: 'bytes'|'coverage'}[]}}
220
+ */
221
+ export function intersect(rows) {
222
+ if (rows.length === 0) return { shared: [], divergent: [] };
223
+ // The union of every root's ids, not the first root's: a rule only beta selects would
224
+ // otherwise never be named, and the report would leave a reader guessing whether it
225
+ // moved or stayed.
226
+ const ids = [...new Set(rows.flatMap((row) => [...row.shareable.keys()]))].sort();
227
+ const shared = [];
228
+ const divergent = [];
229
+ for (const id of ids) {
230
+ const carriers = rows.filter((row) => row.shareable.has(id));
231
+ const rule = carriers[0].shareable.get(id);
232
+ const same = carriers.every((row) => row.shareable.get(id).rendered === rule.rendered);
233
+ if (carriers.length === rows.length && same) {
234
+ // Sorted in the order a region already keeps its rules, so the shared file and a
235
+ // region's index are read in the same order and neither is sorted twice.
236
+ shared.push({ id, ...rule });
237
+ continue;
238
+ }
239
+ divergent.push({
240
+ id,
241
+ roots: carriers.map((row) => row.path),
242
+ reason: carriers.length === rows.length ? 'bytes' : 'coverage',
243
+ });
244
+ }
245
+ return { shared: shared.sort((a, b) => byPriorityThenId(a, b)), divergent };
246
+ }
247
+
248
+ /**
249
+ * The document the user-level file carries.
250
+ *
251
+ * One lead paragraph, and it is the paragraph a reader needs: these rules are here rather
252
+ * than in a repository, which is why a rule they expect in AGENTS.md is not in it. Below
253
+ * the lead the frames are byte-identical to what the regions used to carry, which is what
254
+ * lets a reader — or `check` — compare one copy against the other rather than against a
255
+ * reconstruction of it.
256
+ */
257
+ export function sharedBody(rules) {
258
+ const lead =
259
+ 'The rules every repository in this workspace shares, carried once here rather than in each ' +
260
+ "repository. A repository's own region carries what is left and names anything here it does " +
261
+ `not carry. Read one of these from any repository in the workspace with: ${CLI} rules show <id>`;
262
+ return rules.length === 0 ? lead : `${lead}\n\n${rules.map((r) => r.rendered).join('\n\n')}`;
263
+ }
264
+
265
+ /** The provenance a copy is stamped with. Same digest `sync` stamps, so one sha answers
266
+ * both questions: a copy of the user file written by a different release of these rules
267
+ * is stale, and there is nothing else in the comparison that would notice. */
268
+ export async function sharedMeta(rules) {
269
+ return { sha: await provenanceDigest(), sources: rules.map((r) => r.source) };
270
+ }
271
+
272
+ /**
273
+ * Is the copy in the reader's home the bytes this workspace would write today?
274
+ *
275
+ * `diff` rather than a string compare, because the file may carry the reader's own prose
276
+ * above the region and a region that matches is current whether or not the bytes around it
277
+ * do. A whole-file comparison would report a stale copy for a file this tool only partly
278
+ * owns, and the reader would be told to run a command that changes nothing.
279
+ *
280
+ * @returns {Promise<{state: 'current'|'stale'|'absent'|'unreadable', action?: string,
281
+ * region?: string, reason?: string}>}
282
+ */
283
+ export async function sharedState(path, rules, meta) {
284
+ let text;
285
+ try {
286
+ text = await readFile(path, 'utf8');
287
+ } catch (error) {
288
+ if (error.code === 'ENOENT') return { state: 'absent' };
289
+ return { state: 'unreadable', reason: error.message };
290
+ }
291
+ // A file with no region at all is absent, not stale: the reader has never installed one,
292
+ // and the remedy for that is the same command with a different sentence.
293
+ if (parse(text).status === 'absent') return { state: 'absent' };
294
+ const probe = diffRegion(text, sharedBody(rules), meta, { compose: composeShared });
295
+ return {
296
+ state: probe.action === 'unchanged' ? 'current' : 'stale',
297
+ action: probe.action,
298
+ region: probe.region,
299
+ reason: probe.action === 'malformed' ? probe.reason : undefined,
300
+ };
301
+ }
302
+
303
+ /**
304
+ * The write, with the file's current bytes as an argument.
305
+ *
306
+ * `inject` rather than `writeFile` for the reason the whole module exists in one line: the
307
+ * user-level AGENTS.md is the most likely file in a reader's home directory to contain
308
+ * something they wrote themselves. Only the bytes between the markers are ours.
309
+ */
310
+ function injectShared(before, rules, meta) {
311
+ return inject(before, sharedBody(rules), meta, { compose: composeShared });
312
+ }
313
+
314
+ /**
315
+ * Write one user-level copy, creating the directory when the table named one.
316
+ *
317
+ * Idempotent: the same bytes come back `unchanged` and nothing is written, which is what
318
+ * lets `install` be run on every machine in an estate without anyone diffing their home
319
+ * directories afterwards.
320
+ *
321
+ * @returns {Promise<{path: string, action: string, created: boolean}>}
322
+ */
323
+ export async function writeSharedFile(path, rules, meta) {
324
+ let before = '';
325
+ try {
326
+ before = await readFile(path, 'utf8');
327
+ } catch (error) {
328
+ if (error.code !== 'ENOENT') throw error;
329
+ }
330
+ const injected = injectShared(before, rules, meta);
331
+ if (injected.action === 'unchanged') return { path, action: 'unchanged', created: false };
332
+ await mkdir(dirname(path), { recursive: true });
333
+ await writeFile(path, injected.text);
334
+ return { path, action: injected.action, created: before === '' };
335
+ }
336
+
337
+ /**
338
+ * Are the rules this repository handed over actually in the reader's copy?
339
+ *
340
+ * A subset check rather than equality, and the difference matters: the file carries the
341
+ * union of what every repository in the workspace declared, so a file with extra rules is
342
+ * current for this repository while an exact comparison would call it stale and send the
343
+ * reader to reinstall it on every sibling. What must hold is the direction that costs
344
+ * something — every rule THIS repository stopped carrying is still there, byte for byte.
345
+ *
346
+ * @returns {Promise<{state: 'current'|'stale'|'absent'|'unreadable', missing: string[],
347
+ * checked: number, reason?: string}>}
348
+ */
349
+ export async function sharedCoverage(path, rules) {
350
+ let text;
351
+ try {
352
+ text = await readFile(path, 'utf8');
353
+ } catch (error) {
354
+ if (error.code === 'ENOENT') return { state: 'absent', missing: [], checked: 0 };
355
+ return { state: 'unreadable', missing: [], checked: 0, reason: error.message };
356
+ }
357
+ const parsed = parse(text);
358
+ if (parsed.status === 'absent') return { state: 'absent', missing: [], checked: 0 };
359
+ if (parsed.status === 'malformed') {
360
+ // Reported as unreadable rather than as missing: the rules may be in there, but a file
361
+ // with two regions cannot be checked, and "run shared install" would not fix it.
362
+ return { state: 'unreadable', missing: [], checked: 0, reason: parsed.reason };
363
+ }
364
+ const missing = rules.filter((r) => !parsed.body.includes(r.rendered)).map((r) => r.id);
365
+ return {
366
+ state: missing.length === 0 ? 'current' : 'stale',
367
+ missing,
368
+ checked: rules.length,
369
+ bytes: Buffer.byteLength(parsed.body),
370
+ };
371
+ }
372
+
373
+ /**
374
+ * The bytes the reader pays once for the whole workspace (ADR-084).
375
+ *
376
+ * Counted ONCE per file and counted at all, which is the whole reason this exists: a
377
+ * report that measured seven regions and ignored the file the rules moved into would
378
+ * show a 72% saving that the reader's context never saw. The accept criterion for R11 is
379
+ * a number, and a number that leaves out the copy is not the reader's number.
380
+ *
381
+ * The whole file is counted, not the span between the markers, because the prose a reader
382
+ * keeps around the region is also always-on for that reader — and because the alternative
383
+ * needs a second definition of "generated bytes" to agree with `measureRoot`'s.
384
+ *
385
+ * @param {{surfaces: string[], desired?: object}[]} rows as `measureRoot` returns them
386
+ * @returns {Promise<{bytes: number, files: {path: string, bytes: number, rules: number}[]}>}
387
+ */
388
+ export async function sharedTotals(rows) {
389
+ const surfaces = [...new Set(rows.flatMap((row) => row.surfaces ?? []))];
390
+ const files = [];
391
+ for (const t of await sharedTargets(surfaces)) {
392
+ if (t.path === null) continue;
393
+ let text;
394
+ try {
395
+ text = await readFile(t.path, 'utf8');
396
+ } catch (error) {
397
+ // ENOENT is the normal state of a reader who has not installed a workspace copy, and
398
+ // reporting it as a fault would make `tokens` fail over something it is measuring.
399
+ if (error.code === 'ENOENT') continue;
400
+ throw error;
401
+ }
402
+ const parsed = parse(text);
403
+ files.push({
404
+ path: t.path,
405
+ bytes: Buffer.byteLength(text),
406
+ rules: parsed.status === 'ok' ? countFrames(parsed.body) : 0,
407
+ });
408
+ }
409
+ return { bytes: files.reduce((n, f) => n + f.bytes, 0), files };
410
+ }
411
+
412
+ /** One shared rule in a file: `## Stack: <id>`, `## Core` or `## Process: <id>`. */
413
+ function countFrames(body) {
414
+ return body.split('\n').filter((line) => /^## (Stack: |Core$|Process: )/.test(line)).length;
415
+ }
416
+
417
+ /**
418
+ * Every repository under `root`, in `discover`'s order.
419
+ *
420
+ * Not re-sorted here, deliberately: that order decides which root owns a shared paragraph
421
+ * in `tokens --repos`, and ADR-084's accept criterion is measured in that number.
422
+ *
423
+ * @returns {Promise<{roots: string[], error: string|null}>}
424
+ */
425
+ export async function sharedRoots(root) {
426
+ const found = await discover(root);
427
+ return { roots: found.repos, error: found.error ?? null };
428
+ }
@@ -91,13 +91,13 @@ export const SURFACES = [
91
91
  id: 'opencode',
92
92
  label: 'opencode (AGENTS.md + .agents/skills)',
93
93
  readers:
94
- 'opencode, and AGENTS.md is also read natively by Codex, Cursor, Windsurf, Zed, Amp, Jules, Copilot CLI and the DeepSeek harness; Claude Code falls back to it, Antigravity and Gemini CLI accept GEMINI.md as an equal name',
94
+ 'OpenCode v2, and AGENTS.md is also read natively by Codex, Cursor, Windsurf, Zed, Amp, Jules, Copilot CLI and the DeepSeek harness; Claude Code falls back to it, Antigravity and Gemini CLI accept GEMINI.md as an equal name',
95
95
  primary: 'AGENTS.md',
96
96
  kind: 'region',
97
- // opencode documents NO per-rule directory: it reads AGENTS.md (walking up to the
98
- // git worktree), CLAUDE.md only as a fallback, and .opencode/AGENTS.md in
99
- // addition. So `foldRules` — this surface carries every rule inside the one
100
- // generated region, and the other rows are how a rule reaches an editor that
97
+ // OpenCode v2 documents no per-rule directory. It reads AGENTS.md only — every
98
+ // file from the workspace up to the project root, combined — and it does not fall
99
+ // back to CLAUDE.md. So `foldRules`: this surface carries every rule inside the
100
+ // one generated region, and the other rows are how a rule reaches an editor that
101
101
  // cannot read AGENTS.md. Writing .opencode/rules/*.md would be a guess, and a
102
102
  // guessed path is the failure this table exists to prevent.
103
103
  rule: null,
@@ -118,7 +118,7 @@ export const SURFACES = [
118
118
  },
119
119
  ],
120
120
  verified:
121
- 'opencode docs, "Rules" and "Agent Skills" (read 2026-10-07) — AGENTS.md walked up from cwd to the git worktree; CLAUDE.md used only if no AGENTS.md exists; .opencode/AGENTS.md additionally; skills at .opencode/skills/, .claude/skills/ and .agents/skills/<name>/SKILL.md; skill frontmatter recognises only name, description, license, compatibility, metadata',
121
+ 'OpenCode v2 instructions and skills docs (opencode.ai/v2/docs, read 2026-10-14) — AGENTS.md only, combined from the workspace up to the project root; CLAUDE.md is not a fallback; skills at .opencode/skills/, .claude/skills/ and .agents/skills/<name>/SKILL.md, with the model shown name and description',
122
122
  note:
123
123
  '.agents/skills is one of the three skill locations opencode documents, so the same directory serves the standard surface without a symlink.',
124
124
  },
@@ -286,8 +286,12 @@ export const POINTERS = [
286
286
  to: 'AGENTS.md',
287
287
  readers: 'Gemini CLI, Antigravity',
288
288
  verified:
289
- 'Google Antigravity docs, "Rules": workspace rules are AGENTS.md or GEMINI.md in the root or any subdirectory; Copilot CLI also discovers GEMINI.md',
290
- note: 'Covers Gemini CLI and gives Antigravity a second, equally-native name for the same file.',
289
+ 'Google Antigravity docs, "Rules": workspace rules are AGENTS.md or GEMINI.md in the root or any subdirectory; Copilot CLI also discovers GEMINI.md. ' +
290
+ 'Gemini CLI, read today: its own docs (core memory index, and the configuration reference) say the memory service loads GEMINI.md hierarchically from the working directory up to the project root and the home directory and also into subdirectories, and that `context.fileName` in settings.json "accepts either a single string or an array of strings" naming the context file(s) to load, default undefined',
291
+ note:
292
+ 'Covers Gemini CLI and gives Antigravity a second, equally-native name for the same file. ' +
293
+ 'A repository can also point Gemini CLI at AGENTS.md itself with `context.fileName: ["AGENTS.md"]` and need no pointer at all, ' +
294
+ 'which is the reason there is no separate Gemini CLI row here: this pointer and a native row would be two ways to hand one reader the same rule.',
291
295
  },
292
296
  ];
293
297
 
@@ -399,6 +403,16 @@ export const CANDIDATE_DIRS = [
399
403
  { dir: '.continue', harness: 'Continue' },
400
404
  { dir: '.amp', harness: 'Amp' },
401
405
  { dir: '.goose', harness: 'Goose' },
406
+ // Home directory, seen only with `harness detect --user`. DeepSeek keeps user data
407
+ // under ~/.dsh and reads the repository's AGENTS.md; it has no project config
408
+ // directory and no per-rule file to project (ADR-067).
409
+ {
410
+ dir: '.dsh',
411
+ harness: 'DeepSeek harness',
412
+ readsAgentsMd: 'all',
413
+ verified:
414
+ 'DeepSeek harness agent-instructions docs (read 2026-10-14): user data lives at $DSH_HOME or ~/.dsh, and the project chain is AGENTS.md',
415
+ },
402
416
  ];
403
417
 
404
418
  /**
@@ -18,6 +18,7 @@ import { join } from 'node:path';
18
18
  import { diff, parse, inject } from './markers.mjs';
19
19
  import { ruleName } from './mdc.mjs';
20
20
  import { foldsRules, frontmatterBlock, POINTERS, rulePath, SURFACES } from './surfaces.mjs';
21
+ import { foldChecks } from '../kernel/checks.mjs';
21
22
 
22
23
  /**
23
24
  * A managed rule file is a GENERATED FILE, unlike AGENTS.md.
@@ -169,7 +170,7 @@ export async function scopedTargets(root, rules, sha, resolved, notes) {
169
170
  alwaysApply: false,
170
171
  scope: rule.scope,
171
172
  };
172
- targets.push(...(await ruleTargets(root, scoped, rule.body, sha, resolved, notes)).targets);
173
+ targets.push(...(await ruleTargets(root, scoped, foldChecks(rule.body).body, sha, resolved, notes)).targets);
173
174
  }
174
175
  return { targets };
175
176
  }
@@ -0,0 +1,71 @@
1
+ // The `simt-` names this tool owns, and the records that argue about the prefix itself.
2
+ //
3
+ // Two readers need to tell this tool's own vocabulary from a repository in the estate
4
+ // these records were written about: the decision reader, which redacts the second kind
5
+ // and names it, and the guard on shipped text, which refuses to ship it. Both answered
6
+ // the question with a shape — `simt-` followed by anything but `dev` — and the shape is
7
+ // false about the tool's own names, which the corpus prints. `rules why ADR-073` reported
8
+ // that the ruling surveyed `simt-priority` as a repository, and printed the same word as
9
+ // `a repository: hard | command | convention | style` where the record says
10
+ // `simt-priority: hard | command | convention | style`: a reader with the tool and not
11
+ // the estate got a sentence with no subject, from the command whose whole job is to
12
+ // explain a citation.
13
+ //
14
+ // The words are derived from the modules that own them (ADR-022) rather than listed
15
+ // here, because a second list is a list that drifts and then forgives. Both readers read
16
+ // this one, and a name added to a frontmatter vocabulary reaches both without an edit.
17
+ import { KNOWN_KEYS } from './projection/mdc.mjs';
18
+ import { PROFILE_KEYS } from './projection/rules.mjs';
19
+ import { INFERABLE_MARKER } from './kernel/inferability.mjs';
20
+ import { CHECK_MARKER } from './kernel/checks.mjs';
21
+
22
+ /**
23
+ * `simt-` names that belong to this tool and never to a repository.
24
+ *
25
+ * The last three are not frontmatter: they are names this tool writes into files it
26
+ * adopts a repository into — the package, the gate file it puts in `.github`, and the
27
+ * hooks directory it points `core.hooksPath` at. `simt-dev` itself is here for the same
28
+ * reason the estate pattern exempts it, and stating it once here is what stops the two
29
+ * readers from disagreeing about the tool's own name.
30
+ */
31
+ export const TOOL_WORDS = new Set([
32
+ ...KNOWN_KEYS,
33
+ ...PROFILE_KEYS,
34
+ INFERABLE_MARKER,
35
+ CHECK_MARKER,
36
+ 'simt-dev',
37
+ 'simt-dev-check',
38
+ 'simt-hooks',
39
+ ]);
40
+
41
+ /**
42
+ * Records whose subject is the prefix itself, so the prefixed names in them are the
43
+ * argument rather than a citation.
44
+ *
45
+ * Each entry is a file and a reason, never a repository. That distinction is the whole
46
+ * point: an exemption says "this document discusses the namespace", which is a fact
47
+ * about this tool and carries nothing about whoever uses it. A vocabulary cannot settle
48
+ * these two — a hypothetical key in an argument and a repository name are the same shape
49
+ * — so the file is exempted where the word cannot be.
50
+ *
51
+ * The guard fails on a stale entry: each one must still be tripping the rule, so an
52
+ * exemption that stops being needed is removed rather than kept as a precedent.
53
+ */
54
+ export const PREFIX_ARGUING = new Map([
55
+ [
56
+ 'ADR-022-a-key-simt-dev-reads-is-prefixed.md',
57
+ 'is the record arguing for the prefix, using hypothetical keys as examples',
58
+ ],
59
+ [
60
+ 'ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md',
61
+ 'names one prefixed key to record Dependabot rejecting it',
62
+ ],
63
+ [
64
+ 'ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md',
65
+ 'quotes the prefixed names it measured, which are its evidence rather than a citation',
66
+ ],
67
+ [
68
+ 'ADR-081-a-tracker-is-read-through-the-repositorys-template.md',
69
+ 'names the plan overlay file the next record withdraws, and that filename has the shape of a repository',
70
+ ],
71
+ ]);
@@ -0,0 +1,36 @@
1
+ {
2
+ "$comment": "Tracker format delivered with the tool. A repository replaces any section with the plans key in simt.json; a section it omits stays as written here. directories are the only places read, and each word is what a dependency uses to name that place (\"review 0002 S41\"). Headers are tried in the order written. Only a state marked satisfies unblocks a slice that depends on it. ready is the state plan next returns. dependency.group matches a wave of rows; wave names that table's columns.",
3
+ "name": "tracker",
4
+ "directories": [
5
+ { "kind": "plans", "word": "plan", "path": "docs/plans" },
6
+ { "kind": "reviews", "word": "review", "path": "docs/reviews" }
7
+ ],
8
+ "id": {
9
+ "pattern": "^[A-Za-z]{0,4}[- ]?[0-9]{1,4}$",
10
+ "headers": ["id", "#", "step", "item", "slice", "task"]
11
+ },
12
+ "columns": {
13
+ "state": ["state", "status", "done when"],
14
+ "depends": ["depends on", "depends", "dependency", "dependencies", "deps", "needs"],
15
+ "title": ["slice", "deliverable", "title", "summary", "description", "content"],
16
+ "evidence": ["sha / evidence", "sha/evidence", "evidence", "sha", "notes", "note"]
17
+ },
18
+ "states": [
19
+ { "id": "queued", "write": "Queued", "read": ["queued"] },
20
+ { "id": "in-progress", "write": "In progress", "read": ["in progress", "in-progress"] },
21
+ { "id": "complete", "write": "Complete", "read": ["complete", "completed", "done"], "satisfies": true },
22
+ { "id": "accepted", "write": "Accepted", "read": ["accepted"] }
23
+ ],
24
+ "ready": "queued",
25
+ "dependency": {
26
+ "empty": ["none", "null", "n/a", "n.a.", "na", "—", "-", "–"],
27
+ "separators": ",;",
28
+ "notes": true,
29
+ "group": "^waves?\\s+(.+)$"
30
+ },
31
+ "wave": {
32
+ "label": ["wave"],
33
+ "members": ["slices"]
34
+ },
35
+ "section": { "depth": 3 }
36
+ }