simt-dev 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +138 -74
  3. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  4. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
  5. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  6. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  7. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  8. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  9. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  10. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  11. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  12. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  13. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  14. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  15. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  16. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  17. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  18. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  19. package/package.json +2 -2
  20. package/rules/core.md +2 -2
  21. package/src/agenthooks/policy.mjs +10 -2
  22. package/src/agenthooks/prompt.mjs +81 -0
  23. package/src/agenthooks/render.mjs +138 -17
  24. package/src/argv.mjs +34 -1
  25. package/src/bench/grade.mjs +3 -0
  26. package/src/bench/run.mjs +85 -39
  27. package/src/bench/variants.mjs +456 -0
  28. package/src/brief/assemble.mjs +346 -0
  29. package/src/brief/kind.mjs +134 -0
  30. package/src/brief/packet.mjs +197 -0
  31. package/src/ci/workflow.mjs +11 -1
  32. package/src/commands/agenthook.mjs +84 -5
  33. package/src/commands/bench.mjs +53 -16
  34. package/src/commands/brief.mjs +78 -0
  35. package/src/commands/compare.mjs +10 -2
  36. package/src/commands/decide.mjs +116 -0
  37. package/src/commands/evidence.mjs +96 -0
  38. package/src/commands/gate.mjs +165 -0
  39. package/src/commands/help.mjs +65 -3
  40. package/src/commands/hooks.mjs +45 -5
  41. package/src/commands/impact.mjs +205 -0
  42. package/src/commands/index.mjs +52 -1
  43. package/src/commands/lessons.mjs +127 -0
  44. package/src/commands/local.mjs +97 -0
  45. package/src/commands/map.mjs +126 -0
  46. package/src/commands/repoindex.mjs +55 -0
  47. package/src/commands/rules.mjs +17 -2
  48. package/src/commands/shared.mjs +398 -0
  49. package/src/commands/skills.mjs +3 -17
  50. package/src/commands/skillscompile.mjs +255 -0
  51. package/src/commands/sync.mjs +87 -3
  52. package/src/commands/tests.mjs +145 -0
  53. package/src/commands/tokens.mjs +30 -1
  54. package/src/commands/why.mjs +172 -0
  55. package/src/compare/learn.mjs +680 -0
  56. package/src/decisions/index.mjs +116 -65
  57. package/src/federation/client.mjs +188 -0
  58. package/src/federation/config.mjs +134 -0
  59. package/src/federation/query.mjs +48 -0
  60. package/src/graph/compile.mjs +475 -0
  61. package/src/graph/spec.mjs +1 -1
  62. package/src/hooks/dispatcher.mjs +15 -0
  63. package/src/hooks/shim.mjs +31 -0
  64. package/src/index/compact.mjs +228 -0
  65. package/src/index/extract.mjs +331 -0
  66. package/src/index/gate.mjs +89 -0
  67. package/src/index/govern.mjs +234 -0
  68. package/src/index/impact.mjs +186 -0
  69. package/src/index/map.mjs +627 -0
  70. package/src/index/persist.mjs +239 -0
  71. package/src/index/store.mjs +515 -0
  72. package/src/index/tests.mjs +294 -0
  73. package/src/kernel/budget.mjs +100 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/variants.mjs +353 -0
  76. package/src/ledger/index.mjs +147 -0
  77. package/src/local/client.mjs +482 -0
  78. package/src/local/config.mjs +234 -0
  79. package/src/mcp/tools.mjs +138 -1
  80. package/src/measure/context.mjs +7 -5
  81. package/src/memory/decide.mjs +462 -0
  82. package/src/memory/lessons.mjs +858 -0
  83. package/src/projection/disclosure.mjs +92 -0
  84. package/src/projection/lock.mjs +125 -0
  85. package/src/projection/manifest.mjs +52 -0
  86. package/src/projection/markers.mjs +56 -17
  87. package/src/projection/project.mjs +24 -17
  88. package/src/projection/rules.mjs +407 -49
  89. package/src/projection/shared.mjs +428 -0
  90. package/src/projection/targets.mjs +2 -1
  91. package/src/toolwords.mjs +8 -0
@@ -0,0 +1,165 @@
1
+ // `simt gate` runs the tests a change reaches (R16, ADR-094).
2
+ //
3
+ // Paths name the change. `--changed` reads the diff instead. The tests are the
4
+ // ones that import a changed file, directly or through other files, and the
5
+ // runner is the one the package already uses. Nothing else runs. A report
6
+ // this tool knows how to read is compacted; the raw log is kept.
7
+
8
+ import { execFile } from 'node:child_process';
9
+ import { mkdir, writeFile } 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 { detectTestRunner } from '../bench/mine.mjs';
16
+ import { compactOutput } from '../index/compact.mjs';
17
+ import { readImportEdges, selectTests } from '../index/gate.mjs';
18
+ import { packageOf } from '../index/store.mjs';
19
+
20
+ const exec = promisify(execFile);
21
+
22
+ export async function gateCommand(args, flags) {
23
+ const root = flags.root ?? process.cwd();
24
+ const listed = await tracked(root);
25
+ if (listed.error) return listed.error;
26
+ let changed = args.filter(Boolean);
27
+ if (flags.changed === true) {
28
+ const diff = await changedFiles(root);
29
+ if (diff.error) return diff.error;
30
+ changed = [...new Set([...changed, ...diff.files])].sort();
31
+ }
32
+ if (changed.length === 0) {
33
+ if (flags.changed === true) {
34
+ if (isJson(flags)) {
35
+ emitJson({ ok: true, command: 'gate', root, selected: [], code: 0 }, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
36
+ } else {
37
+ emit('selected 0', { maxBytes: flags.maxBytes ?? 0 });
38
+ }
39
+ return EXIT.OK;
40
+ }
41
+ return fail(`nothing changed`, EXIT.USAGE, `pass a path, or ${CLI} gate --changed`);
42
+ }
43
+ const files = new Set(listed.files);
44
+ for (const file of changed) files.add(file);
45
+ const selected = selectTests(changed, await readImportEdges(root, files));
46
+ if (selected.length === 0) {
47
+ const payload = { ok: true, command: 'gate', root, selected, code: 0 };
48
+ if (isJson(flags)) {
49
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
50
+ return EXIT.OK;
51
+ }
52
+ emit('selected 0', { maxBytes: flags.maxBytes ?? 0 });
53
+ return EXIT.OK;
54
+ }
55
+ return runSelected(root, flags, selected);
56
+ }
57
+
58
+ async function runSelected(root, flags, selected) {
59
+ const cache = new Map();
60
+ const groups = new Map();
61
+ for (const file of selected) {
62
+ const pkg = await packageOf(root, file, cache);
63
+ const list = groups.get(pkg);
64
+ if (list) list.push(file);
65
+ else groups.set(pkg, [file]);
66
+ }
67
+ const runs = [];
68
+ let code = 0;
69
+ const text = [];
70
+ const rawParts = [];
71
+ for (const [pkg, files] of [...groups.entries()].sort((a, b) => cmp(a[0], b[0]))) {
72
+ const relative = files.map((file) => (pkg && file.startsWith(`${pkg}/`) ? file.slice(pkg.length + 1) : file));
73
+ const cwd = join(root, pkg);
74
+ const runner = await detectTestRunner(cwd, relative);
75
+ if (!runner) {
76
+ return fail(
77
+ `no runner for ${files.length} test${files.length === 1 ? '' : 's'} in ${pkg || 'this repository'}`,
78
+ EXIT.UNSUPPORTED,
79
+ 'declare a package manifest the test runner can see',
80
+ );
81
+ }
82
+ const built = runner.buildExec(cwd);
83
+ text.push(`selected ${files.length}`, ...files, '', runner.displayCmd);
84
+ // A parent `node --test` puts NODE_TEST_CONTEXT in the environment. Left
85
+ // there, the child reports to that parent and exits 0 whether or not it failed.
86
+ const env = { ...process.env };
87
+ for (const key of Object.keys(env)) {
88
+ if (key.startsWith('NODE_TEST')) delete env[key];
89
+ }
90
+ const result = await exec(built.cmd, built.args, { cwd, env, maxBuffer: 32 << 20 }).then(
91
+ (out) => ({ code: 0, stdout: out.stdout, stderr: out.stderr }),
92
+ (error) => ({
93
+ code: typeof error.code === 'number' ? error.code : 1,
94
+ stdout: error.stdout ?? '',
95
+ stderr: error.stderr ?? '',
96
+ }),
97
+ );
98
+ if (result.code !== 0) code = result.code;
99
+ const stdout = String(result.stdout ?? '');
100
+ const stderr = String(result.stderr ?? '');
101
+ const body = stderr ? `${stdout}\n${stderr}` : stdout;
102
+ rawParts.push(body);
103
+ const compact = compactOutput(body);
104
+ runs.push({ package: pkg, command: runner.displayCmd, code: result.code, stdout, stderr });
105
+ text.push(compact ?? body);
106
+ }
107
+ const raw = await writeRaw(root, rawParts.join('\n'));
108
+ if (raw) text.push('', `raw ${raw}`);
109
+ const payload = { ok: code === 0, command: 'gate', root, selected, code, raw, runs };
110
+ if (isJson(flags)) {
111
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
112
+ return code === 0 ? EXIT.OK : EXIT.FAIL;
113
+ }
114
+ emit(text.filter((line) => line !== undefined).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
115
+ return code === 0 ? EXIT.OK : EXIT.FAIL;
116
+ }
117
+
118
+ async function writeRaw(root, body) {
119
+ try {
120
+ const dir = join(root, '.simt-dev', 'gate');
121
+ await mkdir(dir, { recursive: true });
122
+ const name = `gate-${Date.now()}.log`;
123
+ await writeFile(join(dir, name), body);
124
+ return `.simt-dev/gate/${name}`;
125
+ } catch {
126
+ return '';
127
+ }
128
+ }
129
+
130
+ async function tracked(root) {
131
+ try {
132
+ const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
133
+ return { files: stdout.split('\n').filter(Boolean) };
134
+ } catch (error) {
135
+ return {
136
+ error: fail(
137
+ `could not read the change (${error.message})`,
138
+ EXIT.UNSUPPORTED,
139
+ 'run this inside a git repository',
140
+ ),
141
+ };
142
+ }
143
+ }
144
+
145
+ async function changedFiles(root) {
146
+ try {
147
+ const { stdout } = await exec('git', ['diff', '--name-only', 'HEAD'], { cwd: root, maxBuffer: 8 << 20 });
148
+ const untracked = await exec('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, maxBuffer: 8 << 20 });
149
+ return { files: [...new Set([...stdout.split('\n'), ...untracked.stdout.split('\n')].filter(Boolean))].sort() };
150
+ } catch (error) {
151
+ return {
152
+ error: fail(
153
+ `could not read the diff (${error.message})`,
154
+ EXIT.UNSUPPORTED,
155
+ 'run this inside a git repository that has a commit',
156
+ ),
157
+ };
158
+ }
159
+ }
160
+
161
+ function cmp(a, b) {
162
+ if (a < b) return -1;
163
+ if (a > b) return 1;
164
+ return 0;
165
+ }
@@ -16,6 +16,12 @@ ${CLI} upgrade move every consumer to this simt-dev SHA, as one s
16
16
  (dry-run unless --yes; moves a pin only - it never
17
17
  adopts or repairs a repo; never commits or pushes)
18
18
 
19
+ ${CLI} brief "<task>" one packet for a task: the kind, the commands, the
20
+ likely files, the tests and the decisions that govern
21
+ them, inside --budget bytes (default 2048; 0 is
22
+ unlimited). Says how many items it cut; --explain
23
+ says why each was kept or dropped
24
+
19
25
  ${CLI} skills index tier-0 catalog: every skill, one line each
20
26
  ${CLI} skills brief --task tier-1 pre-assembled brief <- the common case
21
27
  ${CLI} skills show <name> tier-2 body (respects --max-bytes)
@@ -28,6 +34,9 @@ ${CLI} skills eval [corpus] measure the resolver against a gold corpus
28
34
  ${CLI} skills install <name> pin a skill into the repo as a local override
29
35
  (dry-run unless --yes; a multi-variant skill needs
30
36
  --variant or --from rather than a guess)
37
+ ${CLI} skills compile [<name>] compile a skill to Agent Skills standard, flag bodies
38
+ over 5000 tokens with a split plan, and report per-skill
39
+ trigger recall and precision from R02 transcripts
31
40
 
32
41
  ${CLI} rules list the rule catalogue (--root marks what applies to a repo)
33
42
  ${CLI} rules show <id> one rule body
@@ -39,7 +48,8 @@ ${CLI} hooks install [--yes] install the shared dispatcher; report what it wou
39
48
  ${CLI} hooks status what each git event routes to, and whether it is wired
40
49
  ${CLI} hooks run <event> run one event's gates now, as git would
41
50
  (pre-commit, pre-push, post-merge, ...)
42
- ${CLI} agent-hook <event> the entry point a harness calls before a tool call
51
+ ${CLI} agent-hook <event> the entry point a harness calls: pre-tool-use decides,
52
+ prompt-submit delivers the packet as prompt context
43
53
  (reads one JSON payload on stdin and answers in that
44
54
  harness's dialect; exit 2 blocks; needs --harness)
45
55
  ${CLI} agent-hooks install write this repo's policies into the harnesses that
@@ -61,6 +71,46 @@ ${CLI} ci simt-dev's own .github files: create or refresh th
61
71
  ${CLI} compare org-wide matrix across every sibling repo
62
72
  (read-only; exits non-zero on defects, not on
63
73
  repos that have not adopted yet)
74
+ ${CLI} index rebuild the local file index from git blob hashes
75
+ ${CLI} why [path] what governs a file: a decision whose Affects line names
76
+ it, a rule whose scope matches it, a skill, a gate
77
+ or a lesson. With no path, the Affects tokens this
78
+ tree cannot resolve (UNRESOLVED-AFFECTS)
79
+ ${CLI} tests [path] the tests that exercise a file: an import, the conventional
80
+ source beside the test, or a coverage record that
81
+ names both (--cochange adds files that only share
82
+ commits, and only those)
83
+ ${CLI} gate [path...] the tests a change reaches, and only those
84
+ (--changed reads the diff instead of the paths)
85
+ ${CLI} impact <file|symbol> who a change reaches: importers, their tests,
86
+ what governs it, and a contract another repository references
87
+ ${CLI} map [--task "<text>"] [--level outline|names|signatures]
88
+ the files this task is most likely to touch, within
89
+ --budget bytes (default 2048; 0 is unlimited). outline
90
+ is paths, names adds exports, signatures adds the line.
91
+ --recall reports gold-file recall@10 on mined tasks
92
+ ${CLI} evidence the runs recorded against the tree on disk
93
+ ${CLI} evidence check <file> a commit message that claims a run this tree does not
94
+ have: a warning, or a block when gates.evidence is "block"
95
+ ${CLI} lessons list deterministic lesson candidates in .simt-dev/lessons.jsonl
96
+ and promoted lessons in docs/lessons/*.md
97
+ ${CLI} lessons review [<id>] promote a lesson candidate to docs/lessons/NNNN-<slug>.md
98
+ (dry-run unless --yes; writes Appendix A frontmatter)
99
+ ${CLI} decide [<title>] scaffold an ADR with the next free number, a Status: line,
100
+ a rulings skeleton, and structured simt-affects +
101
+ simt-supersedes frontmatter (dry-run unless --yes;
102
+ --check lints ADRs for prose-only partial supersessions)
103
+ ${CLI} local doctor probe configured local model endpoints (chat and embed)
104
+ (reads local in ${MANIFEST_FILE}; opens no socket when
105
+ unset; refuses non-loopback URLs without --allow-remote)
106
+ ${CLI} shared plan what a workspace's repeated rules cost to carry
107
+ once: the shared set, its bytes, and every path that would be written
108
+ (read-only; touches nothing, not even your home directory)
109
+ ${CLI} shared install [--yes] carry those rules once per reader, at user level,
110
+ and declare the delta in each repo's manifest (dry-run unless
111
+ --yes; copies land in files the vendors' own docs name, and a reader
112
+ with no such file gets the block printed to paste; ${CLI} doctor
113
+ reports a copy that is missing or stale)
64
114
  ${CLI} tokens [--repos] what a reader loads at session start, in bytes
65
115
  (read-only; exits 0 whatever it finds, because a
66
116
  measurement is not a verdict; --repos measures every
@@ -88,6 +138,9 @@ ${CLI} bench ablate [--yes] leave-one-out ablation across core, profile and p
88
138
  ${CLI} bench probes [--yes] 12 synthetic rule-compliance probes with deterministic graders
89
139
  (dry-run unless --yes; grades git state, transcript and ledger,
90
140
  and reports compliance per arm)
141
+ ${CLI} bench variants [--yes] evaluate simt-variants rule slots on the canary suite and lock
142
+ winning variants in simt-dev.lock.json (dry-run unless --yes;
143
+ re-opens when model or harness changes)
91
144
  ${CLI} mcp the read-only surface as MCP tools over stdio
92
145
  (--list prints it without reading stdin; init, sync,
93
146
  upgrade and the installers stay CLI-only by design)`;
@@ -95,7 +148,8 @@ ${CLI} mcp the read-only surface as MCP tools over stdio
95
148
  const FLAGS = `--json structured output (compact; one document, one line)
96
149
  --pretty indent --json for a human
97
150
  --max-bytes <n> hard ceiling on emitted bytes (0 = unlimited, default 0)
98
- --budget <n> context budget for skills brief (default 20480)
151
+ --budget <n> context budget for a brief (default 2048)
152
+ (skills brief keeps its own default of 20480)
99
153
  --plan emit a fetch plan instead of inlining skill bodies
100
154
  --skill <name> force a skill into the brief (repeatable)
101
155
  --task "<text>" the task text used for capability resolution
@@ -110,8 +164,11 @@ const FLAGS = `--json structured output (compact; one document,
110
164
  --dry-run report what would change, change nothing
111
165
  --yes assume yes; required in automation, never prompts
112
166
  --check report drift and write nothing (sync, ci)
167
+ --frozen check: the lock must match this projection; an output
168
+ change no input change explains fails the gate
113
169
  --explain sync: name each line dropped as already inferable, with
114
- the fact behind it
170
+ the fact behind it; brief: name every item and why it
171
+ was kept or cut, with its section's value
115
172
  --all ignore a cap: every ruling of a record, or the skills budget
116
173
  --variant <id> which body of a multi-variant skill to use
117
174
  --from <repo/path> install from a declared source by name
@@ -145,6 +202,11 @@ const FLAGS = `--json structured output (compact; one document,
145
202
  --compare <a> <b> bench report: compare two arms with deltas and p-values
146
203
  --test <cmd> plan done: the test command that closes the row
147
204
  --state <name> plan done: target state (default: Complete)
205
+ --level <name> map: detail level (outline, names, signatures)
206
+ --supersedes <spec> decide: superseded ADR or ruling (ADR-NNN or ADR-NNN:<ruling>[:<kind>]; repeatable)
207
+ --allow-remote local doctor: allow non-loopback model URLs (refused by default)
208
+ --embed index / map / brief: compute or query optional local vector embeddings
209
+ (off by default unless recall@10 gains at least 10 points)
148
210
  --help this text
149
211
  --version the version, and the SHA it was built from`;
150
212
 
@@ -19,7 +19,7 @@ import { promisify } from 'node:util';
19
19
 
20
20
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
21
21
  import { MANIFEST, readManifest } from '../projection/manifest.mjs';
22
- import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, fileFor, renderChainedShim, renderShim } from '../hooks/shim.mjs';
22
+ import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderEvidenceShim, renderShim } from '../hooks/shim.mjs';
23
23
  import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
24
24
  import { GATES } from '../hooks/gates.mjs';
25
25
  import { knownGates } from '../hooks/resolve.mjs';
@@ -222,11 +222,29 @@ async function installHooks(root, flags) {
222
222
  (i) => i.present && i.ours && !wanted.includes(i.event) && !chained.some((c) => c.event === i.event),
223
223
  );
224
224
 
225
+ // The claim check is not one of EVENTS. Those shims do not forward git's
226
+ // arguments, and commit-msg's only argument is the message. It is installed
227
+ // when the repository sets gates.evidence, and removed when that setting goes,
228
+ // because a shim that stays after the setting left still replaces git's own hook.
229
+ const evidenceDeclared = manifest?.gates?.evidence === 'warn' || manifest?.gates?.evidence === 'block';
230
+ const evidenceFile = fileFor('commit-msg');
231
+ const evidenceExisting = await readIfExists(join(root, evidenceFile));
232
+ const evidenceSource = evidenceDeclared ? renderEvidenceShim({ npm, version }) : null;
233
+ const evidenceOurs = evidenceExisting !== null && evidenceExisting.includes(SHIM_MARKER);
234
+ if (!evidenceDeclared && evidenceOurs) obsolete.push({ event: 'commit-msg' });
235
+ // A foreign commit-msg would otherwise be chained onto the same path. The chain
236
+ // is written after this shim, so it would replace the claim check and the check
237
+ // would never run. The repository's hook stays on disk and the note says it stopped.
238
+ if (evidenceDeclared) {
239
+ const at = chained.findIndex((c) => c.event === 'commit-msg');
240
+ if (at !== -1) chained.splice(at, 1);
241
+ }
242
+
225
243
  // Nothing to write and nothing to take away is the only refusal. It sits after
226
244
  // `obsolete` because a repository whose last gate just left the manifest has
227
245
  // something to take away, and blocking that one left no way out except undoing
228
246
  // the change by hand - see the note on `obsolete`.
229
- if (wanted.length === 0 && obsolete.length === 0) {
247
+ if (wanted.length === 0 && obsolete.length === 0 && !evidenceDeclared) {
230
248
  if (isJson(flags)) {
231
249
  emitJson({ ok: false, command: 'hooks install', root, nothingToEnforce: true, filesWritten: false });
232
250
  return EXIT.FAIL;
@@ -276,10 +294,20 @@ async function installHooks(root, flags) {
276
294
  // Named in the plan because the reader approving this write is about to lose a
277
295
  // file, and "REMOVED pre-push" does not say why - which is the difference between
278
296
  // agreeing with it and discovering later that a gate had been dropped by accident.
279
- note: `no gate for ${h.event} in ${MANIFEST_FILE}; the shim enforced nothing`,
297
+ note:
298
+ h.event === 'commit-msg'
299
+ ? `gates.evidence is unset, so the claim check stopped`
300
+ : `no gate for ${h.event} in ${MANIFEST_FILE}; the shim enforced nothing`,
301
+ });
302
+ }
303
+ if (evidenceSource) {
304
+ plan.push({
305
+ file: evidenceFile,
306
+ action: evidenceExisting === evidenceSource ? 'unchanged' : evidenceExisting === null ? 'created' : 'updated',
307
+ note: 'commit-msg checks a verification claim against the ledger',
280
308
  });
281
309
  }
282
- const routed = wanted.length > 0 || chained.length > 0;
310
+ const routed = wanted.length > 0 || chained.length > 0 || evidenceDeclared;
283
311
 
284
312
  // The routing this install leaves behind, which is not always `wants`. A repository
285
313
  // that removed its last gate has no shims to route to, and leaving `core.hooksPath`
@@ -362,6 +390,12 @@ async function installHooks(root, flags) {
362
390
  for (const h of obsolete) {
363
391
  await rm(join(root, fileFor(h.event)));
364
392
  }
393
+ // After the chain loop, so a chained commit-msg cannot replace the claim check.
394
+ if (evidenceSource) {
395
+ const path = join(root, evidenceFile);
396
+ await writeFile(path, evidenceSource);
397
+ await chmod(path, 0o755);
398
+ }
365
399
 
366
400
  // `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
367
401
  // pointing at a directory of no shims stops the repository's own hooks - the ones in
@@ -385,6 +419,10 @@ async function installHooks(root, flags) {
385
419
  notes.push(
386
420
  `chained ${f.event}: ${fileFor(f.event)} runs ${f.path} (${f.bytes} bytes), so it keeps running`,
387
421
  );
422
+ } else if (f.event === 'commit-msg' && evidenceDeclared) {
423
+ notes.push(
424
+ `stopped running commit-msg at ${f.path} (${f.bytes} bytes); the claim check replaced it, and the file is still on disk, untouched`,
425
+ );
388
426
  } else if (leftRunning.some((c) => c.event === f.event)) {
389
427
  // A third case, and the one that was misreported: a hook this tool could not chain
390
428
  // because the shim would have had to run itself. Nothing overwrote it, so it still
@@ -403,7 +441,9 @@ async function installHooks(root, flags) {
403
441
  }
404
442
  for (const h of obsolete) {
405
443
  notes.push(
406
- `removed ${fileFor(h.event)}: ${MANIFEST_FILE} declares no ${h.event} gate, so the shim enforced nothing and still replaced git's own hook`,
444
+ h.event === 'commit-msg'
445
+ ? `removed ${fileFor(h.event)}: ${MANIFEST_FILE} no longer sets gates.evidence, so the claim check stopped replacing git's own commit-msg`
446
+ : `removed ${fileFor(h.event)}: ${MANIFEST_FILE} declares no ${h.event} gate, so the shim enforced nothing and still replaced git's own hook`,
407
447
  );
408
448
  }
409
449
  if (leaves === null && current !== null) {
@@ -0,0 +1,205 @@
1
+ // `simt impact` reports who a file or an export reaches (R23, ADR-096).
2
+ //
3
+ // Importers, the tests that cover them, the decisions and rules that govern
4
+ // the file, the CODEOWNERS line, and — when a workspace is configured —
5
+ // another repository that references a contract this change touches.
6
+
7
+ import { execFile } from 'node:child_process';
8
+ import { readFile } from 'node:fs/promises';
9
+ import { basename, join, resolve } from 'node:path';
10
+ import { promisify } from 'node:util';
11
+
12
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
13
+ import { CLI } from '../name.mjs';
14
+ import { discover } from '../compare/discover.mjs';
15
+ import { extract } from '../index/extract.mjs';
16
+ import { importEdges, readEntries } from '../index/gate.mjs';
17
+ import {
18
+ contractReferences,
19
+ dependents,
20
+ filesExporting,
21
+ isContractPath,
22
+ ownersOf,
23
+ relatedContracts,
24
+ } from '../index/impact.mjs';
25
+ import { decisionHits, scopeHits, sortHits, sourceRoots } from '../index/govern.mjs';
26
+ import { decisionRecords, ruleScopes } from './why.mjs';
27
+ import { resolveWorkspace } from '../workspace.mjs';
28
+
29
+ const exec = promisify(execFile);
30
+ const TEXT_EXT = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'md', 'toml', 'xml', 'py', 'go', 'kt', 'java', 'rs']);
31
+ const READ_CAP = 256 * 1024;
32
+ const FILE_CAP = 2000;
33
+
34
+ export async function impactCommand(args, flags, commands = []) {
35
+ if (args.length > 1) return fail(`unknown impact argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} impact <file|symbol>`);
36
+ const target = args[0];
37
+ if (!target) return fail(`a file or a symbol is required`, EXIT.USAGE, `run: ${CLI} impact <file|symbol>`);
38
+ const root = flags.root ?? process.cwd();
39
+ const listed = await tracked(root);
40
+ if (listed.error) return listed.error;
41
+ const files = new Set(listed.files);
42
+ const entries = await readEntries(root, files);
43
+ const imports = importEdges(entries);
44
+ const symbols = entries.flatMap((entry) => (entry.lang ? extract(entry.path, entry.text, entry.lang).symbols : []));
45
+ const resolved = resolveTarget(target, files, symbols);
46
+ if (resolved.error) return resolved.error;
47
+ const reach = dependents(resolved.file, imports);
48
+ const governs = await governance(root, resolved.file, files, commands);
49
+ const owners = await readOwners(root, resolved.file);
50
+ const contracts = await contractsFor(root, listed.files, resolved, reach.importers);
51
+ const consumers = await consumersFor(root, flags, basename(root), contracts);
52
+ const truncated = reach.truncated || consumers.truncated;
53
+ const payload = {
54
+ ok: true,
55
+ command: 'impact',
56
+ root,
57
+ file: resolved.file,
58
+ symbol: resolved.symbol,
59
+ importers: reach.importers,
60
+ tests: reach.tests,
61
+ governs,
62
+ owners,
63
+ consumers: consumers.references,
64
+ truncated,
65
+ };
66
+ if (isJson(flags)) {
67
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
68
+ return EXIT.OK;
69
+ }
70
+ emit(render(payload), { maxBytes: flags.maxBytes ?? 0 });
71
+ return EXIT.OK;
72
+ }
73
+
74
+ function resolveTarget(target, files, symbols) {
75
+ if (files.has(target)) return { file: target, symbol: '' };
76
+ const exported = filesExporting(target, symbols);
77
+ if (exported.length === 1) return { file: exported[0], symbol: target };
78
+ if (exported.length === 0) {
79
+ return { error: fail(`not a file or an export: ${target}`, EXIT.USAGE, `pass a path git tracks, or the name of one export`) };
80
+ }
81
+ return {
82
+ error: fail(
83
+ `${target} is exported by ${exported.join(', ')}`,
84
+ EXIT.USAGE,
85
+ `pass the path of the export you mean`,
86
+ ),
87
+ };
88
+ }
89
+
90
+ async function governance(root, file, files, commands) {
91
+ const records = await decisionRecords(root);
92
+ const rules = await ruleScopes(root);
93
+ const ctx = { files, commands: new Set(commands), roots: sourceRoots(files) };
94
+ const { hits } = decisionHits(records, file, ctx);
95
+ return sortHits([...hits, ...scopeHits(file, { rules })]);
96
+ }
97
+
98
+ async function readOwners(root, file) {
99
+ for (const path of ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS']) {
100
+ try {
101
+ return ownersOf(await readFile(join(root, path), 'utf8'), file);
102
+ } catch {
103
+ // The next location may hold the file. GitHub reads the first that exists.
104
+ }
105
+ }
106
+ return [];
107
+ }
108
+
109
+ async function contractsFor(root, files, resolved, importers) {
110
+ const contracts = [];
111
+ for (const path of files) {
112
+ if (!isContractPath(path) && !mightSniff(path)) continue;
113
+ let text = '';
114
+ try {
115
+ text = await readFile(join(root, path), 'utf8');
116
+ } catch {
117
+ continue;
118
+ }
119
+ if (!isContractPath(path) && !sniffs(text)) continue;
120
+ contracts.push({ path, text });
121
+ }
122
+ return relatedContracts(contracts, { file: resolved.file, symbol: resolved.symbol, importers });
123
+ }
124
+
125
+ async function consumersFor(root, flags, repoName, contracts) {
126
+ if (contracts.length === 0) return { references: [], truncated: false };
127
+ const workspace = await resolveWorkspace(flags);
128
+ if (!workspace.root) return { references: [], truncated: false };
129
+ const found = await discover(workspace.root);
130
+ if (found.shape !== 'workspace') return { references: [], truncated: false };
131
+ const here = resolve(root);
132
+ const files = [];
133
+ let truncated = false;
134
+ for (const repo of found.repos) {
135
+ if (resolve(repo) === here) continue;
136
+ const listed = await tracked(repo);
137
+ if (listed.error) continue;
138
+ const name = basename(repo);
139
+ let read = 0;
140
+ for (const path of listed.files) {
141
+ if (!textExt(path)) continue;
142
+ if (read >= FILE_CAP) {
143
+ truncated = true;
144
+ break;
145
+ }
146
+ read += 1;
147
+ try {
148
+ const text = await readFile(join(repo, path), 'utf8');
149
+ if (text.length > READ_CAP) continue;
150
+ files.push({ repo: name, path, text });
151
+ } catch {
152
+ // A file git lists and the worktree no longer has is not a consumer.
153
+ }
154
+ }
155
+ }
156
+ const refs = contractReferences(contracts, repoName, files);
157
+ return { references: refs.references, truncated: truncated || refs.truncated };
158
+ }
159
+
160
+ function render(payload) {
161
+ const lines = [`file ${payload.file}`];
162
+ if (payload.symbol) lines.push(`symbol ${payload.symbol}`);
163
+ for (const path of payload.importers) lines.push(`importer ${path}`);
164
+ for (const path of payload.tests) lines.push(`test ${path}`);
165
+ for (const hit of payload.governs) lines.push(`${hit.kind.padEnd(10)}${hit.id} ${hit.detail}`);
166
+ for (const owner of payload.owners) lines.push(`owner ${owner}`);
167
+ for (const consumer of payload.consumers) lines.push(`consumer ${consumer.repo}/${consumer.path} ${consumer.contract}`);
168
+ if (payload.truncated) lines.push('truncated 1');
169
+ return lines.join('\n');
170
+ }
171
+
172
+ function mightSniff(file) {
173
+ const base = file.slice(file.lastIndexOf('/') + 1);
174
+ const dot = base.lastIndexOf('.');
175
+ if (dot <= 0) return false;
176
+ const ext = base.slice(dot + 1).toLowerCase();
177
+ return ext === 'json' || ext === 'yaml' || ext === 'yml';
178
+ }
179
+
180
+ function sniffs(text) {
181
+ const head = text.slice(0, 500);
182
+ return head.includes('"openapi"') || head.includes('"$schema"');
183
+ }
184
+
185
+ function textExt(file) {
186
+ const base = file.slice(file.lastIndexOf('/') + 1);
187
+ const dot = base.lastIndexOf('.');
188
+ if (dot <= 0) return false;
189
+ return TEXT_EXT.has(base.slice(dot + 1).toLowerCase());
190
+ }
191
+
192
+ async function tracked(root) {
193
+ try {
194
+ const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
195
+ return { files: stdout.split('\n').filter(Boolean) };
196
+ } catch (error) {
197
+ return {
198
+ error: fail(
199
+ `could not read the tree (${error.message})`,
200
+ EXIT.UNSUPPORTED,
201
+ 'run this inside a git repository',
202
+ ),
203
+ };
204
+ }
205
+ }