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
@@ -0,0 +1,375 @@
1
+ /**
2
+ * The machine-wide HOOK SHIM — where it lives, what it says, whether it still resolves — and
3
+ * the two shim-shaped readers `migrate` needs (`migrateShape`, the autostart scan).
4
+ *
5
+ * SPLIT OUT OF `js/hosts/settings.mjs` (2026-10) for its cheapest caller. Every CLI run asks
6
+ * `shimDead()` (the dead-shim warning in `bin/geneseed-cli.mjs`), and that used to load the
7
+ * whole settings-merge module — the JSONC reader, the JSON printer, the text helpers — to read
8
+ * one small file. `uninstall` had the same problem one level up: it imported
9
+ * `js/build/driver.mjs`, and through it the whole generator, for the two-line
10
+ * `hookRunnerEntry`, which now lives here beside the `hookPrefix` that consumes its answer.
11
+ *
12
+ * Keep this module's imports to `node:` builtins, `js/lib/fs.mjs` and `./hosts.mjs`: being
13
+ * cheap to load is its whole reason to exist.
14
+ */
15
+ import {
16
+ chmodSync, existsSync, mkdirSync, realpathSync, renameSync, statSync, writeFileSync,
17
+ } from 'node:fs';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+ import { readText, isOsError } from '../lib/fs.mjs';
21
+ import { expanduser } from './hosts.mjs';
22
+
23
+ /** The checkout this module ships in — `js/build/source.mjs`'s `ROOT`, computed locally so the
24
+ * shim does not import the source loader. */
25
+ const ROOT = path.resolve(import.meta.dirname, '../..');
26
+
27
+ // `|| exit 0` on the emitted commands does NOT mean "ignore failures": git-gate and
28
+ // rule-gate return 0 on EVERY path and signal their verdict as a JSON object on stdout.
29
+ // The `|| exit 0` is there because a hook that fails to LAUNCH (a moved checkout, a dead
30
+ // interpreter) must not block the tool call. A stray byte on stdout — a cmd.exe command
31
+ // echo, a "created ~/.geneseed" notice — corrupts that JSON and the gate stops gating
32
+ // while still reporting success. Hence `@echo off`, and a shim that prints nothing.
33
+ //
34
+ // The marker lives in the FILENAME, not the directory: GENESEED_HOME relocates the dir,
35
+ // and a relocated install's hooks must stay recognisable to `GENESEED_HOOK_SNIFF`.
36
+ export const SHIM_MARK = 'geneseed-hook';
37
+
38
+ /**
39
+ * `_build_settings._SHIM_ARGV` — the tokens in a shim body that are NOT paths.
40
+ *
41
+ * `shimProblems` reads every quoted token back out of the body and requires each to name an
42
+ * existing file. On Windows that is exactly the two baked paths (`%*` is bare); on POSIX the
43
+ * body forwards argv as `"$@"`, quoted, so both implementations' `doctor` reported a healthy
44
+ * shim as dead on every Linux and macOS host. A defect the port faithfully inherited, and
45
+ * one no Windows cell could see.
46
+ */
47
+ export const SHIM_ARGV = new Set(['$@', '%*']);
48
+
49
+ /**
50
+ * Every quoted token in a shim body that names a path which is not there — empty is healthy.
51
+ *
52
+ * ONE OWNER, because the rule has two consumers now and a second copy is a second place for the
53
+ * `"$@"` defect above to come back: `js/inspect/checks-repo.mjs` turns this into the `[shim] … does not
54
+ * exist` report, and `hookPrefix` reads it to decide whether the shim already on disk is worth
55
+ * protecting from a checkout that will not outlive the emit.
56
+ */
57
+ export function shimDeadPaths(body) {
58
+ return [...body.matchAll(/"([^"]+)"/g)].map((m) => m[1])
59
+ .filter((q) => !SHIM_ARGV.has(q) && !existsSync(q));
60
+ }
61
+
62
+ /** `_SHIM_REL`, with the platform as an argument.
63
+ *
64
+ * Python computes it at IMPORT time from `sys.platform`, which is what makes the other
65
+ * platform's branch unreachable on any one machine. Taking it as a parameter is what lets
66
+ * the parity gate compare both branches in one run (Python's side is reachable because
67
+ * `_hook_shim_body` reads `sys.platform` at CALL time). */
68
+ export function shimRel(platform = process.platform) {
69
+ return ['bin', SHIM_MARK + (platform === 'win32' ? '.cmd' : '')];
70
+ }
71
+
72
+ /**
73
+ * `_build_settings._shim_home`.
74
+ *
75
+ * `expanduser` is `js/hosts/hosts.mjs`'s — GENESEED_HOME is user-set, same as any config-dir
76
+ * env var there, so a `~user` value here has the identical failure mode (a hook shim written
77
+ * under a literal `~user` directory) and gets the identical refusal. This module used to carry
78
+ * a private "same guard" twin; the two had drifted (this one's refusal message dropped the
79
+ * parenthetical explaining WHY), which is exactly the kind of divergence one owner prevents.
80
+ */
81
+ export function shimHome() {
82
+ const env = process.env.GENESEED_HOME;
83
+ return env ? expanduser(env) : path.join(os.homedir(), '.geneseed');
84
+ }
85
+
86
+ /** `_build_settings._hook_shim_path`. */
87
+ export function hookShimPath(platform = process.platform) {
88
+ return path.join(shimHome(), ...shimRel(platform));
89
+ }
90
+
91
+ /**
92
+ * The paths the machine's hook shim names that are gone — `[]` when the shim is absent or live.
93
+ *
94
+ * Cheap on purpose: `status`, the doctor and EVERY CLI run call it (the run-time warning in
95
+ * `bin/geneseed-cli.mjs`), because a dead shim disables every hook of every Claude-shaped
96
+ * install and nothing else would say so. An absent shim is not dead — a checkout that has never
97
+ * emitted owns none — and an unreadable one is reported by the doctor, not here.
98
+ */
99
+ export function shimDead() {
100
+ try { return shimDeadPaths(readText(hookShimPath())); } catch { return []; }
101
+ }
102
+
103
+ /**
104
+ * `_build_settings._hook_shim_body`, with the two volatile values INJECTED.
105
+ *
106
+ * This is the one function in the unit whose Python output is legitimately
107
+ * runtime-dependent: it bakes `sys.executable` and `<checkout>/rituals/harness.py`, and a
108
+ * Node twin at GA bakes `process.execPath` and a different entry point. Golden already
109
+ * refuses to compare the emitted shim for that reason (`_SHIM_GLOB`) and asserts
110
+ * `_shim_health` instead.
111
+ *
112
+ * So the answer here is NOT "skip it". Taking `runner`, `entry` and `platform` as arguments
113
+ * makes the BODY a pure function of three inputs, which is what lets
114
+ * `tests/unit/hook_form.test.mjs` assert BOTH platform shapes absolutely in one run on
115
+ * whichever host it happens to be on: the `@echo off`, the CRLF, `exit /b` vs `exec`, the
116
+ * quoting that keeps `--root "<cfg>"` intact. What it does not prove is WHICH values the
117
+ * driver passes — that is `hookRunnerEntry()` below, gated separately.
118
+ */
119
+ export function hookShimBody(runner, entry, platform = process.platform) {
120
+ if (platform === 'win32') {
121
+ // Bare `exit /b` propagates the LIVE errorlevel; `%ERRORLEVEL%` would expand at parse
122
+ // time and return a stale one. Never plain `exit` — that kills the parent cmd.exe, so
123
+ // the emitted `|| exit 0` would never get to evaluate.
124
+ return '@echo off\r\n'
125
+ + 'rem Generated by Geneseed - do not edit. Rewritten on every emit.\r\n'
126
+ + 'setlocal\r\n'
127
+ + `"${runner}" "${entry}" %*\r\n`
128
+ + 'exit /b\r\n';
129
+ }
130
+ return '#!/bin/sh\n'
131
+ + '# Generated by Geneseed - do not edit. Rewritten on every emit.\n'
132
+ + `exec "${runner}" "${entry}" "$@"\n`;
133
+ }
134
+
135
+ /**
136
+ * `_build_settings._write_hook_shim` — create or refresh the shim, or null on failure.
137
+ *
138
+ * The path and body are arguments for the same reason `hookShimBody`'s two values are: the
139
+ * routine itself has no runtime dependency at all once they are supplied, so all of it is
140
+ * comparable — the unchanged-content fast path (a Windows shim a hook is executing right
141
+ * now cannot be replaced), the newline-folded comparison that makes that fast path
142
+ * reachable, the pid-suffixed temp name that keeps concurrent emits from unlinking each
143
+ * other's file, and the chmod.
144
+ */
145
+ export function writeHookShim(p, body, platform = process.platform) {
146
+ try {
147
+ // Newline-normalised: the body carries explicit CRLF on Windows but `read_text()`
148
+ // translates back to `\n`, so a raw `===` would never match and the "unchanged" fast
149
+ // path — the whole point of this branch — would never be taken.
150
+ if (existsSync(p) && statSync(p).isFile()
151
+ && readText(p).replaceAll('\r\n', '\n') === body.replaceAll('\r\n', '\n')) {
152
+ return p;
153
+ }
154
+ mkdirSync(path.dirname(p), { recursive: true });
155
+ const tmp = `${p}.${process.pid}.tmp`;
156
+ // `newline=''` on the Python side: raw, so the CRLF in the body survives verbatim.
157
+ writeFileSync(tmp, body, 'utf8');
158
+ if (platform !== 'win32') chmodSync(tmp, 0o755);
159
+ renameSync(tmp, p);
160
+ return p;
161
+ } catch (e) {
162
+ if (!isOsError(e)) throw e;
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /** The OS temp root, resolved once. `.native` because a Windows `TEMP` can be an 8.3 alias —
168
+ * the same trap `tests/helpers/sandbox.mjs` documents, reached from the other side. */
169
+ const TMP_REAL = (() => {
170
+ try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
171
+ })();
172
+
173
+ /** Is `child` inside `parent`? `path.relative` compares case-insensitively on win32. */
174
+ function isUnder(child, parent) {
175
+ const rel = path.relative(parent, child);
176
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
177
+ }
178
+
179
+ /**
180
+ * Will the checkout holding `entry` outlive the emit that is baking it into the shim?
181
+ *
182
+ * TWO SHAPES, and both are in `docs/extending.md` §5.3 because both have really happened on this
183
+ * repo: a copied checkout under the OS temp root — the `copyCheckout` fixture, which builds its
184
+ * copy from `git ls-files` and therefore carries no `.git` at all, so only the temp root
185
+ * identifies it — and a git WORKTREE, whose `.git` is a FILE pointing at the main checkout
186
+ * rather than a directory. A plain checkout and an npm install are neither, and keep the
187
+ * ownership they have always had.
188
+ *
189
+ * `tmpRoot` IS A PARAMETER for the reason `shimRel`'s `platform` is: with the real temp root
190
+ * baked in, the second rule is unreachable in a test — anything a test can build under `mkdtemp`
191
+ * has already answered true on the first — so the worktree arm could only ever be exercised by
192
+ * whichever kind of checkout the run happened to sit in.
193
+ */
194
+ export function ephemeralCheckout(entry, tmpRoot = TMP_REAL) {
195
+ let real = entry;
196
+ try { real = realpathSync.native(entry); } catch { /* not there yet — test the literal */ }
197
+ if (isUnder(real, tmpRoot)) return true;
198
+ // `<checkout>/bin/geneseed-hook.mjs` — the one shape `hookRunnerEntry` produces.
199
+ try { return statSync(path.join(path.dirname(path.dirname(real)), '.git')).isFile(); } catch {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ /** Does the shim already on disk still name paths that all exist? */
205
+ function shimIsLive(p) {
206
+ try { return statSync(p).isFile() && shimDeadPaths(readText(p)).length === 0; } catch {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * `_build_settings._hook_prefix` — the `<runner> <entrypoint>` every emitted hook starts
213
+ * with, falling back to the pre-shim direct form when the shim cannot be written.
214
+ *
215
+ * The fallback is strictly no worse than the old behaviour and far better than emitting
216
+ * commands naming a shim that does not exist — those fail on every hook (9009 under
217
+ * cmd.exe, 127 under sh) and take both gates down with them.
218
+ */
219
+ export function hookPrefix({ runner, entry, platform = process.platform } = {}) {
220
+ // NOT a default. `runner`/`entry` have no computable fallback on this side — this process's
221
+ // own `process.execPath` is whichever node ran the EMIT, not a runner anyone chose — and
222
+ // the failure mode of letting them through undefined is the worst kind this unit has:
223
+ // `hookShimBody` bakes `"undefined" "undefined" %*`, the shim is rewritten with it, every hook in the install
224
+ // dies, and because the hooks signal through stdout and return 0 on every path, nothing
225
+ // reports it. The shim is also excluded from golden's byte comparison by name, so no
226
+ // acceptance gate would catch it either. Throw where it is cheap to see.
227
+ if (!runner || !entry) {
228
+ throw new Error('hookPrefix: runner and entry are required — the emitted hook shim '
229
+ + 'bakes them, and there is no correct value to guess from inside Node');
230
+ }
231
+ const p = hookShimPath(platform);
232
+ // ⚠ A DISPOSABLE CHECKOUT DOES NOT GET TO CLAIM THE MACHINE-WIDE SHIM. The shim has no
233
+ // per-install component, so the last checkout to emit owns EVERY install's hooks — and when
234
+ // that checkout is a test sandbox or a git worktree, deleting it kills hooks machine-wide with
235
+ // nothing to report it: hooks signal through stdout and return 0 on every path, and this file
236
+ // is excluded from the byte corpora by name. Measured twice in one session, from
237
+ // `tests/unit/harness.test.mjs`'s copied-checkout fixture; `docs/extending.md` §5.3 carries
238
+ // the worktree half.
239
+ //
240
+ // KEEP, NOT REFUSE, and that distinction is what makes this safe to add here rather than at
241
+ // each call site. Returning the existing shim path leaves the emitted hook command
242
+ // byte-identical to what a normal emit writes; taking the fallback below instead would move a
243
+ // hook command in every recorded bundle. The install being emitted then runs the DURABLE
244
+ // checkout's entry — which is exactly what last-writer-wins already handed every other install
245
+ // on the machine, so nothing is lost by it.
246
+ //
247
+ // ONLY A SHIM THAT STILL RESOLVES IS PROTECTED. An absent or already-dead one is no worse for
248
+ // being rewritten from here, and a sandboxed `GENESEED_HOME` (every emit test, every cell) has
249
+ // none — so the suite's own emits are unaffected and this cannot go green by silently
250
+ // skipping the write.
251
+ if (ephemeralCheckout(entry) && shimIsLive(p)) return `"${p}"`;
252
+ const shim = writeHookShim(p, hookShimBody(runner, entry, platform), platform);
253
+ if (shim !== null) return `"${shim}"`;
254
+ process.stderr.write(`[geneseed] WARN: could not write the hook shim at ${p} — emitting `
255
+ + 'hooks that call the interpreter directly. They will break if this checkout moves; '
256
+ + 're-run the build to repair them.\n');
257
+ return `"${runner}" "${entry}"`;
258
+ }
259
+
260
+ /**
261
+ * `_build_settings._hook_runner_entry()`'s two values — this driver's answer, which since
262
+ * P5b is NOT Python's.
263
+ *
264
+ * The Python original returns `sys.executable` and `<checkout>/rituals/harness.py`: the
265
+ * interpreter running `build.py`, and the harness it will hand `%*` to. This driver returns
266
+ * `process.execPath` and `<checkout>/bin/geneseed-hook.mjs`, because the four verbs the
267
+ * emitted hooks invoke — context, git-gate, rule-gate, learn — are now Node, and an install
268
+ * this driver emits therefore needs no Python at all for its hooks.
269
+ *
270
+ * WHAT THIS RETIRED, AND WHY IT IS A DELETION. Until this phase the function was
271
+ * `hookOptsOrDie`: it scanned PATH for an interpreter and refused the four Claude-shaped
272
+ * emits with exit 4 when it found none, because writing a shim that names a nonexistent
273
+ * interpreter silently disables every hook in the install. Both halves are gone. `runner` is
274
+ * `process.execPath` — the node already running this file, which by construction exists —
275
+ * so there is nothing left to discover and no way for discovery to fail. P4e kept the
276
+ * unreachable exit-3 branch because 'the driver classifies every emit'
277
+ * (`tests/unit/node_driver.test.mjs`) asserts a
278
+ * partition it belongs to; nothing asserts a partition over this one, so keeping it would
279
+ * be keeping code no test can reach for no stated reason.
280
+ *
281
+ * THE SHIM IS MACHINE-WIDE, AND THAT IS THE DECISION THIS PHASE ACTUALLY TOOK.
282
+ * `hookShimPath()` is `$GENESEED_HOME`-or-`~/.geneseed` + `bin/geneseed-hook[.cmd]`, with no
283
+ * per-install component: every emit of every install on the machine rewrites the same file,
284
+ * and every install's hooks execute it. While both drivers baked Python that was invisible,
285
+ * because last-writer-wins wrote the same thing. It is observable now — a machine whose last
286
+ * emit ran through this file has EVERY install's hooks running under Node, including
287
+ * installs the Python driver wrote, and the reverse.
288
+ *
289
+ * That is correct exactly while the two entry points answer the same verbs the same way, so
290
+ * the gate that used to be a formality is now load-bearing:
291
+ * 'the hook entry carries exactly the verbs the emitter wires' (`tests/unit/hook_cli.test.mjs`)
292
+ * reads the emitter's wiring
293
+ * and `bin/geneseed-hook.mjs`'s VERBS table and requires them EQUAL — a wired verb the entry
294
+ * lacks is a dead hook on every install on the machine, not just this one. The alternative
295
+ * considered and rejected was a per-driver shim path: the path is baked into every already
296
+ * emitted hook command, so changing it makes every existing install's hooks stale until
297
+ * re-emit, which is P10's migration arriving five phases early.
298
+ *
299
+ * EXPORTED SINCE P6i, because the emitter is no longer its only caller, and HERE since 2026-10
300
+ * rather than in `js/build/driver.mjs`: the second caller imported the whole generator for it.
301
+ * `js/maintain/uninstall.mjs`'s `remergeClaudeHooks` re-merges the canonical hooks when a disabled
302
+ * Claude install is turned back on, and `mergeClaudeSettings` refuses to guess the pair —
303
+ * correctly. There is exactly one right answer on this side and it is this function, so the
304
+ * second caller imports it rather than restating two lines that must never drift: a
305
+ * reactivate whose shim path differed from the emitter's would wire hooks pointing at a file
306
+ * the emitter never writes.
307
+ */
308
+ export function hookRunnerEntry() {
309
+ return { runner: process.execPath, entry: path.join(ROOT, 'bin', 'geneseed-hook.mjs') };
310
+ }
311
+
312
+ // P10d. `GENESEED_HOOK_SNIFF` answers "is this hook Geneseed's?". A MIGRATION needs the other
313
+ // question — "is this Geneseed's OLD one?" — and the two are not the same sniff.
314
+ //
315
+ // THREE SHAPES ARE IN THE WILD, AND TWO OF THEM ARE IDENTICAL IN THE SETTINGS FILE:
316
+ // 1. legacy-direct the command itself names `harness.py` (pre-shim, P0 and earlier)
317
+ // 2. shim-python the command names the shim; the shim BODY runs python + harness.py
318
+ // 3. shim-node the command names the shim; the shim BODY runs node + the .mjs entry
319
+ //
320
+ // 2 and 3 differ ONLY inside the shim body. A classifier reading the host config alone would
321
+ // call a fully-unmigrated machine "already migrated" — silently — and `migrate` would no-op on
322
+ // exactly the installs it exists for. The shim body is a REQUIRED input, not a refinement.
323
+ const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
324
+
325
+ /**
326
+ * `_build_settings._migrate_shape` — 'legacy' | 'current' | 'none'.
327
+ *
328
+ * PURE — and currently reached only END TO END, by the `migrate/a-node-baked-shim-reads-as-current`
329
+ * cell. No unit corpus drives it directly, which is worth knowing before trusting it.
330
+ * 'none' is not a fault: a user's own settings.json may carry three hand-written hooks
331
+ * and no Geneseed entry. That must read as
332
+ * "nothing to migrate" rather than "unrecognised", or `migrate` refuses on the commonest
333
+ * config on any machine.
334
+ */
335
+ export function migrateShape(commands, shimBody) {
336
+ if (commands.some((c) => c.includes('harness.py'))) return 'legacy';
337
+ if (commands.some((c) => c.includes(SHIM_MARK))) {
338
+ return shimBody.includes(SHIM_ENTRY_MARK) ? 'current' : 'legacy';
339
+ }
340
+ return 'none';
341
+ }
342
+
343
+ /**
344
+ * `_build_settings._autostart_paths` — where a hand-written web-daemon autostart entry lives.
345
+ *
346
+ * NOTHING IN THIS REPOSITORY HAS EVER WRITTEN ONE — the install guide tells the user to create them
347
+ * by hand, and no .py/.mjs/.sh/.cmd in the tree contains `vbs`, `LaunchAgents` or `plist`. So
348
+ * `migrate` REPORTS a stale one and never rewrites it, on the rule `settingsIntegrityCheck`
349
+ * already states: an entry the manifest does not claim is "possibly user-authored; left alone".
350
+ *
351
+ * Both platforms' paths on both platforms, deliberately — the scan is a read that misses
352
+ * harmlessly, and returning only the host platform's would make the macOS arm unreachable
353
+ * from the Windows machine this port is developed on: an ungated branch dressed as a fork.
354
+ */
355
+ export function autostartPaths() {
356
+ const home = os.homedir();
357
+ return [
358
+ path.join(home, 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu',
359
+ 'Programs', 'Startup', 'geneseed-web.vbs'),
360
+ path.join(home, 'Library', 'LaunchAgents', 'dev.geneseed.web.plist'),
361
+ ];
362
+ }
363
+
364
+ /**
365
+ * `_build_settings._autostart_stale` — the entry names a Geneseed launcher somewhere else.
366
+ *
367
+ * Weak in one direction and strict in the other on purpose: it fires only when the file
368
+ * mentions geneseed at all (an unrelated Startup entry is never named), and clears only when
369
+ * the CURRENT root appears verbatim. A false positive costs one printed line; a false negative
370
+ * leaves a login task pointing at a checkout npm is about to make stale.
371
+ */
372
+ export function autostartStale(text, root) {
373
+ if (!text.toLowerCase().includes('geneseed')) return false;
374
+ return !text.includes(root) && !text.includes(root.replaceAll('\\', '/'));
375
+ }