geneseed 3.1.1 → 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 (98) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/DESIGN.md +15 -15
  3. package/README.md +1 -1
  4. package/SETUP.md +1 -1
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +39 -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 +111 -83
  11. package/harness.config.json +1 -1
  12. package/js/README.md +203 -0
  13. package/js/build/bundle.mjs +231 -0
  14. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  15. package/js/build/emit-claude.mjs +287 -0
  16. package/js/build/emit-common.mjs +255 -0
  17. package/js/build/emit-opencode.mjs +214 -0
  18. package/js/{generate.mjs → build/generate.mjs} +27 -28
  19. package/js/{render.mjs → build/render.mjs} +13 -9
  20. package/js/{checkout.mjs → build/source.mjs} +9 -9
  21. package/js/build/stubs.mjs +256 -0
  22. package/js/{themes.mjs → build/themes.mjs} +24 -24
  23. package/js/build/version.mjs +155 -0
  24. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  25. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  26. package/js/{installs.mjs → hosts/installs.mjs} +18 -16
  27. package/js/{link.mjs → hosts/link.mjs} +29 -28
  28. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  29. package/js/{native.mjs → hosts/native.mjs} +24 -22
  30. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  31. package/js/{settings.mjs → hosts/settings.mjs} +63 -55
  32. package/js/inspect/checks-authoring.mjs +761 -0
  33. package/js/inspect/checks-build.mjs +231 -0
  34. package/js/inspect/checks-repo.mjs +308 -0
  35. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  36. package/js/inspect/doctor.mjs +286 -0
  37. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  38. package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
  39. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  40. package/js/inspect/scan.mjs +125 -0
  41. package/js/{status.mjs → inspect/status.mjs} +27 -25
  42. package/js/inspect/validate.mjs +199 -0
  43. package/js/lib/fs.mjs +37 -759
  44. package/js/lib/json.mjs +344 -0
  45. package/js/lib/paths.mjs +242 -0
  46. package/js/lib/proc.mjs +3 -3
  47. package/js/lib/text.mjs +181 -0
  48. package/js/lib/udiff.mjs +1 -1
  49. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  50. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  51. package/js/{setup.mjs → maintain/setup.mjs} +34 -32
  52. package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
  53. package/js/{update.mjs → maintain/update.mjs} +51 -51
  54. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  55. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  56. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  57. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  58. package/js/web/actions.mjs +84 -85
  59. package/js/web/activity.mjs +22 -21
  60. package/js/web/api.mjs +72 -50
  61. package/js/web/daemon.mjs +319 -0
  62. package/js/web/docs.mjs +24 -21
  63. package/js/web/graph.mjs +0 -0
  64. package/js/web/handler.mjs +442 -0
  65. package/js/web/jobs.mjs +21 -19
  66. package/js/web/routes.mjs +223 -0
  67. package/js/web/server.mjs +56 -987
  68. package/package.json +11 -3
  69. package/src/doctrines/README.md +1 -1
  70. package/themes/opencode/README.md +1 -1
  71. package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
  72. package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
  73. package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
  74. package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
  75. package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
  76. package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
  77. package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
  78. package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
  79. package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
  80. package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
  81. package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
  82. package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
  83. package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
  84. package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
  85. package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
  86. package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
  87. package/web/dist/assets/index-BE226Gv6.css +1 -0
  88. package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
  89. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  90. package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
  91. package/web/dist/index.html +2 -2
  92. package/web/src/pages/Laws.jsx +1 -1
  93. package/docs/declined.md +0 -200
  94. package/docs/limits.md +0 -329
  95. package/js/doctor.mjs +0 -1750
  96. package/js/emit.mjs +0 -1411
  97. package/web/dist/assets/index-B-mXquBZ.css +0 -1
  98. package/web/dist/assets/index-CH_Z725C.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
  }
@@ -349,7 +354,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
349
354
  raw = readText(target);
350
355
  } catch (e) {
351
356
  if (!isOsError(e)) throw e;
352
- process.stderr.write(`[geneseed] WARN: could not read ${target} (${pyOsError(e)}) `
357
+ process.stderr.write(`[geneseed] WARN: could not read ${target} (${asOsError(e)}) `
353
358
  + `— NOT touching it. Add ${jsonDumps(agentPath)} to its "instructions" array by `
354
359
  + "hand once it's readable again.\n");
355
360
  return target;
@@ -367,7 +372,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
367
372
  if (!has(config, '$schema')) config.$schema = OPENCODE_SCHEMA;
368
373
  let instr = get(config, 'instructions');
369
374
  if (!Array.isArray(instr)) instr = [];
370
- const addInstr = indexOfEq(instr, agentPath) < 0;
375
+ const addInstr = indexOfDeepEqual(instr, agentPath) < 0;
371
376
  if (addInstr) instr.push(agentPath);
372
377
  config.instructions = instr;
373
378
  // Absent ⇒ write the whole block; present ⇒ reconcile Geneseed's own git keys. Adding is
@@ -428,7 +433,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
428
433
  atomicWriteJson(target, config);
429
434
  } catch (e) {
430
435
  if (!isOsError(e)) throw e;
431
- process.stderr.write(`[geneseed] WARN: could not write ${target} (${pyOsError(e)}) — `
436
+ process.stderr.write(`[geneseed] WARN: could not write ${target} (${asOsError(e)}) — `
432
437
  + `the harness will NOT auto-load until this is fixed. Add ${jsonDumps(agentPath)} `
433
438
  + 'to its "instructions" array by hand.\n');
434
439
  }
@@ -444,7 +449,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
444
449
  * with an injectable failure instead and compares everything except the OS's own wording.
445
450
  * Named here so the gap is a decision rather than a surprise at GA.
446
451
  */
447
- function pyOsError(e) {
452
+ function asOsError(e) {
448
453
  return e.message;
449
454
  }
450
455
 
@@ -475,7 +480,7 @@ export const SHIM_ARGV = new Set(['$@', '%*']);
475
480
  * Every quoted token in a shim body that names a path which is not there — empty is healthy.
476
481
  *
477
482
  * ONE OWNER, because the rule has two consumers now and a second copy is a second place for the
478
- * `"$@"` 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
479
484
  * exist` report, and `hookPrefix` reads it to decide whether the shim already on disk is worth
480
485
  * protecting from a checkout that will not outlive the emit.
481
486
  */
@@ -501,7 +506,7 @@ export function shimHome() {
501
506
  }
502
507
 
503
508
  /**
504
- * `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.
505
510
  *
506
511
  * `GENESEED_HOME` is user-set, same as any config-dir env var there, so a `~user` value here
507
512
  * has the identical failure mode (a hook shim written under a literal `~user` directory) and
@@ -514,7 +519,7 @@ function expanduser(p) {
514
519
  if (p.startsWith('~')) {
515
520
  const msg = `refusing '${p}': a '~user' path is not expanded by this port; pass an `
516
521
  + 'absolute path, or "~" / "~/…" for your own home directory';
517
- pyPrintErr(`${msg}\n`);
522
+ printErr(`${msg}\n`);
518
523
  const e = new Error(msg);
519
524
  e.exitCode = 1;
520
525
  throw e;
@@ -536,12 +541,12 @@ export function hookShimPath(platform = process.platform) {
536
541
  * refuses to compare the emitted shim for that reason (`_SHIM_GLOB`) and asserts
537
542
  * `_shim_health` instead.
538
543
  *
539
- * So the parity gate's answer here is NOT "skip it". Taking `runner` and `entry` as
540
- * arguments makes the BODY a pure function of three inputs, and the gate feeds Node the
541
- * same two values Python computed — which makes every byte of it comparable: the `@echo
542
- * off`, the CRLF, `exit /b` vs `exec`, the quoting that keeps `--root "<cfg>"` intact. What
543
- * the gate does not prove is WHICH values a Node driver will pass, and that is a one-line
544
- * 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.
545
550
  */
546
551
  export function hookShimBody(runner, entry, platform = process.platform) {
547
552
  if (platform === 'win32') {
@@ -757,7 +762,7 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [])
757
762
  *
758
763
  * `scope` is accepted and unused, exactly as in the Python.
759
764
  */
760
- export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {},
765
+ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hookOpts = {},
761
766
  doctrines = null, excluded = []) {
762
767
  const prior = (priorHooks || []).filter(isDict);
763
768
  let config = {};
@@ -785,12 +790,12 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
785
790
  }
786
791
  let pruned = false;
787
792
  for (const rec of prior) {
788
- if (indexOfEq(canonFlat, rec) >= 0) continue;
793
+ if (indexOfDeepEqual(canonFlat, rec) >= 0) continue;
789
794
  const event = get(rec, 'event');
790
795
  const group = get(rec, 'group');
791
796
  const arr = get(hooks, event);
792
797
  if (Array.isArray(arr)) {
793
- const at = indexOfEq(arr, group);
798
+ const at = indexOfDeepEqual(arr, group);
794
799
  if (at >= 0) {
795
800
  arr.splice(at, 1);
796
801
  pruned = true;
@@ -803,14 +808,14 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
803
808
  let arr = get(hooks, event);
804
809
  if (!Array.isArray(arr)) arr = [];
805
810
  for (const g of newGroups) {
806
- if (indexOfEq(arr, g) >= 0) continue;
811
+ if (indexOfDeepEqual(arr, g) >= 0) continue;
807
812
  arr.push(g);
808
813
  added.push({ event, group: g });
809
814
  }
810
815
  hooks[event] = arr;
811
816
  }
812
- const survivors = prior.filter((r) => indexOfEq(canonFlat, r) >= 0);
813
- 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));
814
819
  if (!added.length && !pruned) return [p, managedNow];
815
820
  if (hadComments) {
816
821
  process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
@@ -822,8 +827,10 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
822
827
  // carries four events, so by the time control reaches here `hooks` is never empty. A
823
828
  // mutation keeping the empty block instead of deleting it is the one of thirty-three that
824
829
  // stays green, and it stays green because there is nothing to detect — recorded the way
825
- // `themed_rel` and `lstripNewlines` are, so "the gate cannot see it" and "there is
826
- // 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.
827
834
  if (Object.keys(hooks).length) config.hooks = hooks;
828
835
  else delete config.hooks;
829
836
  mkdirSync(path.dirname(p), { recursive: true });
@@ -855,7 +862,7 @@ export function unwireClaudeSettings(p, added) {
855
862
  const group = get(rec, 'group');
856
863
  let arr = get(hooks, event);
857
864
  if (Array.isArray(arr)) {
858
- const at = indexOfEq(arr, group);
865
+ const at = indexOfDeepEqual(arr, group);
859
866
  if (at >= 0) arr.splice(at, 1);
860
867
  }
861
868
  arr = get(hooks, event);
@@ -896,7 +903,7 @@ export function wireClaudeExcludes(p, excludes) {
896
903
  }
897
904
  let cur = get(config, 'claudeMdExcludes');
898
905
  if (!Array.isArray(cur)) cur = [];
899
- const added = want.filter((e) => indexOfEq(cur, e) < 0);
906
+ const added = want.filter((e) => indexOfDeepEqual(cur, e) < 0);
900
907
  if (!added.length) return [];
901
908
  if (hadComments) {
902
909
  process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
@@ -911,7 +918,7 @@ export function wireClaudeExcludes(p, excludes) {
911
918
  atomicWriteJson(p, config);
912
919
  } catch (e) {
913
920
  if (!isOsError(e)) throw e;
914
- process.stderr.write(`[geneseed] WARN: could not write ${p} (${pyOsError(e)}) — `
921
+ process.stderr.write(`[geneseed] WARN: could not write ${p} (${asOsError(e)}) — `
915
922
  + 'claudeMdExcludes were not wired. Add to its "claudeMdExcludes" array by hand: '
916
923
  + `${jsonDumpsCompact(added)}\n`);
917
924
  return [];
@@ -934,7 +941,7 @@ export function unwireClaudeExcludes(p, excludes) {
934
941
  const cur = get(loaded, 'claudeMdExcludes');
935
942
  if (!Array.isArray(cur)) return;
936
943
  for (const e of excludes) {
937
- const at = indexOfEq(cur, e);
944
+ const at = indexOfDeepEqual(cur, e);
938
945
  if (at >= 0) cur.splice(at, 1);
939
946
  }
940
947
  if (cur.length) loaded.claudeMdExcludes = cur;
@@ -971,8 +978,9 @@ export const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
971
978
  /**
972
979
  * `_build_settings._migrate_shape` — 'legacy' | 'current' | 'none'.
973
980
  *
974
- * PURE, so `tests/test_settings_parity.py` runs it over a corpus of real config text no cell
975
- * 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
976
984
  * settings.json may carry three hand-written hooks and no Geneseed entry. Both must read as
977
985
  * "nothing to migrate" rather than "unrecognised", or `migrate` refuses on the commonest
978
986
  * config on any machine.
@@ -1060,7 +1068,7 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1060
1068
  [loaded] = readJsonc(readText(p));
1061
1069
  } catch (e) {
1062
1070
  if (!isOsError(e)) throw e;
1063
- problems.push(`${p}: could not read the file (${pyOsError(e)})`);
1071
+ problems.push(`${p}: could not read the file (${asOsError(e)})`);
1064
1072
  return flush();
1065
1073
  }
1066
1074
  if (!isDict(loaded)) {
@@ -1077,25 +1085,25 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1077
1085
  for (const rec of recordedHooks) {
1078
1086
  const event = get(rec, 'event');
1079
1087
  const group = get(rec, 'group');
1080
- 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));
1081
1089
  if (expect === 'present' && !hit) {
1082
- problems.push(`${p}: recorded hook group missing — event=${pyRepr(event ?? null)} `
1090
+ problems.push(`${p}: recorded hook group missing — event=${formatRepr(event ?? null)} `
1083
1091
  + `group=${jsonDumpsCompact(group ?? null)}`);
1084
1092
  } else if (expect === 'absent' && hit) {
1085
1093
  problems.push(`${p}: recorded hook group still present after unwire — `
1086
- + `event=${pyRepr(event ?? null)} group=${jsonDumpsCompact(group ?? null)}`);
1094
+ + `event=${formatRepr(event ?? null)} group=${jsonDumpsCompact(group ?? null)}`);
1087
1095
  }
1088
1096
  }
1089
1097
 
1090
1098
  let exclCur = get(loaded, 'claudeMdExcludes');
1091
1099
  exclCur = Array.isArray(exclCur) ? exclCur : [];
1092
1100
  for (const entry of (get(m, 'settings_excludes') || [])) {
1093
- const hit = indexOfEq(exclCur, entry) >= 0;
1101
+ const hit = indexOfDeepEqual(exclCur, entry) >= 0;
1094
1102
  if (expect === 'present' && !hit) {
1095
- problems.push(`${p}: recorded claudeMdExcludes entry missing: ${pyRepr(entry)}`);
1103
+ problems.push(`${p}: recorded claudeMdExcludes entry missing: ${formatRepr(entry)}`);
1096
1104
  } else if (expect === 'absent' && hit) {
1097
1105
  problems.push(`${p}: recorded claudeMdExcludes entry still present after unwire: `
1098
- + `${pyRepr(entry)}`);
1106
+ + `${formatRepr(entry)}`);
1099
1107
  }
1100
1108
  }
1101
1109
 
@@ -1103,17 +1111,17 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1103
1111
  // auto-delete (they may be user-authored, or a claim this run legitimately did not
1104
1112
  // inherit).
1105
1113
  const recordedSet = new Set(recordedHooks.map(
1106
- (r) => `${pyRepr(get(r, 'event') ?? null)}\u0000`
1114
+ (r) => `${formatRepr(get(r, 'event') ?? null)}\u0000`
1107
1115
  + `${jsonDumpsCompact(get(r, 'group') ?? null, { sortKeys: true })}`));
1108
1116
  for (const [event, group] of presentGroups) {
1109
- const key = `${pyRepr(event)}\u0000${jsonDumpsCompact(group, { sortKeys: true })}`;
1117
+ const key = `${formatRepr(event)}\u0000${jsonDumpsCompact(group, { sortKeys: true })}`;
1110
1118
  if (recordedSet.has(key)) continue;
1111
1119
  const cmds = (get(group, 'hooks') || []).filter(isDict)
1112
1120
  .map((h) => (has(h, 'command') ? h.command : ''));
1113
1121
  if (cmds.some((c) => typeof c === 'string'
1114
1122
  && GENESEED_HOOK_SNIFF.some((mk) => c.includes(mk)))) {
1115
1123
  problems.push(`${p}: Geneseed-pattern hook present but NOT recorded in the manifest `
1116
- + `(event=${pyRepr(event)}) — possibly user-authored; left alone`);
1124
+ + `(event=${formatRepr(event)}) — possibly user-authored; left alone`);
1117
1125
  }
1118
1126
  }
1119
1127
 
@@ -1133,7 +1141,7 @@ function delimiters(blockId) {
1133
1141
  */
1134
1142
  export function managedBlockWrite(p, content, blockId = 'GENESEED') {
1135
1143
  const [begin, end] = delimiters(blockId);
1136
- const block = `${begin}\n${pyRstrip(content)}\n${end}\n`;
1144
+ const block = `${begin}\n${stripEnd(content)}\n${end}\n`;
1137
1145
  if (!existsSync(p)) {
1138
1146
  mkdirSync(path.dirname(p), { recursive: true });
1139
1147
  writeText(p, block);
@@ -1163,7 +1171,7 @@ export function managedBlockRemove(p, blockId = 'GENESEED', whole = false) {
1163
1171
  if (!existing.includes(begin) || !existing.includes(end)) return;
1164
1172
  const pre = existing.split(begin)[0];
1165
1173
  const post = existing.slice(existing.indexOf(end) + end.length);
1166
- const rest = pyStrip(`${pyRstripNewlines(pre)}\n${lstripNewlines(post)}`);
1174
+ const rest = stripEnds(`${stripTrailingNewlines(pre)}\n${lstripNewlines(post)}`);
1167
1175
  if (rest) writeText(p, `${rest}\n`);
1168
1176
  else unlinkSync(p);
1169
1177
  }
@@ -1183,17 +1191,17 @@ export function managedBlockRead(p, blockId = 'GENESEED') {
1183
1191
  // U+001C–U+001F, which `trim()` leaves. A managed block is user-editable prose in a file
1184
1192
  // Geneseed shares with the user, so the set is spelled out rather than approximated — and
1185
1193
  // spelled with escapes, never literals: a raw U+2028 in a JS source file ends the line.
1186
- 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'
1187
1195
  + '\u2028\u2029\u202f\u205f\u3000]';
1188
1196
 
1189
- const RSTRIP_RE = new RegExp(`${PY_SPACE}+$`);
1190
- const LSTRIP_RE = new RegExp(`^${PY_SPACE}+`);
1197
+ const RSTRIP_RE = new RegExp(`${WHITESPACE}+$`);
1198
+ const LSTRIP_RE = new RegExp(`^${WHITESPACE}+`);
1191
1199
 
1192
- function pyRstrip(s) {
1200
+ function stripEnd(s) {
1193
1201
  return s.replace(RSTRIP_RE, '');
1194
1202
  }
1195
1203
 
1196
- function pyStrip(s) {
1204
+ function stripEnds(s) {
1197
1205
  return s.replace(LSTRIP_RE, '').replace(RSTRIP_RE, '');
1198
1206
  }
1199
1207
 
@@ -1201,10 +1209,10 @@ function lstripNewlines(s) {
1201
1209
  return s.replace(/^\n+/, '');
1202
1210
  }
1203
1211
 
1204
- function pyRstripNewlines(s) {
1212
+ function stripTrailingNewlines(s) {
1205
1213
  return s.replace(/\n+$/, '');
1206
1214
  }
1207
1215
 
1208
1216
  function stripNewlines(s) {
1209
- return lstripNewlines(pyRstripNewlines(s));
1217
+ return lstripNewlines(stripTrailingNewlines(s));
1210
1218
  }