simt-dev 0.7.0 → 0.8.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 (235) hide show
  1. package/CHANGELOG.md +191 -870
  2. package/README.md +188 -213
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
  5. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  7. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  8. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  9. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  10. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  11. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  12. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  13. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  14. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  15. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  16. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  17. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  18. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  19. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  20. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  21. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  22. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  23. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  24. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  25. package/ecosystems/bun.json +6 -0
  26. package/ecosystems/cargo.json +25 -0
  27. package/ecosystems/composer.json +6 -0
  28. package/ecosystems/dart.json +24 -0
  29. package/ecosystems/deno.json +21 -0
  30. package/ecosystems/devcontainers.json +6 -0
  31. package/ecosystems/docker-compose.json +6 -0
  32. package/ecosystems/docker.json +6 -0
  33. package/ecosystems/dotnet.json +25 -0
  34. package/ecosystems/elixir.json +23 -0
  35. package/ecosystems/gitsubmodule.json +6 -0
  36. package/ecosystems/go.json +25 -0
  37. package/ecosystems/gradle.json +13 -0
  38. package/ecosystems/maven.json +23 -0
  39. package/ecosystems/node.json +25 -0
  40. package/ecosystems/python.json +20 -0
  41. package/ecosystems/ruby.json +23 -0
  42. package/ecosystems/swift.json +25 -0
  43. package/ecosystems/terraform.json +6 -0
  44. package/ecosystems/uv.json +6 -0
  45. package/harnesses/antigravity.json +162 -0
  46. package/harnesses/claude.json +206 -0
  47. package/harnesses/codex.json +136 -0
  48. package/harnesses/copilot.json +88 -0
  49. package/harnesses/cursor.json +140 -0
  50. package/harnesses/deepseek.json +99 -0
  51. package/harnesses/gemini.json +122 -0
  52. package/harnesses/opencode.json +173 -0
  53. package/package.json +12 -2
  54. package/rules/core.md +7 -0
  55. package/rules/mdc/intake-questions.mdc +8 -0
  56. package/rules/mdc/slice-commit.mdc +57 -4
  57. package/src/advice/advice.mjs +3 -2
  58. package/src/agenthooks/prompt.mjs +5 -2
  59. package/src/agenthooks/render.mjs +86 -180
  60. package/src/agenthooks/shim.mjs +1 -1
  61. package/src/agents/render.mjs +15 -18
  62. package/src/api.mjs +31 -0
  63. package/src/argv.mjs +48 -3
  64. package/src/bench/ablate.mjs +3 -4
  65. package/src/bench/adapters.json +163 -105
  66. package/src/bench/adapters.mjs +466 -0
  67. package/src/bench/arms.mjs +333 -0
  68. package/src/bench/grade.mjs +33 -73
  69. package/src/bench/graders.mjs +42 -0
  70. package/src/bench/mine.mjs +121 -475
  71. package/src/bench/probe-ledger.mjs +149 -0
  72. package/src/bench/probes.catalog.mjs +918 -0
  73. package/src/bench/probes.mjs +79 -1064
  74. package/src/bench/render.mjs +111 -0
  75. package/src/bench/report.mjs +120 -962
  76. package/src/bench/run.mjs +185 -802
  77. package/src/bench/variants.mjs +4 -5
  78. package/src/bench/workspace.mjs +160 -0
  79. package/src/brief/assemble.mjs +48 -15
  80. package/src/ci/dependabot.mjs +25 -55
  81. package/src/ci/workflow.mjs +30 -7
  82. package/src/cli.mjs +45 -0
  83. package/src/commands/agent.mjs +4 -13
  84. package/src/commands/agenthook.mjs +70 -40
  85. package/src/commands/bench.mjs +191 -18
  86. package/src/commands/brief.mjs +5 -4
  87. package/src/commands/ci.mjs +13 -14
  88. package/src/commands/compare.mjs +42 -42
  89. package/src/commands/completions.mjs +2 -6
  90. package/src/commands/decide.mjs +4 -4
  91. package/src/commands/distill.mjs +4 -3
  92. package/src/commands/docs.mjs +88 -0
  93. package/src/commands/doctor.mjs +11 -7
  94. package/src/commands/evidence.mjs +6 -9
  95. package/src/commands/explain.mjs +0 -1
  96. package/src/commands/gate.mjs +26 -25
  97. package/src/commands/graph.mjs +2 -3
  98. package/src/commands/harness.mjs +6 -5
  99. package/src/commands/help.mjs +168 -19
  100. package/src/commands/hooks.mjs +119 -49
  101. package/src/commands/ignore.mjs +13 -8
  102. package/src/commands/impact.mjs +8 -15
  103. package/src/commands/index.mjs +318 -193
  104. package/src/commands/init-scan.mjs +90 -0
  105. package/src/commands/init.mjs +124 -105
  106. package/src/commands/lease.mjs +73 -25
  107. package/src/commands/lessons.mjs +10 -9
  108. package/src/commands/local.mjs +3 -3
  109. package/src/commands/map.mjs +24 -14
  110. package/src/commands/mcp.mjs +2 -18
  111. package/src/commands/pack.mjs +9 -6
  112. package/src/commands/plan.mjs +6 -5
  113. package/src/commands/plugin.mjs +2 -2
  114. package/src/commands/prbody.mjs +3 -3
  115. package/src/commands/repoindex.mjs +12 -11
  116. package/src/commands/review.mjs +2 -2
  117. package/src/commands/rules.mjs +37 -16
  118. package/src/commands/shared.mjs +14 -19
  119. package/src/commands/skills.mjs +33 -32
  120. package/src/commands/skillscompile.mjs +7 -8
  121. package/src/commands/skillsinstall.mjs +7 -6
  122. package/src/commands/slice.mjs +220 -74
  123. package/src/commands/spec.mjs +167 -0
  124. package/src/commands/status.mjs +27 -18
  125. package/src/commands/sync.mjs +27 -25
  126. package/src/commands/tests.mjs +18 -21
  127. package/src/commands/tokens.mjs +21 -26
  128. package/src/commands/undo.mjs +5 -15
  129. package/src/commands/upgrade.mjs +15 -7
  130. package/src/commands/why.mjs +15 -21
  131. package/src/compare/hooksstate.mjs +2 -6
  132. package/src/compare/learn.mjs +3 -1
  133. package/src/compare/row.mjs +0 -2
  134. package/src/context.mjs +64 -0
  135. package/src/decisions/index-doc.mjs +46 -0
  136. package/src/decisions/index.mjs +3 -2
  137. package/src/diff.mjs +1 -1
  138. package/src/distill/distill.mjs +14 -1
  139. package/src/distill/engine.mjs +5 -1
  140. package/src/docs/commands.mjs +65 -0
  141. package/src/ecosystem/records.mjs +219 -0
  142. package/src/explain.mjs +31 -0
  143. package/src/gates/index.mjs +385 -0
  144. package/src/graph/compile.mjs +8 -4
  145. package/src/graph/load.mjs +6 -2
  146. package/src/graph/spec.mjs +10 -8
  147. package/src/harness/records.mjs +143 -0
  148. package/src/hooks/capability.mjs +7 -5
  149. package/src/hooks/dispatcher.mjs +209 -60
  150. package/src/hooks/gates.mjs +10 -43
  151. package/src/hooks/resolve.mjs +4 -14
  152. package/src/hooks/shim.mjs +5 -2
  153. package/src/hooks/where.mjs +2 -7
  154. package/src/identity.mjs +31 -12
  155. package/src/index/coverage.mjs +58 -0
  156. package/src/index/extract.mjs +6 -16
  157. package/src/index/gate.mjs +1 -1
  158. package/src/index/govern.mjs +26 -9
  159. package/src/index/impact.mjs +4 -8
  160. package/src/index/map.mjs +14 -15
  161. package/src/index/open.mjs +173 -0
  162. package/src/index/persist.mjs +224 -57
  163. package/src/index/store.mjs +82 -39
  164. package/src/index/tests.mjs +2 -6
  165. package/src/journal.mjs +94 -20
  166. package/src/kernel/card.mjs +37 -63
  167. package/src/kernel/checks.mjs +4 -10
  168. package/src/lease.mjs +102 -7
  169. package/src/ledger/index.mjs +150 -14
  170. package/src/local/client.mjs +4 -6
  171. package/src/locate.mjs +33 -9
  172. package/src/manifest/schema.mjs +213 -0
  173. package/src/mcp/paginate.mjs +33 -0
  174. package/src/mcp/resources.mjs +7 -10
  175. package/src/mcp/schemas.mjs +512 -0
  176. package/src/mcp/server.mjs +119 -25
  177. package/src/mcp/tools-facts.mjs +326 -0
  178. package/src/mcp/tools-read.mjs +221 -0
  179. package/src/mcp/tools.mjs +4 -967
  180. package/src/measure/corpus.mjs +150 -0
  181. package/src/measure/metrics.mjs +236 -0
  182. package/src/measure/records.mjs +211 -0
  183. package/src/measure/session.mjs +3 -2
  184. package/src/measure/stats.mjs +538 -0
  185. package/src/measure/task-schema.mjs +125 -0
  186. package/src/measure/tasks.mjs +69 -0
  187. package/src/memory/decide.mjs +6 -15
  188. package/src/memory/lesson-text.mjs +61 -0
  189. package/src/memory/lessons.mjs +29 -82
  190. package/src/name.mjs +7 -0
  191. package/src/node-version.mjs +37 -0
  192. package/src/output/index.mjs +209 -10
  193. package/src/packs/manage.mjs +32 -12
  194. package/src/packs.mjs +20 -2
  195. package/src/plan/index.mjs +1 -1
  196. package/src/plugins.mjs +22 -16
  197. package/src/prbody.mjs +7 -11
  198. package/src/projection/loaders.mjs +7 -692
  199. package/src/projection/lock.mjs +11 -1
  200. package/src/projection/manifest.mjs +29 -468
  201. package/src/projection/mdc.mjs +7 -8
  202. package/src/projection/project.mjs +126 -221
  203. package/src/projection/region.mjs +561 -0
  204. package/src/projection/rules.mjs +20 -550
  205. package/src/projection/surfaces.mjs +43 -210
  206. package/src/projection/targets.mjs +5 -5
  207. package/src/repo-root.mjs +31 -0
  208. package/src/resolve/eval.mjs +2 -1
  209. package/src/resolve/fingerprint.mjs +19 -22
  210. package/src/resolve/resolve.mjs +1 -41
  211. package/src/review/critic.mjs +10 -13
  212. package/src/run.mjs +98 -0
  213. package/src/secrets.mjs +14 -22
  214. package/src/state/events.mjs +129 -0
  215. package/src/status-snapshot.mjs +233 -0
  216. package/src/status.mjs +2 -6
  217. package/src/sys/git.mjs +82 -0
  218. package/src/sys/proc.mjs +151 -0
  219. package/src/testing/parsers.mjs +265 -0
  220. package/src/testing/patches.mjs +23 -0
  221. package/src/testing/paths.mjs +49 -0
  222. package/src/testing/runners.mjs +186 -0
  223. package/src/testing/vault.mjs +68 -0
  224. package/src/text/frontmatter.mjs +49 -0
  225. package/src/text/tokens.mjs +52 -0
  226. package/src/toolwords.mjs +3 -0
  227. package/src/util/fs.mjs +20 -0
  228. package/src/util/sort.mjs +12 -0
  229. package/src/workspace.mjs +58 -11
  230. package/src/write/plan.mjs +82 -0
  231. package/templates/plan/tracker.json +1 -0
  232. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  233. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  234. package/docs/decisions/README.md +0 -600
  235. package/src/bench/tasks.mjs +0 -36
@@ -11,24 +11,21 @@
11
11
  // the rest around a hand edit would silently merge two authors' intentions.
12
12
  // Like every other writer this command previews without `--yes`.
13
13
 
14
- import { EXIT, emit, emitJson, isJson, writeMode } from '../output/index.mjs';
14
+ import { EXIT, emit, emitJson, isJson, writeMode, present } from '../output/index.mjs';
15
15
  import { CLI } from '../name.mjs';
16
16
  import { beforeText, entryLabel, listJournalSets, readGitValue, readJournalSet, undoLastJournalSet } from '../journal.mjs';
17
17
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
18
18
  import { join } from 'node:path';
19
19
  import { invalidateCheckCache } from '../status.mjs';
20
+ import { commandRoot } from '../repo-root.mjs';
20
21
 
21
22
  export async function undoCommand(positional, flags) {
22
- const root = flags.root ?? positional[0] ?? process.cwd();
23
+ const root = commandRoot(flags, positional);
23
24
  const mode = writeMode(flags);
24
25
 
25
26
  const [id] = await listJournalSets(root);
26
27
  if (!id) {
27
- if (isJson(flags)) {
28
- emitJson({ ok: true, command: 'undo', root, set: null, restored: [], dryRun: !mode.apply });
29
- return EXIT.OK;
30
- }
31
- emit('nothing to undo: no journaled writes', { maxBytes: flags.maxBytes ?? 0 });
28
+ present({ ok: true, command: 'undo', root, set: null, restored: [], dryRun: !mode.apply }, () => 'nothing to undo: no journaled writes', flags);
32
29
  return EXIT.OK;
33
30
  }
34
31
 
@@ -64,7 +61,6 @@ export async function undoCommand(positional, flags) {
64
61
  '',
65
62
  `would restore ${files} file(s)${keys > 0 ? ` and ${keys} git config key(s)` : ''}; apply with: ${CLI} undo --yes`,
66
63
  ].join('\n'),
67
- { maxBytes: flags.maxBytes ?? 0 },
68
64
  );
69
65
  // After the report: the report says what, the diff shows it (R50).
70
66
  emitDiffs(diffs, flags);
@@ -73,11 +69,7 @@ export async function undoCommand(positional, flags) {
73
69
 
74
70
  const result = await undoLastJournalSet(root);
75
71
  if (result.empty) {
76
- if (isJson(flags)) {
77
- emitJson({ ok: true, command: 'undo', root, set: null, restored: [] });
78
- return EXIT.OK;
79
- }
80
- emit('nothing to undo: no journaled writes', { maxBytes: flags.maxBytes ?? 0 });
72
+ present({ ok: true, command: 'undo', root, set: null, restored: [] }, () => 'nothing to undo: no journaled writes', flags);
81
73
  return EXIT.OK;
82
74
  }
83
75
  if (result.refused) {
@@ -92,7 +84,6 @@ export async function undoCommand(positional, flags) {
92
84
  '',
93
85
  'nothing was restored; re-run the writing command or merge by hand, then undo again',
94
86
  ].join('\n'),
95
- { maxBytes: flags.maxBytes ?? 0 },
96
87
  );
97
88
  return EXIT.FAIL;
98
89
  }
@@ -106,7 +97,6 @@ export async function undoCommand(positional, flags) {
106
97
  }
107
98
  emit(
108
99
  [`undone ${result.set}`, ...result.restored.map((p) => `restored ${p}`)].join('\n'),
109
- { maxBytes: flags.maxBytes ?? 0 },
110
100
  );
111
101
  // After the report: the report says what, the diff shows it (R50).
112
102
  emitDiffs(diffs, flags);
@@ -26,12 +26,14 @@
26
26
  // default resolved the workspace and a dry run inside one repo planned all sixteen.
27
27
 
28
28
  import { mkdir } from 'node:fs/promises';
29
- import { commitJournal, journaledWriteFile } from '../journal.mjs';
29
+ import { commitJournal } from '../journal.mjs';
30
+ import { applyPlan, change } from '../write/plan.mjs';
30
31
  import { invalidateCheckCache } from '../status.mjs';
31
32
 
32
33
  import { basename, dirname, resolve } from 'node:path';
33
34
 
34
35
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
36
+ import { namedRoot } from '../repo-root.mjs';
35
37
  import { provenanceDigest, plan } from '../projection/project.mjs';
36
38
  import { MANIFEST, MANIFEST_COMMENT, manifestPath, manifestText, readManifest, writeManifest } from '../projection/manifest.mjs';
37
39
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
@@ -42,7 +44,7 @@ import { CLI } from '../name.mjs';
42
44
  import { MANIFEST_FILE } from '../name.mjs';
43
45
 
44
46
  export async function upgradeCommand(positional, flags) {
45
- const explicit = flags.root ?? positional[0];
47
+ const explicit = namedRoot(flags, positional);
46
48
 
47
49
  // `--all` is a claim about SCOPE, so it is checked rather than inferred. "All" of
48
50
  // nothing is a contradiction, and defaulting it to the current directory would make
@@ -179,12 +181,14 @@ export async function upgradeCommand(positional, flags) {
179
181
  // `undo` in one consumer must never reach into another (R49).
180
182
  const journal = [];
181
183
  const { targets } = await plan(p.path);
184
+ const targetChanges = [];
182
185
  for (const t of targets) {
183
186
  if (t.action === 'unchanged' || t.action === 'malformed') continue;
184
- await mkdir(dirname(t.path), { recursive: true });
185
- await journaledWriteFile(p.path, journal, t.path, t.text);
187
+ targetChanges.push(change({ path: t.path, before: await readBefore(t.path), after: t.text, owner: 'upgrade', reason: t.action }));
186
188
  writtenFiles.add(t.file);
187
189
  }
190
+ // The caller's journal, committed once when the workspace walk ends.
191
+ await applyPlan(p.path, targetChanges, { mode: { apply: true }, journal, commit: false });
188
192
  const { manifest } = await readManifest(p.path);
189
193
  // Preserve what the repo chose; refresh what simt-dev generates. $comment is
190
194
  // ours - it describes this file's provenance - so carrying the previous copy
@@ -216,8 +220,12 @@ export async function upgradeCommand(positional, flags) {
216
220
  }
217
221
  }
218
222
 
223
+ // One verdict, stated once. `ok` used to be `skipped === 0`, so a workspace with a
224
+ // repo that never adopted answered ok:false and exited 0: the document and the process
225
+ // disagreed, and a caller had no way to tell which one was the answer (review 0003 Q65).
226
+ const code = exitFor(plans, failed);
219
227
  const payload = {
220
- ok: summary.skipped === 0 && failed.length === 0,
228
+ ok: code === EXIT.OK,
221
229
  command: 'upgrade',
222
230
  root,
223
231
  shape: found.shape,
@@ -232,7 +240,7 @@ export async function upgradeCommand(positional, flags) {
232
240
 
233
241
  if (isJson(flags)) {
234
242
  emitJson(payload);
235
- return exitFor(plans, failed);
243
+ return code;
236
244
  }
237
245
 
238
246
  emit(render(plans, summary, { root, shape: found.shape, source: target.source, sha, applied, failed }));
@@ -256,7 +264,7 @@ export async function upgradeCommand(positional, flags) {
256
264
  } else {
257
265
  note('note every consumer already runs this simt-dev');
258
266
  }
259
- return exitFor(plans, failed);
267
+ return code;
260
268
  }
261
269
 
262
270
  /**
@@ -5,33 +5,30 @@
5
5
  // is `git ls-files`. The index is not rebuilt: governance is the records, and
6
6
  // writing the cache to answer a question would change the tree.
7
7
 
8
- import { execFile } from 'node:child_process';
9
8
  import { readFile, readdir } from 'node:fs/promises';
10
9
  import { join } from 'node:path';
11
- import { promisify } from 'node:util';
12
10
 
13
11
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
- import { CLI, MANIFEST_FILE } from '../name.mjs';
12
+ import { CLI } from '../name.mjs';
15
13
  import { FORMATTABLE, GATES } from '../hooks/gates.mjs';
14
+ import { loadManifest } from '../manifest/schema.mjs';
16
15
  import { decisionsDir } from '../decisions/dir.mjs';
17
16
  import {
18
- decisionHits,
19
- scopeHits,
17
+ governs,
20
18
  scopePaths,
21
- sortHits,
22
19
  sourceRoots,
23
20
  unresolvedAffects,
24
21
  } from '../index/govern.mjs';
25
- import { splitFrontmatter } from '../memory/decide.mjs';
22
+ import { parseFrontmatter } from '../text/frontmatter.mjs';
26
23
  import { listPromotedLessons } from '../memory/lessons.mjs';
27
-
28
- const exec = promisify(execFile);
24
+ import { commandRoot } from '../repo-root.mjs';
25
+ import { gitPaths } from '../sys/git.mjs';
29
26
 
30
27
  const RULE_DIRS = ['rules', 'rules/stacks', 'rules/mdc', '.simt/profiles'];
31
28
 
32
29
  export async function whyCommand(args, flags, commands = []) {
33
30
  if (args.length > 1) return fail(`unknown why argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} why [path]`);
34
- const root = flags.root ?? process.cwd();
31
+ const root = commandRoot(flags);
35
32
  const listed = await tracked(root);
36
33
  if (listed.error) return listed.error;
37
34
  const files = new Set(listed.files);
@@ -41,26 +38,25 @@ export async function whyCommand(args, flags, commands = []) {
41
38
  if (!target) return reportUnresolved(root, flags, unresolvedAffects(records, ctx));
42
39
  if (!files.has(target)) return fail(`not in the tree: ${target}`, EXIT.USAGE, `pass a path git tracks`);
43
40
  const corpus = await loadCorpus(root);
44
- const { hits } = decisionHits(records, target, ctx);
45
- const items = sortHits([...hits, ...scopeHits(target, corpus)]);
41
+ const items = governs(target, { records, ...corpus, ctx });
46
42
  const payload = { ok: true, command: 'why', root, path: target, items };
47
43
  if (isJson(flags)) {
48
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
44
+ emitJson(payload);
49
45
  return EXIT.OK;
50
46
  }
51
47
  const lines = items.map((item) => `${item.kind.padEnd(10)}${item.id} ${item.detail}`);
52
- emit(lines.length > 0 ? lines.join('\n') : 'none', { maxBytes: flags.maxBytes ?? 0 });
48
+ emit(lines.length > 0 ? lines.join('\n') : 'none');
53
49
  return EXIT.OK;
54
50
  }
55
51
 
56
52
  function reportUnresolved(root, flags, unresolved) {
57
53
  const payload = { ok: true, command: 'why', root, unresolved };
58
54
  if (isJson(flags)) {
59
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
55
+ emitJson(payload);
60
56
  return EXIT.OK;
61
57
  }
62
58
  const lines = unresolved.map((item) => `UNRESOLVED-AFFECTS ${item.token} ${item.id}`);
63
- emit([`unresolved ${unresolved.length}`, ...lines].join('\n'), { maxBytes: flags.maxBytes ?? 0 });
59
+ emit([`unresolved ${unresolved.length}`, ...lines].join('\n'));
64
60
  return EXIT.OK;
65
61
  }
66
62
 
@@ -70,8 +66,7 @@ export async function tracked(root) {
70
66
  try {
71
67
  // `-z`: without it git C-quotes a non-ASCII path and the reader's real
72
68
  // filename never matches it (review 0003 Q39).
73
- const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root, maxBuffer: 16 << 20 });
74
- return { files: stdout.split('\0').filter(Boolean) };
69
+ return { files: await gitPaths(root, ['ls-files']) };
75
70
  } catch (error) {
76
71
  return {
77
72
  error: fail(
@@ -97,7 +92,7 @@ export async function decisionRecords(root) {
97
92
  const records = [];
98
93
  for (const name of names) {
99
94
  const text = await readFile(join(dir, name), 'utf8');
100
- const { body } = splitFrontmatter(text);
95
+ const { body } = parseFrontmatter(text);
101
96
  const id = /^ADR-\d{3,}/i.exec(name)[0].toUpperCase();
102
97
  const heading = /^#\s+(.*)$/m.exec(body)?.[1] ?? id;
103
98
  const title = heading.replace(/^ADR-\d{3,}\s*[—–-]\s*/i, '').trim();
@@ -172,8 +167,7 @@ export async function lessonScopes(root) {
172
167
 
173
168
  async function gateCommands(root) {
174
169
  try {
175
- const parsed = JSON.parse(await readFile(join(root, MANIFEST_FILE), 'utf8'));
176
- const commands = parsed?.gates?.commands;
170
+ const commands = (await loadManifest(root)).manifest?.gates?.commands;
177
171
  return commands && typeof commands === 'object' ? commands : {};
178
172
  } catch {
179
173
  return {};
@@ -6,16 +6,15 @@
6
6
  // duplicated-source-of-truth problem the tool exists to remove. `compare` reads it
7
7
  // for N repos, so it must not shell out per repo per event.
8
8
 
9
- import { execFile } from 'node:child_process';
10
9
  import { readFile, stat } from 'node:fs/promises';
11
10
  import { basename, join } from 'node:path';
12
- import { promisify } from 'node:util';
13
11
 
14
12
  import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderShim } from '../hooks/shim.mjs';
15
13
  import { foreignHooks, resolveHooksDir } from '../hooks/where.mjs';
16
14
  import { packageVersion, hasNpm } from '../package-version.mjs';
17
15
  import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
18
16
  import { fingerprint } from '../resolve/fingerprint.mjs';
17
+ import { gitText } from '../sys/git.mjs';
19
18
 
20
19
  /**
21
20
  * The Node manager id for this repository, or null without a package.json.
@@ -34,8 +33,6 @@ async function nodeManagerFor(root) {
34
33
  return resolveNodeRunner({ facts: detected.facts, packageManager }).id;
35
34
  }
36
35
 
37
- const exec = promisify(execFile);
38
-
39
36
  /**
40
37
  * The chained shim at `path`, read back out of the file itself.
41
38
  *
@@ -120,8 +117,7 @@ export function wantsShim(manifest, exists, event) {
120
117
  /** `core.hooksPath`, or null when unset or the repo is not a git repo. */
121
118
  export async function readHooksPath(root) {
122
119
  try {
123
- const { stdout } = await exec('git', ['config', '--get', HOOKS_PATH_KEY], { cwd: root });
124
- const value = stdout.trim();
120
+ const value = await gitText(root, ['config', '--get', HOOKS_PATH_KEY]);
125
121
  return value === '' ? null : value;
126
122
  } catch {
127
123
  return null;
@@ -19,7 +19,9 @@ import { mapCapped, DEFAULT_CONCURRENCY } from './parallel.mjs';
19
19
 
20
20
  import { policiesFor } from '../agenthooks/policy.mjs';
21
21
  import { DEL_R_TOLERANCE, parseAblationArmName } from '../bench/ablate.mjs';
22
- import { compareArms, evaluateRunRecord, loadPrices, loadRunRecords } from '../bench/report.mjs';
22
+ import { evaluateRunRecord, loadPrices } from '../measure/metrics.mjs';
23
+ import { loadRunRecords } from '../measure/records.mjs';
24
+ import { compareArms } from '../measure/stats.mjs';
23
25
  import { GATES } from '../hooks/gates.mjs';
24
26
  import { readLedger } from '../ledger/index.mjs';
25
27
  import { readLessonCandidates } from '../memory/lessons.mjs';
@@ -21,7 +21,6 @@ import { checkTargets, PRIMARY_TARGET } from '../projection/project.mjs';
21
21
  import { manifestDrift, readManifest } from '../projection/manifest.mjs';
22
22
  import { loadRules, select } from '../projection/rules.mjs';
23
23
  import { resolvePacks } from '../packs.mjs';
24
- import { knownGates } from '../hooks/resolve.mjs';
25
24
  import { fingerprint } from '../resolve/fingerprint.mjs';
26
25
  import { readHooksState, wantsShim } from './hooksstate.mjs';
27
26
  import { CLI } from '../name.mjs';
@@ -421,4 +420,3 @@ export function summarise(rows, extra = {}) {
421
420
  };
422
421
  }
423
422
 
424
- export { knownGates };
@@ -0,0 +1,64 @@
1
+ // The per-run memo a command reads its repository through (review 0003 Q67).
2
+ //
3
+ // `check` did the same work twice in one run: `fingerprint(root)` at two sites, a
4
+ // manifest read that `manifestDrift` repeated, and a rules load that every consumer
5
+ // paid for again. Each fingerprint spawns `git ls-files` and walks the tree once per
6
+ // glob fact, so the second call was never cheap - it was invisible.
7
+ //
8
+ // The cache lives for one command, not for the process. `bench` writes manifests and
9
+ // rewrites trees mid-run; a process-lifetime memo would hand a benchmark the answer
10
+ // for a repository it had just changed, which is the one way a cache can be worse
11
+ // than no cache. A caller that wants a fresh answer makes a fresh context.
12
+ //
13
+ // A rejected computation is not cached: a transient failure (a git lock, a permission
14
+ // error) should not become the answer for the rest of the run, and every one of these
15
+ // readers is safe to retry.
16
+
17
+ import { fingerprint } from './resolve/fingerprint.mjs';
18
+ import { readManifest } from './projection/manifest.mjs';
19
+ import { loadRules, provenanceDigest } from './projection/rules.mjs';
20
+
21
+ /**
22
+ * One repository, read once per question.
23
+ *
24
+ * Each accessor is a promise, and two calls with the same key share it rather than
25
+ * racing: the first caller to ask starts the work, and everyone else awaits it.
26
+ *
27
+ * @param {string} root the repository this context answers for
28
+ */
29
+ export function repoContext(root) {
30
+ const cache = new Map();
31
+ const counts = { hits: 0, misses: 0 };
32
+
33
+ const once = (key, compute) => {
34
+ if (cache.has(key)) {
35
+ counts.hits += 1;
36
+ return cache.get(key);
37
+ }
38
+ counts.misses += 1;
39
+ const pending = Promise.resolve()
40
+ .then(compute)
41
+ .catch((error) => {
42
+ // Not cached, or one failed read would answer every later caller.
43
+ cache.delete(key);
44
+ throw error;
45
+ });
46
+ cache.set(key, pending);
47
+ return pending;
48
+ };
49
+
50
+ return {
51
+ root,
52
+ /** What the cache did, for a test or a measurement - never printed by a command. */
53
+ get stats() {
54
+ return { ...counts, entries: cache.size };
55
+ },
56
+ fingerprint: () => once('fingerprint', () => fingerprint(root)),
57
+ // The legacy shape, because that is what the commands print: `error` is the
58
+ // unreadable-file case and every caller already renders it. Memoising the loader's
59
+ // `problems` array here instead gave doctor `undefined` where it tests for null.
60
+ manifest: () => once('manifest', () => readManifest(root)),
61
+ rules: () => once('rules', () => loadRules(root)),
62
+ digest: () => once('digest', () => provenanceDigest()),
63
+ };
64
+ }
@@ -0,0 +1,46 @@
1
+ // The decisions index document, rendered from the records (review 0003 Q91).
2
+ //
3
+ // The file was 590 hand-written lines: a table of every ADR kept in step by whoever
4
+ // remembered. It agreed with the records until it did not, and nothing read it, so
5
+ // nothing noticed - the failure the generated rule regions exist to prevent, one
6
+ // directory over. The table is output now: this module renders it, `simt rules
7
+ // decisions --yes` writes it, and test/decisions-index.test.mjs fails on drift.
8
+ //
9
+ // The narrative that trailed the table moved to docs/architecture/history.md: prose a
10
+ // reader consults is not prose a command rewrites.
11
+
12
+ const HEADER = `# Decisions
13
+
14
+ Architecture decision records for the SIMT development ecosystem. One canonical home per
15
+ decision; nothing here is duplicated into a repo's \`AGENTS.md\`.
16
+
17
+ A decision is superseded by writing a new ADR with a higher number and editing the old
18
+ record's status line — never by editing the ruling in place.
19
+
20
+ This table is generated from the records: \`simt rules decisions --yes\` writes it, and
21
+ \`test/decisions-index.test.mjs\` fails when it drifts. The history behind the numbers is
22
+ in [architecture/history.md](architecture/history.md).
23
+
24
+ | ADR | Decision | Status |
25
+ |---|---|---|
26
+ `;
27
+
28
+ /**
29
+ * The index document, from the records.
30
+ *
31
+ * Ordered by number, not by filename: the number IS the order, and a directory listing
32
+ * is not (`ADR-100` sorts before `ADR-99` as text).
33
+ *
34
+ * @param {{id: string, title: string, status: string, file: string}[]} rows
35
+ * @returns {string}
36
+ */
37
+ export function renderDecisionsIndex(rows) {
38
+ const ordered = [...rows].sort((a, b) => number(a.id) - number(b.id));
39
+ const lines = ordered.map((row) => `| [${number(row.id)}](decisions/${row.file}) | ${row.title} | ${row.status} |`);
40
+ return `${HEADER}${lines.join('\n')}\n`;
41
+ }
42
+
43
+ /** `ADR-007` -> `007`, the number the table and the ordering both use. */
44
+ function number(id) {
45
+ return id.replace(/^ADR-/i, '').padStart(3, '0');
46
+ }
@@ -28,7 +28,8 @@ import { packageVersion } from '../package-version.mjs';
28
28
  // evaluation would read an initialising binding on one of the two load
29
29
  // orders. Every use below happens at runtime, when both sides are loaded.
30
30
  import { COMMANDS } from '../commands/index.mjs';
31
- import { PARTIAL_CLAUSE, parseDecisionFrontmatter, splitFrontmatter } from '../memory/decide.mjs';
31
+ import { PARTIAL_CLAUSE, parseDecisionFrontmatter } from '../memory/decide.mjs';
32
+ import { parseFrontmatter } from '../text/frontmatter.mjs';
32
33
  import { affectsTokens } from '../index/govern.mjs';
33
34
 
34
35
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -189,7 +190,7 @@ export function citations(text) {
189
190
  */
190
191
  export function parseDecision(file, text, { all = false } = {}) {
191
192
  const fm = parseDecisionFrontmatter(text);
192
- const { body } = splitFrontmatter(text);
193
+ const { body } = parseFrontmatter(text);
193
194
  const fileId = /^ADR-(\d{3,})/i.exec(file)?.[0]?.toUpperCase();
194
195
  const heading = /^#\s+(.*)$/m.exec(body)?.[1] ?? null;
195
196
  const headId = heading ? /^(ADR-\d{3,})/i.exec(heading)?.[1]?.toUpperCase() : null;
package/src/diff.mjs CHANGED
@@ -49,7 +49,7 @@ export async function readBefore(path) {
49
49
  export function emitDiffs(pairs, flags) {
50
50
  if (isJson(flags)) return;
51
51
  const text = renderDiffs(pairs);
52
- if (text !== '') emit(text, { maxBytes: flags.maxBytes ?? 0 });
52
+ if (text !== '') emit(text);
53
53
  }
54
54
 
55
55
  /** The `--diff` bytes for a JSON payload: absent entirely without the flag, so
@@ -35,6 +35,7 @@ import {
35
35
 
36
36
  export const DEFAULT_BUDGET = 2048;
37
37
  import { LOCAL_DIR } from '../state.mjs';
38
+ import { commandRoot } from '../repo-root.mjs';
38
39
 
39
40
  const DISTILL_DIR = join(LOCAL_DIR, 'distilled');
40
41
 
@@ -175,7 +176,7 @@ export async function distillTarget(args, flags) {
175
176
  );
176
177
  }
177
178
 
178
- const root = resolve(flags.root ?? process.cwd());
179
+ const root = resolve(commandRoot(flags));
179
180
  const source = await resolveTarget(target, root, flags);
180
181
  if (source.exit !== undefined) return source.exit;
181
182
 
@@ -192,6 +193,16 @@ export async function distillTarget(args, flags) {
192
193
  const cached = flags.refresh === true ? null : await readFile(absProposal, 'utf8').catch(() => null);
193
194
  if (cached !== null) {
194
195
  const fields = parseExistingFrontmatter(cached);
196
+ let faults = [];
197
+ if (typeof fields.faults === 'string') {
198
+ try {
199
+ const parsedFaults = JSON.parse(fields.faults);
200
+ if (Array.isArray(parsedFaults)) faults = parsedFaults.map(String);
201
+ } catch {
202
+ // A malformed faults line is not a reason to refuse the proposal; the proposal
203
+ // still says it is unverified, and `--refresh` re-derives the list.
204
+ }
205
+ }
195
206
  return {
196
207
  ok: true,
197
208
  command: 'distill',
@@ -201,6 +212,7 @@ export async function distillTarget(args, flags) {
201
212
  budget: Number(fields.budget ?? budget),
202
213
  citations: countCitations(cached),
203
214
  verified: fields.verified !== 'false',
215
+ faults,
204
216
  metric: { note: METRIC_NOTE },
205
217
  proposal: { path: relProposal, state: 'cached', bytes: Buffer.byteLength(cached, 'utf8') },
206
218
  };
@@ -227,6 +239,7 @@ export async function distillTarget(args, flags) {
227
239
  engine: provenance,
228
240
  budget,
229
241
  verified: faults.length === 0,
242
+ faults,
230
243
  }) + `\n${outcome.body}\n`;
231
244
 
232
245
  await mkdir(join(root, DISTILL_DIR), { recursive: true });
@@ -167,7 +167,7 @@ export function verifyCitations(body, sourceLineCount) {
167
167
  }
168
168
 
169
169
  /** The proposal file's frontmatter. No wall-clock value here: byte-stable (P4). */
170
- export function proposalFrontmatter({ source, digest, engine, budget, prompt, verified, verdict, advisory }) {
170
+ export function proposalFrontmatter({ source, digest, engine, budget, prompt, verified, verdict, advisory, faults = [] }) {
171
171
  return [
172
172
  '---',
173
173
  `source: ${source}`,
@@ -178,6 +178,10 @@ export function proposalFrontmatter({ source, digest, engine, budget, prompt, ve
178
178
  verdict ? `verdict: ${verdict}` : null,
179
179
  advisory ? 'advisory: true' : null,
180
180
  verified === false ? 'verified: false' : 'verified: true',
181
+ // The faults belong to the proposal, not only to the report that wrote it: a cached
182
+ // proposal re-read without them reported `verified:false` and nothing to fix
183
+ // (review 0003 Q52).
184
+ Array.isArray(faults) && faults.length > 0 ? `faults: ${JSON.stringify(faults)}` : null,
181
185
  'status: proposal',
182
186
  '---',
183
187
  '',
@@ -0,0 +1,65 @@
1
+ // The README's command list, rendered from the spec (review 0003 Q60).
2
+ //
3
+ // The README carried the fifth copy of the command list this review found. The other
4
+ // four now come from `spec.mjs`; this one could not, because a README line is a sentence
5
+ // for a reader rather than a table row for a parser. It does not need to be: a row's
6
+ // `path` is the command and its `summary` is already one phrase, written for help. So the
7
+ // list is rendered from those two fields, and `test/docs.test.mjs` fails when the README
8
+ // and the spec stop agreeing.
9
+ //
10
+ // The group comment is the first word of the path, which is what a reader scans for. The
11
+ // order is the order the spec lists groups in, so a new group lands where its author put
12
+ // it rather than wherever it sorts.
13
+ import { SPEC } from '../commands/spec.mjs';
14
+ import { CLI } from '../name.mjs';
15
+
16
+ export const README_FILE = 'README.md';
17
+ export const BEGIN = '<!-- simt:commands:begin -->';
18
+ export const END = '<!-- simt:commands:end -->';
19
+
20
+ /** The fenced command list, markers included, exactly as the README must carry it. */
21
+ export function renderCommandBlock() {
22
+ const groups = new Map();
23
+ for (const row of SPEC) {
24
+ const group = row.path.split(' ')[0];
25
+ if (!groups.has(group)) groups.set(group, []);
26
+ groups.get(group).push(row);
27
+ }
28
+ // The invocation, not the path: `brief` without `"<task>"` is a command a reader has
29
+ // to go and look up, which is what the hand-written list it replaced was for.
30
+ const command = (row) => `${row.path}${row.usage === undefined ? '' : ` ${row.usage}`}`;
31
+ const width = Math.max(...SPEC.map((row) => command(row).length));
32
+ const lines = [];
33
+ for (const [group, rows] of groups) {
34
+ lines.push('', `# ${group}`);
35
+ for (const row of [...rows].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
36
+ lines.push(`${CLI} ${command(row).padEnd(width)} # ${row.summary}`);
37
+ }
38
+ }
39
+ // The first group's leading blank line would sit directly under the fence, which reads
40
+ // as a stray newline in the block rather than a separation.
41
+ if (lines[0] === '') lines.shift();
42
+ return [
43
+ BEGIN,
44
+ '<!-- Rendered from the spec by `simt docs readme --yes`; do not edit by hand. -->',
45
+ '',
46
+ '```sh',
47
+ ...lines,
48
+ '```',
49
+ END,
50
+ ].join('\n');
51
+ }
52
+
53
+ /**
54
+ * Replace the region between the markers, or return null when the file carries none.
55
+ *
56
+ * Null rather than an appended block: a README without the markers has the list somewhere
57
+ * else, and writing a second one beside it is the duplicated list this rendering exists
58
+ * to remove.
59
+ */
60
+ export function replaceBlock(text, block) {
61
+ const start = text.indexOf(BEGIN);
62
+ const end = text.indexOf(END);
63
+ if (start === -1 || end === -1 || end < start) return null;
64
+ return `${text.slice(0, start)}${block}${text.slice(end + END.length)}`;
65
+ }