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.
- package/CHANGELOG.md +93 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +23 -9
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +45 -10
- package/adapters/opencode/plugins/geneseed-notify.js +37 -19
- package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
- package/adapters/opencode/workflows/_runtime.js +16 -3
- package/bin/geneseed-cli.mjs +32 -34
- package/bin/geneseed-hook.mjs +28 -18
- package/docs/extending.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +28 -15
- package/js/build/args.mjs +375 -0
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +145 -560
- package/js/build/emit-claude.mjs +5 -6
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +21 -14
- package/js/build/render.mjs +6 -10
- package/js/build/source.mjs +9 -17
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/hosts/hooks-context.mjs +432 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +31 -25
- package/js/hosts/installs.mjs +38 -20
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/mcp.mjs +12 -48
- package/js/hosts/memory-files.mjs +190 -0
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +44 -36
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +375 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +4 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +9 -7
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +19 -22
- package/js/inspect/validate.mjs +50 -37
- package/js/lib/fs.mjs +12 -11
- package/js/lib/json.mjs +4 -4
- package/js/lib/paths.mjs +27 -33
- package/js/lib/proc.mjs +3 -3
- package/js/lib/prompt.mjs +132 -0
- package/js/lib/text.mjs +18 -4
- package/js/lib/udiff.mjs +3 -2
- package/js/loop/catalog.mjs +5 -3
- package/js/loop/cli.mjs +26 -12
- package/js/loop/graph.mjs +1 -1
- package/js/loop/registry.mjs +10 -15
- package/js/loop/score.mjs +1 -1
- package/js/loop/state.mjs +35 -11
- package/js/maintain/memory.mjs +6 -7
- package/js/maintain/migrate.mjs +22 -33
- package/js/maintain/setup.mjs +9 -131
- package/js/maintain/uninstall.mjs +57 -97
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +37 -43
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +109 -403
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +41 -725
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- package/js/web/docs.mjs +29 -72
- package/js/web/handler.mjs +51 -27
- package/js/web/jobs.mjs +24 -2
- package/js/web/routes.mjs +61 -6
- package/js/web/server.mjs +4 -4
- package/js/web/state.mjs +138 -0
- package/js/web/user-files.mjs +339 -0
- package/package.json +1 -1
- package/web/dist/assets/Activity-kpWxd70F.js +1 -0
- package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/Docs-BHyaggFy.js +5 -0
- package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
- package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
- package/web/dist/assets/Library-MBxadRzn.js +1 -0
- package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
- package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
- package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
- package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
- package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
- package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
- package/web/dist/assets/api-CxgfYu18.js +1 -0
- package/web/dist/assets/drawable-BbWQxe82.js +1 -0
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-ujKG1ZNf.js +2 -0
- package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
- package/web/dist/assets/sections-GalMS0Zv.js +1 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +5 -6
- package/web/src/pages/Laws.jsx +0 -1
- package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
- package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
- package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
- package/web/dist/assets/Docs-BzccCllG.js +0 -5
- package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
- package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
- package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
- package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
- package/web/dist/assets/Laws-BANxq_I_.js +0 -3
- package/web/dist/assets/Library-DW4x0fbh.js +0 -1
- package/web/dist/assets/Loops-DEBibbBP.js +0 -2
- package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
- package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
- package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
- package/web/dist/assets/activity-CObqyxkL.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-C_x5xncq.js +0 -1
- package/web/dist/assets/index-CMPgezFm.js +0 -2
- package/web/dist/assets/index-DMsT4Aa6.css +0 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/web/dist/assets/sections-Dg23Hel5.js +0 -1
- 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
|
+
}
|