geneseed 3.1.0 → 3.1.2

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 (102) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DESIGN.md +31 -20
  3. package/README.md +2 -2
  4. package/SETUP.md +30 -3
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +88 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +113 -85
  11. package/docs/web/rules.md +2 -0
  12. package/docs/web/setup-choices.md +13 -1
  13. package/harness.config.json +1 -1
  14. package/js/README.md +203 -0
  15. package/js/build/bundle.mjs +231 -0
  16. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  17. package/js/build/emit-claude.mjs +287 -0
  18. package/js/build/emit-common.mjs +255 -0
  19. package/js/build/emit-opencode.mjs +214 -0
  20. package/js/{generate.mjs → build/generate.mjs} +45 -31
  21. package/js/{render.mjs → build/render.mjs} +72 -11
  22. package/js/{checkout.mjs → build/source.mjs} +52 -11
  23. package/js/build/stubs.mjs +256 -0
  24. package/js/{themes.mjs → build/themes.mjs} +24 -24
  25. package/js/build/version.mjs +155 -0
  26. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  27. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  28. package/js/{installs.mjs → hosts/installs.mjs} +68 -16
  29. package/js/{link.mjs → hosts/link.mjs} +29 -28
  30. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  31. package/js/{native.mjs → hosts/native.mjs} +24 -22
  32. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  33. package/js/{settings.mjs → hosts/settings.mjs} +94 -69
  34. package/js/inspect/checks-authoring.mjs +761 -0
  35. package/js/inspect/checks-build.mjs +231 -0
  36. package/js/inspect/checks-repo.mjs +308 -0
  37. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  38. package/js/inspect/doctor.mjs +286 -0
  39. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  40. package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
  41. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  42. package/js/inspect/scan.mjs +125 -0
  43. package/js/{status.mjs → inspect/status.mjs} +27 -25
  44. package/js/inspect/validate.mjs +199 -0
  45. package/js/lib/fs.mjs +37 -759
  46. package/js/lib/json.mjs +344 -0
  47. package/js/lib/paths.mjs +242 -0
  48. package/js/lib/proc.mjs +3 -3
  49. package/js/lib/text.mjs +181 -0
  50. package/js/lib/udiff.mjs +1 -1
  51. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  52. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  53. package/js/{setup.mjs → maintain/setup.mjs} +49 -37
  54. package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
  55. package/js/{update.mjs → maintain/update.mjs} +51 -51
  56. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  57. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  58. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  59. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  60. package/js/web/actions.mjs +124 -89
  61. package/js/web/activity.mjs +22 -21
  62. package/js/web/api.mjs +88 -58
  63. package/js/web/daemon.mjs +319 -0
  64. package/js/web/docs.mjs +24 -21
  65. package/js/web/graph.mjs +0 -0
  66. package/js/web/handler.mjs +442 -0
  67. package/js/web/jobs.mjs +29 -21
  68. package/js/web/routes.mjs +223 -0
  69. package/js/web/server.mjs +56 -987
  70. package/package.json +11 -3
  71. package/src/AGENT.md.tmpl +1 -1
  72. package/src/doctrines/README.md +1 -1
  73. package/themes/opencode/README.md +1 -1
  74. package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
  75. package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
  76. package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
  77. package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
  78. package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
  79. package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
  80. package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
  81. package/web/dist/assets/Laws-BK07NKw9.js +3 -0
  82. package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
  83. package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
  84. package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
  85. package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
  86. package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
  87. package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
  88. package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
  89. package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
  90. package/web/dist/assets/index-BE226Gv6.css +1 -0
  91. package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
  92. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  93. package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +135 -69
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1712
  99. package/js/emit.mjs +0 -1410
  100. package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
  101. package/web/dist/assets/index-1pPSz7Kl.css +0 -1
  102. package/web/dist/assets/index-C-gb-q-r.js +0 -12
@@ -7,16 +7,20 @@
7
7
  * last is that it is also the unit the RUNTIME drives: eleven of its names have a consumer
8
8
  * outside the emit tree, ten of them in `rituals/_harness_mcp.py` (remerge, deactivate,
9
9
  * reactivate, uninstall), two in `rituals/_harness_exclude.py`, one in
10
- * `rituals/_harness_build.py`. Nothing imports this module yet — P3a proves the unit,
11
- * P3b flips the call sites.
10
+ * Every emit and every lifecycle verb goes through this module:
11
+ * `js/build/emit-opencode.mjs` and `js/build/emit-claude.mjs` wire the host config,
12
+ * `js/maintain/uninstall.mjs` and `js/maintain/migrate.mjs` drive the unwire and the shim
13
+ * migration, `js/inspect/checks-repo.mjs` reads `shimDeadPaths` for the doctor, and
14
+ * `js/web/api.mjs` borrows the same readers for the console.
12
15
  *
13
16
  * THE STDOUT RULE BINDS HARDEST HERE. The hooks this module writes signal their verdict as
14
17
  * a JSON object on stdout and return 0 on EVERY path (`|| exit 0` is not what it looks
15
18
  * like — see the shim comment below), so a stray byte printed on a hook path does not make
16
19
  * noise, it silently disables a gate. Everything printed here is a generator-time message;
17
- * the streams are compared byte for byte by `tests/test_settings_parity.py`, and the
18
- * asymmetry between the two (`_warn_commented_jsonc` prints to STDOUT, every other message
19
- * to stderr) is the Python's own and is reproduced, not tidied.
20
+ * the split between them is asserted absolutely by `tests/unit/settings_jsonc.test.mjs` and
21
+ * `tests/unit/settings_integrity.test.mjs`. The asymmetry (`_warn_commented_jsonc` prints to
22
+ * STDOUT, every other message to stderr) is inherited from `_build_settings.py` and kept
23
+ * deliberately rather than tidied.
20
24
  *
21
25
  * HOOKS ARE APPEND-ONLY AND FAIL-CLOSED. Nothing here rewrites a hook group in place:
22
26
  * `mergeClaudeSettings` appends what is missing and removes only groups the manifest
@@ -38,10 +42,11 @@ import {
38
42
  } from 'node:fs';
39
43
  import path from 'node:path';
40
44
  import os from 'node:os';
45
+ import { printErr, readText, writeText } from '../lib/fs.mjs';
41
46
  import {
42
- jsonDumps, jsonDumpsCompact, jsonDumpsIndent, parseJson, pyEq, pyRepr, indexOfEq,
43
- pyPrintErr, readText, writeText,
44
- } from './lib/fs.mjs';
47
+ jsonDumps, jsonDumpsCompact, jsonDumpsIndent, parseJson, deepEquals, formatRepr,
48
+ indexOfDeepEqual,
49
+ } from '../lib/json.mjs';
45
50
 
46
51
  export const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
47
52
 
@@ -49,7 +54,7 @@ export const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
49
54
  *
50
55
  * `loaded[event]` where `event` came out of a manifest hands back `Object.prototype`'s
51
56
  * member for `constructor`, `toString` or `valueOf`, where Python's `.get` returns None.
52
- * The same hazard `js/native.mjs` hit with `overrides[stem]`, on a dict the USER edits. */
57
+ * The same hazard `js/hosts/native.mjs` hit with `overrides[stem]`, on a dict the USER edits. */
53
58
  function get(obj, key) {
54
59
  return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
55
60
  }
@@ -79,6 +84,23 @@ function isDict(v) {
79
84
  */
80
85
  const processPackOn = (d) => !Array.isArray(d) || d.includes('process');
81
86
 
87
+ /**
88
+ * The address of the rule the git gate IS. Not a pack any more — a rule.
89
+ *
90
+ * ⚠ THE GATE MOVED FROM THE PACK TO THE RULE, and the reason is that the pack stopped being
91
+ * the unit a user toggles. With per-rule exclusion an install can keep every other `process`
92
+ * rule and drop this one; keying the boundary on `d.includes('process')` would then leave the
93
+ * hook wired for a rule the carrier no longer states — prompt and boundary disagreeing, which
94
+ * is the single thing this design forbids in either direction.
95
+ *
96
+ * `excluded` defaults to `[]` for the same fail-closed reason `excludedRulesOfDir` does: a
97
+ * caller that has not learned about the second axis excludes nothing, so the gate stays. Every
98
+ * pre-existing call site keeps its exact behaviour without being touched.
99
+ */
100
+ const CONSENT_RULE = 'process.5';
101
+ const consentRuleOn = (d, excluded = []) =>
102
+ processPackOn(d) && !(Array.isArray(excluded) && excluded.includes(CONSENT_RULE));
103
+
82
104
  /**
83
105
  * `_build_settings._default_permission`.
84
106
  *
@@ -92,9 +114,9 @@ const processPackOn = (d) => !Array.isArray(d) || d.includes('process');
92
114
  * Law IV's territory — an always-on invariant — not the process pack's, and the guard
93
115
  * plugin's own force-push arm is unconditional for the same reason.
94
116
  */
95
- export function defaultPermission(doctrines = null) {
117
+ export function defaultPermission(doctrines = null, excluded = []) {
96
118
  const bash = { 'rm -rf *': 'ask' };
97
- if (processPackOn(doctrines)) {
119
+ if (consentRuleOn(doctrines, excluded)) {
98
120
  bash['git commit*'] = 'ask';
99
121
  bash['git push*'] = 'ask';
100
122
  }
@@ -146,7 +168,7 @@ const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*', 'git push --force*',
146
168
  * FAIL-CLOSED direction — the boundary asks for a confirmation the constitution no longer
147
169
  * compels, exactly as the commit skill's own consent step does with the pack off. The unsafe
148
170
  * direction, a rule with no gate behind it, cannot occur here. A FRESH file is unaffected:
149
- * `mergeOpencodeJson` writes `defaultPermission(doctrines)` whole, so a new pack-off install
171
+ * `mergeOpencodeJson` writes `defaultPermission(doctrines, excluded)` whole, so a new pack-off install
150
172
  * has no git gate at all.
151
173
  *
152
174
  * ⚠ KEY ORDER IS NOT RECONCILED, DELIBERATELY. `defaultPermission` emits its keys in a fixed
@@ -284,7 +306,7 @@ export function readJsonc(text) {
284
306
  * `'add'`, which is what the boolean callers this replaced meant.
285
307
  */
286
308
  export function warnCommentedJsonc(target, agentPath, permission,
287
- includeLsp = false, prefix = 'geneseed', doctrines = null) {
309
+ includeLsp = false, prefix = 'geneseed', doctrines = null, excluded = []) {
288
310
  process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
289
311
  + 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
290
312
  process.stdout.write(`[${prefix}] ${jsonDumps(agentPath)}\n`);
@@ -293,7 +315,7 @@ export function warnCommentedJsonc(target, agentPath, permission,
293
315
  ? `[${prefix}] and its "permission".bash is missing gates this build wires — `
294
316
  + 'reconcile it by hand against:\n'
295
317
  : `[${prefix}] and, for Geneseed's default ask-gates, a "permission" key:\n`);
296
- for (const line of jsonDumpsIndent(defaultPermission(doctrines)).split('\n')) {
318
+ for (const line of jsonDumpsIndent(defaultPermission(doctrines, excluded)).split('\n')) {
297
319
  process.stdout.write(`[${prefix}] ${line}\n`);
298
320
  }
299
321
  }
@@ -322,7 +344,7 @@ export function atomicWriteJson(p, config) {
322
344
  * re-implements the inverse itself rather than inherit this one's permission/lsp side
323
345
  * effects, so the only crossings are the two emit-tree ones (`_build_emit.emit_opencode`,
324
346
  * `_build_global.emit_opencode_global`). */
325
- export function mergeOpencodeJson(p, agentPath, doctrines = null) {
347
+ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = []) {
326
348
  const target = opencodeTarget(p);
327
349
  let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
328
350
  let hadComments = false;
@@ -332,7 +354,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
332
354
  raw = readText(target);
333
355
  } catch (e) {
334
356
  if (!isOsError(e)) throw e;
335
- process.stderr.write(`[geneseed] WARN: could not read ${target} (${pyOsError(e)}) `
357
+ process.stderr.write(`[geneseed] WARN: could not read ${target} (${asOsError(e)}) `
336
358
  + `— NOT touching it. Add ${jsonDumps(agentPath)} to its "instructions" array by `
337
359
  + "hand once it's readable again.\n");
338
360
  return target;
@@ -350,7 +372,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
350
372
  if (!has(config, '$schema')) config.$schema = OPENCODE_SCHEMA;
351
373
  let instr = get(config, 'instructions');
352
374
  if (!Array.isArray(instr)) instr = [];
353
- const addInstr = indexOfEq(instr, agentPath) < 0;
375
+ const addInstr = indexOfDeepEqual(instr, agentPath) < 0;
354
376
  if (addInstr) instr.push(agentPath);
355
377
  config.instructions = instr;
356
378
  // Absent ⇒ write the whole block; present ⇒ reconcile Geneseed's own git keys. Adding is
@@ -361,10 +383,10 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
361
383
  let permChanged = addPerm;
362
384
  let stranded = [];
363
385
  let opaque = null;
364
- if (addPerm) config.permission = defaultPermission(doctrines);
386
+ if (addPerm) config.permission = defaultPermission(doctrines, excluded);
365
387
  else {
366
388
  [permChanged, stranded, opaque] = reconcileOpencodePermission(get(config, 'permission'),
367
- defaultPermission(doctrines));
389
+ defaultPermission(doctrines, excluded));
368
390
  }
369
391
  // The shape said no. This is the direction the residue warning above is NOT — a rule stated
370
392
  // in AGENT.md with nothing behind it — and it used to pass in silence, because a blanket
@@ -374,7 +396,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
374
396
  if (opaque) {
375
397
  process.stderr.write(`[geneseed] WARN: "permission" in ${path.basename(target)} `
376
398
  + `${opaque}, so Geneseed cannot wire its own gates into it — including `
377
- + `${OWNED_BASH.filter((k) => has(defaultPermission(doctrines).bash, k))
399
+ + `${OWNED_BASH.filter((k) => has(defaultPermission(doctrines, excluded).bash, k))
378
400
  .map((k) => jsonDumps(k)).join(', ')}. Your policy is left exactly as written; the `
379
401
  + 'harness states those rules but nothing enforces them at this boundary.\n');
380
402
  }
@@ -404,14 +426,14 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
404
426
  // block to paste, split by which of the two situations the reader is actually in. It is
405
427
  // stdout, not stderr, because on this path the paste-in IS the output.
406
428
  warnCommentedJsonc(target, agentPath, addPerm ? 'add' : (permChanged && 'reconcile'),
407
- addLsp, 'geneseed', doctrines);
429
+ addLsp, 'geneseed', doctrines, excluded);
408
430
  return target;
409
431
  }
410
432
  try {
411
433
  atomicWriteJson(target, config);
412
434
  } catch (e) {
413
435
  if (!isOsError(e)) throw e;
414
- process.stderr.write(`[geneseed] WARN: could not write ${target} (${pyOsError(e)}) — `
436
+ process.stderr.write(`[geneseed] WARN: could not write ${target} (${asOsError(e)}) — `
415
437
  + `the harness will NOT auto-load until this is fixed. Add ${jsonDumps(agentPath)} `
416
438
  + 'to its "instructions" array by hand.\n');
417
439
  }
@@ -427,7 +449,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
427
449
  * with an injectable failure instead and compares everything except the OS's own wording.
428
450
  * Named here so the gap is a decision rather than a surprise at GA.
429
451
  */
430
- function pyOsError(e) {
452
+ function asOsError(e) {
431
453
  return e.message;
432
454
  }
433
455
 
@@ -458,7 +480,7 @@ export const SHIM_ARGV = new Set(['$@', '%*']);
458
480
  * Every quoted token in a shim body that names a path which is not there — empty is healthy.
459
481
  *
460
482
  * ONE OWNER, because the rule has two consumers now and a second copy is a second place for the
461
- * `"$@"` defect above to come back: `js/doctor.mjs` turns this into the `[shim] … does not
483
+ * `"$@"` defect above to come back: `js/inspect/checks-repo.mjs` turns this into the `[shim] … does not
462
484
  * exist` report, and `hookPrefix` reads it to decide whether the shim already on disk is worth
463
485
  * protecting from a checkout that will not outlive the emit.
464
486
  */
@@ -484,7 +506,7 @@ export function shimHome() {
484
506
  }
485
507
 
486
508
  /**
487
- * `Path(x).expanduser()` — private twin of `js/hosts.mjs`'s `expanduser`, same guard.
509
+ * `Path(x).expanduser()` — private twin of `js/hosts/hosts.mjs`'s `expanduser`, same guard.
488
510
  *
489
511
  * `GENESEED_HOME` is user-set, same as any config-dir env var there, so a `~user` value here
490
512
  * has the identical failure mode (a hook shim written under a literal `~user` directory) and
@@ -497,7 +519,7 @@ function expanduser(p) {
497
519
  if (p.startsWith('~')) {
498
520
  const msg = `refusing '${p}': a '~user' path is not expanded by this port; pass an `
499
521
  + 'absolute path, or "~" / "~/…" for your own home directory';
500
- pyPrintErr(`${msg}\n`);
522
+ printErr(`${msg}\n`);
501
523
  const e = new Error(msg);
502
524
  e.exitCode = 1;
503
525
  throw e;
@@ -519,12 +541,12 @@ export function hookShimPath(platform = process.platform) {
519
541
  * refuses to compare the emitted shim for that reason (`_SHIM_GLOB`) and asserts
520
542
  * `_shim_health` instead.
521
543
  *
522
- * So the parity gate's answer here is NOT "skip it". Taking `runner` and `entry` as
523
- * arguments makes the BODY a pure function of three inputs, and the gate feeds Node the
524
- * same two values Python computed — which makes every byte of it comparable: the `@echo
525
- * off`, the CRLF, `exit /b` vs `exec`, the quoting that keeps `--root "<cfg>"` intact. What
526
- * the gate does not prove is WHICH values a Node driver will pass, and that is a one-line
527
- * decision at P3b's call site rather than anything in this body.
544
+ * So the answer here is NOT "skip it". Taking `runner`, `entry` and `platform` as arguments
545
+ * makes the BODY a pure function of three inputs, which is what lets
546
+ * `tests/unit/hook_form.test.mjs` assert BOTH platform shapes absolutely in one run on
547
+ * whichever host it happens to be on: the `@echo off`, the CRLF, `exit /b` vs `exec`, the
548
+ * quoting that keeps `--root "<cfg>"` intact. What it does not prove is WHICH values the
549
+ * driver passes — that is `bin/build-driver.mjs`'s `hookRunnerEntry()`, gated separately.
528
550
  */
529
551
  export function hookShimBody(runner, entry, platform = process.platform) {
530
552
  if (platform === 'win32') {
@@ -698,7 +720,7 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
698
720
  * belongs in — the user's call — not how work is run, so it is not the process pack's to
699
721
  * remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
700
722
  */
701
- export function claudeHookGroups(cfg, hookOpts, doctrines = null) {
723
+ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = []) {
702
724
  const run = hookPrefix(hookOpts);
703
725
  const mem = `--memory "${path.join(cfg, 'memory')}"`;
704
726
  // --root carries the install's own dir so a GLOBAL hook can stand down when a project
@@ -709,7 +731,7 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null) {
709
731
  const learn = `${run} learn ${mem} || exit 0`;
710
732
  return {
711
733
  PreToolUse: [
712
- ...(processPackOn(doctrines)
734
+ ...(consentRuleOn(doctrines, excluded)
713
735
  ? [{ matcher: 'Bash', hooks: [{ type: 'command', command: gate }] }] : []),
714
736
  {
715
737
  matcher: 'Write|Edit|MultiEdit|NotebookEdit',
@@ -740,8 +762,8 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null) {
740
762
  *
741
763
  * `scope` is accepted and unused, exactly as in the Python.
742
764
  */
743
- export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {},
744
- doctrines = null) {
765
+ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hookOpts = {},
766
+ doctrines = null, excluded = []) {
745
767
  const prior = (priorHooks || []).filter(isDict);
746
768
  let config = {};
747
769
  let hadComments = false;
@@ -761,19 +783,19 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
761
783
  }
762
784
  let hooks = get(config, 'hooks');
763
785
  if (!isDict(hooks)) hooks = {};
764
- const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines);
786
+ const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines, excluded);
765
787
  const canonFlat = [];
766
788
  for (const [event, groups] of Object.entries(canonical)) {
767
789
  for (const g of groups) canonFlat.push({ event, group: g });
768
790
  }
769
791
  let pruned = false;
770
792
  for (const rec of prior) {
771
- if (indexOfEq(canonFlat, rec) >= 0) continue;
793
+ if (indexOfDeepEqual(canonFlat, rec) >= 0) continue;
772
794
  const event = get(rec, 'event');
773
795
  const group = get(rec, 'group');
774
796
  const arr = get(hooks, event);
775
797
  if (Array.isArray(arr)) {
776
- const at = indexOfEq(arr, group);
798
+ const at = indexOfDeepEqual(arr, group);
777
799
  if (at >= 0) {
778
800
  arr.splice(at, 1);
779
801
  pruned = true;
@@ -786,14 +808,14 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
786
808
  let arr = get(hooks, event);
787
809
  if (!Array.isArray(arr)) arr = [];
788
810
  for (const g of newGroups) {
789
- if (indexOfEq(arr, g) >= 0) continue;
811
+ if (indexOfDeepEqual(arr, g) >= 0) continue;
790
812
  arr.push(g);
791
813
  added.push({ event, group: g });
792
814
  }
793
815
  hooks[event] = arr;
794
816
  }
795
- const survivors = prior.filter((r) => indexOfEq(canonFlat, r) >= 0);
796
- const managedNow = survivors.concat(added.filter((a) => indexOfEq(survivors, a) < 0));
817
+ const survivors = prior.filter((r) => indexOfDeepEqual(canonFlat, r) >= 0);
818
+ const managedNow = survivors.concat(added.filter((a) => indexOfDeepEqual(survivors, a) < 0));
797
819
  if (!added.length && !pruned) return [p, managedNow];
798
820
  if (hadComments) {
799
821
  process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
@@ -805,8 +827,10 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
805
827
  // carries four events, so by the time control reaches here `hooks` is never empty. A
806
828
  // mutation keeping the empty block instead of deleting it is the one of thirty-three that
807
829
  // stays green, and it stays green because there is nothing to detect — recorded the way
808
- // `themed_rel` and `lstripNewlines` are, so "the gate cannot see it" and "there is
809
- // nothing to see" keep their distance.
830
+ // `themed_rel` is, so "the gate cannot see it" and "there is nothing to see" keep their
831
+ // distance. NOT the way `lstripNewlines` is: that one looked unobservable for two phases
832
+ // and turned out to be mutation M7, killed by `golden.mjs --idempotent` through
833
+ // `managedBlockWrite`'s `updated` branch, which only a RE-emit reaches.
810
834
  if (Object.keys(hooks).length) config.hooks = hooks;
811
835
  else delete config.hooks;
812
836
  mkdirSync(path.dirname(p), { recursive: true });
@@ -838,7 +862,7 @@ export function unwireClaudeSettings(p, added) {
838
862
  const group = get(rec, 'group');
839
863
  let arr = get(hooks, event);
840
864
  if (Array.isArray(arr)) {
841
- const at = indexOfEq(arr, group);
865
+ const at = indexOfDeepEqual(arr, group);
842
866
  if (at >= 0) arr.splice(at, 1);
843
867
  }
844
868
  arr = get(hooks, event);
@@ -879,7 +903,7 @@ export function wireClaudeExcludes(p, excludes) {
879
903
  }
880
904
  let cur = get(config, 'claudeMdExcludes');
881
905
  if (!Array.isArray(cur)) cur = [];
882
- const added = want.filter((e) => indexOfEq(cur, e) < 0);
906
+ const added = want.filter((e) => indexOfDeepEqual(cur, e) < 0);
883
907
  if (!added.length) return [];
884
908
  if (hadComments) {
885
909
  process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
@@ -894,7 +918,7 @@ export function wireClaudeExcludes(p, excludes) {
894
918
  atomicWriteJson(p, config);
895
919
  } catch (e) {
896
920
  if (!isOsError(e)) throw e;
897
- process.stderr.write(`[geneseed] WARN: could not write ${p} (${pyOsError(e)}) — `
921
+ process.stderr.write(`[geneseed] WARN: could not write ${p} (${asOsError(e)}) — `
898
922
  + 'claudeMdExcludes were not wired. Add to its "claudeMdExcludes" array by hand: '
899
923
  + `${jsonDumpsCompact(added)}\n`);
900
924
  return [];
@@ -917,7 +941,7 @@ export function unwireClaudeExcludes(p, excludes) {
917
941
  const cur = get(loaded, 'claudeMdExcludes');
918
942
  if (!Array.isArray(cur)) return;
919
943
  for (const e of excludes) {
920
- const at = indexOfEq(cur, e);
944
+ const at = indexOfDeepEqual(cur, e);
921
945
  if (at >= 0) cur.splice(at, 1);
922
946
  }
923
947
  if (cur.length) loaded.claudeMdExcludes = cur;
@@ -954,8 +978,9 @@ export const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
954
978
  /**
955
979
  * `_build_settings._migrate_shape` — 'legacy' | 'current' | 'none'.
956
980
  *
957
- * PURE, so `tests/test_settings_parity.py` runs it over a corpus of real config text no cell
958
- * can seed. 'none' is not a fault: a Copilot install wires no hooks, and a user's own
981
+ * PURE — and currently reached only END TO END, by the `migrate/a-node-baked-shim-reads-as-current`
982
+ * cell. No unit corpus drives it directly, which is worth knowing before trusting it.
983
+ * 'none' is not a fault: a Copilot install wires no hooks, and a user's own
959
984
  * settings.json may carry three hand-written hooks and no Geneseed entry. Both must read as
960
985
  * "nothing to migrate" rather than "unrecognised", or `migrate` refuses on the commonest
961
986
  * config on any machine.
@@ -1043,7 +1068,7 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1043
1068
  [loaded] = readJsonc(readText(p));
1044
1069
  } catch (e) {
1045
1070
  if (!isOsError(e)) throw e;
1046
- problems.push(`${p}: could not read the file (${pyOsError(e)})`);
1071
+ problems.push(`${p}: could not read the file (${asOsError(e)})`);
1047
1072
  return flush();
1048
1073
  }
1049
1074
  if (!isDict(loaded)) {
@@ -1060,25 +1085,25 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1060
1085
  for (const rec of recordedHooks) {
1061
1086
  const event = get(rec, 'event');
1062
1087
  const group = get(rec, 'group');
1063
- const hit = presentGroups.some(([e, g]) => pyEq(e, event) && pyEq(g, group));
1088
+ const hit = presentGroups.some(([e, g]) => deepEquals(e, event) && deepEquals(g, group));
1064
1089
  if (expect === 'present' && !hit) {
1065
- problems.push(`${p}: recorded hook group missing — event=${pyRepr(event ?? null)} `
1090
+ problems.push(`${p}: recorded hook group missing — event=${formatRepr(event ?? null)} `
1066
1091
  + `group=${jsonDumpsCompact(group ?? null)}`);
1067
1092
  } else if (expect === 'absent' && hit) {
1068
1093
  problems.push(`${p}: recorded hook group still present after unwire — `
1069
- + `event=${pyRepr(event ?? null)} group=${jsonDumpsCompact(group ?? null)}`);
1094
+ + `event=${formatRepr(event ?? null)} group=${jsonDumpsCompact(group ?? null)}`);
1070
1095
  }
1071
1096
  }
1072
1097
 
1073
1098
  let exclCur = get(loaded, 'claudeMdExcludes');
1074
1099
  exclCur = Array.isArray(exclCur) ? exclCur : [];
1075
1100
  for (const entry of (get(m, 'settings_excludes') || [])) {
1076
- const hit = indexOfEq(exclCur, entry) >= 0;
1101
+ const hit = indexOfDeepEqual(exclCur, entry) >= 0;
1077
1102
  if (expect === 'present' && !hit) {
1078
- problems.push(`${p}: recorded claudeMdExcludes entry missing: ${pyRepr(entry)}`);
1103
+ problems.push(`${p}: recorded claudeMdExcludes entry missing: ${formatRepr(entry)}`);
1079
1104
  } else if (expect === 'absent' && hit) {
1080
1105
  problems.push(`${p}: recorded claudeMdExcludes entry still present after unwire: `
1081
- + `${pyRepr(entry)}`);
1106
+ + `${formatRepr(entry)}`);
1082
1107
  }
1083
1108
  }
1084
1109
 
@@ -1086,17 +1111,17 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1086
1111
  // auto-delete (they may be user-authored, or a claim this run legitimately did not
1087
1112
  // inherit).
1088
1113
  const recordedSet = new Set(recordedHooks.map(
1089
- (r) => `${pyRepr(get(r, 'event') ?? null)}\u0000`
1114
+ (r) => `${formatRepr(get(r, 'event') ?? null)}\u0000`
1090
1115
  + `${jsonDumpsCompact(get(r, 'group') ?? null, { sortKeys: true })}`));
1091
1116
  for (const [event, group] of presentGroups) {
1092
- const key = `${pyRepr(event)}\u0000${jsonDumpsCompact(group, { sortKeys: true })}`;
1117
+ const key = `${formatRepr(event)}\u0000${jsonDumpsCompact(group, { sortKeys: true })}`;
1093
1118
  if (recordedSet.has(key)) continue;
1094
1119
  const cmds = (get(group, 'hooks') || []).filter(isDict)
1095
1120
  .map((h) => (has(h, 'command') ? h.command : ''));
1096
1121
  if (cmds.some((c) => typeof c === 'string'
1097
1122
  && GENESEED_HOOK_SNIFF.some((mk) => c.includes(mk)))) {
1098
1123
  problems.push(`${p}: Geneseed-pattern hook present but NOT recorded in the manifest `
1099
- + `(event=${pyRepr(event)}) — possibly user-authored; left alone`);
1124
+ + `(event=${formatRepr(event)}) — possibly user-authored; left alone`);
1100
1125
  }
1101
1126
  }
1102
1127
 
@@ -1116,7 +1141,7 @@ function delimiters(blockId) {
1116
1141
  */
1117
1142
  export function managedBlockWrite(p, content, blockId = 'GENESEED') {
1118
1143
  const [begin, end] = delimiters(blockId);
1119
- const block = `${begin}\n${pyRstrip(content)}\n${end}\n`;
1144
+ const block = `${begin}\n${stripEnd(content)}\n${end}\n`;
1120
1145
  if (!existsSync(p)) {
1121
1146
  mkdirSync(path.dirname(p), { recursive: true });
1122
1147
  writeText(p, block);
@@ -1146,7 +1171,7 @@ export function managedBlockRemove(p, blockId = 'GENESEED', whole = false) {
1146
1171
  if (!existing.includes(begin) || !existing.includes(end)) return;
1147
1172
  const pre = existing.split(begin)[0];
1148
1173
  const post = existing.slice(existing.indexOf(end) + end.length);
1149
- const rest = pyStrip(`${pyRstripNewlines(pre)}\n${lstripNewlines(post)}`);
1174
+ const rest = stripEnds(`${stripTrailingNewlines(pre)}\n${lstripNewlines(post)}`);
1150
1175
  if (rest) writeText(p, `${rest}\n`);
1151
1176
  else unlinkSync(p);
1152
1177
  }
@@ -1166,17 +1191,17 @@ export function managedBlockRead(p, blockId = 'GENESEED') {
1166
1191
  // U+001C–U+001F, which `trim()` leaves. A managed block is user-editable prose in a file
1167
1192
  // Geneseed shares with the user, so the set is spelled out rather than approximated — and
1168
1193
  // spelled with escapes, never literals: a raw U+2028 in a JS source file ends the line.
1169
- const PY_SPACE = '[\t\n\v\f\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a'
1194
+ const WHITESPACE = '[\t\n\v\f\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a'
1170
1195
  + '\u2028\u2029\u202f\u205f\u3000]';
1171
1196
 
1172
- const RSTRIP_RE = new RegExp(`${PY_SPACE}+$`);
1173
- const LSTRIP_RE = new RegExp(`^${PY_SPACE}+`);
1197
+ const RSTRIP_RE = new RegExp(`${WHITESPACE}+$`);
1198
+ const LSTRIP_RE = new RegExp(`^${WHITESPACE}+`);
1174
1199
 
1175
- function pyRstrip(s) {
1200
+ function stripEnd(s) {
1176
1201
  return s.replace(RSTRIP_RE, '');
1177
1202
  }
1178
1203
 
1179
- function pyStrip(s) {
1204
+ function stripEnds(s) {
1180
1205
  return s.replace(LSTRIP_RE, '').replace(RSTRIP_RE, '');
1181
1206
  }
1182
1207
 
@@ -1184,10 +1209,10 @@ function lstripNewlines(s) {
1184
1209
  return s.replace(/^\n+/, '');
1185
1210
  }
1186
1211
 
1187
- function pyRstripNewlines(s) {
1212
+ function stripTrailingNewlines(s) {
1188
1213
  return s.replace(/\n+$/, '');
1189
1214
  }
1190
1215
 
1191
1216
  function stripNewlines(s) {
1192
- return lstripNewlines(pyRstripNewlines(s));
1217
+ return lstripNewlines(stripTrailingNewlines(s));
1193
1218
  }