simt-dev 0.6.3 → 0.7.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 (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  13. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  18. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. 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
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
@@ -28,6 +28,7 @@ import {
28
28
  } from '../packs/manage.mjs';
29
29
  import { readManifest } from '../projection/manifest.mjs';
30
30
  import { diffField, emitDiffs } from '../diff.mjs';
31
+ import { commandRoot } from '../repo-root.mjs';
31
32
 
32
33
  export async function packCommand(sub, args, flags) {
33
34
  if (sub === 'new') return packNew(args, flags);
@@ -40,9 +41,13 @@ export async function packCommand(sub, args, flags) {
40
41
  * Where a lint or publish resolves its pack: the positional directory, the
41
42
  * `--root`, or the working directory. `hooks` reads the same three in the same
42
43
  * order, so the two commands cannot disagree about which directory was meant.
44
+ *
45
+ * The positional wins, as this always said it did. The code read `--root` first,
46
+ * so `pack lint .simt/packs/a --root ../b` judged `b` while the prose and the
47
+ * command's own usage promised `a` (review 0003 Q58).
43
48
  */
44
49
  function packDir(args, flags) {
45
- return resolve(flags.root ?? args[0] ?? process.cwd());
50
+ return resolve(args[0] ?? commandRoot(flags));
46
51
  }
47
52
 
48
53
  /**
@@ -98,7 +103,7 @@ async function packNew(args, flags) {
98
103
  return fail(sourceProblem, EXIT.USAGE, 'a pack source names where the bytes come from: `npm:<package>` or `git+https://…`');
99
104
  }
100
105
 
101
- const dir = join(resolve(flags.root ?? process.cwd()), name);
106
+ const dir = join(resolve(commandRoot(flags)), name);
102
107
  const files = scaffoldPack({ name, source });
103
108
  const { apply, dryRun } = writeMode(flags);
104
109
  // A scaffold always creates into a directory that must not exist, so every
@@ -129,7 +134,6 @@ async function packNew(args, flags) {
129
134
  '',
130
135
  dryRun ? 'DRY-RUN no files written' : `PLAN nothing written; apply with: ${CLI} pack new ${name} --source ${source} --yes`,
131
136
  ].join('\n'),
132
- { maxBytes: flags.maxBytes ?? 0 },
133
137
  );
134
138
  // After the report: the report says what, the diff shows it (R50).
135
139
  emitDiffs(diffs, flags);
@@ -149,7 +153,6 @@ async function packNew(args, flags) {
149
153
  '',
150
154
  `WROTE ${files.length} scaffold file(s); write the rule, then: ${CLI} pack lint ${dir}`,
151
155
  ].join('\n'),
152
- { maxBytes: flags.maxBytes ?? 0 },
153
156
  );
154
157
  // After the report: the report says what, the diff shows it (R50).
155
158
  emitDiffs(diffs, flags);
@@ -187,7 +190,7 @@ async function packLint(args, flags) {
187
190
  lines.push(` remedy: ${f.remedy}`);
188
191
  }
189
192
  lines.push('', ok ? 'PASSED the pack is well-formed' : `FAILED ${findings.length} finding(s)`);
190
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
193
+ emit(lines.join('\n'));
191
194
  return ok ? EXIT.OK : EXIT.FAIL;
192
195
  }
193
196
 
@@ -247,7 +250,7 @@ async function packPublish(args, flags) {
247
250
  } else {
248
251
  lines.push('', `FAILED ${result.findings.length} finding(s)`);
249
252
  }
250
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
253
+ emit(lines.join('\n'));
251
254
  if (result.ok && apply && result.published) {
252
255
  note(`note never committed or pushed by this tool (ADR-001); the registry now has the bytes, the repos still need vendoring`);
253
256
  }
@@ -3,12 +3,13 @@ import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.m
3
3
  import { formatCounts, planDone, planNext, planStatus } from '../plan/index.mjs';
4
4
  import { diffField, emitDiffs } from '../diff.mjs';
5
5
  import { CLI } from '../name.mjs';
6
+ import { commandRoot } from '../repo-root.mjs';
6
7
 
7
8
  // Preview unless --yes, and --dry-run wins even then. A tracker row marked done
8
9
  // for a commit that was never written is a claim the next reader will believe.
9
10
 
10
11
  export async function planCommand(sub, args, flags) {
11
- const root = flags.root ?? process.cwd();
12
+ const root = commandRoot(flags);
12
13
  if (sub === 'status') return runStatus(root, flags);
13
14
  if (sub === 'next') return runNext(root, args[0] ?? null, flags);
14
15
  if (sub === 'done') {
@@ -35,7 +36,7 @@ async function runStatus(root, flags) {
35
36
  }
36
37
  lines.push('', `total ${formatCounts(result.totals, result.stateOrder) || '-'}`);
37
38
  lines.push(`ready ${result.ready}`);
38
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
39
+ emit(lines.join('\n'));
39
40
  return EXIT.OK;
40
41
  }
41
42
 
@@ -55,7 +56,7 @@ async function runNext(root, id, flags) {
55
56
  lines.push(` ${row.id} ${row.doc} depends on: ${row.dependsOn || '(none)'}${unknown}`);
56
57
  }
57
58
  }
58
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
59
+ emit(lines.join('\n'));
59
60
  return EXIT.OK;
60
61
  }
61
62
  const next = result.next;
@@ -68,7 +69,7 @@ async function runNext(root, id, flags) {
68
69
  '',
69
70
  next.section || '(no section for this id)',
70
71
  ];
71
- emit(head.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
72
+ emit(head.join('\n'));
72
73
  return EXIT.OK;
73
74
  }
74
75
 
@@ -104,7 +105,7 @@ async function runDone(root, id, sha, flags) {
104
105
  }
105
106
  if (result.evidenceRecorded) lines.push(`evidence ${result.sha}${result.test ? ` test: ${result.test}` : ''}`);
106
107
  else if (result.evidenceNote) lines.push(`note ${result.evidenceNote}`);
107
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
108
+ emit(lines.join('\n'));
108
109
  // After the report: the report says what, the diff shows it (R50).
109
110
  emitDiffs(diffs, flags);
110
111
  return EXIT.OK;
@@ -1,4 +1,4 @@
1
- // simt plugin <claude|gemini|cursor> --out <dir> [--yes]
1
+ // simt plugin <claude|gemini|cursor|opencode> --out <dir> [--yes]
2
2
  //
3
3
  // Build one harness package from the same tables `agent-hooks install` writes into a
4
4
  // repository, so a package and a repository install cannot drift apart. A dry run prints
@@ -10,6 +10,7 @@ import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.m
10
10
  import { CLI } from '../name.mjs';
11
11
  import { buildPackage, PLUGIN_KINDS } from '../plugins.mjs';
12
12
  import { diffField, emitDiffs } from '../diff.mjs';
13
+ import { commandRoot } from '../repo-root.mjs';
13
14
 
14
15
  export async function pluginCommand(args, flags) {
15
16
  const kind = args[0];
@@ -27,7 +28,7 @@ export async function pluginCommand(args, flags) {
27
28
  }
28
29
 
29
30
  const { apply } = writeMode(flags);
30
- const root = flags.root ?? process.cwd();
31
+ const root = commandRoot(flags);
31
32
  const plan = await buildPackage(kind, { root, out, apply });
32
33
 
33
34
  // The `--diff` pairs: the generated files, created or refreshed (R50). The
@@ -68,7 +69,6 @@ export async function pluginCommand(args, flags) {
68
69
  ? `WROTE ${plan.files.length} generated file(s) plus the runtime`
69
70
  : `PLAN nothing written; apply with: ${CLI} plugin ${kind} --out ${out} --yes`,
70
71
  ].join('\n'),
71
- { maxBytes: flags.maxBytes ?? 0 },
72
72
  );
73
73
  // After the report: the report says what, the diff shows it (R50).
74
74
  emitDiffs(diffs, flags);
@@ -12,9 +12,10 @@
12
12
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
13
13
  import { CLI } from '../name.mjs';
14
14
  import { buildPrBody, renderPrBody } from '../prbody.mjs';
15
+ import { commandRoot } from '../repo-root.mjs';
15
16
 
16
17
  export async function prBodyCommand(args, flags) {
17
- const root = flags.root ?? process.cwd();
18
+ const root = commandRoot(flags);
18
19
  if (args.length > 0) {
19
20
  return fail(`pr-body takes no positional arguments`, EXIT.USAGE, `run: ${CLI} pr-body [--base <ref>]`);
20
21
  }
@@ -36,10 +37,9 @@ export async function prBodyCommand(args, flags) {
36
37
  verdict: report.verdict,
37
38
  unreviewed: report.unreviewed,
38
39
  },
39
- { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 },
40
40
  );
41
41
  return EXIT.OK;
42
42
  }
43
- emit(renderPrBody(report), { maxBytes: flags.maxBytes ?? 0 });
43
+ emit(renderPrBody(report));
44
44
  return EXIT.OK;
45
45
  }
@@ -7,10 +7,11 @@ import { execFile } from 'node:child_process';
7
7
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
8
8
  import { CLI } from '../name.mjs';
9
9
  import { JSONL_FILE, SQLITE_FILE, reindex } from '../index/store.mjs';
10
+ import { commandRoot } from '../repo-root.mjs';
10
11
 
11
12
  export async function indexCommand(args, flags) {
12
13
  if (args.length > 0) return fail(`unknown index argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} index`);
13
- const root = flags.root ?? process.cwd();
14
+ const root = commandRoot(flags);
14
15
  let result;
15
16
  try {
16
17
  result = await reindex(root, {
@@ -31,7 +32,7 @@ export async function indexCommand(args, flags) {
31
32
  const ignored = await isIgnored(root, file);
32
33
  const payload = { ok: true, command: 'index', root, file, ignored, ...result };
33
34
  if (isJson(flags)) {
34
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
35
+ emitJson(payload);
35
36
  return EXIT.OK;
36
37
  }
37
38
  const lines = [
@@ -39,13 +40,14 @@ export async function indexCommand(args, flags) {
39
40
  `files ${result.files}`,
40
41
  `updated ${result.updated}`,
41
42
  `removed ${result.removed}`,
43
+ `skipped ${result.skipped ?? 0}`,
42
44
  `symbols ${result.symbols}`,
43
45
  `edges ${result.edges}`,
44
46
  `ms ${result.ms}`,
45
47
  ];
46
48
  if (result.parsers === 'missing') lines.push('note index.parsers did not load, so the built-in extractors ran');
47
49
  if (!ignored) lines.push(`note ${file} is not gitignored, and the cache is not meant to be committed`);
48
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
50
+ emit(lines.join('\n'));
49
51
  return EXIT.OK;
50
52
  }
51
53
 
@@ -23,7 +23,7 @@ export async function reviewCommand(args, flags, commands = []) {
23
23
  if (typeof result === 'number') return result;
24
24
 
25
25
  if (isJson(flags)) {
26
- emitJson(result, { maxBytes: flags.maxBytes ?? 0 });
26
+ emitJson(result);
27
27
  return EXIT.OK;
28
28
  }
29
29
 
@@ -60,6 +60,6 @@ export async function reviewCommand(args, flags, commands = []) {
60
60
  }
61
61
  if (result.metric) lines.push(`metric ${result.metric.note}`);
62
62
 
63
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
63
+ emit(lines.join('\n'));
64
64
  return EXIT.OK;
65
65
  }
@@ -15,12 +15,17 @@
15
15
  // reviewer needs and exactly the fact an empty output would hide.
16
16
 
17
17
  import { readdir } from 'node:fs/promises';
18
+ import { repoContext } from '../context.mjs';
18
19
  import { join } from 'node:path';
20
+ import { renderDecisionsIndex } from '../decisions/index-doc.mjs';
21
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
19
22
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
20
23
  import { loadRules, select } from '../projection/project.mjs';
21
24
  import { loadMdcRules } from '../projection/mdc.mjs';
22
25
  import { fingerprint } from '../resolve/fingerprint.mjs';
23
- import { byId, citations, loadDecisions, redactEstate } from '../decisions/index.mjs';
26
+ import { byId, citations, loadDecisionsCached, redactEstate } from '../decisions/index.mjs';
27
+ import { DECISIONS_DIR } from '../decisions/index.mjs';
28
+ import { decisionsDir } from '../decisions/dir.mjs';
24
29
  import { nearMatches } from '../resolve/near.mjs';
25
30
  import { CLI } from '../name.mjs';
26
31
 
@@ -47,8 +52,8 @@ function sorted(rules) {
47
52
  return [...rules].sort((a, b) => a.id.localeCompare(b.id));
48
53
  }
49
54
 
50
- async function load(root) {
51
- const rules = await loadRules(root ?? null);
55
+ async function load(root, ctx = repoContext(root)) {
56
+ const rules = await ctx.rules();
52
57
  const broken = rules.filter((r) => r.error);
53
58
  return { rules: [...rules, ...(await loadPerSurfaceRules())], broken };
54
59
  }
@@ -151,12 +156,12 @@ export async function listCommand(args, flags) {
151
156
  if (locals.length > 0) lines.push('', `local profiles come from ${locals.map((r) => r.source).join(', ')}`);
152
157
  }
153
158
 
154
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
159
+ emit(lines.join('\n'));
155
160
  return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
156
161
  }
157
162
 
158
- async function selectionFor(rules, root) {
159
- const { stack, facts } = await fingerprint(root);
163
+ async function selectionFor(rules, root, ctx = repoContext(root)) {
164
+ const { stack, facts } = await ctx.fingerprint();
160
165
  const { selected, notes } = select(rules, stack, facts);
161
166
  return { selected: new Set(selected.map((r) => r.id)), notes };
162
167
  }
@@ -175,21 +180,44 @@ function rulesFailure(message, remedy, exitCode) {
175
180
  throw Object.assign(new Error(message), { remedy, exitCode });
176
181
  }
177
182
 
183
+ /**
184
+ * The merged decision index: the package's shipped records underlay the
185
+ * repository's own, which win on an id collision (review 0003 Q34).
186
+ *
187
+ * Before this, a consumer with its own ADR-001/002 read `rules why core` as
188
+ * settled by *their* ADR-001 while the package's ADR-003…009 — the ones the
189
+ * shipped rules actually cite — reported MISSING. The overlay keeps both
190
+ * visible: every decision carries which layer it came from, so a same-number
191
+ * collision reads as two records, never as one answer wearing the wrong title.
192
+ *
193
+ * @returns {{ok: boolean, decisions: object[], errors: string[], origins: Record<string,'repo'|'package'>}}
194
+ */
178
195
  async function loadDecisionsFor(root, all) {
179
- const localDir = root ? join(root, 'docs', 'decisions') : null;
180
- const hasLocal = localDir
181
- ? (await readdir(localDir).catch(() => [])).some((n) => /^ADR-\d{3,}-.*\.md$/i.test(n))
182
- : false;
183
- return loadDecisions({
184
- all,
185
- ...(hasLocal ? { dir: localDir } : {}),
186
- });
196
+ // Q55: one resolution (manifest key, then detection) and the shared cache,
197
+ // so `rules why` answers from the repository's own records without re-parsing
198
+ // every record on every call. No root means the package's own records.
199
+ const pkg = await loadDecisionsCached({ root, dir: DECISIONS_DIR, all });
200
+ if (!root) return { ...pkg, origins: Object.fromEntries(pkg.decisions.map((d) => [d.id, 'package'])) };
201
+ const relDir = await decisionsDir(root);
202
+ const absDir = join(root, relDir);
203
+ const hasLocal = (await readdir(absDir).catch(() => [])).some((n) => /^ADR-\d{3,}-.*\.md$/i.test(n));
204
+ if (!hasLocal) return { ...pkg, origins: Object.fromEntries(pkg.decisions.map((d) => [d.id, 'package'])) };
205
+ const local = await loadDecisionsCached({ root, dir: absDir, all });
206
+ const merged = new Map(pkg.decisions.map((d) => [d.id, d]));
207
+ const origins = Object.fromEntries(pkg.decisions.map((d) => [d.id, 'package']));
208
+ for (const d of local.decisions) {
209
+ merged.set(d.id, d);
210
+ origins[d.id] = 'repo';
211
+ }
212
+ return { ok: pkg.ok && local.ok, decisions: [...merged.values()], errors: [...pkg.errors, ...local.errors], origins };
187
213
  }
188
214
 
189
215
  /** The catalogue both surfaces answer from: every rule, its decisions, and the selection. */
190
- export async function rulesCatalogue(root, { selectFor = null } = {}) {
191
- const { rules, broken } = await load(root);
192
- const selection = selectFor ? await selectionFor(rules, selectFor) : null;
216
+ export async function rulesCatalogue(root, { selectFor = null, ctx = null } = {}) {
217
+ const { rules, broken } = await load(root, ctx ?? repoContext(root));
218
+ // `selectFor` is a different repository: the caller asked for the catalogue here and
219
+ // the selection there, so it gets its own context rather than this repository answer.
220
+ const selection = selectFor ? await selectionFor(rules, selectFor, repoContext(selectFor)) : null;
193
221
  return {
194
222
  rules: sorted(rules).map((r) => ({
195
223
  id: r.id,
@@ -213,24 +241,26 @@ export async function rulesCatalogue(root, { selectFor = null } = {}) {
213
241
  * The provenance facts for one rule or one decision record, in the shape the
214
242
  * JSON documents carry. Text renders from the same facts; nothing re-derives.
215
243
  */
216
- export async function rulesWhyFacts(root, what, { all = false } = {}) {
217
- const { rules } = await load(root);
244
+ export async function rulesWhyFacts(root, what, { all = false, ctx = null } = {}) {
245
+ const { rules } = await load(root, ctx ?? repoContext(root));
218
246
  if (/^ADR-\d{3,}$/i.test(what)) {
219
247
  const id = what.toUpperCase();
220
- const { ok, decisions, errors } = await loadDecisionsFor(root, all);
248
+ const { ok, decisions, errors, origins } = await loadDecisionsFor(root, all);
221
249
  if (!ok) {
222
250
  rulesFailure(errors[0], 'the decision records must be readable for provenance to work', EXIT.CONFIG);
223
251
  }
224
- const record = decisions.find((x) => x.id === id);
252
+ const record = decisions.find((x) => x.id === id) ?? null;
225
253
  // Was 'list them with: ls docs/decisions/' - a path relative to simt-dev's own
226
254
  // tree, which resolves to nothing in the repo the reader is standing in. The
227
- // remedy must be a command they can run.
228
- if (!record) rulesFailure(`no decision record ${id}`, `list the known decisions with: ${CLI} rules list`, EXIT.USAGE);
255
+ // remedy must be a command they can run (Q34: the decisions list, not the
256
+ // rules list - the reader asked about a decision, not a rule).
257
+ if (!record) rulesFailure(`no decision record ${id}`, `list the known decisions with: ${CLI} rules decisions`, EXIT.USAGE);
229
258
  const replacement = record.supersededBy ? decisions.find((x) => x.id === record.supersededBy) : null;
230
259
  return {
231
260
  direction: 'decision',
232
261
  id,
233
- adr: record,
262
+ adr: { ...record, origin: origins[id] ?? 'package' },
263
+ origins,
234
264
  supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
235
265
  inForce: replacement
236
266
  ? { id: replacement.id, status: replacement.status, summary: replacement.summary }
@@ -247,7 +277,7 @@ export async function rulesWhyFacts(root, what, { all = false } = {}) {
247
277
  EXIT.USAGE,
248
278
  );
249
279
  }
250
- const { ok, decisions, errors } = await loadDecisionsFor(root, all);
280
+ const { ok, decisions, errors, origins } = await loadDecisionsFor(root, all);
251
281
  if (!ok) {
252
282
  rulesFailure(errors[0], 'the decision records must be readable for provenance to work', EXIT.CONFIG);
253
283
  }
@@ -261,9 +291,10 @@ export async function rulesWhyFacts(root, what, { all = false } = {}) {
261
291
  title: rule.title ?? null,
262
292
  source: rule.source,
263
293
  origin: rule.origin,
264
- adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
294
+ adrs: cited.map((c) => (index.has(c) ? { ...index.get(c), origin: origins[c] ?? 'package' } : { id: c, missing: true })),
265
295
  uncited: cited.length === 0,
266
296
  others: others.map((r) => r.id),
297
+ origins,
267
298
  };
268
299
  }
269
300
 
@@ -286,7 +317,7 @@ export async function showCommand(args, flags) {
286
317
 
287
318
  const adrs = citations(rule.body);
288
319
  if (isJson(flags)) {
289
- emitJson({ ok: !rule.error, ...rule, adrs }, { maxBytes: flags.maxBytes ?? 0 });
320
+ emitJson({ ok: !rule.error, ...rule, adrs });
290
321
  return rule.error ? EXIT.FAIL : EXIT.OK;
291
322
  }
292
323
 
@@ -303,7 +334,6 @@ export async function showCommand(args, flags) {
303
334
  '',
304
335
  rule.body,
305
336
  ].join('\n'),
306
- { maxBytes: flags.maxBytes ?? 0 },
307
337
  );
308
338
  return rule.error ? EXIT.FAIL : EXIT.OK;
309
339
  }
@@ -344,17 +374,18 @@ function ruleDirection(facts, flags) {
344
374
  ' this rule cites no decision, so nothing settles it and nothing can overturn it.',
345
375
  ' Either cite the ADR that produced it, or accept that it is this repo\'s own policy.',
346
376
  );
347
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
377
+ emit(lines.join('\n'));
348
378
  return EXIT.OK;
349
379
  }
350
380
 
351
381
  lines.push('', `settled by ${facts.adrs.length} decision${facts.adrs.length === 1 ? '' : 's'}`);
352
382
  for (const d of facts.adrs) {
353
383
  if (d.missing) {
354
- lines.push(` ${d.id} MISSING - the rule cites it, but this installation has no record of it`);
384
+ lines.push(` ${d.id} MISSING - the rule cites it, but neither this repo nor this installation has a record of it`);
385
+ lines.push(` remedy: list the known decisions with: ${CLI} rules decisions`);
355
386
  continue;
356
387
  }
357
- lines.push(` ${d.id} ${d.title}`);
388
+ lines.push(` ${d.id} [${d.origin ?? 'package'}] ${d.title}`);
358
389
  lines.push(` ${d.status}`);
359
390
  if (d.supersededBy) {
360
391
  // Never print a reversed ruling as if it were current.
@@ -368,7 +399,7 @@ function ruleDirection(facts, flags) {
368
399
  lines.push('', `shares a decision with ${facts.others.join(', ')}`);
369
400
  }
370
401
 
371
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
402
+ emit(lines.join('\n'));
372
403
  return EXIT.OK;
373
404
  }
374
405
 
@@ -379,6 +410,7 @@ function adrDirection(facts, flags) {
379
410
  const lines = [
380
411
  `${d.id} - ${d.title}`,
381
412
  '',
413
+ `origin ${d.origin ?? 'package'}${d.origin === 'repo' ? ' (this repository overlays the shipped record of the same number)' : ''}`,
382
414
  `status ${d.status}`,
383
415
  `restates ${restates.length > 0 ? restates.join(', ') : '(no rule cites it)'}`,
384
416
  ];
@@ -458,8 +490,67 @@ function adrDirection(facts, flags) {
458
490
  );
459
491
  }
460
492
 
461
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
493
+ emit(lines.join('\n'));
462
494
  // A superseded record is not a failure; it is the supersession rule working.
463
495
  // The caller asked a question and got a true answer.
464
496
  return EXIT.OK;
497
+ }
498
+
499
+ // --- decisions ------------------------------------------------------------------
500
+
501
+ /**
502
+ * `simt rules decisions`: every known decision record, both layers.
503
+ *
504
+ * The shipped records underlay the repository's own, which win on a number
505
+ * collision (review 0003 Q34). The column that matters is `origin`: a reader in
506
+ * a consumer repository must be able to tell the package's ADR-001 from their
507
+ * own, because they are two records that happen to share a number.
508
+ */
509
+ export async function decisionsCommand(args, flags) {
510
+ const root = flags.root ?? null;
511
+ const { ok, decisions, errors, origins } = await loadDecisionsFor(root, flags.all === true);
512
+ if (!ok) {
513
+ return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable to list them');
514
+ }
515
+ const rows = [...decisions]
516
+ .sort((a, b) => Number(a.id.replace(/^ADR-/i, '')) - Number(b.id.replace(/^ADR-/i, '')))
517
+ .map((d) => ({
518
+ id: d.id,
519
+ title: d.title,
520
+ status: d.status,
521
+ origin: origins[d.id] ?? 'package',
522
+ supersededBy: d.supersededBy ?? null,
523
+ file: d.file,
524
+ }));
525
+
526
+ // The index is output, not authorship (review 0003 Q91): the table is rendered from
527
+ // the same records this command lists, so a new ADR cannot leave it behind. The
528
+ // write is journaled like every other repo-file write, so `undo` restores the old
529
+ // table - the index is not internal state even though it is generated.
530
+ if (flags.yes === true) {
531
+ const rootDir = root ?? process.cwd();
532
+ const journal = [];
533
+ await journaledWriteFile(rootDir, journal, join(rootDir, 'docs', 'decisions-index.md'), renderDecisionsIndex(rows));
534
+ await commitJournal(rootDir, 'rules decisions', journal);
535
+ emit(`wrote docs/decisions-index.md (${rows.length} decisions)`);
536
+ return EXIT.OK;
537
+ }
538
+
539
+ if (isJson(flags)) {
540
+ emitJson({ ok: true, command: 'rules decisions', root, decisions: rows });
541
+ return EXIT.OK;
542
+ }
543
+
544
+ const repo = rows.filter((r) => r.origin === 'repo').length;
545
+ const lines = [
546
+ `${rows.length} decisions${root && repo > 0 ? ` (${repo} from this repo overlay the shipped set)` : ''}`,
547
+ '',
548
+ 'id origin title',
549
+ ];
550
+ for (const r of rows) {
551
+ const head = r.status.length > 80 ? `${r.status.slice(0, 80)}...` : r.status;
552
+ lines.push(`${r.id.padEnd(8)} ${r.origin.padEnd(7)} ${r.title} — ${head}`);
553
+ }
554
+ emit(lines.join('\n'));
555
+ return EXIT.OK;
465
556
  }
@@ -189,7 +189,7 @@ export async function sharedPlanCommand(_args, flags) {
189
189
  if (over !== null) lines.push(`warn ${over}`);
190
190
  }
191
191
  lines.push(`nothing written; re-run with: ${CLI} shared install --yes`);
192
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
192
+ emit(lines.join('\n'));
193
193
  return EXIT.OK;
194
194
  }
195
195
 
@@ -306,7 +306,6 @@ export async function sharedInstallCommand(_args, flags) {
306
306
  `manifest ${manifests.map((m) => `${m.action}:${basename(m.root)}`).join(' ')}`,
307
307
  dryRun ? 'dry-run nothing written' : `nothing written; re-run with: ${CLI} shared install --yes`,
308
308
  ].join('\n'),
309
- { maxBytes: flags.maxBytes ?? 0 },
310
309
  );
311
310
  // After the report: the report says what, the diff shows it (R50).
312
311
  emitDiffs(diffs, flags);
@@ -354,7 +353,7 @@ export async function sharedInstallCommand(_args, flags) {
354
353
  if (over !== null) lines.push(`warn ${over}`);
355
354
  }
356
355
  lines.push(`verify ${CLI} doctor reports the copy as current, and ${CLI} tokens --repos --root . reports what is left`);
357
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
356
+ emit(lines.join('\n'));
358
357
  // After the report: the report says what, the diff shows it (R50).
359
358
  emitDiffs(diffs, flags);
360
359
  return EXIT.OK;