hypomnema 1.8.2 → 1.8.4

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.
@@ -35,7 +35,7 @@
35
35
  */
36
36
 
37
37
  import { execFileSync } from 'child_process';
38
- import { existsSync, lstatSync, realpathSync, statSync } from 'fs';
38
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'fs';
39
39
  import { basename, dirname, isAbsolute, join, resolve, sep } from 'path';
40
40
 
41
41
  // ── shared install/uninstall markers ────────────────────────────────────────
@@ -100,13 +100,34 @@ export function findMarkerSpan(content, startMarker, endMarker) {
100
100
  //
101
101
  // The shell block is fully static — init never bakes a path into it — so its
102
102
  // body can be matched byte-for-byte against SHELL_FUNCTION_BODY below. The
103
- // pre-commit body cannot: it embeds the absolute install root, which moves
104
- // across machines and package versions, so requiring an exact match would
105
- // refuse to remove a hook a real (older, or differently-installed) init.mjs
106
- // actually wrote. It is matched structurally instead — the "one or two `node
107
- // '<path>' ... || exit 1` steps, then `exit 0`" shape — checking only that
108
- // the referenced script is ours (ends in `/hooks/hypo-pre-commit.mjs` or
109
- // `/scripts/lint.mjs`), not which root it lives under.
103
+ // pre-commit body has TWO recognized shapes now:
104
+ //
105
+ // - the OLD, version-pinned shape: `node '<absolute-root>/hooks/
106
+ // hypo-pre-commit.mjs' || exit 1`. An install root baked in like this goes
107
+ // stale every release — the plugin channel moves PKG_ROOT to a new
108
+ // version directory on every upgrade, and nothing ever re-writes an
109
+ // already-installed hook, so it keeps calling whatever release happened
110
+ // to be current the day the hook was written.
111
+ // - the NEW, runtime-resolving shape this issue introduces: `node -e
112
+ // '<resolver script>' || exit 1`, where the resolver script (built by
113
+ // buildPreCommitResolverJs below) looks up the install root itself, at
114
+ // COMMIT TIME, from ~/.claude/hypo-pkg.json or the plugin registry. No
115
+ // root is ever baked in, so there is nothing here for a release to make
116
+ // stale.
117
+ //
118
+ // Both shapes are matched structurally, not byte-for-byte: the OLD shape by
119
+ // checking the referenced worker script ends in `/hooks/hypo-pre-commit.mjs`
120
+ // (not which root it lives under, subject to isValidOldFormRoot below) and,
121
+ // when a lint step is present, that its path is EXACTLY that same root's
122
+ // `/scripts/lint.mjs` — not merely a path ending in that suffix under some
123
+ // OTHER root, which no writer here has ever produced (codex reproduction,
124
+ // 2026-09-11 — see isOwnedWikiPreCommitBody); the NEW shape by
125
+ // reconstructing the exact resolver script buildPreCommitResolverJs would
126
+ // have produced and comparing it byte-for-byte against what is embedded (see
127
+ // isNewFormWorkerStep/isNewFormLintStep below) — loose substring matching on
128
+ // a resolver script would let a forged marker pair around arbitrary content
129
+ // through the same way a forged marker pair around a plain path once did
130
+ // (codex reproduction, 2026-08-27).
110
131
  //
111
132
  // Both directions of a mismatch here are unequal: failing to recognize a
112
133
  // hook init actually wrote costs a re-run with --force-*; deleting a file
@@ -123,20 +144,280 @@ export const PRE_COMMIT_WORKER_LINE = /^node '(.+)' \|\| exit 1$/;
123
144
  // repoints the lint gate at the wrong vault.
124
145
  export const PRE_COMMIT_LINT_LINE = /^node '(.+)' --hypo-dir='(.+)' --strict \|\| exit 1$/;
125
146
 
147
+ // The NEW, runtime-resolving shape: `node -e '<script>' || exit 1`, where
148
+ // `<script>` is shell-single-quoted the same way PRE_COMMIT_WORKER_LINE's
149
+ // path is — via shellSingleQuote() — so unescapeShellSingleQuoted() below
150
+ // recovers it the same way.
151
+ export const PRE_COMMIT_RESOLVER_LINE = /^node -e '(.+)' \|\| exit 1$/;
152
+
126
153
  // Reverses shellSingleQuote()'s escaping (a literal `'` becomes `'\''`) so the
127
154
  // captured path can be compared against the suffix it must end in.
128
155
  export function unescapeShellSingleQuoted(s) {
129
156
  return s.split("'\\''").join("'");
130
157
  }
131
158
 
159
+ // The two scripts a vault's pre-commit hook ever resolves through, relative to
160
+ // the install root. Shared between the OLD form's suffix checks and the NEW
161
+ // form's resolver script (buildPreCommitResolverJs), so the two can never name
162
+ // a different worker/lint path without this file itself failing to load.
163
+ const WORKER_SUFFIX = '/hooks/hypo-pre-commit.mjs';
164
+ const LINT_SUFFIX = '/scripts/lint.mjs';
165
+
166
+ // Builds the body of a `node -e '<this>'` step that resolves the Hypomnema
167
+ // install root AT COMMIT TIME instead of embedding one — the fix for the root
168
+ // this issue removes. No single quotes appear anywhere in this JS: the whole
169
+ // result is wrapped in shell single quotes by the caller (shellSingleQuote,
170
+ // same as the OLD form's path), and sh single quotes have no escape mechanism
171
+ // of their own, so a stray `'` in the FIXED part of this script would corrupt
172
+ // the surrounding shell line no matter how it were JS-escaped. Values that are
173
+ // NOT fixed (the --hypo-dir path for the lint step) are instead embedded via
174
+ // JSON.stringify and passed in through `extraArgvJs`; shellSingleQuote() is
175
+ // applied to the finished script as a whole, so any single quote inside such a
176
+ // value is escaped once, at the one place that can actually do it safely.
177
+ //
178
+ // Resolution order (must match the resolution chain commands/*.md already
179
+ // documents for resolving CLAUDE_PLUGIN_ROOT by hand):
180
+ // 1. ~/.claude/hypo-pkg.json's `pkgRoot`, but only when it is usable (see
181
+ // below). A stale/wrong/relative pkgRoot must not be trusted just
182
+ // because the file parses.
183
+ // 2. ~/.claude/plugins/installed_plugins.json's `hypo@hypomnema` AND the
184
+ // legacy pre-rename `hypomnema@hypomnema` entries, searched TOGETHER as
185
+ // one combined list: every user-scope row first, then any usable row.
186
+ // A prior version searched `hypo@hypomnema` alone and only fell back to
187
+ // the legacy key when that key was entirely absent, so a `hypo@hypomnema`
188
+ // array present but full of unusable rows (a half-migrated registry)
189
+ // hid a perfectly good `hypomnema@hypomnema` row forever (codex
190
+ // reproduction, 2026-09-11: exit 1 with a real legacy row on disk).
191
+ // 3. Neither resolves: print what was checked and how to fix it, then exit 1.
192
+ // A commit that silently skips the .hypoignore guard is worse than one
193
+ // that refuses outright.
194
+ //
195
+ // A candidate root is "usable" only when it is an ABSOLUTE string, its
196
+ // `package.json` parses with a non-empty string `version`, AND
197
+ // `<root><targetSuffix>` exists. Checking existence of the target alone (the
198
+ // prior behavior) let a RELATIVE pkgRoot such as `"."` pass by resolving
199
+ // against whatever the hook's cwd happened to be at commit time (the
200
+ // vault's own working-tree root), so a vault carrying its own
201
+ // `hooks/hypo-pre-commit.mjs` was silently accepted as "the install" and its
202
+ // (attacker-controlled) contents ran instead of the real .hypoignore guard,
203
+ // letting an ignored file commit clean (codex reproduction, 2026-09-11:
204
+ // `pkgRoot: "."` + a forged `hooks/hypo-pre-commit.mjs` inside the vault
205
+ // exited 0 with `.hypoignore`'d `.env` staged). Requiring an absolute path
206
+ // closes that: a relative value can never satisfy `path.isAbsolute`, so it
207
+ // is skipped in favor of the registry, and if nothing else resolves the
208
+ // commit is refused (exit 1) rather than silently let through.
209
+ function buildPreCommitResolverJs(targetSuffix, extraArgvJs = '') {
210
+ return (
211
+ 'var fs=require("fs"),os=require("os"),cp=require("child_process"),path=require("path");' +
212
+ 'function usable(r){' +
213
+ 'if(typeof r!=="string"||!r||!path.isAbsolute(r))return false;' +
214
+ // name AND version. This one runs at commit time in the user's vault and
215
+ // EXECUTES whatever it adopts, so version alone was not enough: any
216
+ // absolute directory carrying a package.json and the target path was
217
+ // accepted, and its script ran. The name check closes that.
218
+ //
219
+ // Not every root judgment here checks the name, and the difference is
220
+ // what each one licenses. isRealOldFormInstallRoot and
221
+ // isRewritableOldFormInstallRoot (below) and hypo-shared.mjs's sidecar
222
+ // proof do check it — they gate deleting, rewriting, executing.
223
+ // plugin-detect.mjs's usablePkgRoot does NOT (it answers the narrower
224
+ // "can scripts be resolved through this pointer at all"), which is why
225
+ // that file now also exports isHypomnemaInstallRoot, the strong form that
226
+ // DOES check the name. Its callers split accordingly: init.mjs's
227
+ // resolveDurableRoot and selectEntry (the registry-row picker behind
228
+ // resolveEnabledPluginEntry) RECORD what they accept, so both moved onto
229
+ // the strong predicate; doctor.mjs's per-row leaf-drift scan only reads
230
+ // for display and deliberately stays on the weak one (narrowing it would
231
+ // silence a foreign registry row doctor exists to surface). Do not cite
232
+ // this comment as proof the name is checked everywhere; check the
233
+ // specific predicate.
234
+ 'try{var p=JSON.parse(fs.readFileSync(r+"/package.json","utf-8"));' +
235
+ 'if(p.name!=="hypomnema")return false;' +
236
+ 'if(typeof p.version!=="string"||!p.version)return false}catch(e){return false}' +
237
+ 'try{return fs.existsSync(r+"' +
238
+ targetSuffix +
239
+ '")}catch(e){return false}' +
240
+ '}' +
241
+ 'function readJson(p){try{return JSON.parse(fs.readFileSync(p,"utf-8"))}catch(e){return null}}' +
242
+ 'var home=os.homedir();var root=null;' +
243
+ 'var pkg=readJson(home+"/.claude/hypo-pkg.json");' +
244
+ 'if(pkg&&usable(pkg.pkgRoot)){root=pkg.pkgRoot}' +
245
+ 'if(!root){' +
246
+ 'var reg=readJson(home+"/.claude/plugins/installed_plugins.json");' +
247
+ 'var plugins=reg&&reg.plugins;' +
248
+ 'var arr=[];' +
249
+ 'if(plugins&&Array.isArray(plugins["hypo@hypomnema"]))arr=arr.concat(plugins["hypo@hypomnema"]);' +
250
+ 'if(plugins&&Array.isArray(plugins["hypomnema@hypomnema"]))arr=arr.concat(plugins["hypomnema@hypomnema"]);' +
251
+ 'var entry=arr.find(function(e){return e&&e.scope==="user"&&usable(e.installPath)})||' +
252
+ 'arr.find(function(e){return e&&usable(e.installPath)});' +
253
+ 'if(entry){root=entry.installPath}' +
254
+ '}' +
255
+ 'if(!root){' +
256
+ 'console.error("hypomnema: could not resolve the install root for ' +
257
+ targetSuffix +
258
+ '.");' +
259
+ 'console.error("Checked ~/.claude/hypo-pkg.json (pkgRoot) and ~/.claude/plugins/installed_plugins.json (hypo@hypomnema installPath, legacy hypomnema@hypomnema).");' +
260
+ 'console.error("Fix: run `hypomnema upgrade --apply` (or `/hypo:upgrade` on a plugin install), or reinstall Hypomnema.");' +
261
+ 'process.exit(1)' +
262
+ '}' +
263
+ 'var r=cp.spawnSync(process.execPath,[root+"' +
264
+ targetSuffix +
265
+ '"' +
266
+ extraArgvJs +
267
+ '],{stdio:"inherit"});' +
268
+ 'if(r.error){console.error(String(r.error.message||r.error))}' +
269
+ 'process.exit(r.status===null?1:r.status)'
270
+ );
271
+ }
272
+
273
+ // The lint step's `--hypo-dir` value is the only dynamic input to the resolver
274
+ // script. Embedding it via JSON.stringify (rather than string-concatenating it
275
+ // raw into the script) means it round-trips exactly through extractLintHypoDir
276
+ // below, including any character that would otherwise need JS escaping.
277
+ function lintExtraArgvJs(hypoDir) {
278
+ return `,${JSON.stringify(`--hypo-dir=${hypoDir}`)},"--strict"`;
279
+ }
280
+
281
+ // Recovers the `--hypo-dir` value baked into a NEW-form lint step's resolver
282
+ // script, or null when the script does not have this exact shape. Matched
283
+ // against the literal argv array text buildPreCommitResolverJs(LINT_SUFFIX, …)
284
+ // produces — `[root+"/scripts/lint.mjs",<json-string>,"--strict"]` — so a
285
+ // script that merely CONTAINS these substrings somewhere else does not count.
286
+ function extractLintHypoDir(js) {
287
+ const m = /\[root\+"\/scripts\/lint\.mjs",("(?:[^"\\]|\\.)*"),"--strict"\]/.exec(js);
288
+ if (!m) return null;
289
+ let arg;
290
+ try {
291
+ arg = JSON.parse(m[1]);
292
+ } catch {
293
+ return null;
294
+ }
295
+ const prefix = '--hypo-dir=';
296
+ return typeof arg === 'string' && arg.startsWith(prefix) ? arg.slice(prefix.length) : null;
297
+ }
298
+
299
+ // Is `step` a NEW-form worker line whose resolver script is EXACTLY the one
300
+ // buildPreCommitResolverJs(WORKER_SUFFIX) would generate? Exact comparison
301
+ // (not a substring/suffix check) because the worker step has no dynamic
302
+ // input at all — anything less than byte-identical is not a script this
303
+ // writer could have produced, and per the module comment above an
304
+ // unrecognized shape must never be treated as "close enough".
305
+ function isNewFormWorkerStep(step) {
306
+ const m = PRE_COMMIT_RESOLVER_LINE.exec(step);
307
+ if (!m) return false;
308
+ return unescapeShellSingleQuoted(m[1]) === buildPreCommitResolverJs(WORKER_SUFFIX);
309
+ }
310
+
311
+ // Is `root` a real, on-disk Hypomnema install that could actually have
312
+ // produced an OLD-form worker line naming it? The suffix check on the worker
313
+ // path alone (endsWith(WORKER_SUFFIX)) proves nothing about the path BEFORE
314
+ // the suffix: any root, including one no writer in this codebase ever
315
+ // touched, satisfies it. codex reproduced this (2026-09-11): a marker pair
316
+ // wrapping `node '<arbitrary-root>/hooks/hypo-pre-commit.mjs' || exit 1`,
317
+ // where `<arbitrary-root>` was a user's own project, was accepted as "ours"
318
+ // and became eligible for upgrade.mjs's rewrite and uninstall.mjs's delete,
319
+ // neither of which this repo ever wrote. This requires the referenced root
320
+ // to be an absolute path to a package literally named "hypomnema" (the same
321
+ // producer-identity check hooks/hypo-shared.mjs applies to a cached pkgRoot)
322
+ // that still contains the worker script the line names.
323
+ //
324
+ // This is the STRICT form: a root that no longer exists on disk cannot be
325
+ // verified this way and reads as NOT ours. That is the right call for a
326
+ // DESTRUCTIVE consumer (uninstall.mjs's delete, the only caller left on this
327
+ // predicate, see isRewritableOldFormInstallRoot below for the non-destructive
328
+ // one, because deleting a hook this check cannot actually verify would be
329
+ // deleting on a guess. It is the wrong call for a REWRITE consumer: an install
330
+ // that moved or was reinstalled leaves its OLD root gone by construction, and
331
+ // refusing to rewrite there left every vault git commit failing
332
+ // `MODULE_NOT_FOUND` with no recovery path in the product (2026-09-11).
333
+ function isRealOldFormInstallRoot(root) {
334
+ if (!isAbsolute(root) || !existsSync(join(root, WORKER_SUFFIX))) return false;
335
+ try {
336
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).name === 'hypomnema';
337
+ } catch {
338
+ return false;
339
+ }
340
+ }
341
+
342
+ // The REWRITE-safe counterpart to isRealOldFormInstallRoot above. Rewriting an
343
+ // OLD-form hook onto the new, runtime-resolving form is non-destructive (it
344
+ // only changes what the hook calls next commit, never deletes anything), so a
345
+ // root that no longer exists is not treated as suspicious the way it is for
346
+ // deletion: it is exactly what a moved-or-reinstalled Hypomnema looks like,
347
+ // and there is nothing left at that path to impersonate. A root that DOES
348
+ // exist is still checked against package.json's name, so the forged case
349
+ // codex reproduced (a real path, but someone else's project) is rejected the
350
+ // same as above; only the "root is gone" branch differs between the two
351
+ // predicates. Keep both in sync: a change to one's package.json/name check
352
+ // almost certainly belongs in the other too.
353
+ // The first free `<hookPath>.bak`, `<hookPath>.bak.1`, `<hookPath>.bak.2`, ...
354
+ // Never overwrites an EARLIER backup: a collision there means something has
355
+ // already been preserved once, and clobbering it would defeat the entire point
356
+ // of taking a backup at all. Shared by upgrade's migration and init's
357
+ // force/marker overwrite so the two cannot disagree about what a backup is.
358
+ export function uniqueBakPath(hookPath) {
359
+ let candidate = `${hookPath}.bak`;
360
+ for (let n = 1; existsSync(candidate); n += 1) {
361
+ // A symlink (or anything that is not a regular file) sitting on a backup
362
+ // name is not a collision to route around — it is the attack the caller's
363
+ // symlink guard exists to stop, and stepping to `.bak.1` would walk past it
364
+ // silently while the guard then inspects the wrong path. Refuse, and let
365
+ // the caller decline the whole write rather than overwrite the hook with
366
+ // its backup gone somewhere unverified.
367
+ if (unsafeHookTargetReason(candidate)) return null;
368
+ candidate = `${hookPath}.bak.${n}`;
369
+ }
370
+ return unsafeHookTargetReason(candidate) ? null : candidate;
371
+ }
372
+
373
+ export function isRewritableOldFormInstallRoot(root) {
374
+ if (!isAbsolute(root)) return false;
375
+ if (!existsSync(root)) return true;
376
+ try {
377
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).name === 'hypomnema';
378
+ } catch {
379
+ return false;
380
+ }
381
+ }
382
+
383
+ // Is `step` a NEW-form lint line whose resolver script is EXACTLY the one
384
+ // buildPreCommitResolverJs(LINT_SUFFIX, …) would generate for SOME --hypo-dir?
385
+ // Extracts the embedded --hypo-dir, rebuilds the expected script around it,
386
+ // and compares byte-for-byte — the only way to validate a script with one
387
+ // dynamic input without loosening the check into a substring match.
388
+ function isNewFormLintStep(step) {
389
+ const m = PRE_COMMIT_RESOLVER_LINE.exec(step);
390
+ if (!m) return false;
391
+ const js = unescapeShellSingleQuoted(m[1]);
392
+ const hypoDir = extractLintHypoDir(js);
393
+ if (hypoDir === null) return false;
394
+ return js === buildPreCommitResolverJs(LINT_SUFFIX, lintExtraArgvJs(hypoDir));
395
+ }
396
+
132
397
  /**
133
398
  * @param {string} content full pre-commit hook file content
134
399
  * @param {{startIdx: number, endIdx: number}} span a `findMarkerSpan` result
135
400
  * already confirmed `ok: true` for WIKI_PRE_COMMIT_MARKER_START/END
401
+ * @param {(root: string) => boolean} [isValidOldFormRoot] which predicate
402
+ * decides an OLD-form worker line's root is really ours. Defaults to the
403
+ * STRICT one (isRealOldFormInstallRoot), the right default for the only
404
+ * direct caller besides parseWikiPreCommitRoot below, uninstall.mjs's
405
+ * delete path, where a root this check cannot verify must not be trusted.
406
+ * parseWikiPreCommitRoot passes the REWRITE-safe one instead, since its
407
+ * consumers (upgrade.mjs's migration, doctor.mjs's report) only ever read
408
+ * or rewrite, never delete.
136
409
  * @returns {boolean} true when the text between the markers is recognizable
137
- * as a body init.mjs's wikiPreCommitContent() writes
410
+ * as a body wikiPreCommitContent() writes NOW (the runtime-resolving form)
411
+ * or COULD HAVE WRITTEN in an older release (the version-pinned form) — see
412
+ * the module comment above PRE_COMMIT_WORKER_LINE for both shapes. A body
413
+ * that mixes the two (one step old-form, the other new-form) is never
414
+ * ours: no writer this codebase has ever shipped produces that.
138
415
  */
139
- export function isOwnedWikiPreCommitBody(content, span) {
416
+ export function isOwnedWikiPreCommitBody(
417
+ content,
418
+ span,
419
+ isValidOldFormRoot = isRealOldFormInstallRoot,
420
+ ) {
140
421
  const body = content.slice(span.startIdx + WIKI_PRE_COMMIT_MARKER_START.length, span.endIdx);
141
422
  const lines = body.split('\n');
142
423
  // wikiPreCommitContent() always places a bare "\n" right after START and
@@ -147,13 +428,36 @@ export function isOwnedWikiPreCommitBody(content, span) {
147
428
  return false;
148
429
  }
149
430
  const steps = middle.slice(0, -1);
150
- const worker = PRE_COMMIT_WORKER_LINE.exec(steps[0]);
151
- if (!worker || !unescapeShellSingleQuoted(worker[1]).endsWith('/hooks/hypo-pre-commit.mjs')) {
152
- return false;
153
- }
431
+
432
+ const oldWorker = PRE_COMMIT_WORKER_LINE.exec(steps[0]);
433
+ const oldWorkerPath = oldWorker ? unescapeShellSingleQuoted(oldWorker[1]) : null;
434
+ const oldWorkerRoot =
435
+ oldWorkerPath && oldWorkerPath.endsWith(WORKER_SUFFIX)
436
+ ? oldWorkerPath.slice(0, -WORKER_SUFFIX.length)
437
+ : null;
438
+ const workerIsOld = oldWorkerRoot !== null && isValidOldFormRoot(oldWorkerRoot);
439
+ const workerIsNew = isNewFormWorkerStep(steps[0]);
440
+ if (!workerIsOld && !workerIsNew) return false;
441
+
154
442
  if (steps.length === 2) {
155
- const lint = PRE_COMMIT_LINT_LINE.exec(steps[1]);
156
- if (!lint || !unescapeShellSingleQuoted(lint[1]).endsWith('/scripts/lint.mjs')) return false;
443
+ if (workerIsOld) {
444
+ const lint = PRE_COMMIT_LINT_LINE.exec(steps[1]);
445
+ // The lint path must be EXACTLY this same root's lint script, not merely
446
+ // END in LINT_SUFFIX — codex reproduction (2026-09-11): a marker hook
447
+ // mixing a real Hypomnema worker path with an arbitrary user lint path
448
+ // (also ending in /scripts/lint.mjs, just under a different root) passed
449
+ // the old suffix-only check and was accepted as "ours", making it
450
+ // eligible for uninstall.mjs's delete and upgrade.mjs's rewrite —
451
+ // neither of which any writer in this codebase could have produced: the
452
+ // legacy writer always built both lines from the SAME root, so this
453
+ // mixed shape is not a form init.mjs's own history could leave behind,
454
+ // and rejecting it costs nothing a real install ever had.
455
+ if (!lint || unescapeShellSingleQuoted(lint[1]) !== oldWorkerRoot + LINT_SUFFIX) {
456
+ return false;
457
+ }
458
+ } else if (!isNewFormLintStep(steps[1])) {
459
+ return false;
460
+ }
157
461
  }
158
462
  return true;
159
463
  }
@@ -166,19 +470,24 @@ export function shellSingleQuote(p) {
166
470
  return `'${p.replace(/'/g, "'\\''")}'`;
167
471
  }
168
472
 
169
- // `root` is the DURABLE install root the hook should resolve hypo-pre-commit.mjs
170
- // (and, when opted in, lint.mjs) through — not necessarily PKG_ROOT. In a dual
171
- // install (a manual/npm init while the plugin is enabled) PKG_ROOT is the
172
- // manual/npm checkout the dual-install notice tells the user to uninstall;
173
- // embedding it here would leave the vault's git hook dangling the moment they
174
- // do, breaking every wiki commit. Callers pass the preserved/positively-resolved
175
- // plugin root instead, so the hook points at the install that actually persists.
473
+ // No install root is passed in here anymore: the generated body
474
+ // resolves it itself, AT COMMIT TIME, via buildPreCommitResolverJs's lookup
475
+ // chain (~/.claude/hypo-pkg.json, then the plugin registry). That is what
476
+ // makes this immune to the failure the old, root-baking version of this
477
+ // function had — a plugin-channel upgrade moves PKG_ROOT to a new version
478
+ // directory on every release, and nothing ever re-writes an already-installed
479
+ // hook, so a baked root goes stale by construction, on a schedule the vault
480
+ // owner does not control. Dual installs (a manual/npm init while the plugin
481
+ // is enabled) no longer need special handling here either: there is no root
482
+ // to pick between "the plugin's real cache root" and "the manual/npm
483
+ // checkout" at write time, because the hook's own copy of the resolver picks
484
+ // between them itself, every time it runs.
176
485
  //
177
486
  // The block runs its steps sequentially rather than tail-calling `exit $?` on
178
487
  // the first one, so a second step (the opt-in --lint-strict gate below) can
179
488
  // run after the .hypoignore guard instead of being unreachable dead code.
180
489
  // `lintStrict` is baked into the generated shim at install time, so toggling it
181
- // means re-running init with/without `--lint-strict` (or upgrade repointing an
490
+ // means re-running init with/without `--lint-strict` (or upgrade migrating an
182
491
  // existing install), not editing the hook by hand.
183
492
  //
184
493
  // `hypoDir` MUST be absolutized before it is baked in. Git runs a pre-commit
@@ -187,53 +496,119 @@ export function shellSingleQuote(p) {
187
496
  // re-resolved AT COMMIT TIME against that root instead of the directory the
188
497
  // caller meant — `wiki` becomes `<wiki-root>/wiki`, a path that doesn't exist,
189
498
  // and lint.mjs falls through to its own default resolution (HYPO_DIR/
190
- // hypo-config.md scan) and may silently lint an unrelated vault. `root` needs
191
- // no such treatment: every caller has already realpath'd it upstream, so it's
192
- // absolute at every call site.
193
- export function wikiPreCommitContent(root, hypoDir, lintStrict) {
499
+ // hypo-config.md scan) and may silently lint an unrelated vault. That is a
500
+ // vault path, not an install path, so unlike the root above it never goes
501
+ // stale across a release — it only needs resolving once, here.
502
+ export function wikiPreCommitContent(hypoDir, lintStrict) {
194
503
  const absHypoDir = resolve(hypoDir);
195
- const worker = join(root, 'hooks', 'hypo-pre-commit.mjs');
196
- const steps = [`node ${shellSingleQuote(worker)} || exit 1`];
504
+ const steps = [`node -e ${shellSingleQuote(buildPreCommitResolverJs(WORKER_SUFFIX))} || exit 1`];
505
+ if (lintStrict) {
506
+ const lintJs = buildPreCommitResolverJs(LINT_SUFFIX, lintExtraArgvJs(absHypoDir));
507
+ steps.push(`node -e ${shellSingleQuote(lintJs)} || exit 1`);
508
+ }
509
+ return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\n${steps.join('\n')}\nexit 0\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
510
+ }
511
+
512
+ // Rebuilds the exact bytes an OLD-form (version-pinned) pre-commit hook holds
513
+ // for a given (root, hypoDir, lintStrict) — the shape a pre-issue release of
514
+ // this writer would have produced, and the shape parseWikiPreCommitRoot reads
515
+ // an OLD-form body back INTO. `hypoDir` is used verbatim (never re-resolved):
516
+ // callers pass the exact string parseWikiPreCommitRoot already extracted from
517
+ // the file being reconstructed, so re-resolving it here could silently paper
518
+ // over a relative value that was never valid in the first place.
519
+ //
520
+ // Exported so upgrade.mjs's applyWikiPreCommitRoot can compare a file ON DISK
521
+ // against this byte-for-byte before overwriting the WHOLE FILE with the
522
+ // migrated (new-form) content. That check exists because isOwnedWikiPreCommitBody
523
+ // only validates the SPAN between the markers, never what sits before the start
524
+ // marker or after the end one — a hand-crafted file combining a forged-but-valid
525
+ // marker span with real content outside it would pass every check up to that
526
+ // point, and an unconditional whole-file write would silently discard the
527
+ // outside content (codex reproduction, 2026-09-11). Requiring the CURRENT file
528
+ // to already equal this reconstruction proves there is nothing outside the span
529
+ // to lose before the overwrite is allowed to happen.
530
+ export function oldFormPreCommitContent(root, hypoDir, lintStrict) {
531
+ const steps = [`node ${shellSingleQuote(root + WORKER_SUFFIX)} || exit 1`];
197
532
  if (lintStrict) {
198
- const lintScript = join(root, 'scripts', 'lint.mjs');
199
533
  steps.push(
200
- `node ${shellSingleQuote(lintScript)} --hypo-dir=${shellSingleQuote(absHypoDir)} --strict || exit 1`,
534
+ `node ${shellSingleQuote(root + LINT_SUFFIX)} --hypo-dir=${shellSingleQuote(hypoDir)} --strict || exit 1`,
201
535
  );
202
536
  }
203
537
  return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\n${steps.join('\n')}\nexit 0\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
204
538
  }
205
539
 
206
- // Read the install root, --lint-strict shape, and (when present) the embedded
207
- // --hypo-dir currently baked into a wiki's pre-commit hook, so upgrade.mjs can
208
- // self-heal it and doctor.mjs can report when it disagrees with the active
209
- // install — without either duplicating the body-shape rules
210
- // isOwnedWikiPreCommitBody already enforces. Returns `{ ok: false }` for
211
- // anything that isn't a body wikiPreCommitContent() could have written: a
540
+ // Read the install root (OLD form only — see below), --lint-strict shape, and
541
+ // (when present) the embedded --hypo-dir currently baked into a wiki's
542
+ // pre-commit hook, so upgrade.mjs can migrate it and doctor.mjs can report on
543
+ // it — without either duplicating the body-shape rules isOwnedWikiPreCommitBody
544
+ // already enforces. Returns `{ ok: false }` for anything that isn't a body
545
+ // wikiPreCommitContent() writes now or wrote in an older release (see the
546
+ // module comment above PRE_COMMIT_WORKER_LINE for both shapes): a
212
547
  // missing/duplicated marker pair, a user's own hook, or one too malformed to
213
- // trust. `ok: true` results always carry an absolute `root` (validated by the
214
- // `/hooks/hypo-pre-commit.mjs` suffix check below, mirroring
215
- // isOwnedWikiPreCommitBody), a `lintStrict` flag, and `hypoDir`: the embedded
216
- // --hypo-dir value when `lintStrict` is true, else `null` (a plain hook never
217
- // bakes one in). The caller that repoints `root` on --apply must reuse this
548
+ // trust.
549
+ //
550
+ // `ok: true` results always carry a `lintStrict` flag and `hypoDir` (the
551
+ // embedded --hypo-dir value when `lintStrict` is true, else `null` — a plain
552
+ // hook never bakes one in, in either form). `root` is where the two forms
553
+ // diverge: for an OLD-form body it is the absolute install root baked into the
554
+ // worker line (validated by the WORKER_SUFFIX check below, mirroring
555
+ // isOwnedWikiPreCommitBody). For a NEW-form body — the runtime-resolving
556
+ // shape this issue introduces — there is no baked root to return: the hook
557
+ // resolves it itself at commit time, so `root` is `null`. A caller must treat
558
+ // `root === null` as "already on the form that never goes stale", never as
559
+ // "root could not be determined" (that failure is `ok: false`).
560
+ //
561
+ // An OLD-form worker line is accepted here even when its root no longer
562
+ // exists on disk (isRewritableOldFormInstallRoot, not the strict
563
+ // isRealOldFormInstallRoot uninstall.mjs's delete path still uses): every
564
+ // caller of this function only reads or rewrites the hook, never deletes it,
565
+ // and a moved-or-reinstalled Hypomnema is exactly what a gone root looks like
566
+ // (2026-09-11: refusing to migrate there left every vault commit failing
567
+ // MODULE_NOT_FOUND with no in-product recovery).
568
+ //
569
+ // `ok: false` results still carry `hasMarker`: true when the content has our
570
+ // start marker at all (whatever is inside it failed to parse: a corrupted or
571
+ // hand-edited body), false when there is no marker here to begin with (not
572
+ // our hook). Callers that only care about migration can ignore it; doctor.mjs
573
+ // and upgrade.mjs use it to tell "nothing installed" apart from "installed
574
+ // but unreadable", which used to collapse into the same silent `ok: false`.
575
+ //
576
+ // The caller that migrates an OLD-form hook to the new one must reuse this
218
577
  // `hypoDir` verbatim rather than the CURRENT run's --hypo-dir — the two are
219
578
  // not guaranteed to be the same directory.
220
579
  export function parseWikiPreCommitRoot(content) {
580
+ const hasMarker = content.includes(WIKI_PRE_COMMIT_MARKER_START);
221
581
  const span = findMarkerSpan(content, WIKI_PRE_COMMIT_MARKER_START, WIKI_PRE_COMMIT_MARKER_END);
222
- if (!span.ok || !isOwnedWikiPreCommitBody(content, span)) return { ok: false };
582
+ if (!span.ok || !isOwnedWikiPreCommitBody(content, span, isRewritableOldFormInstallRoot)) {
583
+ return { ok: false, hasMarker };
584
+ }
223
585
  const body = content.slice(span.startIdx + WIKI_PRE_COMMIT_MARKER_START.length, span.endIdx);
224
586
  const steps = body.split('\n').slice(1, -2); // drop leading '', trailing 'exit 0' + ''
225
- const worker = PRE_COMMIT_WORKER_LINE.exec(steps[0]);
226
- const workerPath = unescapeShellSingleQuoted(worker[1]);
227
- const root = workerPath.slice(0, -'/hooks/hypo-pre-commit.mjs'.length);
228
587
  const lintStrict = steps.length === 2;
588
+
589
+ const oldWorker = PRE_COMMIT_WORKER_LINE.exec(steps[0]);
590
+ if (oldWorker) {
591
+ const workerPath = unescapeShellSingleQuoted(oldWorker[1]);
592
+ const root = workerPath.slice(0, -WORKER_SUFFIX.length);
593
+ let hypoDir = null;
594
+ if (lintStrict) {
595
+ // isOwnedWikiPreCommitBody already confirmed steps[1] matches this shape,
596
+ // so the exec here cannot fail.
597
+ const lint = PRE_COMMIT_LINT_LINE.exec(steps[1]);
598
+ hypoDir = unescapeShellSingleQuoted(lint[2]);
599
+ }
600
+ return { ok: true, hasMarker: true, root, lintStrict, hypoDir };
601
+ }
602
+
603
+ // NEW form: isOwnedWikiPreCommitBody already confirmed steps[0] is an exact
604
+ // resolver script and, when lintStrict, that steps[1] embeds a --hypo-dir
605
+ // extractLintHypoDir can recover — so neither exec below needs a guard.
229
606
  let hypoDir = null;
230
607
  if (lintStrict) {
231
- // isOwnedWikiPreCommitBody already confirmed steps[1] matches this shape,
232
- // so the exec here cannot fail.
233
- const lint = PRE_COMMIT_LINT_LINE.exec(steps[1]);
234
- hypoDir = unescapeShellSingleQuoted(lint[2]);
608
+ const js = unescapeShellSingleQuoted(PRE_COMMIT_RESOLVER_LINE.exec(steps[1])[1]);
609
+ hypoDir = extractLintHypoDir(js);
235
610
  }
236
- return { ok: true, root, lintStrict, hypoDir };
611
+ return { ok: true, hasMarker: true, root: null, lintStrict, hypoDir };
237
612
  }
238
613
 
239
614
  // The exact text init.mjs's shellFunctionBlock() writes between the shell