simt-dev 0.6.2 → 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 (76) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  5. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  6. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  7. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  8. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  9. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  10. package/docs/decisions/README.md +44 -0
  11. package/package.json +1 -1
  12. package/rules/mdc/preview-postflight.mdc +5 -6
  13. package/rules/stacks/android.md +7 -8
  14. package/rules/stacks/cloudflare-workers.md +1 -1
  15. package/rules/stacks/container.md +2 -0
  16. package/rules/stacks/jvm.md +1 -1
  17. package/rules/stacks/react-native.md +3 -5
  18. package/rules/stacks/react-web.md +9 -9
  19. package/src/agenthooks/decide.mjs +101 -10
  20. package/src/agenthooks/policy.mjs +81 -5
  21. package/src/agenthooks/shell.mjs +523 -0
  22. package/src/argv.mjs +54 -9
  23. package/src/commands/agenthook.mjs +25 -3
  24. package/src/commands/ci.mjs +28 -3
  25. package/src/commands/completions.mjs +27 -0
  26. package/src/commands/decide.mjs +14 -1
  27. package/src/commands/doctor.mjs +367 -0
  28. package/src/commands/explain.mjs +52 -0
  29. package/src/commands/help.mjs +66 -17
  30. package/src/commands/hooks.mjs +208 -60
  31. package/src/commands/ignore.mjs +152 -0
  32. package/src/commands/index.mjs +54 -100
  33. package/src/commands/init.mjs +82 -12
  34. package/src/commands/lease.mjs +136 -0
  35. package/src/commands/lessons.mjs +12 -0
  36. package/src/commands/mcp.mjs +2 -2
  37. package/src/commands/pack.mjs +9 -0
  38. package/src/commands/plan.mjs +13 -1
  39. package/src/commands/plugin.mjs +16 -1
  40. package/src/commands/prbody.mjs +45 -0
  41. package/src/commands/repoindex.mjs +3 -0
  42. package/src/commands/rules.mjs +145 -105
  43. package/src/commands/shared.mjs +36 -5
  44. package/src/commands/skills.mjs +293 -205
  45. package/src/commands/skillsinstall.mjs +100 -30
  46. package/src/commands/slice.mjs +353 -0
  47. package/src/commands/status.mjs +62 -0
  48. package/src/commands/sync.mjs +110 -15
  49. package/src/commands/undo.mjs +106 -0
  50. package/src/commands/upgrade.mjs +70 -8
  51. package/src/completions.mjs +191 -0
  52. package/src/diff.mjs +254 -0
  53. package/src/explain.mjs +258 -0
  54. package/src/federation/config.mjs +44 -0
  55. package/src/graph/load.mjs +14 -5
  56. package/src/hooks/dispatcher.mjs +110 -0
  57. package/src/hooks/gates.mjs +14 -3
  58. package/src/hooks/resolve.mjs +14 -0
  59. package/src/ignore.mjs +133 -0
  60. package/src/index/persist.mjs +66 -9
  61. package/src/journal.mjs +326 -0
  62. package/src/lease.mjs +113 -0
  63. package/src/mcp/server.mjs +15 -1
  64. package/src/mcp/tools.mjs +296 -256
  65. package/src/memory/decide.mjs +6 -1
  66. package/src/memory/lessons.mjs +12 -0
  67. package/src/output/index.mjs +82 -12
  68. package/src/package-version.mjs +49 -1
  69. package/src/packs/manage.mjs +13 -4
  70. package/src/plan/index.mjs +6 -0
  71. package/src/plugins.mjs +27 -6
  72. package/src/prbody.mjs +251 -0
  73. package/src/projection/manifest.mjs +13 -2
  74. package/src/projection/shared.mjs +4 -1
  75. package/src/secrets.mjs +231 -0
  76. package/src/status.mjs +152 -0
@@ -1,13 +1,18 @@
1
- import { emit } from '../output/index.mjs';
2
- import { parse } from '../argv.mjs';
3
- import { CLI, MANIFEST_FILE } from '../name.mjs';
1
+ import { emit, fail, jsonFailMode } from '../output/index.mjs';
2
+ import { parse, rawJsonFlag } from '../argv.mjs';
3
+ import { CLI, MANIFEST_FILE, STATE_DIR } from '../name.mjs';
4
+ import { SESSION_ENV } from '../agenthooks/policy.mjs';
4
5
  import { DEFAULT_DIRECTORIES, DEFAULT_TEMPLATE } from '../plan/template.mjs';
5
6
 
6
7
  const COMMANDS = `${CLI} init [path] adopt or scaffold a repo into the ecosystem
7
8
  (dry-run unless --yes; conflicts block without
8
9
  --accept-conflicts; in place unless --backup;
9
10
  never commits or pushes)
10
- ${CLI} doctor classify a repo and check config health
11
+ ${CLI} doctor [--fix] classify a repo and check config health (with --fix:
12
+ apply the remedies flagged safe and list the rest;
13
+ dry-run unless --yes)
14
+ ${CLI} status --line one line from the last check, for prompts and tmux
15
+ (as of the last check; STALE after new commits)
11
16
  ${CLI} check the gate: projection drift, manifest validity, policy
12
17
  ${CLI} sync [--yes] regenerate the projection (idempotent)
13
18
  (prints the plan and exits 1 on drift without --yes,
@@ -58,20 +63,41 @@ ${CLI} agent-hooks install write this repo's policies into the harnesses tha
58
63
  key it does not own; Codex needs the hook trusted)
59
64
  ${CLI} agent-hooks status what each harness will run, and what is not trusted yet
60
65
  ${CLI} plugin <claude|gemini|cursor> generate a harness package from the same tables
61
- (dry-run unless --yes; writes a self-contained runtime, the
62
- hooks and MCP registration, and for cursor the kernel rules;
63
- the skill registry is workspace data and is not shipped)
66
+ (dry-run unless --yes; writes a self-contained runtime, the
67
+ hooks and MCP registration, and for cursor the kernel rules;
68
+ the skill registry is workspace data and is not shipped)
69
+ ${CLI} completions <shell> print a bash, zsh or fish completion script
70
+ (generated from the command registry, so every command
71
+ completes; deterministic bytes; stdout only - redirect
72
+ or source it)
64
73
  ${CLI} plan status count tracker rows
65
74
  (${MANIFEST_FILE} key plans when set, otherwise
66
75
  the ${DEFAULT_TEMPLATE} template:
67
76
  ${DEFAULT_DIRECTORIES.join(' and ')})
68
77
  ${CLI} plan next [<id>] one ready slice's section, or the one named; ~1 KB
69
78
  ${CLI} plan done <id> [<sha>] mark one row; dry-run unless --yes
79
+ ${CLI} slice start <id> give a slice its own worktree and branch, preload the
80
+ brief, and mark the row In progress (dry-run unless
81
+ --yes; the worktree is ${STATE_DIR}/worktrees/<id> on
82
+ branch slice/<id>, the brief ${STATE_DIR}/brief.md in it)
83
+ ${CLI} slice finish <id> run the pre-commit gates, commit what is staged with a
84
+ Conventional subject from the row's title, then record
85
+ the SHA and the --test command on the row (dry-run
86
+ unless --yes; --type picks the type, default feat)
87
+ ${CLI} lease [<paths>...] claim files for one session and a TTL, or list the
88
+ claims (dry-run unless --yes; the session is --session
89
+ <id> or ${SESSION_ENV}; a pre-edit hook refuses an edit
90
+ to a path another session holds — ADR-101)
70
91
  ${CLI} graph lint check the skill registry against the skills on disk
71
92
  (byte counts, dangling edges, unresolvable variants)
72
93
  ${CLI} ci simt-dev's own .github files: create or refresh them
73
94
  (dry-run unless --yes; refreshes the templates' own copies
74
95
  and no repository else's)
96
+ ${CLI} ignore [--yes] render the one .simtignore list into .cursorignore,
97
+ .aiderignore, .geminiignore and Claude's
98
+ permissions.deny Read(...) entries (dry-run unless
99
+ --yes; --diff prints the plan as unified diffs; the
100
+ Claude settings file is merged, never replaced)
75
101
  ${CLI} compare org-wide matrix across every sibling repo
76
102
  (read-only; exits non-zero on defects, not on
77
103
  repos that have not adopted yet)
@@ -93,6 +119,11 @@ ${CLI} map [--task "<text>"] [--level outline|names|signatures]
93
119
  --budget bytes (default 2048; 0 is unlimited). outline
94
120
  is paths, names adds exports, signatures adds the line.
95
121
  --recall reports gold-file recall@10 on mined tasks
122
+ ${CLI} pr-body [--base <ref>] a pull request body: the evidence block for this tree,
123
+ the decision records the diff touches, the gate
124
+ state from the last check, and the residual risk
125
+ in ADR-008 vocabulary (read-only; the exit code
126
+ never carries the verdict)
96
127
  ${CLI} evidence the runs recorded against the tree on disk
97
128
  ${CLI} evidence check <file> a commit message that claims a run this tree does not
98
129
  have: a warning, or a block when gates.evidence is "block"
@@ -100,10 +131,17 @@ ${CLI} lessons list deterministic lesson candidates in .simt/lessons.
100
131
  and promoted lessons in docs/lessons/*.md
101
132
  ${CLI} lessons review [<id>] promote a lesson candidate to docs/lessons/NNNN-<slug>.md
102
133
  (dry-run unless --yes; writes Appendix A frontmatter)
134
+ ${CLI} explain <CODE> what a check finding means: the decision behind it, how
135
+ to fix it, and a concrete trigger (DRIFT, UNMANAGED,
136
+ PACK-DIGEST-MISMATCH, ...; case-insensitive)
103
137
  ${CLI} decide [<title>] scaffold an ADR with the next free number, a Status: line,
104
138
  a rulings skeleton, and structured simt-affects +
105
139
  simt-supersedes frontmatter (dry-run unless --yes;
106
140
  --check lints ADRs for prose-only partial supersessions)
141
+ ${CLI} undo restore the last journaled set of writes, but only
142
+ when every file in it is untouched since (dry-run
143
+ unless --yes; a touched file refuses the whole undo
144
+ and nothing is restored)
107
145
  ${CLI} local doctor probe configured local model endpoints (chat and embed)
108
146
  (reads local in ${MANIFEST_FILE}; opens no socket when
109
147
  unset; refuses non-loopback URLs without --allow-remote)
@@ -192,6 +230,9 @@ const FLAGS = `--json structured output (compact; one document,
192
230
  --task "<text>" the task text used for capability resolution
193
231
  --paths <globs> comma-separated path globs used for path resolution
194
232
  --root <path> repository root (default: cwd)
233
+ --base <ref> pr-body: the ref the body diffs against
234
+ (default: origin/main, then main; falls back to the
235
+ worktree diff against HEAD when neither exists)
195
236
  --out <path> plugin: where the generated package lands (required)
196
237
  --surfaces <ids> comma-separated editors to write rule files for;
197
238
  "" means AGENTS.md only (default: every surface,
@@ -202,6 +243,13 @@ const FLAGS = `--json structured output (compact; one document,
202
243
  --dry-run report what would change, change nothing
203
244
  --yes assume yes; required in automation, never prompts
204
245
  --check report drift and write nothing (sync, ci)
246
+ --diff print the plan as unified diffs (every writer;
247
+ changes nothing about what the run writes)
248
+ --line status: print the one-line gate summary from the last
249
+ check (the only mode status has)
250
+ --fix doctor: apply the remedies flagged safe (local,
251
+ idempotent, journaled); list the rest (dry-run
252
+ unless --yes)
205
253
  --frozen check: the lock must match this projection; an output
206
254
  change no input change explains fails the gate
207
255
  --explain sync: name each line dropped as already inferable, with
@@ -238,8 +286,10 @@ const FLAGS = `--json structured output (compact; one document,
238
286
  --reps <n> bench run: repetitions per task and arm (default: 3)
239
287
  --regrade <run> bench run: re-grade a stored run from its archived agent patch
240
288
  --compare <a> <b> bench report: compare two arms with deltas and p-values
241
- --test <cmd> plan done: the test command that closes the row
289
+ --test <cmd> plan done / slice finish: the test command that closes the row
242
290
  --state <name> plan done: target state (default: Complete)
291
+ --type <name> slice finish: the Conventional Commits type (default: feat)
292
+ --ttl <seconds> lease: how long a claim lasts (default: 3600)
243
293
  --level <name> map: detail level (outline, names, signatures)
244
294
  --supersedes <spec> decide: superseded ADR or ruling (ADR-NNN or ADR-NNN:<ruling>[:<kind>]; repeatable)
245
295
  --source <spec> pack new: where the pack's bytes come from
@@ -268,9 +318,12 @@ const PRINCIPLES = `design rules:
268
318
  bounded output one fact per line, fixed field order, explicit TRUNCATED lines`;
269
319
 
270
320
  export async function help(argv) {
271
- const { ok, value } = parse(argv);
272
- if (!ok) return value.code;
273
- const { flags } = value;
321
+ const parsed = parse(argv);
322
+ if (!parsed.ok) {
323
+ jsonFailMode(rawJsonFlag(argv));
324
+ return fail(parsed.error.message, parsed.error.code);
325
+ }
326
+ const { flags } = parsed.value;
274
327
  if (flags.version) {
275
328
  emit((await version()).trim());
276
329
  return 0;
@@ -300,10 +353,6 @@ export async function help(argv) {
300
353
  }
301
354
 
302
355
  async function version() {
303
- const { readFile } = await import('node:fs/promises');
304
- const { fileURLToPath } = await import('node:url');
305
- const { dirname, join } = await import('node:path');
306
- const root = join(dirname(fileURLToPath(import.meta.url)), '..');
307
- const pkg = JSON.parse(await readFile(join(root, '..', 'package.json'), 'utf8'));
308
- return `simt-dev ${pkg.version}\n`;
356
+ const { describeVersion } = await import('../package-version.mjs');
357
+ return `${describeVersion()}\n`;
309
358
  }
@@ -13,7 +13,10 @@
13
13
  // foreign hook is found by asking git rather than by reading config.
14
14
 
15
15
  import { execFile } from 'node:child_process';
16
- import { chmod, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
16
+ import { chmod, mkdir, readFile, realpath } from 'node:fs/promises';
17
+ import { commitJournal, journaledRemoveFile, journaledWriteFile } from '../journal.mjs';
18
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
19
+ import { invalidateCheckCache } from '../status.mjs';
17
20
  import { isAbsolute, join, relative } from 'node:path';
18
21
  import { promisify } from 'node:util';
19
22
 
@@ -23,7 +26,7 @@ import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedS
23
26
  import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
24
27
  import { GATES } from '../hooks/gates.mjs';
25
28
  import { knownGates } from '../hooks/resolve.mjs';
26
- import { runGates } from '../hooks/dispatcher.mjs';
29
+ import { renderGateReport, runGates } from '../hooks/dispatcher.mjs';
27
30
  import { CLI } from '../name.mjs';
28
31
  import { MANIFEST_FILE } from '../name.mjs';
29
32
  import { packageVersion, hasNpm } from '../package-version.mjs';
@@ -60,14 +63,7 @@ async function runHook(args, flags) {
60
63
  }
61
64
 
62
65
  for (const n of result.notes ?? []) note(`note ${n}`);
63
- const lines = [`hooks ${event}`, `gates ${(manifest.gates[GATE_KEY[event]] ?? []).join(', ') || '(none declared)'}`];
64
- for (const g of result.gates) {
65
- lines.push(`${g.status.toUpperCase().padEnd(8)} ${g.name.padEnd(20)} ${g.detail}${g.adr ? ` [${g.adr}]` : ''}`);
66
- if (g.remedy) lines.push(` remedy: ${g.remedy}`);
67
- if (g.output) lines.push(...g.output.split('\n').map((l) => ` ${l}`));
68
- }
69
- lines.push(result.failed ? 'BLOCKED at least one gate failed' : 'PASSED');
70
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
66
+ emit(renderGateReport(event, manifest.gates[GATE_KEY[event]] ?? [], result).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
71
67
  return result.failed ? EXIT.FAIL : EXIT.OK;
72
68
  }
73
69
 
@@ -101,27 +97,26 @@ async function relativeTarget(root, target) {
101
97
  return rel === '' || rel.startsWith('..') ? resolved : rel;
102
98
  }
103
99
 
104
- async function installHooks(root, flags) {
105
- const { manifest, exists, error } = await readManifest(root);
106
- if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
107
- if (!exists) {
108
- return fail(`${root} has no ${MANIFEST_FILE}; gates must be declared before hooks can run them`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
109
- }
110
-
100
+ /**
101
+ * What `hooks install` would write, computed without writing or reporting (plan 0003 R47).
102
+ *
103
+ * `installHooks` and `doctor --fix` share this rather than each deriving the write
104
+ * set: the set nothing else here touches (obsolete shims, chained foreign hooks)
105
+ * is exactly the set a second derivation gets wrong, and a fix that writes bytes
106
+ * the command would not is a fix that lies about what it did. Reporting stays
107
+ * with the callers — the plan table belongs to the command, the finding line to
108
+ * the doctor — so this returns facts, never text.
109
+ *
110
+ * @returns the install inputs `applyHooksInstall` needs, plus the guards each
111
+ * caller enforces in its own voice (`takeover`, and the `wanted`/`obsolete`
112
+ * emptiness the command refuses on)
113
+ */
114
+ export async function planHooksInstall(root, { manifest, exists }) {
111
115
  const declaresGate = (event) => wantsShim(manifest, exists, event);
112
116
  const state = await readHooksState(root, { declaresGate });
113
117
  const current = state.current;
114
118
  const wants = HOOKS_DIR;
115
119
 
116
- // Read through `writeMode` like every other writer, and only now: this command
117
- // used to consult `flags.yes` and `flags.dryRun` directly and write when neither
118
- // said otherwise, which made it the last writer still writing by default after
119
- // ADR-035 and ADR-036 had settled that none of them may. Review 0002's S24
120
- // listed it among the writers that "all preview first"; that was wrong, and the
121
- // migration script in the same review tells an operator to run `hooks install`
122
- // for a plan and then `--yes` - advice that printed a plan and wrote anyway.
123
- const mode = writeMode(flags);
124
-
125
120
  // The release this install renders, and the kind of repository it installs into: the
126
121
  // remedy a shim prints when it cannot find the binary must name the install that
127
122
  // actually works here — a devDependency where package.json exists, a pinned global
@@ -240,6 +235,148 @@ async function installHooks(root, flags) {
240
235
  if (at !== -1) chained.splice(at, 1);
241
236
  }
242
237
 
238
+ const routed = wanted.length > 0 || chained.length > 0 || evidenceDeclared;
239
+ const takeover = (current !== null && current !== wants) || state.foreign.length > 0;
240
+ return {
241
+ manifest,
242
+ state,
243
+ current,
244
+ wants,
245
+ npm,
246
+ version,
247
+ wanted,
248
+ chained,
249
+ leftRunning,
250
+ obsolete,
251
+ evidenceDeclared,
252
+ evidenceSource,
253
+ evidenceFile,
254
+ // Returned so a caller can tell a current claim check from a stale one
255
+ // without re-reading the file: the evidence shim is not one of EVENTS, so
256
+ // neither the problems list nor the obsolete set names its staleness.
257
+ evidenceExisting,
258
+ routed,
259
+ takeover,
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Write a planned install: shims, chains, removals, and the git routing (plan 0003 R47).
265
+ *
266
+ * The caller's journal records the file writes, and the caller commits it under
267
+ * its own command name — `hooks install` here, `doctor --fix` there — so `undo`
268
+ * names the run that actually changed something. Reporting stays with the
269
+ * caller for the same reason it does in `planHooksInstall`.
270
+ *
271
+ * @returns {{wrote: string[]}} the shim paths written, removed, or refreshed
272
+ */
273
+ export async function applyHooksInstall(root, planned, journal) {
274
+ const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version, routed, wants, current } =
275
+ planned;
276
+ // Shim and evidence bytes are journaled so `undo` restores them (R49). The
277
+ // `core.hooksPath` config below is deliberately not journaled: it is git
278
+ // config, not a repo file, and `undo` restores files.
279
+ const wrote = [];
280
+ await mkdir(join(root, HOOKS_DIR), { recursive: true });
281
+ for (const event of wanted) {
282
+ const path = join(root, fileFor(event));
283
+ await journaledWriteFile(root, journal, path, renderShim(event, { npm, version }));
284
+ await chmod(path, 0o755);
285
+ wrote.push(fileFor(event));
286
+ }
287
+ for (const c of chained) {
288
+ const path = join(root, fileFor(c.event));
289
+ await journaledWriteFile(root, journal, path, renderChainedShim(c.event, c.target));
290
+ await chmod(path, 0o755);
291
+ wrote.push(fileFor(c.event));
292
+ }
293
+ for (const h of obsolete) {
294
+ await journaledRemoveFile(root, journal, join(root, fileFor(h.event)));
295
+ wrote.push(fileFor(h.event));
296
+ }
297
+ // After the chain loop, so a chained commit-msg cannot replace the claim check.
298
+ if (evidenceSource) {
299
+ const path = join(root, evidenceFile);
300
+ await journaledWriteFile(root, journal, path, evidenceSource);
301
+ await chmod(path, 0o755);
302
+ wrote.push(evidenceFile);
303
+ }
304
+ // `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
305
+ // pointing at a directory of no shims stops the repository's own hooks - the ones in
306
+ // `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
307
+ // only way back, and this is the only place that knows the last shim just left.
308
+ if (routed) {
309
+ await exec('git', ['config', HOOKS_PATH_KEY, wants], { cwd: root });
310
+ } else if (current === wants) {
311
+ await exec('git', ['config', '--unset', HOOKS_PATH_KEY], { cwd: root });
312
+ }
313
+ return { wrote };
314
+ }
315
+
316
+ /**
317
+ * The `--diff` pairs for a planned install: current disk against what the
318
+ * install would write, re-rendered from the same inputs `applyHooksInstall`
319
+ * writes (plan 0003 R50). Read-only, so preview and apply share it, and
320
+ * `doctor --fix` reuses it rather than deriving a second answer.
321
+ */
322
+ export async function plannedDiffs(root, planned) {
323
+ const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version } = planned;
324
+ const pairs = [];
325
+ for (const event of wanted) {
326
+ const path = fileFor(event);
327
+ pairs.push({ path, before: await readBefore(join(root, path)), after: renderShim(event, { npm, version }) });
328
+ }
329
+ for (const c of chained) {
330
+ const path = fileFor(c.event);
331
+ pairs.push({ path, before: await readBefore(join(root, path)), after: renderChainedShim(c.event, c.target) });
332
+ }
333
+ for (const h of obsolete) {
334
+ const path = fileFor(h.event);
335
+ pairs.push({ path, before: await readBefore(join(root, path)), after: null });
336
+ }
337
+ if (evidenceSource) {
338
+ pairs.push({ path: evidenceFile, before: await readBefore(join(root, evidenceFile)), after: evidenceSource });
339
+ }
340
+ return pairs;
341
+ }
342
+
343
+ async function installHooks(root, flags) {
344
+ const { manifest, exists, error } = await readManifest(root);
345
+ if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
346
+ if (!exists) {
347
+ return fail(`${root} has no ${MANIFEST_FILE}; gates must be declared before hooks can run them`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
348
+ }
349
+
350
+ // One computation, two callers (R47): the write set is derived once in
351
+ // `planHooksInstall` and shared with `doctor --fix`, so a fix applies exactly
352
+ // the bytes this command would write. Reporting stays here.
353
+ const {
354
+ state,
355
+ current,
356
+ wants,
357
+ npm,
358
+ version,
359
+ wanted,
360
+ chained,
361
+ leftRunning,
362
+ obsolete,
363
+ evidenceDeclared,
364
+ evidenceSource,
365
+ evidenceFile,
366
+ evidenceExisting,
367
+ routed,
368
+ takeover,
369
+ } = await planHooksInstall(root, { manifest, exists });
370
+
371
+ // Read through `writeMode` like every other writer, and only now: this command
372
+ // used to consult `flags.yes` and `flags.dryRun` directly and write when neither
373
+ // said otherwise, which made it the last writer still writing by default after
374
+ // ADR-035 and ADR-036 had settled that none of them may. Review 0002's S24
375
+ // listed it among the writers that "all preview first"; that was wrong, and the
376
+ // migration script in the same review tells an operator to run `hooks install`
377
+ // for a plan and then `--yes` - advice that printed a plan and wrote anyway.
378
+ const mode = writeMode(flags);
379
+
243
380
  // Nothing to write and nothing to take away is the only refusal. It sits after
244
381
  // `obsolete` because a repository whose last gate just left the manifest has
245
382
  // something to take away, and blocking that one left no way out except undoing
@@ -307,7 +444,6 @@ async function installHooks(root, flags) {
307
444
  note: 'commit-msg checks a verification claim against the ledger',
308
445
  });
309
446
  }
310
- const routed = wanted.length > 0 || chained.length > 0 || evidenceDeclared;
311
447
 
312
448
  // The routing this install leaves behind, which is not always `wants`. A repository
313
449
  // that removed its last gate has no shims to route to, and leaving `core.hooksPath`
@@ -324,7 +460,6 @@ async function installHooks(root, flags) {
324
460
  // being unset while a hook sits in the default location (4 KB of real logic in
325
461
  // .git/hooks/pre-commit). Reading unset as
326
462
  // "no hooks" is what let install replace a working gate with no warning.
327
- const takeover = (current !== null && current !== wants) || state.foreign.length > 0;
328
463
  if (takeover && !flags.yes) {
329
464
  const detail = state.foreign.length > 0
330
465
  ? state.foreign.map((f) => ` git runs ${f.event} at ${f.path} (${f.bytes} bytes)`)
@@ -348,12 +483,29 @@ async function installHooks(root, flags) {
348
483
  return EXIT.FAIL;
349
484
  }
350
485
 
486
+ // The `--diff` pairs, read before anything is written so the before side is
487
+ // true in preview and in apply alike (R50).
488
+ const diffs =
489
+ flags.diff === true
490
+ ? await plannedDiffs(root, { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version })
491
+ : [];
492
+
351
493
  if (!mode.apply) {
352
494
  if (isJson(flags)) {
353
495
  // `leaves`, not `wants`. A plan that would remove the last shim also un-routes
354
496
  // git, and a payload promising to set a directory the command is about to unset
355
497
  // is the same lie in a shape a machine reads.
356
- emitJson({ ok: true, command: 'hooks install', root, dryRun: true, currentHooksPath: current, wouldSet: leaves, plan, filesWritten: false });
498
+ emitJson({
499
+ ok: true,
500
+ command: 'hooks install',
501
+ root,
502
+ dryRun: true,
503
+ currentHooksPath: current,
504
+ wouldSet: leaves,
505
+ plan,
506
+ filesWritten: false,
507
+ ...diffField(diffs, flags),
508
+ });
357
509
  return EXIT.OK;
358
510
  }
359
511
  emit(
@@ -373,39 +525,23 @@ async function installHooks(root, flags) {
373
525
  ),
374
526
  { maxBytes: flags.maxBytes ?? 0 },
375
527
  );
528
+ // After the report: the report says what, the diff shows it (R50).
529
+ emitDiffs(diffs, flags);
376
530
  return EXIT.OK;
377
531
  }
378
532
 
379
- await mkdir(join(root, HOOKS_DIR), { recursive: true });
380
- for (const event of wanted) {
381
- const path = join(root, fileFor(event));
382
- await writeFile(path, renderShim(event, { npm, version }));
383
- await chmod(path, 0o755);
384
- }
385
- for (const c of chained) {
386
- const path = join(root, fileFor(c.event));
387
- await writeFile(path, renderChainedShim(c.event, c.target));
388
- await chmod(path, 0o755);
389
- }
390
- for (const h of obsolete) {
391
- await rm(join(root, fileFor(h.event)));
392
- }
393
- // After the chain loop, so a chained commit-msg cannot replace the claim check.
394
- if (evidenceSource) {
395
- const path = join(root, evidenceFile);
396
- await writeFile(path, evidenceSource);
397
- await chmod(path, 0o755);
398
- }
399
-
400
- // `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
401
- // pointing at a directory of no shims stops the repository's own hooks - the ones in
402
- // `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
403
- // only way back, and this is the only place that knows the last shim just left.
404
- if (routed) {
405
- await exec('git', ['config', HOOKS_PATH_KEY, wants], { cwd: root });
406
- } else if (current === wants) {
407
- await exec('git', ['config', '--unset', HOOKS_PATH_KEY], { cwd: root });
408
- }
533
+ // Same bytes, two callers (R47): the write itself lives in `applyHooksInstall`
534
+ // and is shared with `doctor --fix`, so a fix installs what the command would.
535
+ const journal = [];
536
+ await applyHooksInstall(
537
+ root,
538
+ { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version, routed, wants, current },
539
+ journal,
540
+ );
541
+ await commitJournal(root, 'hooks install', journal);
542
+ // Shims and routing changed, so the hooks slot of the last `check` is stale
543
+ // (R48). Gated on the journal: an already-current install changes nothing.
544
+ if (journal.length > 0) await invalidateCheckCache(root);
409
545
 
410
546
  const notes = [];
411
547
  const chainedEvents = new Set(chained.map((c) => c.event));
@@ -453,7 +589,17 @@ async function installHooks(root, flags) {
453
589
  }
454
590
 
455
591
  if (isJson(flags)) {
456
- emitJson({ ok: true, command: 'hooks install', root, hooksPath: leaves, previousHooksPath: current, plan, filesWritten: true, notes });
592
+ emitJson({
593
+ ok: true,
594
+ command: 'hooks install',
595
+ root,
596
+ hooksPath: leaves,
597
+ previousHooksPath: current,
598
+ plan,
599
+ filesWritten: true,
600
+ notes,
601
+ ...diffField(diffs, flags),
602
+ });
457
603
  return EXIT.OK;
458
604
  }
459
605
  emit(
@@ -468,6 +614,8 @@ async function installHooks(root, flags) {
468
614
  ),
469
615
  { maxBytes: flags.maxBytes ?? 0 },
470
616
  );
617
+ // After the report: the report says what, the diff shows it (R50).
618
+ emitDiffs(diffs, flags);
471
619
  for (const n of notes) note(`note ${n}`);
472
620
  note(`note never committed or pushed (ADR-001); review, then commit yourself`);
473
621
  return EXIT.OK;
@@ -0,0 +1,152 @@
1
+ // `simt ignore` — render the one ignore list into every reader's dialect
2
+ // (plan 0003 R52).
3
+ //
4
+ // Shape copied from `ci`: the plan is printed first, nothing is written
5
+ // without `--yes`, `--diff` shows the same plan as unified diffs and `undo`
6
+ // restores the set (R49). Three of the four targets are generated from the
7
+ // list; `.claude/settings.json` is merged, never replaced — see src/ignore.mjs
8
+ // for why that distinction exists.
9
+
10
+ import { join } from 'node:path';
11
+
12
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
13
+ import { CLI } from '../name.mjs';
14
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
15
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
16
+ import { CLAUDE_SETTINGS, IGNORE_FILE, parseIgnoreList, planIgnore } from '../ignore.mjs';
17
+
18
+ export async function ignoreCommand(args, flags) {
19
+ const root = flags.root ?? process.cwd();
20
+
21
+ let source;
22
+ try {
23
+ source = await readBefore(join(root, IGNORE_FILE));
24
+ } catch (error) {
25
+ return fail(`${IGNORE_FILE} could not be read: ${error.message}`, EXIT.FAIL, `fix the file's permissions, then re-run: ${CLI} ignore`);
26
+ }
27
+ if (source === null) {
28
+ // A missing list is a missing input, not an empty one: rendering four empty
29
+ // files from a file that does not exist would answer the request with
30
+ // silence the reader has no way to tell from success.
31
+ return fail(
32
+ `no ${IGNORE_FILE} in ${root}`,
33
+ EXIT.FAIL,
34
+ `create ${IGNORE_FILE} with one path or glob per line (# comments), then: ${CLI} ignore`,
35
+ );
36
+ }
37
+
38
+ const entries = parseIgnoreList(source);
39
+ if (entries.length === 0) {
40
+ return fail(
41
+ `${IGNORE_FILE} parses to zero entries`,
42
+ EXIT.FAIL,
43
+ `add one path or glob per line to ${IGNORE_FILE}, then: ${CLI} ignore`,
44
+ );
45
+ }
46
+
47
+ const plan = await planIgnore(entries, (path) => readBefore(join(root, path)));
48
+ if (!plan.ok) {
49
+ return fail(plan.error, EXIT.FAIL, `repair ${CLAUDE_SETTINGS} by hand, then re-run: ${CLI} ignore`);
50
+ }
51
+
52
+ const mode = writeMode(flags);
53
+ const changed = plan.targets.filter((t) => t.action !== 'unchanged');
54
+ const diffs =
55
+ flags.diff === true
56
+ ? changed.map((t) => ({ path: t.path, before: t.before, after: t.after }))
57
+ : [];
58
+
59
+ // Nothing to do: the report says so from the same plan the write would have
60
+ // used, so "unchanged" cannot disagree with what `--yes` would have written.
61
+ if (changed.length === 0) {
62
+ if (isJson(flags)) {
63
+ emitJson({
64
+ ok: true,
65
+ command: 'ignore',
66
+ root,
67
+ source: IGNORE_FILE,
68
+ entries: entries.length,
69
+ files: plan.targets.map((t) => ({ path: t.path, action: t.action, bytes: byteLen(t.after) })),
70
+ written: false,
71
+ dryRun: false,
72
+ });
73
+ } else {
74
+ emit(plan.targets.map((t) => `current ${t.path} ${byteLen(t.after)} bytes`).join('\n'));
75
+ note('every target already matches the list; nothing to write');
76
+ }
77
+ return EXIT.OK;
78
+ }
79
+
80
+ const lines = [
81
+ `repo ${root}`,
82
+ `source ${IGNORE_FILE} (${entries.length} entr${entries.length === 1 ? 'y' : 'ies'})`,
83
+ '',
84
+ ];
85
+ for (const t of plan.targets) {
86
+ lines.push(`file ${t.path}`, `action ${t.action}`);
87
+ if (t.action === 'update' && t.before !== null) {
88
+ // An update whose before side has lines the list does not: those lines
89
+ // are about to stop existing. Named here because a preview that only says
90
+ // "update" hides the deletion inside the diff the reader has to ask for.
91
+ const dropped = countDropped(t.before, t.after);
92
+ if (dropped > 0) lines.push(`replaces ${dropped} line(s) not in ${IGNORE_FILE} (shown by --diff)`);
93
+ }
94
+ }
95
+
96
+ // Exactly one JSON document per run, emitted once on whichever path ends it:
97
+ // a preview and its write are two phases of ONE answer, and a reader that
98
+ // parses the first document gets "written: false" while the run writes.
99
+ const payload = (written) => ({
100
+ ok: true,
101
+ command: 'ignore',
102
+ root,
103
+ source: IGNORE_FILE,
104
+ entries: entries.length,
105
+ files: plan.targets.map((t) => ({ path: t.path, action: t.action, bytes: byteLen(t.after) })),
106
+ written,
107
+ dryRun: !mode.apply,
108
+ ...diffField(diffs, flags),
109
+ });
110
+
111
+ if (!isJson(flags)) emit(lines.join('\n'));
112
+
113
+ if (!mode.apply) {
114
+ // After the report: the report says what, the diff shows it (R50).
115
+ emitDiffs(diffs, flags);
116
+ if (isJson(flags)) {
117
+ emitJson(payload(false));
118
+ } else {
119
+ note('');
120
+ note(mode.dryRun ? 'dry-run nothing written' : `dry-run nothing written; apply with: ${CLI} ignore --yes`);
121
+ }
122
+ return EXIT.OK;
123
+ }
124
+
125
+ // Journaled so `undo` restores this run (R49). Every note writes to stderr,
126
+ // so the `--json` document remains the only bytes on stdout.
127
+ const journal = [];
128
+ for (const t of changed) {
129
+ await journaledWriteFile(root, journal, join(root, t.path), t.after);
130
+ note(`wrote ${t.path} (${t.action})`);
131
+ }
132
+ await commitJournal(root, 'ignore', journal);
133
+
134
+ if (isJson(flags)) emitJson(payload(true));
135
+ // After the report: the report says what, the diff shows it (R50).
136
+ emitDiffs(diffs, flags);
137
+ return EXIT.OK;
138
+ }
139
+
140
+ function byteLen(text) {
141
+ return Buffer.byteLength(text, 'utf8');
142
+ }
143
+
144
+ /**
145
+ * How many lines the new bytes drop from the old: lines the current file has
146
+ * that the list does not produce. Used only for the plan's `replaces` note —
147
+ * the diff is the authoritative statement of what goes.
148
+ */
149
+ function countDropped(before, after) {
150
+ const kept = new Set(after.split('\n'));
151
+ return before.split('\n').filter((line) => line.trim() !== '' && !kept.has(line)).length;
152
+ }