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
@@ -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
+ }