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
@@ -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
+ }
@@ -29,13 +29,30 @@ import { syncCommand, checkCommand } from './sync.mjs';
29
29
  import { initCommand } from './init.mjs';
30
30
  import { ciCommand } from './ci.mjs';
31
31
  import { hooksCommand } from './hooks.mjs';
32
+ import { agentHookCommand, agentHooksCommand } from './agenthook.mjs';
32
33
  import { compareCommand } from './compare.mjs';
33
34
  import { upgradeCommand } from './upgrade.mjs';
34
35
  import { mcpCommand } from './mcp.mjs';
35
36
  import { installCommand } from './skillsinstall.mjs';
36
37
  import { listCommand as rulesList, showCommand as rulesShow, whyCommand as rulesWhy } from './rules.mjs';
37
38
  import { tokensCommand } from './tokens.mjs';
38
- import { harnessCommand } from './harness.mjs';
39
+ import { harnessCommand, harnessLoadersCommand } from './harness.mjs';
40
+ import {
41
+ benchMineCommand,
42
+ benchRunCommand,
43
+ benchReportCommand,
44
+ benchAblateCommand,
45
+ benchProbesCommand,
46
+ } from './bench.mjs';
47
+ import { planCommand } from './plan.mjs';
48
+ import { sharedPlanCommand, sharedInstallCommand, sharedWarning } from './shared.mjs';
49
+ import { evidenceCommand } from './evidence.mjs';
50
+ import { indexCommand } from './repoindex.mjs';
51
+ import { mapCommand } from './map.mjs';
52
+ import { testsCommand } from './tests.mjs';
53
+ import { whyCommand } from './why.mjs';
54
+ import { gateCommand } from './gate.mjs';
55
+ import { impactCommand } from './impact.mjs';
39
56
  import { parse } from '../argv.mjs';
40
57
  import { fingerprint } from '../resolve/fingerprint.mjs';
41
58
  import { resolveWorkspace } from '../workspace.mjs';
@@ -53,7 +70,15 @@ const GROUPS = new Map([
53
70
  ['graph', 'lint'],
54
71
  ['rules', 'list'],
55
72
  ['hooks', 'status'],
73
+ ['plan', 'status'],
74
+ ['agent-hooks', 'status'],
56
75
  ['harness', 'detect'],
76
+ ['bench', 'mine'],
77
+ // ADR-084. `shared` is the one group whose default writes nothing, and it is `plan` for
78
+ // the same reason `skills`'s is `index`: the read answers "what would this do, and
79
+ // where", and `install` — the subcommand that touches a reader's home directory — is the
80
+ // one that needs `--yes` before it does anything.
81
+ ['shared', 'plan'],
57
82
  ]);
58
83
 
59
84
  /**
@@ -84,14 +109,36 @@ export const IMPLEMENTED = {
84
109
  'rules why': rulesWhy,
85
110
  sync: syncCommand,
86
111
  check: checkCommand,
112
+ 'plan status': (args, flags) => planCommand('status', args, flags),
113
+ 'plan next': (args, flags) => planCommand('next', args, flags),
114
+ 'plan done': (args, flags) => planCommand('done', args, flags),
115
+ 'shared plan': sharedPlanCommand,
116
+ 'shared install': sharedInstallCommand,
117
+ evidence: evidenceCommand,
118
+ index: indexCommand,
119
+ map: mapCommand,
120
+ tests: testsCommand,
121
+ why: (args, flags) => whyCommand(args, flags, COMMANDS),
122
+ gate: gateCommand,
123
+ impact: (args, flags) => impactCommand(args, flags, COMMANDS),
124
+ 'evidence check': (args, flags) => evidenceCommand(['check', ...args], flags),
87
125
  init: initCommand,
88
126
  ci: ciCommand,
89
127
  'hooks install': (args, flags) => hooksCommand('install', args, flags),
90
128
  'hooks status': (args, flags) => hooksCommand('status', args, flags),
91
129
  'hooks run': (args, flags) => hooksCommand('run', args, flags),
130
+ 'agent-hook': agentHookCommand,
131
+ 'agent-hooks install': (args, flags) => agentHooksCommand('install', args, flags),
132
+ 'agent-hooks status': (args, flags) => agentHooksCommand('status', args, flags),
92
133
  compare: compareCommand,
93
134
  tokens: tokensCommand,
94
135
  'harness detect': harnessCommand,
136
+ 'harness loaders': harnessLoadersCommand,
137
+ 'bench mine': benchMineCommand,
138
+ 'bench run': benchRunCommand,
139
+ 'bench report': benchReportCommand,
140
+ 'bench ablate': benchAblateCommand,
141
+ 'bench probes': benchProbesCommand,
95
142
  upgrade: upgradeCommand,
96
143
  mcp: mcpCommand,
97
144
  };
@@ -196,6 +243,13 @@ async function runDoctor(positional, flags) {
196
243
  // answer confidently wrong, so the answer is reported rather than inferred
197
244
  // (ADR-014).
198
245
  const ws = await resolveWorkspace(flags);
246
+ // The shared file this repository hands rules to (ADR-084). Asked here and nowhere else
247
+ // because it is the one thing about a repository that is true of the reader's machine
248
+ // rather than of the tree: `check` must not read `$HOME`, so `doctor` is the only command
249
+ // that can notice a region that dropped rules into a file nobody has. Reported, never
250
+ // blocking — a stale copy costs a reader some of their rules, which is worth knowing and
251
+ // is not this repository's gate failing.
252
+ const shared = await sharedWarning(root, result.manifest ?? null, flags);
199
253
 
200
254
  // Under `--json` the document is the whole report, so it has to carry both
201
255
  // failures the text path reports AND the workspace answer the text path prints.
@@ -230,6 +284,7 @@ async function runDoctor(positional, flags) {
230
284
  detail: ws.detail,
231
285
  error: ws.error ?? null,
232
286
  },
287
+ shared,
233
288
  ...(failure ?? {}),
234
289
  });
235
290
  return code;
@@ -252,6 +307,10 @@ async function runDoctor(positional, flags) {
252
307
  }`,
253
308
  `manifest ${result.manifest ? (result.manifest.error ?? result.manifest.id ?? 'present') : '(absent)'}`,
254
309
  `workspace ${ws.root ?? '(none)'} (${ws.source}: ${ws.detail})`,
310
+ // Printed only when this repository handed rules to a workspace copy: a line in
311
+ // every `doctor` is a line in every reader's terminal, and the reader who has
312
+ // nothing to install is not served by one (ADR-084).
313
+ ...(shared.declared ? [`shared ${shared.state} ${shared.detail}`] : []),
255
314
  ].join('\n'),
256
315
  { maxBytes: flags.maxBytes ?? 0 },
257
316
  );
@@ -0,0 +1,91 @@
1
+ // `simt map` ranks the files a task is most likely to touch (R22, ADR-091).
2
+ // The cache is read, and rebuilt once when it is empty, because a map of no
3
+ // files is not an answer. The rebuild writes the gitignored index, which is
4
+ // the same cache `simt index` writes.
5
+
6
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
7
+ import { CLI } from '../name.mjs';
8
+ import { readEdges, readFiles, readSymbols, reindex } from '../index/store.mjs';
9
+ import { DEFAULT_BUDGET, formatRecall, measureRecall, rankFiles, renderMap } from '../index/map.mjs';
10
+
11
+ const LEVELS = new Set(['outline', 'names', 'signatures']);
12
+
13
+ export async function mapCommand(args, flags) {
14
+ if (args.length > 0) return fail(`unknown map argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} map`);
15
+ const root = flags.root ?? process.cwd();
16
+ if (flags.recall === true) return recall(root, flags);
17
+ const level = flags.level ?? 'outline';
18
+ if (!LEVELS.has(level)) {
19
+ return fail(`unknown level ${level}`, EXIT.USAGE, 'use outline, names or signatures');
20
+ }
21
+ const budget = flags.budget === undefined ? DEFAULT_BUDGET : Number(flags.budget);
22
+ if (!Number.isInteger(budget) || budget < 0) {
23
+ return fail(`budget must be a non-negative integer`, EXIT.USAGE, `run: ${CLI} map --budget 2048`);
24
+ }
25
+ const loaded = await loadGraph(root);
26
+ if (loaded.error) return loaded.error;
27
+ const task = typeof flags.task === 'string' ? flags.task : '';
28
+ const ranked = rankFiles({ ...loaded, task });
29
+ const rendered = renderMap(ranked, loaded.symbols, { level, budget });
30
+ const payload = {
31
+ ok: true,
32
+ command: 'map',
33
+ root,
34
+ level,
35
+ budget,
36
+ shown: rendered.shown,
37
+ total: rendered.total,
38
+ truncated: rendered.truncated,
39
+ files: rendered.files,
40
+ text: rendered.text,
41
+ };
42
+ if (isJson(flags)) {
43
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
44
+ return EXIT.OK;
45
+ }
46
+ const lines = [
47
+ `level ${level}`,
48
+ `shown ${rendered.shown}`,
49
+ `total ${rendered.total}`,
50
+ `budget ${budget}`,
51
+ ];
52
+ if (rendered.text) lines.push(rendered.text);
53
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
54
+ return EXIT.OK;
55
+ }
56
+
57
+ async function recall(root, flags) {
58
+ let result;
59
+ try {
60
+ result = await measureRecall(root);
61
+ } catch (error) {
62
+ return fail(`could not measure recall (${error.message})`, EXIT.UNSUPPORTED, `run ${CLI} index in a git repository`);
63
+ }
64
+ const payload = { ok: true, command: 'map', root, recall: result };
65
+ if (isJson(flags)) {
66
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
67
+ return EXIT.OK;
68
+ }
69
+ emit(formatRecall(result), { maxBytes: flags.maxBytes ?? 0 });
70
+ return EXIT.OK;
71
+ }
72
+
73
+ async function loadGraph(root) {
74
+ let files = await readFiles(root);
75
+ if (files.length === 0) {
76
+ try {
77
+ await reindex(root);
78
+ } catch (error) {
79
+ return {
80
+ error: fail(
81
+ `could not map this repository (${error.message})`,
82
+ EXIT.UNSUPPORTED,
83
+ 'run this inside a git repository',
84
+ ),
85
+ };
86
+ }
87
+ files = await readFiles(root);
88
+ }
89
+ const [symbols, edges] = await Promise.all([readSymbols(root), readEdges(root)]);
90
+ return { files, symbols, edges };
91
+ }
@@ -0,0 +1,99 @@
1
+ import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
2
+ import { formatCounts, planDone, planNext, planStatus } from '../plan/index.mjs';
3
+ import { CLI } from '../name.mjs';
4
+
5
+ // Preview unless --yes, and --dry-run wins even then. A tracker row marked done
6
+ // for a commit that was never written is a claim the next reader will believe.
7
+
8
+ export async function planCommand(sub, args, flags) {
9
+ const root = flags.root ?? process.cwd();
10
+ if (sub === 'status') return runStatus(root, flags);
11
+ if (sub === 'next') return runNext(root, args[0] ?? null, flags);
12
+ if (sub === 'done') {
13
+ if (!args[0]) return fail('plan done needs a slice id', EXIT.USAGE, `run: ${CLI} plan next`);
14
+ return runDone(root, args[0], args[1] ?? null, flags);
15
+ }
16
+ if (!sub) {
17
+ return fail('plan needs a subcommand', EXIT.USAGE, `run: ${CLI} plan status | ${CLI} plan next | ${CLI} plan done <id>`);
18
+ }
19
+ return fail(`unknown plan subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} plan status`);
20
+ }
21
+
22
+ async function runStatus(root, flags) {
23
+ const result = await planStatus(root);
24
+ if (result.error) return fail(result.error, EXIT.USAGE, result.remedy);
25
+ if (isJson(flags)) {
26
+ emitJson({ ok: true, command: 'plan status', ...result });
27
+ return EXIT.OK;
28
+ }
29
+ const lines = [`repo ${root}`, `template ${result.template}`, `dirs ${result.directories.join(', ')}`];
30
+ if (result.documents.length === 0) lines.push(`(no tracker in ${result.directories.join(' or ')})`);
31
+ for (const doc of result.documents) {
32
+ lines.push(`${doc.name} slices=${doc.slices} ${formatCounts(doc.counts, result.stateOrder)}`);
33
+ }
34
+ lines.push('', `total ${formatCounts(result.totals, result.stateOrder) || '-'}`);
35
+ lines.push(`ready ${result.ready}`);
36
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
37
+ return EXIT.OK;
38
+ }
39
+
40
+ async function runNext(root, id, flags) {
41
+ const result = await planNext(root, id);
42
+ if (result.error) return fail(result.error, EXIT.USAGE, result.remedy);
43
+ if (isJson(flags)) {
44
+ emitJson({ ok: true, command: 'plan next', ...result });
45
+ return EXIT.OK;
46
+ }
47
+ if (!result.next) {
48
+ const lines = [`repo ${root}`, result.reason];
49
+ if (result.blocked?.length) {
50
+ lines.push('', 'blocked:');
51
+ for (const row of result.blocked) {
52
+ const unknown = row.unknownDependencies.length ? ` unknown: ${row.unknownDependencies.join(', ')}` : '';
53
+ lines.push(` ${row.id} ${row.doc} depends on: ${row.dependsOn || '(none)'}${unknown}`);
54
+ }
55
+ }
56
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
57
+ return EXIT.OK;
58
+ }
59
+ const next = result.next;
60
+ const head = [
61
+ `repo ${root}`,
62
+ `slice ${next.id} ${next.doc}`,
63
+ `state ${next.state}`,
64
+ `depends ${next.dependsOn || '(none)'}`,
65
+ `bytes ${next.sectionBytes}`,
66
+ '',
67
+ next.section || '(no section for this id)',
68
+ ];
69
+ emit(head.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
70
+ return EXIT.OK;
71
+ }
72
+
73
+ async function runDone(root, id, sha, flags) {
74
+ const mode = writeMode(flags);
75
+ const result = await planDone(root, id, {
76
+ sha,
77
+ test: flags.test ?? null,
78
+ state: flags.state ?? null,
79
+ dryRun: !mode.apply,
80
+ });
81
+ if (result.error) return fail(result.error, EXIT.USAGE, result.remedy);
82
+ if (isJson(flags)) {
83
+ emitJson({ ok: true, command: 'plan done', ...result });
84
+ return EXIT.OK;
85
+ }
86
+ const lines = [`repo ${root}`];
87
+ if (result.dryRun) {
88
+ lines.push(`dry-run ${result.id} ${result.doc} line ${result.line}`);
89
+ lines.push(result.wouldChange ? `would ${result.from} -> ${result.to}` : `state ${result.from}`);
90
+ if (result.remedy) lines.push(`apply ${result.remedy}`);
91
+ } else {
92
+ lines.push(`changed ${result.changed ? 'yes' : 'no'} ${result.id} ${result.doc} line ${result.line}`);
93
+ lines.push(result.from === result.to ? `state ${result.to}` : `state ${result.from} -> ${result.to}`);
94
+ }
95
+ if (result.evidenceRecorded) lines.push(`evidence ${result.sha}${result.test ? ` test: ${result.test}` : ''}`);
96
+ else if (result.evidenceNote) lines.push(`note ${result.evidenceNote}`);
97
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
98
+ return EXIT.OK;
99
+ }
@@ -0,0 +1,52 @@
1
+ // `simt index` rebuilds the local cache from git. The cache is not repository
2
+ // content: it is gitignored, and a second run with the same blobs writes the same
3
+ // rows (ADR-089).
4
+
5
+ import { execFile } from 'node:child_process';
6
+
7
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
8
+ import { CLI } from '../name.mjs';
9
+ import { JSONL_FILE, SQLITE_FILE, reindex } from '../index/store.mjs';
10
+
11
+ export async function indexCommand(args, flags) {
12
+ if (args.length > 0) return fail(`unknown index argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} index`);
13
+ const root = flags.root ?? process.cwd();
14
+ let result;
15
+ try {
16
+ result = await reindex(root);
17
+ } catch (error) {
18
+ return fail(
19
+ `could not index this repository (${error.message})`,
20
+ EXIT.UNSUPPORTED,
21
+ 'run this inside a git repository',
22
+ );
23
+ }
24
+ const file = result.backend === 'sqlite' ? SQLITE_FILE : JSONL_FILE;
25
+ const ignored = await isIgnored(root, file);
26
+ const payload = { ok: true, command: 'index', root, file, ignored, ...result };
27
+ if (isJson(flags)) {
28
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
29
+ return EXIT.OK;
30
+ }
31
+ const lines = [
32
+ `index ${result.backend}`,
33
+ `files ${result.files}`,
34
+ `updated ${result.updated}`,
35
+ `removed ${result.removed}`,
36
+ `symbols ${result.symbols}`,
37
+ `edges ${result.edges}`,
38
+ `ms ${result.ms}`,
39
+ ];
40
+ if (result.parsers === 'missing') lines.push('note index.parsers did not load, so the built-in extractors ran');
41
+ if (!ignored) lines.push(`note ${file} is not gitignored, and the cache is not meant to be committed`);
42
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
43
+ return EXIT.OK;
44
+ }
45
+
46
+ function isIgnored(root, file) {
47
+ return new Promise((resolve) => {
48
+ execFile('git', ['check-ignore', '-q', '--', file], { cwd: root }, (error) => {
49
+ resolve(!error);
50
+ });
51
+ });
52
+ }