simt-dev 0.7.0 → 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 (112) hide show
  1. package/CHANGELOG.md +158 -872
  2. package/README.md +19 -133
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  5. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  6. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  7. 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
  8. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  9. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  10. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  11. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  12. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  13. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  14. package/package.json +10 -2
  15. package/rules/core.md +7 -0
  16. package/rules/mdc/slice-commit.mdc +57 -4
  17. package/src/agenthooks/prompt.mjs +5 -2
  18. package/src/agenthooks/shim.mjs +1 -1
  19. package/src/api.mjs +29 -0
  20. package/src/argv.mjs +29 -3
  21. package/src/bench/adapters.json +16 -0
  22. package/src/bench/corpus.mjs +150 -0
  23. package/src/bench/grade.mjs +23 -49
  24. package/src/bench/mine.mjs +199 -35
  25. package/src/bench/probes.mjs +99 -62
  26. package/src/bench/process.mjs +128 -0
  27. package/src/bench/report.mjs +17 -4
  28. package/src/bench/run.mjs +242 -142
  29. package/src/brief/assemble.mjs +38 -3
  30. package/src/ci/workflow.mjs +30 -7
  31. package/src/cli.mjs +45 -0
  32. package/src/commands/agent.mjs +3 -4
  33. package/src/commands/agenthook.mjs +4 -5
  34. package/src/commands/bench.mjs +190 -17
  35. package/src/commands/brief.mjs +5 -4
  36. package/src/commands/ci.mjs +2 -1
  37. package/src/commands/compare.mjs +42 -42
  38. package/src/commands/completions.mjs +2 -6
  39. package/src/commands/decide.mjs +4 -4
  40. package/src/commands/distill.mjs +4 -3
  41. package/src/commands/doctor.mjs +11 -7
  42. package/src/commands/evidence.mjs +6 -9
  43. package/src/commands/explain.mjs +0 -1
  44. package/src/commands/gate.mjs +16 -12
  45. package/src/commands/graph.mjs +2 -3
  46. package/src/commands/harness.mjs +3 -2
  47. package/src/commands/help.mjs +125 -17
  48. package/src/commands/hooks.mjs +100 -30
  49. package/src/commands/ignore.mjs +2 -1
  50. package/src/commands/impact.mjs +4 -7
  51. package/src/commands/index.mjs +273 -188
  52. package/src/commands/init.mjs +2 -1
  53. package/src/commands/lease.mjs +6 -7
  54. package/src/commands/lessons.mjs +10 -9
  55. package/src/commands/local.mjs +3 -3
  56. package/src/commands/map.mjs +14 -9
  57. package/src/commands/mcp.mjs +0 -1
  58. package/src/commands/pack.mjs +9 -6
  59. package/src/commands/plan.mjs +6 -5
  60. package/src/commands/plugin.mjs +2 -2
  61. package/src/commands/prbody.mjs +3 -3
  62. package/src/commands/repoindex.mjs +4 -3
  63. package/src/commands/review.mjs +2 -2
  64. package/src/commands/rules.mjs +33 -16
  65. package/src/commands/shared.mjs +2 -3
  66. package/src/commands/skills.mjs +28 -26
  67. package/src/commands/skillscompile.mjs +4 -5
  68. package/src/commands/skillsinstall.mjs +2 -2
  69. package/src/commands/slice.mjs +21 -10
  70. package/src/commands/spec.mjs +151 -0
  71. package/src/commands/status.mjs +4 -7
  72. package/src/commands/sync.mjs +13 -11
  73. package/src/commands/tests.mjs +6 -9
  74. package/src/commands/tokens.mjs +2 -2
  75. package/src/commands/undo.mjs +5 -15
  76. package/src/commands/upgrade.mjs +7 -3
  77. package/src/commands/why.mjs +9 -8
  78. package/src/compare/row.mjs +0 -2
  79. package/src/context.mjs +64 -0
  80. package/src/decisions/index-doc.mjs +46 -0
  81. package/src/diff.mjs +1 -1
  82. package/src/distill/distill.mjs +14 -1
  83. package/src/distill/engine.mjs +5 -1
  84. package/src/graph/load.mjs +4 -1
  85. package/src/hooks/capability.mjs +3 -3
  86. package/src/hooks/dispatcher.mjs +198 -43
  87. package/src/hooks/gates.mjs +0 -43
  88. package/src/hooks/resolve.mjs +2 -7
  89. package/src/hooks/shim.mjs +5 -2
  90. package/src/identity.mjs +29 -1
  91. package/src/journal.mjs +78 -9
  92. package/src/ledger/index.mjs +30 -1
  93. package/src/local/client.mjs +4 -6
  94. package/src/locate.mjs +33 -9
  95. package/src/manifest/schema.mjs +213 -0
  96. package/src/mcp/resources.mjs +7 -10
  97. package/src/mcp/server.mjs +70 -16
  98. package/src/mcp/tools.mjs +45 -7
  99. package/src/measure/session.mjs +3 -2
  100. package/src/node-version.mjs +37 -0
  101. package/src/output/index.mjs +111 -3
  102. package/src/packs/manage.mjs +17 -3
  103. package/src/packs.mjs +20 -2
  104. package/src/projection/lock.mjs +11 -1
  105. package/src/projection/manifest.mjs +20 -34
  106. package/src/projection/rules.mjs +4 -3
  107. package/src/repo-root.mjs +19 -0
  108. package/src/resolve/fingerprint.mjs +19 -15
  109. package/src/review/critic.mjs +2 -1
  110. package/src/toolwords.mjs +3 -0
  111. package/src/workspace.mjs +55 -10
  112. package/docs/decisions/README.md +0 -600
@@ -17,12 +17,13 @@ import {
17
17
  promoteLesson,
18
18
  readLessonCandidates,
19
19
  } from '../memory/lessons.mjs';
20
+ import { commandRoot } from '../repo-root.mjs';
20
21
 
21
22
  export async function lessonsListCommand(args, flags) {
22
23
  if (args.length > 0) {
23
24
  return fail(`unknown lessons argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} lessons`);
24
25
  }
25
- const root = resolve(flags.root ?? process.cwd());
26
+ const root = resolve(commandRoot(flags));
26
27
  // Pure: listing must not capture. Capture used to run here, so `list` wrote
27
28
  // to the store it was reading and a read-only question had write side
28
29
  // effects. Capturing is `lessons capture` now (review 0003 Q36).
@@ -48,7 +49,7 @@ export async function lessonsListCommand(args, flags) {
48
49
  };
49
50
 
50
51
  if (isJson(flags)) {
51
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
52
+ emitJson(payload);
52
53
  return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
53
54
  }
54
55
 
@@ -81,7 +82,7 @@ export async function lessonsListCommand(args, flags) {
81
82
 
82
83
  for (const err of errors) lines.push(`error ${err}`);
83
84
 
84
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
85
+ emit(lines.join('\n'));
85
86
  return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
86
87
  }
87
88
 
@@ -96,7 +97,7 @@ export async function lessonsCaptureCommand(args, flags) {
96
97
  if (args.length > 0) {
97
98
  return fail(`unknown lessons capture argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} lessons capture`);
98
99
  }
99
- const root = resolve(flags.root ?? process.cwd());
100
+ const root = resolve(commandRoot(flags));
100
101
  const fromLedger = await captureFromLedger(root);
101
102
  const reverts = await captureRevertedCommits(root);
102
103
 
@@ -109,7 +110,7 @@ export async function lessonsCaptureCommand(args, flags) {
109
110
  captured: [...fromLedger, ...reverts],
110
111
  };
111
112
  if (isJson(flags)) {
112
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
113
+ emitJson(payload);
113
114
  return EXIT.OK;
114
115
  }
115
116
  const lines = [
@@ -120,7 +121,7 @@ export async function lessonsCaptureCommand(args, flags) {
120
121
  for (const c of payload.captured) {
121
122
  lines.push(` ${c.id} ${c.kind} ${c.summary}`);
122
123
  }
123
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
124
+ emit(lines.join('\n'));
124
125
  return EXIT.OK;
125
126
  }
126
127
 
@@ -128,7 +129,7 @@ export async function lessonsReviewCommand(args, flags) {
128
129
  if (args.length > 1) {
129
130
  return fail(`unknown lessons review argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} lessons review [<id>] [--yes]`);
130
131
  }
131
- const root = resolve(flags.root ?? process.cwd());
132
+ const root = resolve(commandRoot(flags));
132
133
  const mode = writeMode(flags);
133
134
  const parsedPaths = parsePathList(flags.paths);
134
135
  const paths = parsedPaths.length > 0 ? parsedPaths : undefined;
@@ -173,7 +174,7 @@ export async function lessonsReviewCommand(args, flags) {
173
174
  };
174
175
 
175
176
  if (isJson(flags)) {
176
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
177
+ emitJson(payload);
177
178
  return EXIT.OK;
178
179
  }
179
180
 
@@ -185,7 +186,7 @@ export async function lessonsReviewCommand(args, flags) {
185
186
  result.markdown.trimEnd(),
186
187
  ];
187
188
 
188
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
189
+ emit(lines.join('\n'));
189
190
  // After the report: the report says what, the diff shows it (R50).
190
191
  emitDiffs(diffs, flags);
191
192
  if (!mode.apply) {
@@ -8,6 +8,7 @@ import { resolve } from 'node:path';
8
8
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
9
9
  import { CLI, MANIFEST_FILE } from '../name.mjs';
10
10
  import { probeLocal } from '../local/client.mjs';
11
+ import { commandRoot } from '../repo-root.mjs';
11
12
 
12
13
  function formatRoleLine(role, report) {
13
14
  if (!report) return `${role.padEnd(8)} (unset)`;
@@ -55,7 +56,7 @@ function remedyFor(result) {
55
56
  }
56
57
 
57
58
  export async function localDoctorCommand(args, flags) {
58
- const root = resolve(flags.root ?? args[0] ?? process.cwd());
59
+ const root = resolve(commandRoot(flags, args));
59
60
  const allowRemote = flags.allowRemote === true;
60
61
  const result = await probeLocal(root, { allowRemote });
61
62
 
@@ -86,7 +87,6 @@ export async function localDoctorCommand(args, flags) {
86
87
  findings: result.findings,
87
88
  ...(error ? { error, remedy, exitCode: code } : {}),
88
89
  },
89
- { maxBytes: flags.maxBytes ?? 0 },
90
90
  );
91
91
  return code;
92
92
  }
@@ -99,7 +99,7 @@ export async function localDoctorCommand(args, flags) {
99
99
  `sockets ${result.socketsOpened}`,
100
100
  ];
101
101
 
102
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
102
+ emit(lines.join('\n'));
103
103
 
104
104
  if (!result.ok) {
105
105
  return fail(error, code, remedy);
@@ -5,7 +5,7 @@
5
5
  // `build` on are ones a person typed at a terminal, where a few hundred
6
6
  // milliseconds is not a bar to meet.
7
7
 
8
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
8
+ import { EXIT, emit, emitJson, fail, isJson, present } from '../output/index.mjs';
9
9
  import { CLI } from '../name.mjs';
10
10
  import { isIndexStale, readEdges, readEmbeddings, readFiles, readFts, readSymbols, reindex, searchFtsBm25 } from '../index/store.mjs';
11
11
  import {
@@ -16,12 +16,13 @@ import {
16
16
  rankFiles,
17
17
  renderMap,
18
18
  } from '../index/map.mjs';
19
+ import { commandRoot } from '../repo-root.mjs';
19
20
 
20
21
  const LEVELS = new Set(['outline', 'names', 'signatures']);
21
22
 
22
23
  export async function mapCommand(args, flags) {
23
24
  if (args.length > 0) return fail(`unknown map argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} map`);
24
- const root = flags.root ?? process.cwd();
25
+ const root = commandRoot(flags);
25
26
  if (flags.recall === true) return recall(root, flags);
26
27
  const level = flags.level ?? 'outline';
27
28
  if (!LEVELS.has(level)) {
@@ -44,12 +45,19 @@ export async function mapCommand(args, flags) {
44
45
  : null;
45
46
  const ranked = rankFiles({ ...loaded, bm25, queryVector, task });
46
47
  const rendered = renderMap(ranked, loaded.symbols, { level, budget });
48
+ // What the ranking is an answer to. `graph-only` says no file or symbol mentioned the
49
+ // task and the list is the graph hub; reading that as a hit is how `map --task
50
+ // zebracorn` looked useful (review 0003 Q52).
51
+ const matched = ranked.filter((row) => (row.score ?? 0) > 0).length;
52
+ const basis = task.trim().length === 0 ? 'no-task' : matched > 0 ? 'matched' : 'graph-only';
47
53
  const payload = {
48
54
  ok: true,
49
55
  command: 'map',
50
56
  root,
51
57
  level,
52
58
  budget,
59
+ matched,
60
+ basis,
53
61
  shown: rendered.shown,
54
62
  total: rendered.total,
55
63
  truncated: rendered.truncated,
@@ -57,17 +65,18 @@ export async function mapCommand(args, flags) {
57
65
  text: rendered.text,
58
66
  };
59
67
  if (isJson(flags)) {
60
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
68
+ emitJson(payload);
61
69
  return EXIT.OK;
62
70
  }
63
71
  const lines = [
64
72
  `level ${level}`,
73
+ `basis ${basis}${basis === 'graph-only' ? ' (no file or symbol mentions this task; the list is the graph hub)' : ''}`,
65
74
  `shown ${rendered.shown}`,
66
75
  `total ${rendered.total}`,
67
76
  `budget ${budget}`,
68
77
  ];
69
78
  if (rendered.text) lines.push(rendered.text);
70
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
79
+ emit(lines.join('\n'));
71
80
  return EXIT.OK;
72
81
  }
73
82
 
@@ -82,11 +91,7 @@ async function recall(root, flags) {
82
91
  return fail(`could not measure recall (${error.message})`, EXIT.UNSUPPORTED, `run ${CLI} index in a git repository`);
83
92
  }
84
93
  const payload = { ok: true, command: 'map', root, recall: result };
85
- if (isJson(flags)) {
86
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
87
- return EXIT.OK;
88
- }
89
- emit(formatRecall(result), { maxBytes: flags.maxBytes ?? 0 });
94
+ present(payload, () => formatRecall(result), flags);
90
95
  return EXIT.OK;
91
96
  }
92
97
 
@@ -53,7 +53,6 @@ export async function mcpCommand(positional, flags) {
53
53
  '',
54
54
  `serve them with: ${positional[0] ?? `${CLI} mcp`}`,
55
55
  ].join('\n'),
56
- { maxBytes: flags.maxBytes ?? 0 },
57
56
  );
58
57
  return EXIT.OK;
59
58
  }
@@ -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;
@@ -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 = [
@@ -46,7 +47,7 @@ export async function indexCommand(args, flags) {
46
47
  ];
47
48
  if (result.parsers === 'missing') lines.push('note index.parsers did not load, so the built-in extractors ran');
48
49
  if (!ignored) lines.push(`note ${file} is not gitignored, and the cache is not meant to be committed`);
49
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
50
+ emit(lines.join('\n'));
50
51
  return EXIT.OK;
51
52
  }
52
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,7 +15,10 @@
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';
@@ -49,8 +52,8 @@ function sorted(rules) {
49
52
  return [...rules].sort((a, b) => a.id.localeCompare(b.id));
50
53
  }
51
54
 
52
- async function load(root) {
53
- const rules = await loadRules(root ?? null);
55
+ async function load(root, ctx = repoContext(root)) {
56
+ const rules = await ctx.rules();
54
57
  const broken = rules.filter((r) => r.error);
55
58
  return { rules: [...rules, ...(await loadPerSurfaceRules())], broken };
56
59
  }
@@ -153,12 +156,12 @@ export async function listCommand(args, flags) {
153
156
  if (locals.length > 0) lines.push('', `local profiles come from ${locals.map((r) => r.source).join(', ')}`);
154
157
  }
155
158
 
156
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
159
+ emit(lines.join('\n'));
157
160
  return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
158
161
  }
159
162
 
160
- async function selectionFor(rules, root) {
161
- const { stack, facts } = await fingerprint(root);
163
+ async function selectionFor(rules, root, ctx = repoContext(root)) {
164
+ const { stack, facts } = await ctx.fingerprint();
162
165
  const { selected, notes } = select(rules, stack, facts);
163
166
  return { selected: new Set(selected.map((r) => r.id)), notes };
164
167
  }
@@ -210,9 +213,11 @@ async function loadDecisionsFor(root, all) {
210
213
  }
211
214
 
212
215
  /** The catalogue both surfaces answer from: every rule, its decisions, and the selection. */
213
- export async function rulesCatalogue(root, { selectFor = null } = {}) {
214
- const { rules, broken } = await load(root);
215
- 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;
216
221
  return {
217
222
  rules: sorted(rules).map((r) => ({
218
223
  id: r.id,
@@ -236,8 +241,8 @@ export async function rulesCatalogue(root, { selectFor = null } = {}) {
236
241
  * The provenance facts for one rule or one decision record, in the shape the
237
242
  * JSON documents carry. Text renders from the same facts; nothing re-derives.
238
243
  */
239
- export async function rulesWhyFacts(root, what, { all = false } = {}) {
240
- 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));
241
246
  if (/^ADR-\d{3,}$/i.test(what)) {
242
247
  const id = what.toUpperCase();
243
248
  const { ok, decisions, errors, origins } = await loadDecisionsFor(root, all);
@@ -312,7 +317,7 @@ export async function showCommand(args, flags) {
312
317
 
313
318
  const adrs = citations(rule.body);
314
319
  if (isJson(flags)) {
315
- emitJson({ ok: !rule.error, ...rule, adrs }, { maxBytes: flags.maxBytes ?? 0 });
320
+ emitJson({ ok: !rule.error, ...rule, adrs });
316
321
  return rule.error ? EXIT.FAIL : EXIT.OK;
317
322
  }
318
323
 
@@ -329,7 +334,6 @@ export async function showCommand(args, flags) {
329
334
  '',
330
335
  rule.body,
331
336
  ].join('\n'),
332
- { maxBytes: flags.maxBytes ?? 0 },
333
337
  );
334
338
  return rule.error ? EXIT.FAIL : EXIT.OK;
335
339
  }
@@ -370,7 +374,7 @@ function ruleDirection(facts, flags) {
370
374
  ' this rule cites no decision, so nothing settles it and nothing can overturn it.',
371
375
  ' Either cite the ADR that produced it, or accept that it is this repo\'s own policy.',
372
376
  );
373
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
377
+ emit(lines.join('\n'));
374
378
  return EXIT.OK;
375
379
  }
376
380
 
@@ -395,7 +399,7 @@ function ruleDirection(facts, flags) {
395
399
  lines.push('', `shares a decision with ${facts.others.join(', ')}`);
396
400
  }
397
401
 
398
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
402
+ emit(lines.join('\n'));
399
403
  return EXIT.OK;
400
404
  }
401
405
 
@@ -486,7 +490,7 @@ function adrDirection(facts, flags) {
486
490
  );
487
491
  }
488
492
 
489
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
493
+ emit(lines.join('\n'));
490
494
  // A superseded record is not a failure; it is the supersession rule working.
491
495
  // The caller asked a question and got a true answer.
492
496
  return EXIT.OK;
@@ -519,6 +523,19 @@ export async function decisionsCommand(args, flags) {
519
523
  file: d.file,
520
524
  }));
521
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
+
522
539
  if (isJson(flags)) {
523
540
  emitJson({ ok: true, command: 'rules decisions', root, decisions: rows });
524
541
  return EXIT.OK;
@@ -534,6 +551,6 @@ export async function decisionsCommand(args, flags) {
534
551
  const head = r.status.length > 80 ? `${r.status.slice(0, 80)}...` : r.status;
535
552
  lines.push(`${r.id.padEnd(8)} ${r.origin.padEnd(7)} ${r.title} — ${head}`);
536
553
  }
537
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
554
+ emit(lines.join('\n'));
538
555
  return EXIT.OK;
539
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;