geneseed 3.13.0 → 3.15.0

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 (131) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +45 -10
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +32 -34
  10. package/bin/geneseed-hook.mjs +28 -18
  11. package/docs/extending.md +5 -5
  12. package/harness.config.json +1 -1
  13. package/js/README.md +28 -15
  14. package/js/build/args.mjs +375 -0
  15. package/js/build/bundle.mjs +10 -10
  16. package/js/build/driver.mjs +145 -560
  17. package/js/build/emit-claude.mjs +5 -6
  18. package/js/build/emit-common.mjs +2 -14
  19. package/js/build/emit-opencode.mjs +12 -8
  20. package/js/build/generate.mjs +21 -14
  21. package/js/build/render.mjs +6 -10
  22. package/js/build/source.mjs +9 -17
  23. package/js/build/stubs.mjs +6 -5
  24. package/js/build/version.mjs +21 -2
  25. package/js/hosts/hooks-context.mjs +432 -0
  26. package/js/hosts/hooks-learn.mjs +275 -0
  27. package/js/hosts/hooks-prims.mjs +108 -0
  28. package/js/hosts/hooks.mjs +50 -977
  29. package/js/hosts/hosts.mjs +31 -25
  30. package/js/hosts/installs.mjs +38 -20
  31. package/js/hosts/link.mjs +75 -26
  32. package/js/hosts/mcp.mjs +12 -48
  33. package/js/hosts/memory-files.mjs +190 -0
  34. package/js/hosts/native.mjs +106 -48
  35. package/js/hosts/opencode.mjs +44 -36
  36. package/js/hosts/settings.mjs +135 -414
  37. package/js/hosts/shim.mjs +375 -0
  38. package/js/inspect/checks-authoring.mjs +21 -828
  39. package/js/inspect/checks-build.mjs +4 -4
  40. package/js/inspect/checks-constitution.mjs +493 -0
  41. package/js/inspect/checks-counts.mjs +242 -0
  42. package/js/inspect/checks-repo.mjs +17 -8
  43. package/js/inspect/diff.mjs +9 -7
  44. package/js/inspect/doctor.mjs +2 -2
  45. package/js/inspect/excludes.mjs +10 -5
  46. package/js/inspect/inventory.mjs +4 -4
  47. package/js/inspect/scan.mjs +1 -1
  48. package/js/inspect/status.mjs +19 -22
  49. package/js/inspect/validate.mjs +50 -37
  50. package/js/lib/fs.mjs +12 -11
  51. package/js/lib/json.mjs +4 -4
  52. package/js/lib/paths.mjs +27 -33
  53. package/js/lib/proc.mjs +3 -3
  54. package/js/lib/prompt.mjs +132 -0
  55. package/js/lib/text.mjs +18 -4
  56. package/js/lib/udiff.mjs +3 -2
  57. package/js/loop/catalog.mjs +5 -3
  58. package/js/loop/cli.mjs +26 -12
  59. package/js/loop/graph.mjs +1 -1
  60. package/js/loop/registry.mjs +10 -15
  61. package/js/loop/score.mjs +1 -1
  62. package/js/loop/state.mjs +35 -11
  63. package/js/maintain/memory.mjs +6 -7
  64. package/js/maintain/migrate.mjs +22 -33
  65. package/js/maintain/setup.mjs +9 -131
  66. package/js/maintain/uninstall.mjs +57 -97
  67. package/js/maintain/update.mjs +115 -34
  68. package/js/ui/anim.mjs +8 -12
  69. package/js/ui/cli.mjs +37 -43
  70. package/js/ui/menu.mjs +7 -13
  71. package/js/ui/tui.mjs +14 -123
  72. package/js/web/actions.mjs +109 -403
  73. package/js/web/activity.mjs +1 -1
  74. package/js/web/api.mjs +41 -725
  75. package/js/web/catalog.mjs +527 -0
  76. package/js/web/daemon.mjs +21 -3
  77. package/js/web/docs.mjs +29 -72
  78. package/js/web/handler.mjs +51 -27
  79. package/js/web/jobs.mjs +24 -2
  80. package/js/web/routes.mjs +61 -6
  81. package/js/web/server.mjs +4 -4
  82. package/js/web/state.mjs +138 -0
  83. package/js/web/user-files.mjs +339 -0
  84. package/package.json +1 -1
  85. package/web/dist/assets/Activity-kpWxd70F.js +1 -0
  86. package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
  87. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  88. package/web/dist/assets/Docs-BHyaggFy.js +5 -0
  89. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  90. package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
  91. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  92. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  93. package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
  94. package/web/dist/assets/Library-MBxadRzn.js +1 -0
  95. package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
  96. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  97. package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
  98. package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
  99. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  100. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  101. package/web/dist/assets/api-CxgfYu18.js +1 -0
  102. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  103. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  104. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  105. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  106. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  107. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  108. package/web/dist/index.html +5 -6
  109. package/web/src/pages/Laws.jsx +0 -1
  110. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  111. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  112. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  113. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  114. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  115. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  116. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  117. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  118. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  119. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  120. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  121. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  122. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  123. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  124. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  125. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  126. package/web/dist/assets/drawable-C_x5xncq.js +0 -1
  127. package/web/dist/assets/index-CMPgezFm.js +0 -2
  128. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  129. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  130. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  131. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
@@ -2,16 +2,14 @@
2
2
  * `_build_settings.py` in Node — the host-config WIRING layer.
3
3
  *
4
4
  * This is the half of the emit that edits files the USER co-owns: the JSONC reader, the
5
- * `opencode.json` / `settings.json` merges, the hook shim, the managed-block machinery and
6
- * the integrity check. It is the last unit of the generator to cross, and the reason it is
7
- * last is that it is also the unit the RUNTIME drives: eleven of its names have a consumer
8
- * outside the emit tree, ten of them in `rituals/_harness_mcp.py` (remerge, deactivate,
9
- * reactivate, uninstall), two in `rituals/_harness_exclude.py`, one in
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.
5
+ * `opencode.json` / `settings.json` merges, the managed-block machinery and the integrity
6
+ * check — and it is the unit the RUNTIME drives as well as the emit. Every emit and every
7
+ * lifecycle verb goes through this module: `js/build/emit-opencode.mjs` and
8
+ * `js/build/emit-claude.mjs` wire the host config, `js/maintain/uninstall.mjs` drives the
9
+ * unwire, `js/hosts/mcp.mjs` and `js/maintain/migrate.mjs` read through `loadJsonObject`, and
10
+ * `js/web/api.mjs` borrows the same readers for the console. The hook SHIM the merged hooks
11
+ * call lives in `./shim.mjs`, split out so the CLI's every-run dead-shim check need not load
12
+ * this module.
15
13
  *
16
14
  * THE STDOUT RULE BINDS HARDEST HERE. The hooks this module writes signal their verdict as
17
15
  * a JSON object on stdout and return 0 on EVERY path (`|| exit 0` is not what it looks
@@ -36,18 +34,14 @@
36
34
  * Fixing it means parsing into a Map and threading that through every accessor, which is a
37
35
  * much larger change than the defect.
38
36
  */
39
- import {
40
- chmodSync, existsSync, mkdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync,
41
- writeFileSync,
42
- } from 'node:fs';
37
+ import { existsSync, mkdirSync, renameSync, rmSync, unlinkSync } from 'node:fs';
43
38
  import path from 'node:path';
44
- import os from 'node:os';
45
39
  import { readText, writeText, isOsError } from '../lib/fs.mjs';
46
40
  import {
47
41
  jsonDumps, jsonDumpsCompact, jsonDumpsIndent, parseJson, deepEquals, formatRepr,
48
42
  indexOfDeepEqual, get, has, isDict,
49
43
  } from '../lib/json.mjs';
50
- import { expanduser } from './hosts.mjs';
44
+ import { SHIM_MARK, hookPrefix } from './shim.mjs';
51
45
  import { stripWhitespace, stripWhitespaceEnd } from '../lib/text.mjs';
52
46
 
53
47
  const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
@@ -94,9 +88,14 @@ const consentRuleOn = (d, excluded = []) =>
94
88
  * `clean -f`, `branch -D`, `checkout --`) stay in EVERY build: they are Law IV's territory —
95
89
  * an always-on invariant — not the process pack's. They mirror `DESTRUCTIVE_GIT_RE` in
96
90
  * js/hosts/hooks.mjs, so Claude's git-gate and OpenCode's permission ask about the same acts;
97
- * before, OpenCode only logged the last four.
91
+ * before, OpenCode only logged the last four. A glob is not a regex, so the mirror is the
92
+ * closest glob for each act rather than an exact copy: the `* ` forms catch a flag that comes
93
+ * after other words (`push origin -f`, `reset -q --hard`, `clean -d -f`) and `git push *+*`
94
+ * a `+refspec` force push. The regex stays the stricter reader — it also sees a flag cluster
95
+ * with `f` not first (`clean -d -xf`), which a glob cannot say without matching `--exclude=f`.
98
96
  */
99
- const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git reset --hard*', 'git clean -f*',
97
+ const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git push *--force*', 'git push * -f*',
98
+ 'git push *+*', 'git reset --hard*', 'git reset * --hard*', 'git clean -f*', 'git clean * -f*',
100
99
  'git branch -D*', 'git checkout -- *'];
101
100
 
102
101
  function defaultPermission(doctrines = null, excluded = []) {
@@ -267,6 +266,46 @@ export function readJsonc(text) {
267
266
  }
268
267
  }
269
268
 
269
+ /**
270
+ * A config file read as a JSON OBJECT, classified rather than decided:
271
+ * `{ state, data, hadComments }`, `state` one of
272
+ * - `'absent'` — no file at `p`
273
+ * - `'unreadable'` — it exists and the read threw (`error` carries why)
274
+ * - `'invalid'` — it does not parse (`data` null)
275
+ * - `'notObject'` — it parses, to something that is not an object (`[]`, `3`)
276
+ * - `'ok'` — `data` is the object.
277
+ *
278
+ * ONE READER, MANY POLICIES. Ten call sites had each restated read → `readJsonc` → `isDict`, and
279
+ * their refusal rules had drifted: some warned, some returned `false`, some swallowed a
280
+ * non-OS throw. The reading is the same everywhere and lives here; what a state MEANS stays at
281
+ * the caller, because it really does differ — a merge must refuse to rewrite a file it could
282
+ * not read, an unwire just declines, an integrity check reports. `hadComments` is reported in
283
+ * every state that got as far as the text, since `mcpCommented` asks it of files that do not
284
+ * parse.
285
+ *
286
+ * `strict` reads plain JSON (`parseJson`) — comments and trailing commas are then `'invalid'`.
287
+ * The Claude-style MCP configs are strict by contract (see `mcpLoad`).
288
+ */
289
+ export function loadJsonObject(p, { strict = false } = {}) {
290
+ if (!existsSync(p)) return { state: 'absent', data: null, hadComments: false };
291
+ let text;
292
+ try {
293
+ text = readText(p);
294
+ } catch (e) {
295
+ if (!isOsError(e)) throw e;
296
+ return { state: 'unreadable', data: null, hadComments: false, error: e };
297
+ }
298
+ let data;
299
+ let hadComments = false;
300
+ if (strict) {
301
+ try { data = parseJson(text); } catch { data = null; }
302
+ } else {
303
+ [data, hadComments] = readJsonc(text);
304
+ }
305
+ if (data === null) return { state: 'invalid', data, hadComments };
306
+ return { state: isDict(data) ? 'ok' : 'notObject', data, hadComments };
307
+ }
308
+
270
309
  /**
271
310
  * `_build_settings._warn_commented_jsonc`. Prints to STDOUT — the Python's own choice.
272
311
  *
@@ -297,8 +336,17 @@ function warnCommentedJsonc(target, agentPath, permission,
297
336
  }
298
337
  }
299
338
 
300
- /** `_build_settings._atomic_write_json`. Throws on failure, as the Python does. */
339
+ /**
340
+ * `_build_settings._atomic_write_json` — the ONE atomic JSON writer: pretty JSON through a
341
+ * sibling temp file and a rename, creating the parent directory first. Throws on failure, as
342
+ * the Python does, and never leaves the temp file behind.
343
+ *
344
+ * Every config this codebase rewrites goes through it — settings, `opencode.json`, MCP
345
+ * configs, the install manifest — because a plain write that dies half-way through leaves a
346
+ * truncated file where the user's config was.
347
+ */
301
348
  export function atomicWriteJson(p, config) {
349
+ mkdirSync(path.dirname(p), { recursive: true });
302
350
  const tmp = `${p}.geneseed-tmp`;
303
351
  writeText(tmp, `${jsonDumpsIndent(config)}\n`);
304
352
  try {
@@ -319,28 +367,20 @@ export function atomicWriteJson(p, config) {
319
367
  export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = []) {
320
368
  const target = opencodeTarget(p);
321
369
  let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
322
- let hadComments = false;
323
- if (existsSync(target)) {
324
- let raw;
325
- try {
326
- raw = readText(target);
327
- } catch (e) {
328
- if (!isOsError(e)) throw e;
329
- process.stderr.write(`[geneseed] WARN: could not read ${target} (${e.message}) `
330
- + `— NOT touching it. Add ${jsonDumps(agentPath)} to its "instructions" array by `
331
- + "hand once it's readable again.\n");
332
- return target;
333
- }
334
- const [loaded, hc] = readJsonc(raw);
335
- hadComments = hc;
336
- if (loaded === null || !isDict(loaded)) {
337
- process.stderr.write(`[geneseed] ${path.basename(target)} is not a JSON object — NOT `
338
- + `rewriting it (fix the file, then re-run). Add ${jsonDumps(agentPath)} to its `
339
- + '"instructions" once repaired.\n');
340
- return target;
341
- }
342
- config = loaded;
370
+ const { state, data, hadComments, error } = loadJsonObject(target);
371
+ if (state === 'unreadable') {
372
+ process.stderr.write(`[geneseed] WARN: could not read ${target} (${error.message}) `
373
+ + `— NOT touching it. Add ${jsonDumps(agentPath)} to its "instructions" array by `
374
+ + "hand once it's readable again.\n");
375
+ return target;
376
+ }
377
+ if (state === 'invalid' || state === 'notObject') {
378
+ process.stderr.write(`[geneseed] ${path.basename(target)} is not a JSON object — NOT `
379
+ + `rewriting it (fix the file, then re-run). Add ${jsonDumps(agentPath)} to its `
380
+ + '"instructions" once repaired.\n');
381
+ return target;
343
382
  }
383
+ if (state === 'ok') config = data;
344
384
  if (!has(config, '$schema')) config.$schema = OPENCODE_SCHEMA;
345
385
  let instr = get(config, 'instructions');
346
386
  if (!Array.isArray(instr)) instr = [];
@@ -418,240 +458,6 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
418
458
  // gate does not drive a cell through a real fs failure — it exercises the branch with an
419
459
  // injectable failure instead and compares everything except the OS's own wording.
420
460
 
421
- // ---- the hook shim ----------------------------------------------------------------
422
- // `|| exit 0` on the emitted commands does NOT mean "ignore failures": git-gate and
423
- // rule-gate return 0 on EVERY path and signal their verdict as a JSON object on stdout.
424
- // The `|| exit 0` is there because a hook that fails to LAUNCH (a moved checkout, a dead
425
- // interpreter) must not block the tool call. A stray byte on stdout — a cmd.exe command
426
- // echo, a "created ~/.geneseed" notice — corrupts that JSON and the gate stops gating
427
- // while still reporting success. Hence `@echo off`, and a shim that prints nothing.
428
- //
429
- // The marker lives in the FILENAME, not the directory: GENESEED_HOME relocates the dir,
430
- // and a relocated install's hooks must stay recognisable to `GENESEED_HOOK_SNIFF`.
431
- export const SHIM_MARK = 'geneseed-hook';
432
-
433
- /**
434
- * `_build_settings._SHIM_ARGV` — the tokens in a shim body that are NOT paths.
435
- *
436
- * `shimProblems` reads every quoted token back out of the body and requires each to name an
437
- * existing file. On Windows that is exactly the two baked paths (`%*` is bare); on POSIX the
438
- * body forwards argv as `"$@"`, quoted, so both implementations' `doctor` reported a healthy
439
- * shim as dead on every Linux and macOS host. A defect the port faithfully inherited, and
440
- * one no Windows cell could see.
441
- */
442
- export const SHIM_ARGV = new Set(['$@', '%*']);
443
-
444
- /**
445
- * Every quoted token in a shim body that names a path which is not there — empty is healthy.
446
- *
447
- * ONE OWNER, because the rule has two consumers now and a second copy is a second place for the
448
- * `"$@"` defect above to come back: `js/inspect/checks-repo.mjs` turns this into the `[shim] … does not
449
- * exist` report, and `hookPrefix` reads it to decide whether the shim already on disk is worth
450
- * protecting from a checkout that will not outlive the emit.
451
- */
452
- export function shimDeadPaths(body) {
453
- return [...body.matchAll(/"([^"]+)"/g)].map((m) => m[1])
454
- .filter((q) => !SHIM_ARGV.has(q) && !existsSync(q));
455
- }
456
-
457
- /** `_SHIM_REL`, with the platform as an argument.
458
- *
459
- * Python computes it at IMPORT time from `sys.platform`, which is what makes the other
460
- * platform's branch unreachable on any one machine. Taking it as a parameter is what lets
461
- * the parity gate compare both branches in one run (Python's side is reachable because
462
- * `_hook_shim_body` reads `sys.platform` at CALL time). */
463
- export function shimRel(platform = process.platform) {
464
- return ['bin', SHIM_MARK + (platform === 'win32' ? '.cmd' : '')];
465
- }
466
-
467
- /**
468
- * `_build_settings._shim_home`.
469
- *
470
- * `expanduser` is `js/hosts/hosts.mjs`'s — GENESEED_HOME is user-set, same as any config-dir
471
- * env var there, so a `~user` value here has the identical failure mode (a hook shim written
472
- * under a literal `~user` directory) and gets the identical refusal. This module used to carry
473
- * a private "same guard" twin; the two had drifted (this one's refusal message dropped the
474
- * parenthetical explaining WHY), which is exactly the kind of divergence one owner prevents.
475
- */
476
- export function shimHome() {
477
- const env = process.env.GENESEED_HOME;
478
- return env ? expanduser(env) : path.join(os.homedir(), '.geneseed');
479
- }
480
-
481
- /** `_build_settings._hook_shim_path`. */
482
- export function hookShimPath(platform = process.platform) {
483
- return path.join(shimHome(), ...shimRel(platform));
484
- }
485
-
486
- /**
487
- * The paths the machine's hook shim names that are gone — `[]` when the shim is absent or live.
488
- *
489
- * Cheap on purpose: `status`, the doctor and EVERY CLI run call it (the run-time warning in
490
- * `bin/geneseed-cli.mjs`), because a dead shim disables every hook of every Claude-shaped
491
- * install and nothing else would say so. An absent shim is not dead — a checkout that has never
492
- * emitted owns none — and an unreadable one is reported by the doctor, not here.
493
- */
494
- export function shimDead() {
495
- try { return shimDeadPaths(readText(hookShimPath())); } catch { return []; }
496
- }
497
-
498
- /**
499
- * `_build_settings._hook_shim_body`, with the two volatile values INJECTED.
500
- *
501
- * This is the one function in the unit whose Python output is legitimately
502
- * runtime-dependent: it bakes `sys.executable` and `<checkout>/rituals/harness.py`, and a
503
- * Node twin at GA bakes `process.execPath` and a different entry point. Golden already
504
- * refuses to compare the emitted shim for that reason (`_SHIM_GLOB`) and asserts
505
- * `_shim_health` instead.
506
- *
507
- * So the answer here is NOT "skip it". Taking `runner`, `entry` and `platform` as arguments
508
- * makes the BODY a pure function of three inputs, which is what lets
509
- * `tests/unit/hook_form.test.mjs` assert BOTH platform shapes absolutely in one run on
510
- * whichever host it happens to be on: the `@echo off`, the CRLF, `exit /b` vs `exec`, the
511
- * quoting that keeps `--root "<cfg>"` intact. What it does not prove is WHICH values the
512
- * driver passes — that is `bin/build-driver.mjs`'s `hookRunnerEntry()`, gated separately.
513
- */
514
- export function hookShimBody(runner, entry, platform = process.platform) {
515
- if (platform === 'win32') {
516
- // Bare `exit /b` propagates the LIVE errorlevel; `%ERRORLEVEL%` would expand at parse
517
- // time and return a stale one. Never plain `exit` — that kills the parent cmd.exe, so
518
- // the emitted `|| exit 0` would never get to evaluate.
519
- return '@echo off\r\n'
520
- + 'rem Generated by Geneseed - do not edit. Rewritten on every emit.\r\n'
521
- + 'setlocal\r\n'
522
- + `"${runner}" "${entry}" %*\r\n`
523
- + 'exit /b\r\n';
524
- }
525
- return '#!/bin/sh\n'
526
- + '# Generated by Geneseed - do not edit. Rewritten on every emit.\n'
527
- + `exec "${runner}" "${entry}" "$@"\n`;
528
- }
529
-
530
- /**
531
- * `_build_settings._write_hook_shim` — create or refresh the shim, or null on failure.
532
- *
533
- * The path and body are arguments for the same reason `hookShimBody`'s two values are: the
534
- * routine itself has no runtime dependency at all once they are supplied, so all of it is
535
- * comparable — the unchanged-content fast path (a Windows shim a hook is executing right
536
- * now cannot be replaced), the newline-folded comparison that makes that fast path
537
- * reachable, the pid-suffixed temp name that keeps concurrent emits from unlinking each
538
- * other's file, and the chmod.
539
- */
540
- export function writeHookShim(p, body, platform = process.platform) {
541
- try {
542
- // Newline-normalised: the body carries explicit CRLF on Windows but `read_text()`
543
- // translates back to `\n`, so a raw `===` would never match and the "unchanged" fast
544
- // path — the whole point of this branch — would never be taken.
545
- if (existsSync(p) && statSync(p).isFile()
546
- && readText(p).replaceAll('\r\n', '\n') === body.replaceAll('\r\n', '\n')) {
547
- return p;
548
- }
549
- mkdirSync(path.dirname(p), { recursive: true });
550
- const tmp = `${p}.${process.pid}.tmp`;
551
- // `newline=''` on the Python side: raw, so the CRLF in the body survives verbatim.
552
- writeFileSync(tmp, body, 'utf8');
553
- if (platform !== 'win32') chmodSync(tmp, 0o755);
554
- renameSync(tmp, p);
555
- return p;
556
- } catch (e) {
557
- if (!isOsError(e)) throw e;
558
- return null;
559
- }
560
- }
561
-
562
- /** The OS temp root, resolved once. `.native` because a Windows `TEMP` can be an 8.3 alias —
563
- * the same trap `tests/helpers/sandbox.mjs` documents, reached from the other side. */
564
- const TMP_REAL = (() => {
565
- try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
566
- })();
567
-
568
- /** Is `child` inside `parent`? `path.relative` compares case-insensitively on win32. */
569
- function isUnder(child, parent) {
570
- const rel = path.relative(parent, child);
571
- return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
572
- }
573
-
574
- /**
575
- * Will the checkout holding `entry` outlive the emit that is baking it into the shim?
576
- *
577
- * TWO SHAPES, and both are in `docs/extending.md` §5.3 because both have really happened on this
578
- * repo: a copied checkout under the OS temp root — the `copyCheckout` fixture, which builds its
579
- * copy from `git ls-files` and therefore carries no `.git` at all, so only the temp root
580
- * identifies it — and a git WORKTREE, whose `.git` is a FILE pointing at the main checkout
581
- * rather than a directory. A plain checkout and an npm install are neither, and keep the
582
- * ownership they have always had.
583
- *
584
- * `tmpRoot` IS A PARAMETER for the reason `shimRel`'s `platform` is: with the real temp root
585
- * baked in, the second rule is unreachable in a test — anything a test can build under `mkdtemp`
586
- * has already answered true on the first — so the worktree arm could only ever be exercised by
587
- * whichever kind of checkout the run happened to sit in.
588
- */
589
- export function ephemeralCheckout(entry, tmpRoot = TMP_REAL) {
590
- let real = entry;
591
- try { real = realpathSync.native(entry); } catch { /* not there yet — test the literal */ }
592
- if (isUnder(real, tmpRoot)) return true;
593
- // `<checkout>/bin/geneseed-hook.mjs` — the one shape `hookRunnerEntry` produces.
594
- try { return statSync(path.join(path.dirname(path.dirname(real)), '.git')).isFile(); } catch {
595
- return false;
596
- }
597
- }
598
-
599
- /** Does the shim already on disk still name paths that all exist? */
600
- function shimIsLive(p) {
601
- try { return statSync(p).isFile() && shimDeadPaths(readText(p)).length === 0; } catch {
602
- return false;
603
- }
604
- }
605
-
606
- /**
607
- * `_build_settings._hook_prefix` — the `<runner> <entrypoint>` every emitted hook starts
608
- * with, falling back to the pre-shim direct form when the shim cannot be written.
609
- *
610
- * The fallback is strictly no worse than the old behaviour and far better than emitting
611
- * commands naming a shim that does not exist — those fail on every hook (9009 under
612
- * cmd.exe, 127 under sh) and take both gates down with them.
613
- */
614
- export function hookPrefix({ runner, entry, platform = process.platform } = {}) {
615
- // NOT a default. `runner`/`entry` have no computable fallback on this side — the child's
616
- // own `process.execPath` is node and the hooks it wires are Python — and the failure mode
617
- // of letting them through undefined is the worst kind this unit has: `hookShimBody` bakes
618
- // `"undefined" "undefined" %*`, the shim is rewritten with it, every hook in the install
619
- // dies, and because the hooks signal through stdout and return 0 on every path, nothing
620
- // reports it. The shim is also excluded from golden's byte comparison by name, so no
621
- // acceptance gate would catch it either. Throw where it is cheap to see.
622
- if (!runner || !entry) {
623
- throw new Error('hookPrefix: runner and entry are required — the emitted hook shim '
624
- + 'bakes them, and there is no correct value to guess from inside Node');
625
- }
626
- const p = hookShimPath(platform);
627
- // ⚠ A DISPOSABLE CHECKOUT DOES NOT GET TO CLAIM THE MACHINE-WIDE SHIM. The shim has no
628
- // per-install component, so the last checkout to emit owns EVERY install's hooks — and when
629
- // that checkout is a test sandbox or a git worktree, deleting it kills hooks machine-wide with
630
- // nothing to report it: hooks signal through stdout and return 0 on every path, and this file
631
- // is excluded from the byte corpora by name. Measured twice in one session, from
632
- // `tests/unit/harness.test.mjs`'s copied-checkout fixture; `docs/extending.md` §5.3 carries
633
- // the worktree half.
634
- //
635
- // KEEP, NOT REFUSE, and that distinction is what makes this safe to add here rather than at
636
- // each call site. Returning the existing shim path leaves the emitted hook command
637
- // byte-identical to what a normal emit writes; taking the fallback below instead would move a
638
- // hook command in every recorded bundle. The install being emitted then runs the DURABLE
639
- // checkout's entry — which is exactly what last-writer-wins already handed every other install
640
- // on the machine, so nothing is lost by it.
641
- //
642
- // ONLY A SHIM THAT STILL RESOLVES IS PROTECTED. An absent or already-dead one is no worse for
643
- // being rewritten from here, and a sandboxed `GENESEED_HOME` (every emit test, every cell) has
644
- // none — so the suite's own emits are unaffected and this cannot go green by silently
645
- // skipping the write.
646
- if (ephemeralCheckout(entry) && shimIsLive(p)) return `"${p}"`;
647
- const shim = writeHookShim(p, hookShimBody(runner, entry, platform), platform);
648
- if (shim !== null) return `"${shim}"`;
649
- process.stderr.write(`[geneseed] WARN: could not write the hook shim at ${p} — emitting `
650
- + 'hooks that call the interpreter directly. They will break if this checkout moves; '
651
- + 're-run the build to repair them.\n');
652
- return `"${runner}" "${entry}"`;
653
- }
654
-
655
461
  /**
656
462
  * `_build_settings._claude_hook_groups` — Geneseed's Claude hooks, keyed by event.
657
463
  *
@@ -749,6 +555,38 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
749
555
  };
750
556
  }
751
557
 
558
+ /**
559
+ * An existing settings file read for a read-modify-write: `[config, hadComments]`, or `null`
560
+ * after saying on stderr why the file must NOT be rewritten.
561
+ *
562
+ * Every refusal is the same refusal `mergeOpencodeJson` makes, for the same reason: the merge
563
+ * starts from `{}` when it has nothing to start from, and `atomicWriteJson` would then
564
+ * replace the user's file with Geneseed's keys alone. A syntax error, valid JSON that is not
565
+ * an object (`[]`), and a file that exists but cannot be read are all "nothing to start
566
+ * from", and none of them is the user's consent to be overwritten. An ABSENT file is the one
567
+ * real empty start: `[{}, false]`.
568
+ */
569
+ function readSettingsForMerge(p, consequence) {
570
+ const { state, data, hadComments, error } = loadJsonObject(p);
571
+ if (state === 'absent') return [{}, false];
572
+ if (state === 'unreadable') {
573
+ process.stderr.write(`[geneseed] WARN: could not read ${p} (${error.message}) — NOT `
574
+ + `touching it. ${consequence}\n`);
575
+ return null;
576
+ }
577
+ if (state === 'invalid') {
578
+ process.stderr.write(`[geneseed] ${path.basename(p)} is not valid JSON — NOT `
579
+ + `rewriting it (fix the syntax, then re-run). ${consequence}\n`);
580
+ return null;
581
+ }
582
+ if (state === 'notObject') {
583
+ process.stderr.write(`[geneseed] ${path.basename(p)} is not a JSON object — NOT `
584
+ + `rewriting it (fix the file, then re-run). ${consequence}\n`);
585
+ return null;
586
+ }
587
+ return [data, hadComments];
588
+ }
589
+
752
590
  /**
753
591
  * `_build_settings._merge_claude_settings` — returns `[target, managed]`.
754
592
  *
@@ -758,33 +596,18 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
758
596
  * that a re-emit stacks the new group beside the stale one and `learn` runs twice per Stop.
759
597
  * `managed` is the complete current claim set, so unwire removes exactly those.
760
598
  *
761
- * `scope` is accepted and unused, exactly as in the Python.
762
- *
763
599
  * `cfgDir` is the install's own dir — what the hooks carry as `--root` and under which
764
600
  * `memory/` lives. It is NOT always `dirname(p)`: Bob's global settings file is nested
765
601
  * (`~/.bob/settings/settings.json`), and deriving the root from it pointed every global Bob
766
602
  * hook at `~/.bob/settings` — memory learned where nothing reads it, excludes missed, and a
767
603
  * global that never stood down for a project install.
768
604
  */
769
- export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hookOpts = {},
605
+ export function mergeClaudeSettings(p, priorHooks = null, hookOpts = {},
770
606
  doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p)) {
771
607
  const prior = (priorHooks || []).filter(isDict);
772
- let config = {};
773
- let hadComments = false;
774
- if (existsSync(p)) {
775
- try {
776
- const [loaded, hc] = readJsonc(readText(p));
777
- hadComments = hc;
778
- if (loaded === null) {
779
- process.stderr.write(`[geneseed] ${path.basename(p)} is not valid JSON — NOT `
780
- + 'rewriting it (fix the syntax, then re-run). Hooks were not wired.\n');
781
- return [p, prior];
782
- }
783
- if (isDict(loaded)) config = loaded;
784
- } catch (e) {
785
- if (!isOsError(e)) throw e;
786
- }
787
- }
608
+ const loaded = readSettingsForMerge(p, 'Hooks were not wired.');
609
+ if (!loaded) return [p, prior];
610
+ const [config, hadComments] = loaded;
788
611
  let hooks = get(config, 'hooks');
789
612
  if (!isDict(hooks)) hooks = {};
790
613
  const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host);
@@ -827,17 +650,9 @@ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hoo
827
650
  + 'adapters/claude-code/settings.json.\n');
828
651
  return [p, prior];
829
652
  }
830
- // The `else` is DEAD on both sides and reproduced rather than dropped: `canonical` always
831
- // carries at least three events, so by the time control reaches here `hooks` is never empty. A
832
- // mutation keeping the empty block instead of deleting it is the one of thirty-three that
833
- // stays green, and it stays green because there is nothing to detect — recorded the way
834
- // `themed_rel` is, so "the gate cannot see it" and "there is nothing to see" keep their
835
- // distance. NOT the way `lstripNewlines` is: that one looked unobservable for two phases
836
- // and turned out to be mutation M7, killed by `golden.mjs --idempotent` through
837
- // `managedBlockWrite`'s `updated` branch, which only a RE-emit reaches.
838
- if (Object.keys(hooks).length) config.hooks = hooks;
839
- else delete config.hooks;
840
- mkdirSync(path.dirname(p), { recursive: true });
653
+ // Never empty here: `canonical` always carries at least three events, and every one of them
654
+ // was just written into `hooks`.
655
+ config.hooks = hooks;
841
656
  atomicWriteJson(p, config);
842
657
  return [p, managedNow];
843
658
  }
@@ -849,16 +664,11 @@ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hoo
849
664
  * can report reality instead of assuming success.
850
665
  */
851
666
  export function unwireClaudeSettings(p, added) {
852
- if (!existsSync(p) || !added || !added.length) return false;
853
- let loaded;
854
- let hadComments;
855
- try {
856
- [loaded, hadComments] = readJsonc(readText(p));
857
- } catch (e) {
858
- if (!isOsError(e)) throw e;
859
- return false;
860
- }
861
- if (hadComments || !isDict(loaded)) return false;
667
+ if (!added || !added.length) return false;
668
+ // Every state but a comment-free object declines: an unwire never rewrites what it could not
669
+ // read whole, and a commented file would lose its comments.
670
+ const { state, data: loaded, hadComments } = loadJsonObject(p);
671
+ if (state !== 'ok' || hadComments) return false;
862
672
  const hooks = get(loaded, 'hooks');
863
673
  if (!isDict(hooks)) return false;
864
674
  for (const rec of added) {
@@ -889,22 +699,9 @@ export function unwireClaudeSettings(p, added) {
889
699
  export function wireClaudeExcludes(p, excludes) {
890
700
  const want = (excludes || []).filter(Boolean);
891
701
  if (!want.length) return [];
892
- let config = {};
893
- let hadComments = false;
894
- if (existsSync(p)) {
895
- try {
896
- const [loaded, hc] = readJsonc(readText(p));
897
- hadComments = hc;
898
- if (loaded === null) {
899
- process.stderr.write(`[geneseed] ${path.basename(p)} is not valid JSON — NOT `
900
- + 'rewriting it (fix the syntax, then re-run). Excludes were not wired.\n');
901
- return [];
902
- }
903
- if (isDict(loaded)) config = loaded;
904
- } catch (e) {
905
- if (!isOsError(e)) throw e;
906
- }
907
- }
702
+ const loaded = readSettingsForMerge(p, 'Excludes were not wired.');
703
+ if (!loaded) return [];
704
+ const [config, hadComments] = loaded;
908
705
  let cur = get(config, 'claudeMdExcludes');
909
706
  if (!Array.isArray(cur)) cur = [];
910
707
  const added = want.filter((e) => indexOfDeepEqual(cur, e) < 0);
@@ -917,7 +714,6 @@ export function wireClaudeExcludes(p, excludes) {
917
714
  }
918
715
  cur.push(...added);
919
716
  config.claudeMdExcludes = cur;
920
- mkdirSync(path.dirname(p), { recursive: true });
921
717
  try {
922
718
  atomicWriteJson(p, config);
923
719
  } catch (e) {
@@ -932,16 +728,9 @@ export function wireClaudeExcludes(p, excludes) {
932
728
 
933
729
  /** `_build_settings._unwire_claude_excludes`. */
934
730
  export function unwireClaudeExcludes(p, excludes) {
935
- if (!existsSync(p) || !excludes || !excludes.length) return;
936
- let loaded;
937
- let hadComments;
938
- try {
939
- [loaded, hadComments] = readJsonc(readText(p));
940
- } catch (e) {
941
- if (!isOsError(e)) throw e;
942
- return;
943
- }
944
- if (hadComments || !isDict(loaded)) return;
731
+ if (!excludes || !excludes.length) return;
732
+ const { state, data: loaded, hadComments } = loadJsonObject(p);
733
+ if (state !== 'ok' || hadComments) return;
945
734
  const cur = get(loaded, 'claudeMdExcludes');
946
735
  if (!Array.isArray(cur)) return;
947
736
  for (const e of excludes) {
@@ -966,71 +755,6 @@ export function unwireClaudeExcludes(p, excludes) {
966
755
  // silently leave live hooks behind in every config emitted before the shim landed.
967
756
  export const GENESEED_HOOK_SNIFF = ['harness.py', SHIM_MARK];
968
757
 
969
- // P10d. `GENESEED_HOOK_SNIFF` answers "is this hook Geneseed's?". A MIGRATION needs the other
970
- // question — "is this Geneseed's OLD one?" — and the two are not the same sniff.
971
- //
972
- // THREE SHAPES ARE IN THE WILD, AND TWO OF THEM ARE IDENTICAL IN THE SETTINGS FILE:
973
- // 1. legacy-direct the command itself names `harness.py` (pre-shim, P0 and earlier)
974
- // 2. shim-python the command names the shim; the shim BODY runs python + harness.py
975
- // 3. shim-node the command names the shim; the shim BODY runs node + the .mjs entry
976
- //
977
- // 2 and 3 differ ONLY inside the shim body. A classifier reading the host config alone would
978
- // call a fully-unmigrated machine "already migrated" — silently — and `migrate` would no-op on
979
- // exactly the installs it exists for. The shim body is a REQUIRED input, not a refinement.
980
- const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
981
-
982
- /**
983
- * `_build_settings._migrate_shape` — 'legacy' | 'current' | 'none'.
984
- *
985
- * PURE — and currently reached only END TO END, by the `migrate/a-node-baked-shim-reads-as-current`
986
- * cell. No unit corpus drives it directly, which is worth knowing before trusting it.
987
- * 'none' is not a fault: a user's own settings.json may carry three hand-written hooks
988
- * and no Geneseed entry. That must read as
989
- * "nothing to migrate" rather than "unrecognised", or `migrate` refuses on the commonest
990
- * config on any machine.
991
- */
992
- export function migrateShape(commands, shimBody) {
993
- if (commands.some((c) => c.includes('harness.py'))) return 'legacy';
994
- if (commands.some((c) => c.includes(SHIM_MARK))) {
995
- return shimBody.includes(SHIM_ENTRY_MARK) ? 'current' : 'legacy';
996
- }
997
- return 'none';
998
- }
999
-
1000
- /**
1001
- * `_build_settings._autostart_paths` — where a hand-written web-daemon autostart entry lives.
1002
- *
1003
- * NOTHING IN THIS REPOSITORY HAS EVER WRITTEN ONE — the install guide tells the user to create them
1004
- * by hand, and no .py/.mjs/.sh/.cmd in the tree contains `vbs`, `LaunchAgents` or `plist`. So
1005
- * `migrate` REPORTS a stale one and never rewrites it, on the rule `settingsIntegrityCheck`
1006
- * already states: an entry the manifest does not claim is "possibly user-authored; left alone".
1007
- *
1008
- * Both platforms' paths on both platforms, deliberately — the scan is a read that misses
1009
- * harmlessly, and returning only the host platform's would make the macOS arm unreachable
1010
- * from the Windows machine this port is developed on: an ungated branch dressed as a fork.
1011
- */
1012
- export function autostartPaths() {
1013
- const home = os.homedir();
1014
- return [
1015
- path.join(home, 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu',
1016
- 'Programs', 'Startup', 'geneseed-web.vbs'),
1017
- path.join(home, 'Library', 'LaunchAgents', 'dev.geneseed.web.plist'),
1018
- ];
1019
- }
1020
-
1021
- /**
1022
- * `_build_settings._autostart_stale` — the entry names a Geneseed launcher somewhere else.
1023
- *
1024
- * Weak in one direction and strict in the other on purpose: it fires only when the file
1025
- * mentions geneseed at all (an unrelated Startup entry is never named), and clears only when
1026
- * the CURRENT root appears verbatim. A false positive costs one printed line; a false negative
1027
- * leaves a login task pointing at a checkout npm is about to make stale.
1028
- */
1029
- export function autostartStale(text, root) {
1030
- if (!text.toLowerCase().includes('geneseed')) return false;
1031
- return !text.includes(root) && !text.includes(root.replaceAll('\\', '/'));
1032
- }
1033
-
1034
758
  /** `_build_settings._settings_hook_groups` — flatten `hooks` to [event, group] pairs. */
1035
759
  function settingsHookGroups(loaded) {
1036
760
  const hooks = get(loaded, 'hooks');
@@ -1060,22 +784,19 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1060
784
  return problems;
1061
785
  };
1062
786
  const m = isDict(managed) ? managed : {};
1063
- if (!existsSync(p)) {
787
+ const { state, data: loaded, error } = loadJsonObject(p);
788
+ if (state === 'absent') {
1064
789
  if (expect === 'present') {
1065
790
  problems.push(`${p}: file does not exist, but hooks/excludes were supposed to be `
1066
791
  + 'wired into it');
1067
792
  }
1068
793
  return flush();
1069
794
  }
1070
- let loaded;
1071
- try {
1072
- [loaded] = readJsonc(readText(p));
1073
- } catch (e) {
1074
- if (!isOsError(e)) throw e;
1075
- problems.push(`${p}: could not read the file (${e.message})`);
795
+ if (state === 'unreadable') {
796
+ problems.push(`${p}: could not read the file (${error.message})`);
1076
797
  return flush();
1077
798
  }
1078
- if (!isDict(loaded)) {
799
+ if (state !== 'ok') {
1079
800
  problems.push(`${p}: not a JSON object — cannot verify hooks/excludes`);
1080
801
  return flush();
1081
802
  }