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,192 @@
1
+ // `simt tests [path]` lists the tests that exercise a file (R24, ADR-092).
2
+ //
3
+ // The index already stores imports, but a test import that spans lines is not
4
+ // one of them. Test files are read back from their blobs, once, in a single
5
+ // batch. Coverage is read only from a fixed list of paths. History is the last
6
+ // 200 non-merge commits, which is a map and not the whole changelog.
7
+
8
+ import { execFile } from 'node:child_process';
9
+ import { readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { promisify } from 'node:util';
12
+
13
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
+ import { CLI } from '../name.mjs';
15
+ import { isTestPath } from '../bench/mine.mjs';
16
+ import { readEdges, readFiles, reindex } from '../index/store.mjs';
17
+ import { HISTORY, importsOf, parseCoverage, testEdges, testsFor } from '../index/tests.mjs';
18
+
19
+ const exec = promisify(execFile);
20
+
21
+ const LANG = {
22
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
23
+ ts: 'typescript', tsx: 'typescript', kt: 'kotlin', kts: 'kotlin', java: 'java',
24
+ py: 'python', go: 'go', rs: 'rust', swift: 'swift', dart: 'dart', rb: 'ruby', cs: 'csharp',
25
+ };
26
+
27
+ const COVERAGE_PATHS = [
28
+ 'coverage/lcov.info',
29
+ 'lcov.info',
30
+ 'coverage/coverage-final.json',
31
+ 'coverage.json',
32
+ 'jacoco.xml',
33
+ 'build/reports/jacoco/test/jacocoTestReport.xml',
34
+ 'coverage.out',
35
+ 'cover.out',
36
+ ];
37
+
38
+ export async function testsCommand(args, flags) {
39
+ if (args.length > 1) return fail(`unknown tests argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} tests [path]`);
40
+ const root = flags.root ?? process.cwd();
41
+ const loaded = await loadFiles(root);
42
+ if (loaded.error) return loaded.error;
43
+ const { files } = loaded;
44
+ const [stored, texts, coverage, commits] = await Promise.all([
45
+ readEdges(root),
46
+ testBlobs(root, files),
47
+ coverageTexts(root),
48
+ history(root),
49
+ ]);
50
+ const paths = new Set(files.map((file) => file.path));
51
+ const imports = stored.filter((edge) => edge.kind === 'import');
52
+ for (const file of files) {
53
+ if (!isTestPath(file.path)) continue;
54
+ const text = texts.get(file.blob);
55
+ if (!text) continue;
56
+ imports.push(...importsOf(file.path, text, langOf(file.path), paths));
57
+ }
58
+ const edges = testEdges({ files, imports, coverage, commits });
59
+ const target = args[0];
60
+ if (!target) return summarise(root, flags, edges);
61
+ if (!paths.has(target)) {
62
+ return fail(`not in the index: ${target}`, EXIT.USAGE, `pass a path ${CLI} index knows`);
63
+ }
64
+ const tests = testsFor(target, edges, { cochange: flags.cochange === true });
65
+ const payload = { ok: true, command: 'tests', root, path: target, tests };
66
+ if (isJson(flags)) {
67
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
68
+ return EXIT.OK;
69
+ }
70
+ const lines = [`tests ${tests.length}`, ...tests];
71
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
72
+ return EXIT.OK;
73
+ }
74
+
75
+ function summarise(root, flags, edges) {
76
+ let test = 0;
77
+ let cochange = 0;
78
+ for (const edge of edges) {
79
+ if (edge.kind === 'test') test += 1;
80
+ else if (edge.kind === 'cochange') cochange += 1;
81
+ }
82
+ const payload = { ok: true, command: 'tests', root, test, cochange };
83
+ if (isJson(flags)) {
84
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
85
+ return EXIT.OK;
86
+ }
87
+ emit(`test ${test}\ncochange ${cochange}`, { maxBytes: flags.maxBytes ?? 0 });
88
+ return EXIT.OK;
89
+ }
90
+
91
+ async function loadFiles(root) {
92
+ let files = await readFiles(root);
93
+ if (files.length === 0) {
94
+ try {
95
+ await reindex(root);
96
+ } catch (error) {
97
+ return {
98
+ error: fail(
99
+ `could not map tests in this repository (${error.message})`,
100
+ EXIT.UNSUPPORTED,
101
+ 'run this inside a git repository',
102
+ ),
103
+ };
104
+ }
105
+ files = await readFiles(root);
106
+ }
107
+ return { files };
108
+ }
109
+
110
+ async function testBlobs(root, files) {
111
+ const blobs = [];
112
+ for (const file of files) {
113
+ if (isTestPath(file.path) && file.blob) blobs.push(file.blob);
114
+ }
115
+ return cat(root, blobs);
116
+ }
117
+
118
+ async function coverageTexts(root) {
119
+ const texts = [];
120
+ for (const path of COVERAGE_PATHS) {
121
+ try {
122
+ const text = await readFile(join(root, path), 'utf8');
123
+ if (parseCoverage(text).length > 0 || text.trim() !== '') texts.push(text);
124
+ } catch {
125
+ // Absent coverage is the usual case. It is not a failed map.
126
+ }
127
+ }
128
+ return texts;
129
+ }
130
+
131
+ async function history(root) {
132
+ let stdout = '';
133
+ try {
134
+ ({ stdout } = await exec('git', ['log', '-n', String(HISTORY), '--no-merges', '--name-only', '--pretty=format:%x1e'], {
135
+ cwd: root,
136
+ maxBuffer: 16 << 20,
137
+ }));
138
+ } catch {
139
+ return [];
140
+ }
141
+ const commits = [];
142
+ for (const block of stdout.split('\x1e')) {
143
+ const paths = [];
144
+ for (const line of block.split('\n')) {
145
+ const path = line.trim();
146
+ if (path) paths.push(path);
147
+ }
148
+ if (paths.length > 0) commits.push(paths);
149
+ }
150
+ return commits;
151
+ }
152
+
153
+ async function cat(root, blobs) {
154
+ const unique = [...new Set(blobs)];
155
+ const texts = new Map();
156
+ if (unique.length === 0) return texts;
157
+ const body = await batch(root, unique);
158
+ let offset = 0;
159
+ while (offset < body.length) {
160
+ const nl = body.indexOf(0x0a, offset);
161
+ if (nl < 0) break;
162
+ const header = body.toString('utf8', offset, nl);
163
+ const parts = header.split(' ');
164
+ const size = Number(parts[2]);
165
+ if (parts[1] !== 'blob' || !Number.isFinite(size)) {
166
+ offset = nl + 1;
167
+ continue;
168
+ }
169
+ const start = nl + 1;
170
+ const bytes = body.subarray(start, start + size);
171
+ offset = start + size + 1;
172
+ texts.set(parts[0], bytes.includes(0) ? null : bytes.toString('utf8'));
173
+ }
174
+ return texts;
175
+ }
176
+
177
+ function batch(root, blobs) {
178
+ return new Promise((resolve) => {
179
+ const child = execFile('git', ['cat-file', '--batch'], { cwd: root, maxBuffer: 32 << 20, encoding: 'buffer' }, (error, stdout) => {
180
+ resolve(error ? Buffer.alloc(0) : stdout);
181
+ });
182
+ child.stdin.write(`${blobs.join('\n')}\n`);
183
+ child.stdin.end();
184
+ });
185
+ }
186
+
187
+ function langOf(path) {
188
+ const base = path.slice(path.lastIndexOf('/') + 1);
189
+ const dot = base.lastIndexOf('.');
190
+ if (dot <= 0) return '';
191
+ return LANG[base.slice(dot + 1)] ?? '';
192
+ }
@@ -33,6 +33,8 @@ import {
33
33
  mcpTools,
34
34
  totals,
35
35
  } from '../measure/context.mjs';
36
+ import { measureSession } from '../measure/session.mjs';
37
+ import { sharedTotals } from '../projection/shared.mjs';
36
38
 
37
39
  /** `1234567`, never `1,234,567`. `toLocaleString` output depends on the locale, and
38
40
  * this command exists so two machines produce two numbers a pull request can diff. */
@@ -45,6 +47,36 @@ function files(n) {
45
47
  }
46
48
 
47
49
  export async function tokensCommand(positional, flags) {
50
+ if (flags.session !== undefined) {
51
+ const session = await measureSession(flags.session);
52
+ if (!session.ok) {
53
+ return fail(session.error, EXIT.CONFIG, session.remedy);
54
+ }
55
+ if (isJson(flags)) {
56
+ emitJson({
57
+ ok: true,
58
+ command: 'tokens',
59
+ mode: 'session',
60
+ session: session.path,
61
+ bytesPerToken: BYTES_PER_TOKEN,
62
+ rules: session.rules,
63
+ fileReads: session.fileReads,
64
+ toolOutput: session.toolOutput,
65
+ total: session.total,
66
+ });
67
+ return EXIT.OK;
68
+ }
69
+ const lines = [
70
+ `tokens ${session.path} (session)`,
71
+ `rules ${plain(session.rules.bytes)} B ~${plain(session.rules.tokens)} tokens (${plain(session.rules.share)}%)`,
72
+ `file reads ${plain(session.fileReads.bytes)} B ~${plain(session.fileReads.tokens)} tokens (${plain(session.fileReads.share)}%)`,
73
+ `tool output ${plain(session.toolOutput.bytes)} B ~${plain(session.toolOutput.tokens)} tokens (${plain(session.toolOutput.share)}%)`,
74
+ `total ${plain(session.total.bytes)} B ~${plain(session.total.tokens)} tokens`,
75
+ ];
76
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
77
+ return EXIT.OK;
78
+ }
79
+
48
80
  // `targetRoot` owns the precedence, so `tokens`, `compare` and `upgrade` cannot
49
81
  // disagree about which directory was named (ADR-014).
50
82
  const target = await targetRoot(flags, positional);
@@ -78,7 +110,17 @@ export async function tokensCommand(positional, flags) {
78
110
  for (const path of roots) rows.push(await measureRoot(path));
79
111
  annotate(rows);
80
112
 
81
- const summary = totals(rows);
113
+ const measured = totals(rows);
114
+ // The workspace copy, counted once (ADR-084). A figure that measured the regions and
115
+ // ignored the file the rules moved into would show a saving the reader's context never
116
+ // saw, so it is in the total from the first run rather than as an afterthought.
117
+ const shared = await sharedTotals(rows);
118
+ const summary = {
119
+ ...measured,
120
+ sharedBytes: shared.bytes,
121
+ estateBytes: measured.generatedBytes + shared.bytes,
122
+ sharedFiles: shared.files,
123
+ };
82
124
  const mcp = mcpTools();
83
125
 
84
126
  if (isJson(flags)) {
@@ -111,6 +153,15 @@ export async function tokensCommand(positional, flags) {
111
153
  lines.push(
112
154
  `totals generated ${plain(summary.generatedBytes)} B duplicate ${plain(summary.duplicateBytes)} B (${plain(share)}% of generated) unique ${plain(summary.uniqueBytes)} B repeat ${plain(summary.repeatBytes)} B ~${plain(summary.tokens)} tokens`,
113
155
  );
156
+ // Only when there is a copy to count. The line a reader sees most is the one that
157
+ // says what their whole estate loads, and adding a second number to it that is
158
+ // usually zero is how a report stops being read.
159
+ if (summary.sharedBytes > 0) {
160
+ lines.push(
161
+ `shared ${plain(summary.sharedBytes)} B carried once in ${plain(summary.sharedFiles.length)} user-level file(s) estate total ${plain(summary.estateBytes)} B`,
162
+ );
163
+ for (const f of summary.sharedFiles) lines.push(` ${f.path} ${plain(f.bytes)} B ${plain(f.rules)} rule(s)`);
164
+ }
114
165
  } else {
115
166
  const row = rows[0];
116
167
  lines.push(`tokens ${row.path}`);
@@ -124,6 +175,15 @@ export async function tokensCommand(positional, flags) {
124
175
  lines.push(
125
176
  `always-on ${plain(row.generatedBytes)} B generated ~${plain(row.generatedTokens)} tokens budget ${plain(row.budget.bytes)} B ${verdict}`,
126
177
  );
178
+ // The same reason as the `--repos` line, and it matters more here: measuring one
179
+ // repository is the default, so a reader who owns the workspace copy and never saw it
180
+ // counted would read a saving their own session does not get.
181
+ if (summary.sharedBytes > 0) {
182
+ lines.push(
183
+ `shared ${plain(summary.sharedBytes)} B carried once in ${plain(summary.sharedFiles.length)} user-level file(s) always-on for you: ${plain(summary.estateBytes)} B`,
184
+ );
185
+ for (const f of summary.sharedFiles) lines.push(` ${f.path} ${plain(f.bytes)} B ${plain(f.rules)} rule(s)`);
186
+ }
127
187
  lines.push(
128
188
  `redundant ${plain(row.redundancy.duplicateBytes)} B repeats an earlier repository ${plain(row.redundancy.repeatBytes)} B repeats an earlier document here ${plain(row.redundancy.uniqueBytes)} B unique of ${plain(row.redundancy.blockBytes)} B counted in blocks`,
129
189
  );
@@ -0,0 +1,177 @@
1
+ // `simt why [path]` lists what governs a file (R25, ADR-093).
2
+ //
3
+ // With a path, one line per decision, rule, skill, gate and lesson. With no
4
+ // path, the Affects tokens that name nothing this tree contains. The file list
5
+ // is `git ls-files`. The index is not rebuilt: governance is the records, and
6
+ // writing the cache to answer a question would change the tree.
7
+
8
+ import { execFile } from 'node:child_process';
9
+ import { readFile, readdir } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { promisify } from 'node:util';
12
+
13
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
15
+ import { FORMATTABLE, GATES } from '../hooks/gates.mjs';
16
+ import {
17
+ decisionHits,
18
+ lessonPaths,
19
+ scopeHits,
20
+ scopePaths,
21
+ sortHits,
22
+ sourceRoots,
23
+ unresolvedAffects,
24
+ } from '../index/govern.mjs';
25
+
26
+ const exec = promisify(execFile);
27
+
28
+ const RULE_DIRS = ['rules', 'rules/stacks', 'rules/mdc', '.simt-dev/profiles'];
29
+
30
+ export async function whyCommand(args, flags, commands = []) {
31
+ if (args.length > 1) return fail(`unknown why argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} why [path]`);
32
+ const root = flags.root ?? process.cwd();
33
+ const listed = await tracked(root);
34
+ if (listed.error) return listed.error;
35
+ const files = new Set(listed.files);
36
+ const ctx = { files, commands: new Set(commands), roots: sourceRoots(files) };
37
+ const records = await decisionRecords(root);
38
+ const target = args[0];
39
+ if (!target) return reportUnresolved(root, flags, unresolvedAffects(records, ctx));
40
+ if (!files.has(target)) return fail(`not in the tree: ${target}`, EXIT.USAGE, `pass a path git tracks`);
41
+ const corpus = await loadCorpus(root);
42
+ const { hits } = decisionHits(records, target, ctx);
43
+ const items = sortHits([...hits, ...scopeHits(target, corpus)]);
44
+ const payload = { ok: true, command: 'why', root, path: target, items };
45
+ if (isJson(flags)) {
46
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
47
+ return EXIT.OK;
48
+ }
49
+ const lines = items.map((item) => `${item.kind.padEnd(10)}${item.id} ${item.detail}`);
50
+ emit(lines.length > 0 ? lines.join('\n') : 'none', { maxBytes: flags.maxBytes ?? 0 });
51
+ return EXIT.OK;
52
+ }
53
+
54
+ function reportUnresolved(root, flags, unresolved) {
55
+ const payload = { ok: true, command: 'why', root, unresolved };
56
+ if (isJson(flags)) {
57
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
58
+ return EXIT.OK;
59
+ }
60
+ const lines = unresolved.map((item) => `UNRESOLVED-AFFECTS ${item.token} ${item.id}`);
61
+ emit([`unresolved ${unresolved.length}`, ...lines].join('\n'), { maxBytes: flags.maxBytes ?? 0 });
62
+ return EXIT.OK;
63
+ }
64
+
65
+ async function tracked(root) {
66
+ try {
67
+ const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
68
+ return { files: stdout.split('\n').filter(Boolean) };
69
+ } catch (error) {
70
+ return {
71
+ error: fail(
72
+ `could not read what governs files here (${error.message})`,
73
+ EXIT.UNSUPPORTED,
74
+ 'run this inside a git repository',
75
+ ),
76
+ };
77
+ }
78
+ }
79
+
80
+ export async function decisionRecords(root) {
81
+ const dir = join(root, 'docs', 'decisions');
82
+ let names = [];
83
+ try {
84
+ names = (await readdir(dir)).filter((name) => /^ADR-\d{3}-.*\.md$/.test(name)).sort();
85
+ } catch {
86
+ return [];
87
+ }
88
+ const records = [];
89
+ for (const name of names) {
90
+ const text = await readFile(join(dir, name), 'utf8');
91
+ const id = /^ADR-\d{3}/.exec(name)[0];
92
+ const heading = /^#\s+(.*)$/m.exec(text)?.[1] ?? id;
93
+ const title = heading.replace(/^ADR-\d{3}\s+[—–-]\s+/, '');
94
+ records.push({ id, title, text });
95
+ }
96
+ return records;
97
+ }
98
+
99
+ async function loadCorpus(root) {
100
+ const [rules, skills, lessons, commands] = await Promise.all([
101
+ ruleScopes(root),
102
+ skillScopes(root),
103
+ lessonScopes(root),
104
+ gateCommands(root),
105
+ ]);
106
+ const gates = Object.values(GATES).map((gate) => ({
107
+ id: gate.name,
108
+ detail: gate.why,
109
+ extensions: gate.kind === 'staged' ? FORMATTABLE : null,
110
+ }));
111
+ return { rules, skills, gates, lessons, commands };
112
+ }
113
+
114
+ export async function ruleScopes(root) {
115
+ const rules = [];
116
+ for (const dir of RULE_DIRS) {
117
+ let names = [];
118
+ try {
119
+ names = await readdir(join(root, dir));
120
+ } catch {
121
+ continue;
122
+ }
123
+ for (const name of names.sort()) {
124
+ if (!name.endsWith('.md') && !name.endsWith('.mdc')) continue;
125
+ const text = await readFile(join(root, dir, name), 'utf8');
126
+ const paths = scopePaths(text);
127
+ if (paths.length === 0) continue;
128
+ const id = /^simt-id:\s*(\S+)/m.exec(text)?.[1] ?? name;
129
+ const detail = /^simt-title:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? id;
130
+ rules.push({ id, detail, paths });
131
+ }
132
+ }
133
+ return rules;
134
+ }
135
+
136
+ async function skillScopes(root) {
137
+ let parsed;
138
+ try {
139
+ parsed = JSON.parse(await readFile(join(root, 'graph', 'skills.graph.json'), 'utf8'));
140
+ } catch {
141
+ return [];
142
+ }
143
+ if (!Array.isArray(parsed?.skills)) return [];
144
+ return parsed.skills
145
+ .filter((skill) => Array.isArray(skill.paths) && skill.paths.length > 0)
146
+ .map((skill) => ({ id: String(skill.name ?? ''), detail: String(skill.summary ?? ''), paths: skill.paths }));
147
+ }
148
+
149
+ async function lessonScopes(root) {
150
+ const dir = join(root, 'docs', 'lessons');
151
+ let names = [];
152
+ try {
153
+ names = (await readdir(dir)).filter((name) => name.endsWith('.md')).sort();
154
+ } catch {
155
+ return [];
156
+ }
157
+ const lessons = [];
158
+ for (const name of names) {
159
+ const text = await readFile(join(dir, name), 'utf8');
160
+ const paths = lessonPaths(text);
161
+ if (paths.length === 0) continue;
162
+ const id = name.replace(/\.md$/, '');
163
+ const detail = /^#\s+(.*)$/m.exec(text)?.[1] ?? id;
164
+ lessons.push({ id, detail, paths });
165
+ }
166
+ return lessons;
167
+ }
168
+
169
+ async function gateCommands(root) {
170
+ try {
171
+ const parsed = JSON.parse(await readFile(join(root, MANIFEST_FILE), 'utf8'));
172
+ const commands = parsed?.gates?.commands;
173
+ return commands && typeof commands === 'object' ? commands : {};
174
+ } catch {
175
+ return {};
176
+ }
177
+ }
@@ -20,6 +20,7 @@ import { dirname, join, resolve } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { MANIFEST_FILE } from '../name.mjs';
22
22
  import { CLI } from '../name.mjs';
23
+ import { PREFIX_ARGUING, TOOL_WORDS } from '../toolwords.mjs';
23
24
 
24
25
  const HERE = dirname(fileURLToPath(import.meta.url));
25
26
  export const DECISIONS_DIR = resolve(HERE, '..', '..', 'docs', 'decisions');
@@ -240,7 +241,7 @@ export async function loadDecisions({ all = false } = {}) {
240
241
  (m) => `ADR-${m[1]}`,
241
242
  ),
242
243
  partial,
243
- summary: summarise(text, partial, { all }),
244
+ summary: summarise(text, partial, { all, file }),
244
245
  bytes: Buffer.byteLength(text),
245
246
  });
246
247
  }
@@ -288,7 +289,7 @@ export function byId(decisions) {
288
289
  * answer "why is this here"; the reader who needs the rest asks.
289
290
  * @returns {{ruled: string[], rulingNumbers: (number|null)[], rulingCount: number, rejected: string[], writtenAgainst: string[]}}
290
291
  */
291
- function summarise(text, partial = [], { all = false } = {}) {
292
+ function summarise(text, partial = [], { all = false, file = null } = {}) {
292
293
  // A section ends at the next `## ` — but not at one inside a fenced code block.
293
294
  // ADR-007's entire ruling is a document skeleton in a fence whose headings are `##`,
294
295
  // so the old lookahead ended the section at the first line of the ruling itself. It
@@ -446,7 +447,15 @@ const section = (name) => {
446
447
  // Named rather than stripped. A record that cites this org's repositories is still
447
448
  // the record; silently deleting the citation would hide which decisions were made in
448
449
  // response to what, and would mangle sentences mid-clause.
449
- writtenAgainst: [...new Set(text.match(ESTATE) ?? [])].sort(),
450
+ //
451
+ // What is named is a repository, and the shape was wrong about that: every
452
+ // `simt-<word>` that was not `simt-dev` was reported as one, so the tool's own
453
+ // `simt-priority` was listed as a surveyed repository and the record that settled the
454
+ // kernel read as if it had been decided by looking at some other repository (ADR-080).
455
+ // A record whose subject is the prefix is arguing rather than citing, so it names none.
456
+ writtenAgainst: PREFIX_ARGUING.has(file)
457
+ ? []
458
+ : [...new Set((text.match(ESTATE) ?? []).filter((name) => !TOOL_WORDS.has(name)))].sort(),
450
459
  };
451
460
  }
452
461
 
@@ -458,6 +467,10 @@ const section = (name) => {
458
467
  * The skill registry's `source.repo` values are asserted to match this (S14); a name
459
468
  * the registry adds that is not `simt-*` has to be added here or that assertion fails.
460
469
  *
470
+ * The shape is not the whole answer, and treating it as one is what ADR-080 settles: a
471
+ * `simt-` name this tool owns is not a repository, and neither reader can tell that from
472
+ * the shape. `src/toolwords.mjs` is the list both of them share.
473
+ *
461
474
  * Not global: callers reuse it with `exec`, and a `/g` flag would remember `lastIndex`.
462
475
  */
463
476
  /**
@@ -486,7 +499,12 @@ export const ESTATE_NAME =
486
499
 
487
500
  /** Replace estate repository names with a phrase a reader who does not have them can use. */
488
501
  export function redactEstate(text) {
489
- return text.replace(new RegExp(ESTATE_NAME.source, 'g'), 'a repository');
502
+ return String(text ?? '').replace(new RegExp(ESTATE_NAME.source, 'g'), (name) =>
503
+ // The tool's own names are left where they are. Redacting them turned a ruling about
504
+ // `simt-priority` into a sentence about `a repository`, which is how a reader learned
505
+ // that the record had surveyed something and not what it said (ADR-080).
506
+ TOOL_WORDS.has(name) ? name : 'a repository',
507
+ );
490
508
  }
491
509
 
492
510
  /**
@@ -506,7 +524,7 @@ export function redactEstate(text) {
506
524
  * Only a real verb follows, which is what keeps `simt-dev.json`, `simt-dev-check.yml` and
507
525
  * `simt-dev's` untouched - those are the project's artefacts and the project keeps its name.
508
526
  */
509
- const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|mcp|rules|skills|graph|--[a-z-]+)';
527
+ const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|mcp|rules|skills|graph|tokens|harness|bench|shared|evidence|index|map|tests|why|gate|impact|--[a-z-]+)';
510
528
 
511
529
  function redactCommand(text) {
512
530
  return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${VERB})\\b`, 'g'), `${CLI} `);
@@ -164,7 +164,7 @@ export function checkSkill({ directoryName, fileName = 'SKILL.md', text, at }) {
164
164
  * characters. That is the whole failure this file exists to prevent, one level down - a
165
165
  * lint that is confidently wrong about a conforming file.
166
166
  */
167
- function readFrontmatter(text) {
167
+ export function readFrontmatter(text) {
168
168
  const meta = {};
169
169
  const present = [];
170
170
  const body = text.replace(/^/, '');
@@ -28,6 +28,7 @@ import { resolveGate } from './resolve.mjs';
28
28
  import { CLI } from '../name.mjs';
29
29
  import { MANIFEST_FILE } from '../name.mjs';
30
30
  import { assertVersionPin } from '../package-version.mjs';
31
+ import { recordRun } from '../ledger/index.mjs';
31
32
 
32
33
  const exec = promisify(execFile);
33
34
 
@@ -179,7 +180,21 @@ export async function runGates(event, { root, manifest }) {
179
180
  cwd = resolved;
180
181
  }
181
182
 
183
+ const started = Date.now();
182
184
  const outcome = await runGate(resolution, cwd);
185
+ // The ledger is not the gate. A tree that cannot be named leaves no line, and the
186
+ // claim check then finds nothing, which is the direction that cannot invent a run.
187
+ try {
188
+ const cmd = typeof resolution.command === 'string' ? resolution.command : resolution.argv.join(' ');
189
+ await recordRun(root, {
190
+ cmd,
191
+ exit: outcome.code,
192
+ ms: Date.now() - started,
193
+ output: `${outcome.stdout ?? ''}\n${outcome.stderr ?? ''}`,
194
+ });
195
+ } catch {
196
+ // Recorded by absence: the next evidence read for this tree has no line.
197
+ }
183
198
  results.push({
184
199
  name,
185
200
  status: outcome.code === 0 ? 'passed' : 'failed',
@@ -90,6 +90,37 @@ ${renderLocator(`hooks run ${event} --root "${D}ROOT"`, {
90
90
  })}`;
91
91
  }
92
92
 
93
+ /**
94
+ * The commit-msg shim. It is not a dispatcher event: those shims do not forward
95
+ * git's arguments, and this one has to, because the message file is `$1`.
96
+ *
97
+ * Written only when the manifest sets `gates.evidence`. An unset value does not
98
+ * install a hook the repository did not ask for.
99
+ *
100
+ * @param {{npm?: boolean, version?: string}} [opts]
101
+ * @returns {string} POSIX sh source, LF-terminated
102
+ */
103
+ export function renderEvidenceShim({ npm = true, version } = {}) {
104
+ const v = version ?? packageVersion();
105
+ const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : `npm i -g ${PACKAGE_NAME}@${v}`;
106
+ const D = '$';
107
+ return `#!/bin/sh
108
+ ${SHIM_MARKER}
109
+ #
110
+ # A commit message that claims a run is checked against the ledger. The message
111
+ # file is the first argument git passes, so this shim forwards it. Reinstall with:
112
+ # ${CLI} hooks install --yes
113
+
114
+ set -eu
115
+
116
+ ROOT="${D}(git rev-parse --show-toplevel 2>/dev/null || pwd)"
117
+
118
+ ${renderLocator(`evidence check "${D}1" --root "${D}ROOT"`, {
119
+ subject: 'the claim check',
120
+ remedy: `REMEDY run: ${install}, or set ${BIN_ENV} to the ${CLI} binary, or unset ${HOOKS_PATH_KEY} to opt out`,
121
+ })}`;
122
+ }
123
+
93
124
  /**
94
125
  * A line only a chained shim carries.
95
126
  *