simt-dev 0.6.0 → 0.6.3

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 (79) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +1 -1
  5. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +79 -0
  6. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  7. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  8. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  9. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  10. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  11. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  12. package/docs/decisions/README.md +44 -0
  13. package/package.json +1 -1
  14. package/rules/mdc/preview-postflight.mdc +5 -6
  15. package/rules/stacks/android.md +7 -8
  16. package/rules/stacks/cloudflare-workers.md +1 -1
  17. package/rules/stacks/container.md +2 -0
  18. package/rules/stacks/jvm.md +1 -1
  19. package/rules/stacks/react-native.md +3 -5
  20. package/rules/stacks/react-web.md +9 -9
  21. package/src/agenthooks/decide.mjs +101 -10
  22. package/src/agenthooks/policy.mjs +81 -5
  23. package/src/agenthooks/shell.mjs +523 -0
  24. package/src/argv.mjs +54 -9
  25. package/src/ci/workflow.mjs +7 -5
  26. package/src/commands/agenthook.mjs +25 -3
  27. package/src/commands/ci.mjs +28 -3
  28. package/src/commands/completions.mjs +27 -0
  29. package/src/commands/decide.mjs +14 -1
  30. package/src/commands/doctor.mjs +367 -0
  31. package/src/commands/explain.mjs +52 -0
  32. package/src/commands/help.mjs +66 -17
  33. package/src/commands/hooks.mjs +208 -60
  34. package/src/commands/ignore.mjs +152 -0
  35. package/src/commands/index.mjs +54 -100
  36. package/src/commands/init.mjs +82 -12
  37. package/src/commands/lease.mjs +136 -0
  38. package/src/commands/lessons.mjs +12 -0
  39. package/src/commands/mcp.mjs +2 -2
  40. package/src/commands/pack.mjs +9 -0
  41. package/src/commands/plan.mjs +13 -1
  42. package/src/commands/plugin.mjs +16 -1
  43. package/src/commands/prbody.mjs +45 -0
  44. package/src/commands/repoindex.mjs +3 -0
  45. package/src/commands/rules.mjs +145 -105
  46. package/src/commands/shared.mjs +36 -5
  47. package/src/commands/skills.mjs +293 -205
  48. package/src/commands/skillsinstall.mjs +100 -30
  49. package/src/commands/slice.mjs +353 -0
  50. package/src/commands/status.mjs +62 -0
  51. package/src/commands/sync.mjs +110 -15
  52. package/src/commands/undo.mjs +106 -0
  53. package/src/commands/upgrade.mjs +70 -8
  54. package/src/completions.mjs +191 -0
  55. package/src/diff.mjs +254 -0
  56. package/src/explain.mjs +258 -0
  57. package/src/federation/config.mjs +44 -0
  58. package/src/graph/load.mjs +14 -5
  59. package/src/hooks/dispatcher.mjs +110 -0
  60. package/src/hooks/gates.mjs +14 -3
  61. package/src/hooks/resolve.mjs +14 -0
  62. package/src/ignore.mjs +133 -0
  63. package/src/index/persist.mjs +66 -9
  64. package/src/journal.mjs +326 -0
  65. package/src/lease.mjs +113 -0
  66. package/src/mcp/server.mjs +15 -1
  67. package/src/mcp/tools.mjs +296 -256
  68. package/src/memory/decide.mjs +6 -1
  69. package/src/memory/lessons.mjs +12 -0
  70. package/src/output/index.mjs +82 -12
  71. package/src/package-version.mjs +49 -1
  72. package/src/packs/manage.mjs +13 -4
  73. package/src/plan/index.mjs +6 -0
  74. package/src/plugins.mjs +27 -6
  75. package/src/prbody.mjs +251 -0
  76. package/src/projection/manifest.mjs +13 -2
  77. package/src/projection/shared.mjs +4 -1
  78. package/src/secrets.mjs +231 -0
  79. package/src/status.mjs +152 -0
@@ -7,6 +7,7 @@ import { execFile } from 'node:child_process';
7
7
  import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
  import { promisify } from 'node:util';
10
+ import { journaledWriteFile } from '../journal.mjs';
10
11
 
11
12
  const exec = promisify(execFile);
12
13
 
@@ -360,9 +361,13 @@ export async function scaffoldDecision(root, options = {}) {
360
361
  supersedes,
361
362
  });
362
363
 
364
+ // A caller-supplied journal records the scaffold so `undo` can restore it
365
+ // (R49). Callers without one (probes, tests staging fixtures) keep the old
366
+ // behaviour exactly.
363
367
  if (options.write) {
364
368
  await mkdir(decisionsDir, { recursive: true });
365
- await writeFile(fullPath, text, 'utf8');
369
+ if (options.journal) await journaledWriteFile(root, options.journal, fullPath, text);
370
+ else await writeFile(fullPath, text, 'utf8');
366
371
  }
367
372
 
368
373
  return {
@@ -642,6 +642,17 @@ export async function promoteLesson(root, options = {}) {
642
642
  const relPath = `${LESSONS_DIR}/${num}-${slug}.md`;
643
643
  const markdown = renderLessonMarkdown(target, options);
644
644
 
645
+ // The `--diff` before-side, read before anything is written so the diff is true
646
+ // in preview and in apply alike (R50). Always a creation in practice — the
647
+ // number is fresh — but a hand-placed file at the same path would be
648
+ // overwritten, and a diff that claimed a creation there would lie about it.
649
+ let before = null;
650
+ try {
651
+ before = await readFile(join(root, relPath), 'utf8');
652
+ } catch (error) {
653
+ if (error.code !== 'ENOENT') throw error;
654
+ }
655
+
645
656
  if (options.apply) {
646
657
  const fullPath = join(root, relPath);
647
658
  await mkdir(dirname(fullPath), { recursive: true });
@@ -656,6 +667,7 @@ export async function promoteLesson(root, options = {}) {
656
667
  candidate: target,
657
668
  path: relPath,
658
669
  markdown,
670
+ before,
659
671
  written: Boolean(options.apply),
660
672
  };
661
673
  }
@@ -144,20 +144,90 @@ export function jsonIndentDefault(pretty = process.stdout.isTTY === true) {
144
144
  * the only consumer is a person: the compact document saves them nothing and costs them
145
145
  * the diff they were about to read.
146
146
  */
147
+ function withSchemaVersion(value) {
148
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && value.schemaVersion === undefined
149
+ ? { schemaVersion: SCHEMA_VERSION, ...value }
150
+ : value;
151
+ }
152
+
153
+ function stringifyJson(value, pretty) {
154
+ return pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value);
155
+ }
156
+
157
+ /**
158
+ * Shrink a payload to a byte ceiling by emptying its longest arrays first.
159
+ *
160
+ * Byte-cutting JSON produces a document that parses or exits non-zero, never both —
161
+ * `why --json --max-bytes 120` used to exit 0 with half a token on stdout (Q10). An
162
+ * importer owns nothing here: the trimmed arrays are named in `truncated`, so a reader
163
+ * knows exactly which list lost how many items, and the document still parses.
164
+ *
165
+ * Returns the fitted payload, or null when no array-trimming fits (the caller then
166
+ * answers with the `MAX_BYTES` error envelope instead).
167
+ */
168
+ function fitJsonArrays(payload, maxBytes, pretty) {
169
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null;
170
+ const slots = [];
171
+ const walk = (node, path) => {
172
+ if (Array.isArray(node)) {
173
+ if (node.length > 0) slots.push({ node, path, length: node.length });
174
+ node.forEach((item, i) => walk(item, `${path}[${i}]`));
175
+ } else if (node !== null && typeof node === 'object') {
176
+ for (const key of Object.keys(node)) walk(node[key], path === '' ? key : `${path}.${key}`);
177
+ }
178
+ };
179
+ walk(payload, '');
180
+ if (slots.length === 0) return null;
181
+ slots.sort((a, b) => b.length - a.length);
182
+ const candidate = structuredClone(payload);
183
+ const trimmed = [];
184
+ const sizeOf = (extra) =>
185
+ Buffer.byteLength(stringifyJson(extra ? { ...candidate, truncated: extra } : candidate, pretty));
186
+ const segmentsOf = (path) =>
187
+ path.split('.').flatMap((part) => {
188
+ const m = /^(.*?)\[(\d+)\]$/.exec(part);
189
+ return m ? [m[1], Number(m[2])] : [part];
190
+ });
191
+ for (const slot of slots) {
192
+ const at = segmentsOf(slot.path);
193
+ let target = candidate;
194
+ for (let i = 0; i < at.length - 1; i += 1) target = target[at[i]];
195
+ if (target === null || typeof target !== 'object') continue;
196
+ target[at[at.length - 1]] = [];
197
+ trimmed.push({ field: slot.path, omitted: slot.length });
198
+ const extra = trimmed.length === 1
199
+ ? { field: trimmed[0].field, omitted: trimmed[0].omitted, limit: maxBytes }
200
+ : { fields: trimmed, limit: maxBytes };
201
+ if (sizeOf(extra) <= maxBytes) return { ...candidate, truncated: extra };
202
+ }
203
+ return null;
204
+ }
205
+
147
206
  export function emitJson(value, { stream = process.stdout, pretty = prettyDefault, maxBytes = 0 } = {}) {
148
- const payload =
149
- value !== null && typeof value === 'object' && !Array.isArray(value) && value.schemaVersion === undefined
150
- ? { schemaVersion: SCHEMA_VERSION, ...value }
151
- : value;
152
- const text = `${pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload)}\n`;
153
- // The same ceiling and the same announcement `emit` applies: a reader who asked for a
154
- // bounded answer gets a bounded answer and is told how much is missing, rather than a
155
- // half-parsed document it believes is whole.
207
+ const payload = withSchemaVersion(value);
208
+ const text = `${stringifyJson(payload, pretty)}\n`;
209
+ // JSON is never byte-truncated: a cut document parses as a lie, and exit 0 calls it
210
+ // true. Arrays shrink with a `truncated` record naming what lost how much, and a
211
+ // document no trimming can fit becomes a `MAX_BYTES` error envelope (Q10, ADR-105).
156
212
  if (maxBytes > 0 && Buffer.byteLength(text) > maxBytes) {
157
- const slice = Buffer.from(text).subarray(0, maxBytes).toString('utf8');
158
- const cut = slice.lastIndexOf('\n');
159
- stream.write(cut > 0 ? `${slice.slice(0, cut)}\n` : slice);
160
- stream.write(`TRUNCATED ${Buffer.byteLength(text) - maxBytes} bytes omitted, raise maxBytes\n`);
213
+ const fitted = fitJsonArrays(payload, maxBytes, pretty);
214
+ if (fitted !== null) {
215
+ stream.write(`${stringifyJson(fitted, pretty)}\n`);
216
+ return false;
217
+ }
218
+ const envelope = withSchemaVersion({
219
+ ...(payload !== null && typeof payload === 'object' && !Array.isArray(payload) && typeof payload.command === 'string'
220
+ ? { command: payload.command }
221
+ : {}),
222
+ ok: false,
223
+ error: {
224
+ code: 'MAX_BYTES',
225
+ message: `output exceeded the --max-bytes ceiling of ${maxBytes} bytes`,
226
+ remedy: 'rerun with --max-bytes 0 for the whole document',
227
+ },
228
+ truncated: { omitted: Buffer.byteLength(text) - maxBytes, limit: maxBytes },
229
+ });
230
+ stream.write(`${stringifyJson(envelope, pretty)}\n`);
161
231
  return false;
162
232
  }
163
233
  stream.write(text);
@@ -14,9 +14,11 @@
14
14
  // must match: npm pins the node_modules copy for us, but nothing pins a binary, so
15
15
  // the gate compares the running version against the pin and refuses on a mismatch.
16
16
 
17
+ import { execFileSync } from 'node:child_process';
17
18
  import { readFileSync } from 'node:fs';
18
19
  import { stat } from 'node:fs/promises';
19
- import { join } from 'node:path';
20
+ import { dirname, join } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
20
22
 
21
23
  import { MANIFEST_FILE } from './name.mjs';
22
24
 
@@ -38,6 +40,52 @@ export function packageVersion() {
38
40
  }
39
41
  }
40
42
 
43
+ /** The directory this package is installed in, whatever resolved the import. */
44
+ function packageRootDir() {
45
+ return dirname(dirname(fileURLToPath(import.meta.url)));
46
+ }
47
+
48
+ /**
49
+ * The short commit this checkout is at, or null outside a git checkout (an
50
+ * installed tarball or wheel has no `.git`). Read-only and best-effort: a
51
+ * version string must never fail because the VCS is absent (Q05).
52
+ */
53
+ export function checkoutSha() {
54
+ try {
55
+ const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
56
+ cwd: packageRootDir(),
57
+ encoding: 'utf8',
58
+ stdio: ['ignore', 'pipe', 'ignore'],
59
+ }).trim();
60
+ return /^[0-9a-f]+$/.test(sha) ? sha : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * What `--version` prints: the package version, plus the checkout SHA when run
68
+ * from a source tree past its tag. A `main` 26 commits past v0.6.2 reporting
69
+ * bare `0.6.2` is the drift Q05 closes: the number alone claims a release the
70
+ * bytes are not (Q05).
71
+ */
72
+ export function describeVersion() {
73
+ const version = packageVersion();
74
+ const sha = checkoutSha();
75
+ if (!sha) return `simt-dev ${version}`;
76
+ try {
77
+ const tag = execFileSync('git', ['describe', '--tags', '--exact-match'], {
78
+ cwd: packageRootDir(),
79
+ encoding: 'utf8',
80
+ stdio: ['ignore', 'pipe', 'ignore'],
81
+ }).trim();
82
+ if (tag === `v${version}`) return `simt-dev ${version}`;
83
+ } catch {
84
+ // Not exactly on the release tag: fall through to the SHA suffix.
85
+ }
86
+ return `simt-dev ${version} (${sha})`;
87
+ }
88
+
41
89
  /**
42
90
  * Whether a repository has a package.json, i.e. whether it pins simt-dev as a
43
91
  * devDependency (npm) or must download the release binary (ADR-098). Async because the render
@@ -14,7 +14,8 @@
14
14
  // copied in and pinned by digest, never fetched at render time.
15
15
 
16
16
  import { execFile } from 'node:child_process';
17
- import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
17
+ import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
18
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
18
19
  import { createHash } from 'node:crypto';
19
20
  import { basename, dirname, join } from 'node:path';
20
21
  import { promisify } from 'node:util';
@@ -545,8 +546,14 @@ export async function publishPack(dir, { apply = false } = {}) {
545
546
  return { pack: linted.pack, ok: true, published: true, skipped: false, planned, findings };
546
547
  }
547
548
 
548
- /** Write scaffold files into a directory that must not already exist. */
549
- export async function writeScaffold(dir, files) {
549
+ /**
550
+ * Write scaffold files into a directory that must not already exist.
551
+ *
552
+ * `root` is the repository the journal set belongs to — usually the scaffold's
553
+ * own parent, so `undo` runs where the files live. Recorded (R49) because a
554
+ * scaffold is repo files like any other write.
555
+ */
556
+ export async function writeScaffold(dir, files, root = dirname(dir)) {
550
557
  try {
551
558
  await stat(dir);
552
559
  throw new Error(
@@ -555,9 +562,11 @@ export async function writeScaffold(dir, files) {
555
562
  } catch (error) {
556
563
  if (error?.message?.startsWith('refusing to scaffold')) throw error;
557
564
  }
565
+ const journal = [];
558
566
  for (const file of files) {
559
567
  const abs = join(dir, file.path);
560
568
  await mkdir(dirname(abs), { recursive: true });
561
- await writeFile(abs, file.text, 'utf8');
569
+ await journaledWriteFile(root, journal, abs, file.text);
562
570
  }
571
+ await commitJournal(root, 'pack new', journal);
563
572
  }
@@ -632,6 +632,12 @@ export async function planDone(root, id, { sha = null, test = null, state = null
632
632
  line: found.index + 1,
633
633
  from,
634
634
  to: cells[slice.stateIdx],
635
+ // The `--diff` sides for the one changed row (plan 0003 R50). The full file
636
+ // is not carried: one table row is the whole write, and a context-less hunk
637
+ // over the row says exactly what changed. Captured pre-write, so the diff
638
+ // is true in preview and in apply alike.
639
+ beforeLine: original,
640
+ afterLine: newLine,
635
641
  state: format.readState(display),
636
642
  wouldChange,
637
643
  changed: wouldChange && !dryRun,
package/src/plugins.mjs CHANGED
@@ -24,7 +24,9 @@
24
24
  // leaves the machine it was built on. The command says so rather than pretending.
25
25
 
26
26
  import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
27
- import { basename, join } from 'node:path';
27
+ import { basename, dirname, join } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { readBefore } from './diff.mjs';
28
30
  import { hookDocument } from './agenthooks/render.mjs';
29
31
  import { desiredRegion } from './projection/rules.mjs';
30
32
  import { PACKAGE_NAME, BIN_REL } from './name.mjs';
@@ -32,6 +34,15 @@ import { PACKAGE_NAME, BIN_REL } from './name.mjs';
32
34
  /** The kinds `simt plugin` builds, in the order the help line names them. */
33
35
  export const PLUGIN_KINDS = ['claude', 'gemini', 'cursor'];
34
36
 
37
+ /**
38
+ * The package this code ships in: the runtime a generated package copies. Resolved
39
+ * from the module URL, not from the consumer's cwd, so `plugin` built with
40
+ * `--root <consumer>` still ships this tool rather than the consumer's files (Q03).
41
+ */
42
+ function packageRoot() {
43
+ return dirname(dirname(fileURLToPath(import.meta.url)));
44
+ }
45
+
35
46
  /** Where the runtime copy lands inside a package, relative to the package root. */
36
47
  function runtimeDir(kind) {
37
48
  return kind === 'cursor' ? join('.cursor', 'simt') : '.';
@@ -71,7 +82,7 @@ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
71
82
  * it is many files; this returns only the handful that are authored here.
72
83
  */
73
84
  async function packageFiles(kind, { root = process.cwd(), out } = {}) {
74
- const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
85
+ const pkg = JSON.parse(await readFile(join(packageRoot(), 'package.json'), 'utf8'));
75
86
  const command = `${entryPoint(kind)} agent-hook pre-tool-use --harness ${kind}${rootFlag(kind)}`;
76
87
  const files = [];
77
88
 
@@ -128,12 +139,21 @@ export async function buildPackage(kind, { root = process.cwd(), out, apply = fa
128
139
  throw new Error(`unknown package kind \`${kind}\`; one of: ${PLUGIN_KINDS.join(', ')}`);
129
140
  }
130
141
 
131
- const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
142
+ const pkg = JSON.parse(await readFile(join(packageRoot(), 'package.json'), 'utf8'));
132
143
  const files = await packageFiles(kind, { root, out });
133
144
  const notes = [
134
145
  'the skill registry is workspace data (ADR-061) and is not shipped; run `simt skills index` in the profile to list what a workspace would offer',
135
146
  ];
136
147
 
148
+ // The `--diff` before-sides, read before anything is written so the diff is
149
+ // true in preview and in apply alike (R50). Always captured: the files are a
150
+ // handful of small generated configs. The runtime copy below is excluded — it
151
+ // is a mechanical copy of this tool's own sources, not planned content, and
152
+ // diffing it would reprint the tool into the review.
153
+ for (const f of files) {
154
+ f.before = await readBefore(join(out, f.path));
155
+ }
156
+
137
157
  if (apply) {
138
158
  const runtimeTarget = join(out, runtimeDir(kind));
139
159
  await mkdir(runtimeTarget, { recursive: true });
@@ -142,10 +162,11 @@ export async function buildPackage(kind, { root = process.cwd(), out, apply = fa
142
162
  // dies on the first MCP call: the server reads templates/ and rules/, which a
143
163
  // bin+src-only copy leaves behind. The `files` field is the definition of "a working
144
164
  // copy of this tool", so it is the set a package ships.
165
+ const runtimeRoot = packageRoot();
145
166
  for (const part of pkg.files) {
146
- await cp(join(root, part), join(runtimeTarget, part), { recursive: true });
167
+ await cp(join(runtimeRoot, part), join(runtimeTarget, part), { recursive: true });
147
168
  }
148
- await cp(join(root, 'package.json'), join(runtimeTarget, 'package.json'));
169
+ await cp(join(runtimeRoot, 'package.json'), join(runtimeTarget, 'package.json'));
149
170
  for (const f of files) {
150
171
  const target = join(out, f.path);
151
172
  await mkdir(join(target, '..'), { recursive: true });
@@ -158,7 +179,7 @@ export async function buildPackage(kind, { root = process.cwd(), out, apply = fa
158
179
  out,
159
180
  apply,
160
181
  runtime: { target: runtimeDir(kind), parts: [...pkg.files, 'package.json'] },
161
- files: files.map(({ text, ...rest }) => rest),
182
+ files,
162
183
  notes,
163
184
  };
164
185
  }
package/src/prbody.mjs ADDED
@@ -0,0 +1,251 @@
1
+ // The `simt pr-body` engine (plan 0003 R55).
2
+ //
3
+ // A pull request body assembled from what the tool already knows, rather than
4
+ // what its author remembers to write: the evidence block from the ledger (R17),
5
+ // the decision records the diff touches, the gate state from the last check,
6
+ // and the residual risk in ADR-008's verdict vocabulary. Read-only throughout:
7
+ // it names the tree, reads the ledger, reads the check cache and diffs names,
8
+ // and writes nothing, so it needs no preview, no `--yes` and no journal entry.
9
+ //
10
+ // The base defaults to the branch's upstream fork point (`origin/main`, then
11
+ // `main`), and `--base` names anything else. When no base resolves, the body
12
+ // falls back to the working-tree diff against HEAD: a report that refused
13
+ // without a base would be useless on a repository with a single branch.
14
+
15
+ import { execFile } from 'node:child_process';
16
+ import { readFile, readdir } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+ import { promisify } from 'node:util';
19
+
20
+ import { readLedger, treeId } from './ledger/index.mjs';
21
+ import { readCheckCache } from './status.mjs';
22
+ import { readManifest } from './projection/manifest.mjs';
23
+ import { splitFrontmatter } from './memory/decide.mjs';
24
+
25
+ const exec = promisify(execFile);
26
+
27
+ const DECISIONS_DIR = join('docs', 'decisions');
28
+
29
+ /**
30
+ * The ref the body diffs against: the override when given, else the first
31
+ * candidate that shares history with HEAD. Returns the ref, or null when the
32
+ * repository has a single branch and the body should diff the worktree.
33
+ */
34
+ async function resolveBase(root, override = null) {
35
+ if (override !== null && override !== undefined && String(override) !== '') {
36
+ try {
37
+ await exec('git', ['rev-parse', '--verify', '--quiet', `${override}^{commit}`], { cwd: root });
38
+ return { base: String(override), error: null };
39
+ } catch {
40
+ return { base: null, error: `unknown base ${override}` };
41
+ }
42
+ }
43
+ for (const candidate of ['origin/main', 'main', 'origin/master', 'master']) {
44
+ try {
45
+ await exec('git', ['merge-base', 'HEAD', candidate], { cwd: root });
46
+ return { base: candidate, error: null };
47
+ } catch {
48
+ // Not present or shares no history; the next candidate may.
49
+ }
50
+ }
51
+ return { base: null, error: null };
52
+ }
53
+
54
+ /**
55
+ * Every path the pull request would carry: the branch range when there is a
56
+ * base, plus whatever the worktree adds on top. Sorted, deduplicated, and
57
+ * without `.simt/` state: the ledger and the check cache prove the body, they
58
+ * are not its content.
59
+ */
60
+ async function changedPaths(root, base) {
61
+ const paths = new Set();
62
+ const add = (stdout) => {
63
+ for (const line of String(stdout ?? '').split('\n')) {
64
+ const trimmed = line.trim();
65
+ if (trimmed !== '' && !trimmed.startsWith('.simt/')) paths.add(trimmed);
66
+ }
67
+ };
68
+ if (base !== null) {
69
+ try {
70
+ const { stdout } = await exec('git', ['diff', '--name-only', `${base}...HEAD`], {
71
+ cwd: root,
72
+ maxBuffer: 16 << 20,
73
+ });
74
+ add(stdout);
75
+ } catch {
76
+ // An unborn HEAD or an unreadable range contributes nothing; the worktree
77
+ // reads below still answer.
78
+ }
79
+ }
80
+ for (const args of [
81
+ ['diff', '--name-only', 'HEAD'],
82
+ ['ls-files', '--others', '--exclude-standard'],
83
+ ]) {
84
+ try {
85
+ const { stdout } = await exec('git', args, { cwd: root, maxBuffer: 16 << 20 });
86
+ add(stdout);
87
+ } catch {
88
+ // Not a repository, or no commit yet: the body reports the absence.
89
+ }
90
+ }
91
+ return [...paths].sort();
92
+ }
93
+
94
+ /**
95
+ * The decision records the diff touches: one entry per `docs/decisions/*.md`
96
+ * in the changed paths, carrying the id from the filename and the title and
97
+ * status from the file itself. Sorted by id; a file that will not parse is
98
+ * reported with what it has rather than dropping the row.
99
+ */
100
+ async function changedDecisions(root, paths) {
101
+ const names = paths.filter((p) => p.startsWith(`${DECISIONS_DIR}/`) && p.toLowerCase().endsWith('.md'));
102
+ try {
103
+ await readdir(join(root, DECISIONS_DIR));
104
+ } catch {
105
+ return [];
106
+ }
107
+ const out = [];
108
+ for (const name of names.sort()) {
109
+ const id = /^ADR-\d{3,}/i.exec(name.slice(DECISIONS_DIR.length + 1))?.[0].toUpperCase() ?? name;
110
+ let title = id;
111
+ let status = 'unknown';
112
+ try {
113
+ const text = await readFile(join(root, name), 'utf8');
114
+ const { body } = splitFrontmatter(text);
115
+ const heading = /^#\s+(.*)$/m.exec(body)?.[1] ?? id;
116
+ title = heading.replace(/^ADR-\d{3,}\s*[—–-]\s*/i, '').trim() || id;
117
+ status = /^Status:\s*(.+)$/im.exec(body)?.[1].trim() ?? 'unknown';
118
+ } catch {
119
+ // An unreadable record is still a changed decision; the body says so
120
+ // rather than silently omitting a file the diff names.
121
+ }
122
+ out.push({ path: name, id, title, status });
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * Assemble the whole body. Never throws for a missing ledger, cache or base:
129
+ * each absence is a line in the report, because a PR body whose evidence is
130
+ * missing is precisely the case the residual-risk section exists to say out loud.
131
+ */
132
+ export async function buildPrBody(root, { base = null } = {}) {
133
+ const resolved = await resolveBase(root, base);
134
+ if (resolved.error !== null) return { error: resolved.error };
135
+
136
+ let tree = null;
137
+ let treeError = null;
138
+ try {
139
+ tree = await treeId(root);
140
+ } catch (error) {
141
+ treeError = String(error.message ?? error);
142
+ }
143
+
144
+ let entries = [];
145
+ let ledgerErrors = [];
146
+ try {
147
+ const ledger = await readLedger(root);
148
+ entries = tree === null ? [] : ledger.entries.filter((e) => e.tree === tree);
149
+ ledgerErrors = ledger.errors;
150
+ } catch (error) {
151
+ ledgerErrors = [String(error.message ?? error)];
152
+ }
153
+
154
+ const cache = await readCheckCache(root).catch(() => null);
155
+ const { manifest } = await readManifest(root).catch(() => ({ manifest: null }));
156
+ const declared = [
157
+ ...(manifest?.gates?.preCommit ?? []),
158
+ ...(manifest?.gates?.prePush ?? []),
159
+ ];
160
+ const evidenceMode = manifest?.gates?.evidence;
161
+
162
+ const paths = await changedPaths(root, resolved.base);
163
+ const decisions = await changedDecisions(root, paths);
164
+
165
+ const passing = entries.filter((e) => e.exit === 0);
166
+ const unreviewed = [];
167
+ if (tree === null) unreviewed.push(`the tree could not be named (${treeError ?? 'not a git repository'})`);
168
+ else if (passing.length === 0) unreviewed.push('no passing run is recorded for this tree');
169
+ if (cache === null) unreviewed.push('gate state unmeasured (no check has run)');
170
+ for (const error of ledgerErrors) unreviewed.push(`ledger unreadable: ${error}`);
171
+
172
+ // ADR-008's vocabulary, exactly: BLOCK for a known failure, APPROVE WITH
173
+ // RESIDUAL RISK when the review budget ran out before something was read,
174
+ // APPROVE only when everything was. The constraint travels with the verdict:
175
+ // a residual-risk body never converts a known failure into an approval.
176
+ const blocking = cache?.blocking ?? 0;
177
+ const verdict = blocking > 0 ? 'BLOCK' : unreviewed.length > 0 ? 'APPROVE WITH RESIDUAL RISK' : 'APPROVE';
178
+
179
+ return {
180
+ error: null,
181
+ base: resolved.base,
182
+ tree,
183
+ evidence: { entries, errors: ledgerErrors },
184
+ decisions,
185
+ paths,
186
+ gates: {
187
+ declared,
188
+ evidenceMode: evidenceMode ?? null,
189
+ run: cache === null ? null : { gatesRun: cache.gatesRun, gatesTotal: cache.gatesTotal },
190
+ rulesOk: cache?.rulesOk ?? null,
191
+ hooksOk: cache?.hooksOk ?? null,
192
+ blocking,
193
+ warnings: cache?.warnings ?? 0,
194
+ cached: cache !== null,
195
+ },
196
+ verdict,
197
+ unreviewed,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * The markdown body: four sections in a fixed order — evidence, changed
203
+ * decisions, gates, residual risk — so a snapshot can pin it byte for byte.
204
+ */
205
+ export function renderPrBody(report) {
206
+ const lines = [];
207
+ lines.push('## Evidence');
208
+ lines.push('');
209
+ if (report.tree === null) {
210
+ lines.push('No tree recorded: run this inside a git repository.');
211
+ } else if (report.evidence.entries.length === 0) {
212
+ lines.push(`tree ${report.tree}`);
213
+ lines.push('No run is recorded for this tree.');
214
+ } else {
215
+ lines.push(`tree ${report.tree}`);
216
+ lines.push('');
217
+ for (const e of report.evidence.entries) {
218
+ lines.push(`- ${e.exit === 0 ? 'pass' : 'fail'} \`${e.cmd}\` (exit ${e.exit}, ${e.summary})`);
219
+ }
220
+ }
221
+ for (const error of report.evidence.errors) lines.push(`- ledger error: ${error}`);
222
+ lines.push('');
223
+ lines.push('## Changed decisions');
224
+ lines.push('');
225
+ if (report.decisions.length === 0) {
226
+ lines.push(`None${report.base === null ? ' (worktree diff against HEAD)' : ` (range ${report.base}...HEAD, plus worktree)`}.`);
227
+ } else {
228
+ for (const d of report.decisions) lines.push(`- ${d.id} — ${d.title} (${d.status})`);
229
+ }
230
+ lines.push('');
231
+ lines.push('## Gates');
232
+ lines.push('');
233
+ if (report.gates.cached) {
234
+ const g = report.gates;
235
+ lines.push(`- rules: ${g.rulesOk ? 'ok' : 'failing'}, hooks: ${g.hooksOk ? 'ok' : 'failing'}`);
236
+ lines.push(`- gates run: ${g.run.gatesRun}/${g.run.gatesTotal}, blocking: ${g.blocking}, warnings: ${g.warnings}`);
237
+ if (g.declared.length > 0) lines.push(`- declared: ${g.declared.join(', ')}`);
238
+ } else {
239
+ lines.push('No check has run: gate state unmeasured.');
240
+ }
241
+ lines.push('');
242
+ lines.push('## Residual risk (ADR-008)');
243
+ lines.push('');
244
+ lines.push(`Verdict: ${report.verdict}`);
245
+ if (report.unreviewed.length > 0) {
246
+ lines.push('');
247
+ lines.push('Not reviewed:');
248
+ for (const item of report.unreviewed) lines.push(`- ${item}`);
249
+ }
250
+ return `${lines.join('\n')}\n`;
251
+ }
@@ -18,6 +18,7 @@
18
18
  import { readFile, rename, writeFile } from 'node:fs/promises';
19
19
  import { existsSync } from 'node:fs';
20
20
  import { basename, join, resolve } from 'node:path';
21
+ import { journaledWriteFile } from '../journal.mjs';
21
22
  // gates.mjs imports nothing, so this direction cannot cycle. It is the gate table
22
23
  // this file already selects from; duplicating the runnability rules here would be two
23
24
  // answers to "can this repo run that gate", which is precisely the kind of drift this
@@ -225,6 +226,12 @@ export function gateRunnable(gate, capability = {}) {
225
226
  : { runnable: true, why: `runs: ${declared}` };
226
227
  }
227
228
 
229
+ if (gate.kind === 'builtin') {
230
+ // The one kind with no external requirement: the implementation ships inside
231
+ // the binary that is reading this manifest (ADR-100). Saying otherwise would
232
+ // make `check` report a gate as unrunnable that runs on every commit.
233
+ return { runnable: true, why: 'runs inside the tool itself; nothing to install' };
234
+ }
228
235
  if (gate.kind === 'command') {
229
236
  return { runnable: false, why: `no command is declared for it in gates.commands` };
230
237
  }
@@ -851,13 +858,17 @@ export async function readManifest(root) {
851
858
  }
852
859
  }
853
860
 
854
- export async function writeManifest(root, manifest) {
861
+ export async function writeManifest(root, manifest, journal = null) {
855
862
  // A repository still holding the old name gets it moved, not shadowed. Two manifests
856
863
  // in one repo is a manifest nobody can reason about, and the second would be the one
857
864
  // that is never read - so the window closes by moving the file, not by leaving both.
858
865
  const previous = join(root, MANIFEST_LEGACY);
859
866
  const fresh = join(root, MANIFEST);
860
867
  if (existsSync(previous) && !existsSync(fresh)) await rename(previous, fresh);
861
- await writeFile(fresh, manifestText(manifest));
868
+ // A journaled manifest is one `undo` can restore, like every other repo file a
869
+ // command writes (R49). Callers that span repos or the repo boundary (shared,
870
+ // bench) pass nothing and keep the old behaviour exactly.
871
+ if (journal) await journaledWriteFile(root, journal, fresh, manifestText(manifest));
872
+ else await writeFile(fresh, manifestText(manifest));
862
873
  return fresh;
863
874
  }
@@ -307,7 +307,10 @@ export async function sharedState(path, rules, meta) {
307
307
  * user-level AGENTS.md is the most likely file in a reader's home directory to contain
308
308
  * something they wrote themselves. Only the bytes between the markers are ours.
309
309
  */
310
- function injectShared(before, rules, meta) {
310
+ // Exported so `shared install --diff` previews the same bytes the write applies:
311
+ // a diff rendered from a second implementation of this injection would be a
312
+ // second opinion about what "current" means (plan 0003 R50).
313
+ export function injectShared(before, rules, meta) {
311
314
  return inject(before, sharedBody(rules), meta, { compose: composeShared });
312
315
  }
313
316