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
@@ -148,6 +148,16 @@ function uses(action) {
148
148
  return ` - uses: ${repo}@${commit} # ${release}`;
149
149
  }
150
150
 
151
+ /** This repository's own gate runs `npm test`, and two of those tests read
152
+ * history. A consumer's gate does not, so its checkout stays shallow. */
153
+ function checkoutFull() {
154
+ return `${uses('checkout')}
155
+ with:
156
+ # \`bench mine\` counts commits, and the Affects snapshot reads
157
+ # 4c27514. A depth-1 checkout fails both, and both run in \`npm test\`.
158
+ fetch-depth: 0`;
159
+ }
160
+
151
161
  /** The YAML every copy shares, so the two cannot drift on triggers, permissions or
152
162
  * concurrency. Only `name`, the runner and `steps` vary, and only because they have
153
163
  * to. The own gate also varies the platforms and the Node versions, because this
@@ -246,7 +256,7 @@ export function renderWorkflow({ own = false, npm = true, version } = {}) {
246
256
  return `${header(true)}${skeleton(
247
257
  'simt-dev own gate',
248
258
  [
249
- uses('checkout'),
259
+ checkoutFull(),
250
260
  setupNode('${{ matrix.node }}'),
251
261
  // The two checks `.githooks/pre-push` runs, named here rather than discovered,
252
262
  // because a reader of this file has to be able to answer "what stops a merge"
@@ -11,6 +11,12 @@
11
11
  // walks the repository. A hook that measured the tree before answering would be exactly
12
12
  // the latency the plan's acceptance criterion exists to prevent.
13
13
  //
14
+ // A second event, `prompt-submit`, delivers rather than decides: it needs the manifest,
15
+ // the index and the packet assembly, so it is reached through a dynamic `import()` that
16
+ // only that event takes. A tool call therefore still pays for nothing that walks the
17
+ // repository, and the one path that does pays for it once per prompt instead of once per
18
+ // command.
19
+ //
14
20
  // A denial exits 2, which is what every harness in the table reads as "block". A mistake
15
21
  // in the hook's own invocation must NOT exit 2: a typo in a config would then block every
16
22
  // tool call. Failures of this command exit 1, which a harness reports as a failed hook,
@@ -60,6 +66,32 @@ export async function agentHookCommand(args, flags) {
60
66
  return EXIT.OK;
61
67
  }
62
68
 
69
+ // Delivery, not decision: the packet goes into the prompt and the exit code stays 0
70
+ // whatever happens. The module behind it is the one thing here that reads the
71
+ // repository, and the tool-call path above must not pay for it, so the door is a
72
+ // dynamic import that only this event opens.
73
+ if (event === 'prompt-submit') {
74
+ const { deliverPrompt } = await import('../agenthooks/prompt.mjs');
75
+ const delivered = await deliverPrompt(row, payload);
76
+ if (delivered.skipped) {
77
+ note(`note ${id}: no packet delivered (${delivered.skipped})`);
78
+ return EXIT.OK;
79
+ }
80
+ emit(JSON.stringify(delivered.json));
81
+ return EXIT.OK;
82
+ }
83
+
84
+ if (event === 'post-tool-use') {
85
+ const { deliverLessonRecall } = await import('../memory/lessons.mjs');
86
+ const recalled = await deliverLessonRecall(row, payload, { root: flags.root });
87
+ if (recalled.skipped) {
88
+ note(`note ${id}: no lesson recalled (${recalled.skipped})`);
89
+ return EXIT.OK;
90
+ }
91
+ emit(JSON.stringify(recalled.json));
92
+ return EXIT.OK;
93
+ }
94
+
63
95
  const { root, command, writes } = factsFrom(row, payload);
64
96
  const facts = await factsFor(root, { command, writes });
65
97
  const decision = decide({ event, facts, env: process.env });
@@ -77,6 +109,22 @@ export async function agentHookCommand(args, flags) {
77
109
  const json = decisionJson(row, decision);
78
110
  if (json === null) return EXIT.OK;
79
111
 
112
+ if (root) {
113
+ try {
114
+ const { recordHookDenial } = await import('../memory/lessons.mjs');
115
+ await recordHookDenial(root, {
116
+ harness: row.id,
117
+ event,
118
+ policies: decision.blocked,
119
+ messages: decision.messages,
120
+ command,
121
+ writes,
122
+ });
123
+ } catch {
124
+ // Recording a lesson candidate must never block or alter the hook decision.
125
+ }
126
+ }
127
+
80
128
  emit(JSON.stringify(json));
81
129
  for (const message of decision.messages) note(`blocked ${message}`);
82
130
  return row.denyExit;
@@ -91,14 +139,37 @@ export async function agentHooksCommand(sub, args, flags) {
91
139
 
92
140
  async function installHooks(root, flags) {
93
141
  const mode = writeMode(flags);
142
+ // The delivery is a second entry in the same config, and a repository asks for it by
143
+ // declaring a `brief` key rather than by having the entry. Read through a dynamic
144
+ // import for the same reason the hook delivers through one.
145
+ const { promptOptIn } = await import('../agenthooks/prompt.mjs');
146
+ const { listPromotedLessons } = await import('../memory/lessons.mjs');
147
+ const [optIn, promotedLessons] = await Promise.all([
148
+ promptOptIn(root),
149
+ listPromotedLessons(root).catch(() => []),
150
+ ]);
151
+ const hasLessons = promotedLessons.length > 0;
94
152
  const plan = [];
95
153
  for (const row of adapters()) {
96
154
  const path = hookPath(row.id);
97
155
  const existing = await readIfExists(join(root, path));
98
- const result = rendered(row, existing);
99
- plan.push({ id: row.id, path, action: result.error ? 'refused' : result.action, error: result.error, text: result.text });
156
+ const result = rendered(row, existing, { prompt: optIn.optedIn, post: hasLessons });
157
+ plan.push({
158
+ id: row.id,
159
+ path,
160
+ action: result.error ? 'refused' : result.action,
161
+ error: result.error,
162
+ text: result.text,
163
+ prompt: row.prompt === null || row.prompt === undefined ? null : optIn.optedIn ? 'declared' : 'not declared',
164
+ });
100
165
  }
101
166
 
167
+ // One line for the repository, not one per adapter: the opt-in is a property of the
168
+ // repository, and someone who has not declared it is told how once.
169
+ const promptLine = plan.some((p) => p.prompt !== null) && !optIn.optedIn
170
+ ? [`PROMPT not opted in (${optIn.why}); add "brief": {} to the manifest to deliver the packet on every prompt`]
171
+ : [];
172
+
102
173
  // The readers this tool does not write an entry for, reported rather than skipped in
103
174
  // silence: a command that writes four files and says nothing about the fifth harness
104
175
  // in the repository reads as "that one is covered too".
@@ -128,6 +199,7 @@ async function installHooks(root, flags) {
128
199
  `repo ${root}`,
129
200
  ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}${p.error ? ` (${p.error})` : ''}`),
130
201
  ...others.map((o) => `${o.state === 'none' ? 'NO FILE ' : 'NOT OURS'} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
202
+ ...promptLine,
131
203
  '',
132
204
  refused.length > 0
133
205
  ? `REFUSED ${refused.length} config file(s) could not be read; nothing written`
@@ -177,6 +249,7 @@ async function installHooks(root, flags) {
177
249
  `repo ${root}`,
178
250
  ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}`),
179
251
  ...others.map((o) => `${o.state === 'none' ? 'NO FILE ' : 'NOT OURS'} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
252
+ ...promptLine,
180
253
  '',
181
254
  `WROTE ${written.length} hooks file(s); each one runs ${CLI} agent-hook before the tool call`,
182
255
  ].join('\n'),
@@ -196,14 +269,14 @@ async function hooksStatus(root, flags) {
196
269
  const path = hookPath(row.id);
197
270
  const existing = await readIfExists(join(root, path));
198
271
  if (existing === null) {
199
- rows.push({ id: row.id, path, state: 'absent', entries: 0, note: null });
272
+ rows.push({ id: row.id, path, state: 'absent', entries: 0, prompt: row.prompt ? 'absent' : null, note: null });
200
273
  continue;
201
274
  }
202
275
  let config;
203
276
  try {
204
277
  config = JSON.parse(existing);
205
278
  } catch (e) {
206
- rows.push({ id: row.id, path, state: 'unreadable', entries: 0, note: e.message });
279
+ rows.push({ id: row.id, path, state: 'unreadable', entries: 0, prompt: row.prompt ? 'unknown' : null, note: e.message });
207
280
  problems.push(`${path} is not valid JSON: ${e.message}`);
208
281
  continue;
209
282
  }
@@ -211,16 +284,18 @@ async function hooksStatus(root, flags) {
211
284
  // refuses to write into one. Status has to agree with it, or one question gets two
212
285
  // answers and the quiet one is the one a reader acts on.
213
286
  if (config === null || typeof config !== 'object' || Array.isArray(config)) {
214
- rows.push({ id: row.id, path, state: 'unreadable', entries: 0, note: 'not a JSON object' });
287
+ rows.push({ id: row.id, path, state: 'unreadable', entries: 0, prompt: row.prompt ? 'unknown' : null, note: 'not a JSON object' });
215
288
  problems.push(`${path} is not a JSON object, so this tool will not write into it`);
216
289
  continue;
217
290
  }
218
291
  const found = installedIn(row, config).length;
292
+ const prompts = row.prompt ? installedIn(row, config, EVENTS[1]).length : 0;
219
293
  rows.push({
220
294
  id: row.id,
221
295
  path,
222
296
  state: found > 0 ? 'present' : 'absent',
223
297
  entries: found,
298
+ prompt: row.prompt ? (prompts > 0 ? 'present' : 'absent') : null,
224
299
  note: found > 0 ? (row.trust ?? null) : null,
225
300
  });
226
301
  }
@@ -243,6 +318,10 @@ async function hooksStatus(root, flags) {
243
318
  const state = { present: 'PRESENT ', absent: 'ABSENT ', unreadable: 'INVALID ', none: 'NO FILE ', other: 'NOT OURS' }[r.state];
244
319
  lines.push(`${state} ${r.id.padEnd(12)} ${r.path ?? '(no hooks file is documented)'}`);
245
320
  if (r.state === 'absent') lines.push(` ${' '.repeat(12)} install: ${CLI} agent-hooks install --yes`);
321
+ // The second event is a separate answer to a separate question: a harness can carry
322
+ // the tool hook and not the delivery, and a reader who is told "PRESENT" would
323
+ // otherwise conclude the packet arrives on every prompt.
324
+ if (r.prompt) lines.push(` ${' '.repeat(12)} prompt-submit: ${r.prompt}`);
246
325
  if (r.note) lines.push(` ${' '.repeat(12)} ${r.note}`);
247
326
  }
248
327
  if (problems.length > 0) {
@@ -12,8 +12,10 @@ import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.m
12
12
  import { DEFAULT_MINE_TARGET, mineTasks } from '../bench/mine.mjs';
13
13
  import { regradeRun, runBench } from '../bench/run.mjs';
14
14
  import { buildBenchReport } from '../bench/report.mjs';
15
+ import { formatRecall, measureRecall } from '../index/map.mjs';
15
16
  import { ablateBench } from '../bench/ablate.mjs';
16
17
  import { runBenchProbes } from '../bench/probes.mjs';
18
+ import { selectRuleVariants } from '../bench/variants.mjs';
17
19
  import { listFlag } from './skills.mjs';
18
20
 
19
21
  export async function benchMineCommand(positional, flags) {
@@ -223,6 +225,8 @@ export async function benchReportCommand(positional, flags) {
223
225
  );
224
226
  }
225
227
 
228
+ const mapRecall = await recallOrUnmeasured(root);
229
+
226
230
  if (isJson(flags)) {
227
231
  emitJson({
228
232
  ok: true,
@@ -232,14 +236,23 @@ export async function benchReportCommand(positional, flags) {
232
236
  runCount: report.runCount,
233
237
  arms: report.arms,
234
238
  comparison: report.comparison,
239
+ mapRecall,
235
240
  });
236
241
  return EXIT.OK;
237
242
  }
238
243
 
239
- emit(report.markdown, { maxBytes: flags.maxBytes ?? 0 });
244
+ emit(`${report.markdown}\n\n### Map\n\n${formatRecall(mapRecall)}`, { maxBytes: flags.maxBytes ?? 0 });
240
245
  return EXIT.OK;
241
246
  }
242
247
 
248
+ async function recallOrUnmeasured(root) {
249
+ try {
250
+ return await measureRecall(root);
251
+ } catch {
252
+ return { measured: false, reason: 'not run', tasks: 0, missing: 0, recall: null };
253
+ }
254
+ }
255
+
243
256
  export async function benchAblateCommand(positional, flags) {
244
257
  const root = resolve(flags.root ?? positional[0] ?? process.cwd());
245
258
  const { dryRun, apply } = writeMode(flags);
@@ -268,16 +281,8 @@ export async function benchAblateCommand(positional, flags) {
268
281
  emitJson({
269
282
  ok: true,
270
283
  command: 'bench ablate',
271
- root: result.root,
272
284
  dryRun,
273
- applied: result.applied,
274
- pricesLoaded: result.pricesLoaded,
275
- canarySize: result.canarySize,
276
- canaryTasks: result.canaryTasks,
277
- totalTasks: result.totalTasks,
278
- sections: result.sections,
279
- ranking: result.ranking,
280
- deletionCandidates: result.deletionCandidates,
285
+ ...result,
281
286
  });
282
287
  return EXIT.OK;
283
288
  }
@@ -328,13 +333,8 @@ export async function benchProbesCommand(positional, flags) {
328
333
  emitJson({
329
334
  ok: true,
330
335
  command: 'bench probes',
331
- root: result.root,
332
336
  dryRun,
333
- applied: result.applied,
334
- probeCount: result.probeCount,
335
- probes: result.probes,
336
- arms: result.arms,
337
- runs: result.runs,
337
+ ...result,
338
338
  });
339
339
  return EXIT.OK;
340
340
  }
@@ -355,5 +355,42 @@ export async function benchProbesCommand(positional, flags) {
355
355
  return EXIT.OK;
356
356
  }
357
357
 
358
+ export async function benchVariantsCommand(positional, flags) {
359
+ const root = resolve(flags.root ?? positional[0] ?? process.cwd());
360
+ const { dryRun, apply } = writeMode(flags);
361
+ let result;
362
+ try {
363
+ result = await selectRuleVariants(root, {
364
+ apply,
365
+ harness: flags.harness ?? 'gold',
366
+ model: flags.model,
367
+ reps: flags.reps ?? 1,
368
+ includeAll: flags.all === true,
369
+ });
370
+ } catch (error) {
371
+ return fail(
372
+ `${root}: ${error.message ?? error}`,
373
+ EXIT.CONFIG,
374
+ `mine tasks first with \`${CLI} bench mine --yes\` and rerun \`${CLI} bench variants --yes\``,
375
+ );
376
+ }
377
+ if (isJson(flags)) {
378
+ emitJson({ ok: true, command: 'bench variants', dryRun, ...result });
379
+ return EXIT.OK;
380
+ }
381
+ const lines = [
382
+ `bench variants ${result.root}`,
383
+ `mode ${result.applied ? 'applied' : dryRun ? 'dry-run (writes nothing)' : 'preview / stored runs (pass --yes to execute canary and lock winners)'}`,
384
+ `harness ${result.harness} model=${result.model}`,
385
+ `slots ${result.slots.length} (canary: ${result.canaryTasks}/${result.totalTasks} tasks)`,
386
+ ...result.slots.map(
387
+ (s) => `slot ${s.slotId} status=${s.status} winner=${s.winner} dR=${s.dR} dC=${s.dC} n=${s.n} p=${s.p}`,
388
+ ),
389
+ ];
390
+ if (!result.applied) lines.push(`remedy run: ${CLI} bench variants --yes`);
391
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
392
+ return EXIT.OK;
393
+ }
394
+
358
395
 
359
396
 
@@ -0,0 +1,78 @@
1
+ // `simt brief "<task>"` — print the packet (plan 0003 R27).
2
+ //
3
+ // What this is for: an agent that has to explore a repository to find out how to run its
4
+ // tests, which files the task will touch and what governs them spends turns discovering
5
+ // what the repository already knows. The packet is that answer, assembled from the index
6
+ // the repository already has, in the bytes a prompt can carry.
7
+ //
8
+ // The assembly itself is in `../brief/assemble.mjs`, because three doors open onto it —
9
+ // this command, MCP's `brief` tool and the prompt-submit hook — and one implementation
10
+ // behind three doors is the only way the three cannot disagree. This file owns the
11
+ // command line: what a task may be, what the ceiling may be, and how the packet is
12
+ // rendered for a terminal or for `--json`.
13
+ //
14
+ // One thing is deliberately missing: `skills brief` keeps its own behaviour, and MCP's
15
+ // `skills_brief` keeps its own shape. Superseding them changes two accepted decisions
16
+ // (ADR-035, ADR-052) and the MCP contract, and this slice carries no ADR to do it.
17
+
18
+ import { CLI } from '../name.mjs';
19
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
20
+ import { DEFAULT_BRIEF_BYTES, explainPacket } from '../brief/packet.mjs';
21
+ import { assembleBrief } from '../brief/assemble.mjs';
22
+
23
+ export async function briefCommand(args, flags) {
24
+ if (args.length > 1) {
25
+ return fail(`brief takes one task, not ${args.length}`, EXIT.USAGE, `run: ${CLI} brief "<task>"`);
26
+ }
27
+ if (args.length === 1 && flags.task !== undefined) {
28
+ return fail('the task was passed twice, as an argument and as --task', EXIT.USAGE, `run: ${CLI} brief "<task>"`);
29
+ }
30
+ const budget = flags.budget === undefined ? DEFAULT_BRIEF_BYTES : Number(flags.budget);
31
+ if (!Number.isInteger(budget) || budget < 0) {
32
+ return fail(`--budget must be a byte count, not ${flags.budget}`, EXIT.USAGE, `run: ${CLI} brief --budget ${DEFAULT_BRIEF_BYTES}`);
33
+ }
34
+
35
+ const result = await assembleBrief({
36
+ root: flags.root ?? process.cwd(),
37
+ task: String(args[0] ?? flags.task ?? ''),
38
+ paths: flags.paths === undefined ? null : flags.paths,
39
+ budget,
40
+ flags,
41
+ });
42
+ if (result.error) return result.error;
43
+ // Unreachable from here — only a delivered brief asks for a cold index to be reported
44
+ // rather than built — but a silent empty packet would be the worst way to find out.
45
+ if (result.cold) {
46
+ return fail('the index is empty, and this command will not assemble a packet from it', EXIT.UNSUPPORTED, `run ${CLI} index`);
47
+ }
48
+ const { root, kind, basis, task, scope, budget: ceiling, items, packed, sections } = result;
49
+ const cut = new Set(packed.cut);
50
+
51
+ if (isJson(flags)) {
52
+ emitJson(
53
+ {
54
+ ok: true,
55
+ command: 'brief',
56
+ root,
57
+ kind,
58
+ basis,
59
+ task,
60
+ scope,
61
+ budget: ceiling,
62
+ bytes: packed.after,
63
+ before: packed.before,
64
+ floorHeld: packed.floorHeld,
65
+ cut: packed.cut,
66
+ sections,
67
+ explain: flags.explain === true ? explainPacket(items, { cut }) : undefined,
68
+ text: packed.text,
69
+ },
70
+ { pretty: flags.pretty === true, maxBytes: Number(flags.maxBytes ?? 0) || 0 },
71
+ );
72
+ return EXIT.OK;
73
+ }
74
+
75
+ const body = flags.explain === true ? [packed.text, '', 'EXPLAIN', ...explainPacket(items, { cut })] : [packed.text];
76
+ emit(body.join('\n'), { maxBytes: Number(flags.maxBytes ?? 0) || 0 });
77
+ return EXIT.OK;
78
+ }
@@ -26,6 +26,7 @@ import { provenanceDigest } from '../projection/project.mjs';
26
26
  import { GATES } from '../hooks/gates.mjs';
27
27
  import { buildRow, summarise } from '../compare/row.mjs';
28
28
  import { discover } from '../compare/discover.mjs';
29
+ import { learnAcrossEstate, renderLearningLines } from '../compare/learn.mjs';
29
30
  import { targetRoot, WORKSPACE_REMEDY } from '../workspace.mjs';
30
31
 
31
32
  /** Gate kinds, so a row can tell a command gate with no command from a script gate. */
@@ -65,6 +66,7 @@ export async function compareCommand(positional, flags) {
65
66
  // bound. Determinism of the output matters more than wall clock on a dashboard.
66
67
  const rows = [];
67
68
  for (const repo of found.repos) rows.push(await buildRow(repo, context));
69
+ const learning = await learnAcrossEstate(found.repos);
68
70
 
69
71
  const summary = summarise(rows, {
70
72
  root,
@@ -106,6 +108,7 @@ export async function compareCommand(positional, flags) {
106
108
  shas: summary.shas,
107
109
  },
108
110
  rows,
111
+ learning,
109
112
  worktrees: found.worktrees.map((w) => ({
110
113
  name: w.name,
111
114
  path: w.path,
@@ -117,7 +120,7 @@ export async function compareCommand(positional, flags) {
117
120
  return rows.some((r) => r.problems.length > 0) ? EXIT.FAIL : EXIT.OK;
118
121
  }
119
122
 
120
- emit(render(rows, summary, found), { maxBytes: flags.maxBytes ?? 0 });
123
+ emit(render(rows, summary, found, learning), { maxBytes: flags.maxBytes ?? 0 });
121
124
  if (summary.shape === 'repo') {
122
125
  note('note one repository compared; pass --root at a workspace directory to compare its siblings');
123
126
  } else if (target.source === 'none') {
@@ -146,7 +149,7 @@ export async function compareCommand(positional, flags) {
146
149
  * the longest value, and an agent cannot tell which column is which without
147
150
  * counting spaces.
148
151
  */
149
- function render(rows, summary, found) {
152
+ function render(rows, summary, found, learning = null) {
150
153
  const lines = [
151
154
  // `workspace` and `repo` are the same field with different names, and the name is the
152
155
  // honest one: a reader who sees "workspace" and gets one repo was told a lie. `root`
@@ -204,6 +207,11 @@ function render(rows, summary, found) {
204
207
  lines.push('');
205
208
  }
206
209
 
210
+ const learningLines = renderLearningLines(learning);
211
+ if (learningLines.length > 0) {
212
+ lines.push(...learningLines, '');
213
+ }
214
+
207
215
  return lines.join('\n').trimEnd();
208
216
  }
209
217
 
@@ -0,0 +1,116 @@
1
+ // `simt decide` — scaffold an ADR with the next free number, a Status: line,
2
+ // a rulings skeleton, and structured `simt-affects` + `simt-supersedes` frontmatter
3
+ // (plan 0003 R34).
4
+
5
+ import { join, resolve } from 'node:path';
6
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
7
+ import { CLI } from '../name.mjs';
8
+ import { DECISIONS_DIR } from '../decisions/index.mjs';
9
+ import { lintDecisions, scaffoldDecision } from '../memory/decide.mjs';
10
+ import { parsePathList } from '../argv.mjs';
11
+
12
+ export async function decideCommand(args, flags) {
13
+ const root = flags.root ?? process.cwd();
14
+
15
+ if (flags.check === true) {
16
+ const decisionsDir = join(root, 'docs', 'decisions');
17
+ const allowHistorical = resolve(decisionsDir) === DECISIONS_DIR;
18
+ const res = await lintDecisions(decisionsDir, { allowHistorical });
19
+
20
+ if (isJson(flags)) {
21
+ emitJson({
22
+ ok: res.ok,
23
+ command: 'decide --check',
24
+ root,
25
+ findings: res.findings,
26
+ });
27
+ return res.ok ? EXIT.OK : EXIT.FAIL;
28
+ }
29
+
30
+ if (res.findings.length === 0) {
31
+ emit('decisions ok', { maxBytes: flags.maxBytes ?? 0 });
32
+ return EXIT.OK;
33
+ }
34
+
35
+ emit(
36
+ res.findings
37
+ .flatMap((f) => [
38
+ `${f.status.toUpperCase()} ${f.file} ${f.detail}`,
39
+ ...(f.remedy ? [` remedy: ${f.remedy}`] : []),
40
+ ])
41
+ .join('\n'),
42
+ { maxBytes: flags.maxBytes ?? 0 },
43
+ );
44
+ return EXIT.FAIL;
45
+ }
46
+
47
+ const mode = writeMode(flags);
48
+ const title = args.join(' ').trim() || flags.task || 'Untitled decision';
49
+ const parsedPaths = parsePathList(flags.paths);
50
+ const explicitPaths = flags.paths !== undefined ? parsedPaths : undefined;
51
+
52
+ const res = await scaffoldDecision(root, {
53
+ title,
54
+ affects: explicitPaths,
55
+ supersedes: flags.supersedes,
56
+ write: mode.apply,
57
+ });
58
+
59
+ if (!res.ok) {
60
+ return fail(
61
+ res.errors[0],
62
+ EXIT.USAGE,
63
+ `usage: ${CLI} decide "<title>" [--supersedes ADR-NNN:<ruling>] [--yes]`,
64
+ );
65
+ }
66
+
67
+ if (mode.apply) {
68
+ note(`wrote ${res.relPath}`);
69
+ } else if (flags.dryRun === true) {
70
+ note('dry-run no files written');
71
+ }
72
+
73
+ if (isJson(flags)) {
74
+ emitJson({
75
+ ok: true,
76
+ command: 'decide',
77
+ root,
78
+ id: res.id,
79
+ number: res.number,
80
+ title: res.title,
81
+ file: res.file,
82
+ path: res.relPath,
83
+ affects: res.affects,
84
+ supersedes: res.supersedes,
85
+ written: res.written,
86
+ dryRun: !mode.apply,
87
+ });
88
+ return EXIT.OK;
89
+ }
90
+
91
+ const supersedesLabel =
92
+ res.supersedes.length > 0
93
+ ? res.supersedes
94
+ .map((s) => (s.ruling !== null ? `${s.adr} ruling ${s.ruling} (${s.kind})` : s.adr))
95
+ .join('; ')
96
+ : '(none)';
97
+
98
+ const lines = [
99
+ `id ${res.id}`,
100
+ `title ${res.title}`,
101
+ `file ${res.relPath}`,
102
+ `affects ${res.affects.length > 0 ? res.affects.join(', ') : '(none)'}`,
103
+ `supersedes ${supersedesLabel}`,
104
+ '',
105
+ res.text.trimEnd(),
106
+ '',
107
+ mode.apply
108
+ ? `wrote ${res.relPath}`
109
+ : flags.dryRun === true
110
+ ? 'dry-run; nothing written'
111
+ : `would write ${res.relPath}; apply with: ${CLI} decide ${JSON.stringify(res.title)} --yes`,
112
+ ];
113
+
114
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
115
+ return EXIT.OK;
116
+ }
@@ -0,0 +1,96 @@
1
+ // `simt evidence` — the proof block for this tree, and the commit-message check.
2
+ //
3
+ // The read answers "what has actually been run against what is on disk". The check
4
+ // answers the other half: a message that says the tests passed, when nothing in the
5
+ // ledger was run against this tree (ADR-087).
6
+
7
+ import { readFile } from 'node:fs/promises';
8
+
9
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
10
+ import { readManifest } from '../projection/manifest.mjs';
11
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
12
+ import { claimsVerification, evidenceMode, readLedger, treeId } from '../ledger/index.mjs';
13
+
14
+ export async function evidenceCommand(args, flags) {
15
+ const root = flags.root ?? process.cwd();
16
+ if (args[0] === 'check') return checkClaim(root, args[1], flags);
17
+ if (args[0] !== undefined) {
18
+ return fail(`unknown evidence subcommand ${args[0]}`, EXIT.USAGE, `run: ${CLI} evidence`);
19
+ }
20
+ return showEvidence(root, flags);
21
+ }
22
+
23
+ async function showEvidence(root, flags) {
24
+ let tree;
25
+ try {
26
+ tree = await treeId(root);
27
+ } catch (error) {
28
+ return fail(
29
+ `could not name this tree (${error.message})`,
30
+ EXIT.UNSUPPORTED,
31
+ 'run this inside a git repository',
32
+ );
33
+ }
34
+ const { entries, errors } = await readLedger(root);
35
+ const mine = entries.filter((e) => e.tree === tree);
36
+ const payload = { ok: errors.length === 0, command: 'evidence', root, tree, entries: mine, errors };
37
+ if (isJson(flags)) {
38
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
39
+ return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
40
+ }
41
+ const lines = [`tree ${tree}`];
42
+ if (mine.length === 0) lines.push('none no run is recorded for this tree');
43
+ for (const e of mine) {
44
+ lines.push(`${e.exit === 0 ? 'pass' : 'fail'} ${e.cmd} exit ${e.exit} ${e.summary}`);
45
+ }
46
+ for (const error of errors) lines.push(`error ${error}`);
47
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
48
+ return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
49
+ }
50
+
51
+ async function checkClaim(root, file, flags) {
52
+ if (!file) return fail('evidence check needs the commit message file', EXIT.USAGE, `run: ${CLI} evidence check <file>`);
53
+ let message;
54
+ try {
55
+ message = await readFile(file, 'utf8');
56
+ } catch (error) {
57
+ return fail(`could not read ${file} (${error.message})`, EXIT.USAGE, `pass the path git gives commit-msg`);
58
+ }
59
+ const { manifest, error } = await readManifest(root);
60
+ if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG, `fix ${MANIFEST_FILE} so it parses`);
61
+ const mode = evidenceMode(manifest);
62
+ if (mode === null) {
63
+ return fail(
64
+ `${MANIFEST_FILE} gates.evidence must be "warn" or "block"`,
65
+ EXIT.CONFIG,
66
+ `set gates.evidence to "warn" or "block", or remove it`,
67
+ );
68
+ }
69
+ const claim = claimsVerification(message);
70
+ if (!claim) {
71
+ const payload = { ok: true, command: 'evidence check', claim: false, mode };
72
+ if (isJson(flags)) emitJson(payload, { pretty: flags.pretty === true });
73
+ return EXIT.OK;
74
+ }
75
+ let tree;
76
+ try {
77
+ tree = await treeId(root);
78
+ } catch (error) {
79
+ return fail(`could not name this tree (${error.message})`, EXIT.UNSUPPORTED, 'run this inside a git repository');
80
+ }
81
+ const { entries } = await readLedger(root);
82
+ const supported = entries.some((e) => e.tree === tree && e.exit === 0);
83
+ const payload = { ok: supported || mode === 'warn', command: 'evidence check', claim: true, supported, mode, tree };
84
+ if (supported) {
85
+ if (isJson(flags)) emitJson(payload, { pretty: flags.pretty === true });
86
+ return EXIT.OK;
87
+ }
88
+ const detail = 'the message claims a run this tree does not have';
89
+ const remedy = `run the command, then commit, or take the claim out of the message`;
90
+ if (isJson(flags)) {
91
+ emitJson({ ...payload, detail, remedy }, { pretty: flags.pretty === true });
92
+ return mode === 'block' ? EXIT.FAIL : EXIT.OK;
93
+ }
94
+ emit(`${mode === 'block' ? 'BLOCKED' : 'WARNING'} ${detail}\n remedy: ${remedy}`, { maxBytes: flags.maxBytes ?? 0 });
95
+ return mode === 'block' ? EXIT.FAIL : EXIT.OK;
96
+ }