hypomnema 1.10.0 → 1.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,7 +35,18 @@
35
35
  */
36
36
 
37
37
  import { execFileSync } from 'child_process';
38
- import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'fs';
38
+ import { randomBytes } from 'crypto';
39
+ import {
40
+ chmodSync,
41
+ existsSync,
42
+ lstatSync,
43
+ readFileSync,
44
+ realpathSync,
45
+ renameSync,
46
+ rmSync,
47
+ statSync,
48
+ writeFileSync,
49
+ } from 'fs';
39
50
  import { basename, dirname, isAbsolute, join, resolve, sep } from 'path';
40
51
 
41
52
  // ── shared install/uninstall markers ────────────────────────────────────────
@@ -175,20 +186,36 @@ const LINT_SUFFIX = '/scripts/lint.mjs';
175
186
  // applied to the finished script as a whole, so any single quote inside such a
176
187
  // value is escaped once, at the one place that can actually do it safely.
177
188
  //
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.
189
+ // Resolution order. commands/*.md read ${CLAUDE_PLUGIN_ROOT} first and the
190
+ // sidecar second, but CLAUDE_PLUGIN_ROOT does not exist at commit time, so this
191
+ // script asks the settings and the plugin registry for the enabled plugin
192
+ // instead (the same choice resolveCommitTimeRoot in plugin-detect.mjs makes):
193
+ // 1. The enabled plugin's root: the first `enabledPlugins` key in
194
+ // ~/.claude/settings.json that is exactly `true`, shaped `name@marketplace`
195
+ // and named `hypo` or `hypomnema`; then that key's row in
196
+ // ~/.claude/plugins/installed_plugins.json (a user-scope row first, then
197
+ // any usable row).
198
+ // 2. ~/.claude/hypo-pkg.json's `pkgRoot` (the sidecar), but only when it is
199
+ // usable (see below). A stale/wrong/relative pkgRoot must not be trusted
200
+ // just because the file parses.
201
+ // When both 1 and 2 resolve, the higher package.json version wins and the
202
+ // plugin root wins a tie. The sidecar can be the newer one: SessionStart
203
+ // rewrites it to the running install, and a second profile that shares the
204
+ // settings but not the registry may sit on a newer release than the default
205
+ // profile's registry row. The comparison is the one hooks/version-check.mjs
206
+ // compareSemver makes (leading `v` allowed, `+build` ignored, prerelease
207
+ // ordered per SemVer section 11), copied below because this script runs
208
+ // standalone. A root whose version does not parse is not a candidate.
209
+ // 3. Neither resolved: ~/.claude/plugins/installed_plugins.json's
210
+ // `hypo@hypomnema` AND the legacy pre-rename `hypomnema@hypomnema`
211
+ // entries, searched TOGETHER as one combined list: every user-scope row
212
+ // first, then any usable row. A prior version searched `hypo@hypomnema`
213
+ // alone and only fell back to the legacy key when that key was entirely
214
+ // absent, so a `hypo@hypomnema` array present but full of unusable rows (a
215
+ // half-migrated registry) hid a perfectly good `hypomnema@hypomnema` row
216
+ // forever (codex reproduction, 2026-09-11: exit 1 with a real legacy row
217
+ // on disk).
218
+ // 4. Nothing resolves: print what was checked and how to fix it, then exit 1.
192
219
  // A commit that silently skips the .hypoignore guard is worse than one
193
220
  // that refuses outright.
194
221
  //
@@ -207,6 +234,108 @@ const LINT_SUFFIX = '/scripts/lint.mjs';
207
234
  // is skipped in favor of the registry, and if nothing else resolves the
208
235
  // commit is refused (exit 1) rather than silently let through.
209
236
  function buildPreCommitResolverJs(targetSuffix, extraArgvJs = '') {
237
+ return (
238
+ 'var fs=require("fs"),os=require("os"),cp=require("child_process"),path=require("path");' +
239
+ 'function usable(r){' +
240
+ 'if(typeof r!=="string"||!r||!path.isAbsolute(r))return false;' +
241
+ // name AND version. This one runs at commit time in the user's vault and
242
+ // EXECUTES whatever it adopts, so version alone was not enough: any
243
+ // absolute directory carrying a package.json and the target path was
244
+ // accepted, and its script ran. The name check closes that.
245
+ //
246
+ // Not every root judgment here checks the name, and the difference is
247
+ // what each one licenses. isRealOldFormInstallRoot and
248
+ // isRewritableOldFormInstallRoot (below) and hypo-shared.mjs's sidecar
249
+ // proof do check it — they gate deleting, rewriting, executing.
250
+ // plugin-detect.mjs's usablePkgRoot does NOT (it answers the narrower
251
+ // "can scripts be resolved through this pointer at all"), which is why
252
+ // that file now also exports isHypomnemaInstallRoot, the strong form that
253
+ // DOES check the name. Its callers split accordingly: init.mjs's
254
+ // resolveDurableRoot and selectEntry (the registry-row picker behind
255
+ // resolveEnabledPluginEntry) RECORD what they accept, so both moved onto
256
+ // the strong predicate; doctor.mjs's per-row leaf-drift scan only reads
257
+ // for display and deliberately stays on the weak one (narrowing it would
258
+ // silence a foreign registry row doctor exists to surface). Do not cite
259
+ // this comment as proof the name is checked everywhere; check the
260
+ // specific predicate.
261
+ 'try{var p=JSON.parse(fs.readFileSync(r+"/package.json","utf-8"));' +
262
+ 'if(p.name!=="hypomnema")return false;' +
263
+ 'if(typeof p.version!=="string"||!p.version)return false}catch(e){return false}' +
264
+ 'try{return fs.existsSync(r+"' +
265
+ targetSuffix +
266
+ '")}catch(e){return false}' +
267
+ '}' +
268
+ 'function readJson(p){try{return JSON.parse(fs.readFileSync(p,"utf-8"))}catch(e){return null}}' +
269
+ // Version helpers: a copy of hooks/version-check.mjs parseSemver and
270
+ // compareSemver (see the resolution order above). Only the regex and the
271
+ // comparison are copied, no comments, because a comment here could carry a
272
+ // single quote.
273
+ 'function sv(v){var m=typeof v==="string"?v.trim().replace(/^v/,"").match(/^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([0-9A-Za-z.-]+))?(?:\\+[0-9A-Za-z.-]+)?$/):null;return m?{c:[m[1],m[2],m[3]],pre:m[4]||""}:null}' +
274
+ 'function nid(x,y){if(x.length!==y.length)return x.length<y.length?-1:1;return x===y?0:(x<y?-1:1)}' +
275
+ 'function cpre(a,b){var ai=a.split("."),bi=b.split(".");var n=Math.max(ai.length,bi.length);' +
276
+ 'for(var i=0;i<n;i++){if(i>=ai.length)return -1;if(i>=bi.length)return 1;' +
277
+ 'var x=ai[i],y=bi[i],xn=/^\\d+$/.test(x),yn=/^\\d+$/.test(y);' +
278
+ 'if(xn&&yn){var c=nid(x,y);if(c!==0)return c}' +
279
+ 'else if(xn!==yn){return xn?-1:1}' +
280
+ 'else if(x!==y){return x<y?-1:1}}return 0}' +
281
+ 'function cmp(a,b){var pa=sv(a),pb=sv(b);if(!pa||!pb)return null;' +
282
+ 'for(var k=0;k<3;k++){var c=nid(pa.c[k],pb.c[k]);if(c!==0)return c}' +
283
+ 'if(pa.pre===pb.pre)return 0;if(!pa.pre)return 1;if(!pb.pre)return -1;return cpre(pa.pre,pb.pre)}' +
284
+ 'function ver(r){return readJson(r+"/package.json").version}' +
285
+ 'var home=os.homedir();var root=null;' +
286
+ 'var reg=readJson(home+"/.claude/plugins/installed_plugins.json");' +
287
+ 'var plugins=reg&&reg.plugins;' +
288
+ 'var set=readJson(home+"/.claude/settings.json");var ep=set&&set.enabledPlugins;var key=null;' +
289
+ 'if(ep&&typeof ep==="object"&&!Array.isArray(ep)){var ks=Object.keys(ep);' +
290
+ 'for(var i=0;i<ks.length;i++){var k=ks[i],at=k.indexOf("@");' +
291
+ 'if(ep[k]!==true||at<=0||at===k.length-1)continue;' +
292
+ 'var nm=k.slice(0,at);if(nm==="hypo"||nm==="hypomnema"){key=k;break}}}' +
293
+ 'var pr=null;' +
294
+ 'if(key&&plugins&&Array.isArray(plugins[key])){var rows=plugins[key];' +
295
+ 'var row=rows.find(function(e){return e&&e.scope==="user"&&usable(e.installPath)})||' +
296
+ 'rows.find(function(e){return e&&usable(e.installPath)});' +
297
+ 'if(row&&sv(ver(row.installPath)))pr=row.installPath}' +
298
+ 'var pkg=readJson(home+"/.claude/hypo-pkg.json");' +
299
+ 'var sc=pkg&&usable(pkg.pkgRoot)&&sv(ver(pkg.pkgRoot))?pkg.pkgRoot:null;' +
300
+ 'if(pr&&sc){var sidecarNewer=cmp(ver(pr),ver(sc))<0;root=sidecarNewer?sc:pr}else{root=pr||sc}' +
301
+ 'if(!root){' +
302
+ 'var arr=[];' +
303
+ 'if(plugins&&Array.isArray(plugins["hypo@hypomnema"]))arr=arr.concat(plugins["hypo@hypomnema"]);' +
304
+ 'if(plugins&&Array.isArray(plugins["hypomnema@hypomnema"]))arr=arr.concat(plugins["hypomnema@hypomnema"]);' +
305
+ 'var entry=arr.find(function(e){return e&&e.scope==="user"&&usable(e.installPath)})||' +
306
+ 'arr.find(function(e){return e&&usable(e.installPath)});' +
307
+ 'if(entry){root=entry.installPath}' +
308
+ '}' +
309
+ 'if(!root){' +
310
+ 'console.error("hypomnema: could not resolve the install root for ' +
311
+ targetSuffix +
312
+ '.");' +
313
+ 'console.error("Checked ~/.claude/settings.json (enabledPlugins), ~/.claude/hypo-pkg.json (pkgRoot) and ~/.claude/plugins/installed_plugins.json (hypo@hypomnema installPath, legacy hypomnema@hypomnema).");' +
314
+ 'console.error("Fix: run `hypomnema upgrade --apply` (or `/hypo:upgrade` on a plugin install), or reinstall Hypomnema.");' +
315
+ // A 1.10.0 uninstaller does not recognize this hook body and keeps it, so once the
316
+ // plugin is gone every commit lands here. Name the file and say how to remove it.
317
+ // git is asked for the path because the hook may live under core.hooksPath; if that
318
+ // fails the text says where to look instead. No single quote may appear in this text.
319
+ 'var hp="";try{var g=cp.spawnSync("git",["rev-parse","--git-path","hooks/pre-commit"],{encoding:"utf-8"});' +
320
+ 'if(g.status===0&&g.stdout.trim())hp=path.resolve(g.stdout.trim())}catch(e){}' +
321
+ 'console.error("If Hypomnema was uninstalled, this hook can be removed by deleting "+(hp||"the pre-commit file in this repository hooks directory")+" (or by running uninstall from Hypomnema 1.10.1 or newer).");' +
322
+ 'process.exit(1)' +
323
+ '}' +
324
+ 'var r=cp.spawnSync(process.execPath,[root+"' +
325
+ targetSuffix +
326
+ '"' +
327
+ extraArgvJs +
328
+ '],{stdio:"inherit"});' +
329
+ 'if(r.error){console.error(String(r.error.message||r.error))}' +
330
+ 'process.exit(r.status===null?1:r.status)'
331
+ );
332
+ }
333
+
334
+ // Frozen, byte-for-byte copy of buildPreCommitResolverJs as it shipped before the
335
+ // enabled plugin root was consulted ahead of the sidecar. Hooks already
336
+ // installed in vaults embed this exact script, so ownership recognition has to
337
+ // keep reproducing it after the live builder moves on. Do not edit the body.
338
+ function buildPreviousPreCommitResolverJs(targetSuffix, extraArgvJs = '') {
210
339
  return (
211
340
  'var fs=require("fs"),os=require("os"),cp=require("child_process"),path=require("path");' +
212
341
  'function usable(r){' +
@@ -301,11 +430,12 @@ function extractLintHypoDir(js) {
301
430
  // (not a substring/suffix check) because the worker step has no dynamic
302
431
  // input at all — anything less than byte-identical is not a script this
303
432
  // 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) {
433
+ // unrecognized shape must never be treated as "close enough". `build` picks the
434
+ // resolver shape: the live one, or the frozen one an earlier release shipped.
435
+ function isNewFormWorkerStep(step, build = buildPreCommitResolverJs) {
306
436
  const m = PRE_COMMIT_RESOLVER_LINE.exec(step);
307
437
  if (!m) return false;
308
- return unescapeShellSingleQuoted(m[1]) === buildPreCommitResolverJs(WORKER_SUFFIX);
438
+ return unescapeShellSingleQuoted(m[1]) === build(WORKER_SUFFIX);
309
439
  }
310
440
 
311
441
  // Is `root` a real, on-disk Hypomnema install that could actually have
@@ -385,13 +515,13 @@ export function isRewritableOldFormInstallRoot(root) {
385
515
  // Extracts the embedded --hypo-dir, rebuilds the expected script around it,
386
516
  // and compares byte-for-byte — the only way to validate a script with one
387
517
  // dynamic input without loosening the check into a substring match.
388
- function isNewFormLintStep(step) {
518
+ function isNewFormLintStep(step, build = buildPreCommitResolverJs) {
389
519
  const m = PRE_COMMIT_RESOLVER_LINE.exec(step);
390
520
  if (!m) return false;
391
521
  const js = unescapeShellSingleQuoted(m[1]);
392
522
  const hypoDir = extractLintHypoDir(js);
393
523
  if (hypoDir === null) return false;
394
- return js === buildPreCommitResolverJs(LINT_SUFFIX, lintExtraArgvJs(hypoDir));
524
+ return js === build(LINT_SUFFIX, lintExtraArgvJs(hypoDir));
395
525
  }
396
526
 
397
527
  /**
@@ -411,7 +541,8 @@ function isNewFormLintStep(step) {
411
541
  * or COULD HAVE WRITTEN in an older release (the version-pinned form) — see
412
542
  * the module comment above PRE_COMMIT_WORKER_LINE for both shapes. A body
413
543
  * 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.
544
+ * ours: no writer this codebase has ever shipped produces that. The same
545
+ * goes for one step on the previous resolver and the other on the current.
415
546
  */
416
547
  export function isOwnedWikiPreCommitBody(
417
548
  content,
@@ -437,7 +568,8 @@ export function isOwnedWikiPreCommitBody(
437
568
  : null;
438
569
  const workerIsOld = oldWorkerRoot !== null && isValidOldFormRoot(oldWorkerRoot);
439
570
  const workerIsNew = isNewFormWorkerStep(steps[0]);
440
- if (!workerIsOld && !workerIsNew) return false;
571
+ const workerIsPrevious = isNewFormWorkerStep(steps[0], buildPreviousPreCommitResolverJs);
572
+ if (!workerIsOld && !workerIsNew && !workerIsPrevious) return false;
441
573
 
442
574
  if (steps.length === 2) {
443
575
  if (workerIsOld) {
@@ -455,7 +587,12 @@ export function isOwnedWikiPreCommitBody(
455
587
  if (!lint || unescapeShellSingleQuoted(lint[1]) !== oldWorkerRoot + LINT_SUFFIX) {
456
588
  return false;
457
589
  }
458
- } else if (!isNewFormLintStep(steps[1])) {
590
+ } else if (
591
+ !isNewFormLintStep(
592
+ steps[1],
593
+ workerIsPrevious ? buildPreviousPreCommitResolverJs : buildPreCommitResolverJs,
594
+ )
595
+ ) {
459
596
  return false;
460
597
  }
461
598
  }
@@ -499,16 +636,55 @@ export function shellSingleQuote(p) {
499
636
  // hypo-config.md scan) and may silently lint an unrelated vault. That is a
500
637
  // vault path, not an install path, so unlike the root above it never goes
501
638
  // stale across a release — it only needs resolving once, here.
502
- export function wikiPreCommitContent(hypoDir, lintStrict) {
639
+ export function wikiPreCommitContent(hypoDir, lintStrict, build = buildPreCommitResolverJs) {
503
640
  const absHypoDir = resolve(hypoDir);
504
- const steps = [`node -e ${shellSingleQuote(buildPreCommitResolverJs(WORKER_SUFFIX))} || exit 1`];
641
+ const steps = [`node -e ${shellSingleQuote(build(WORKER_SUFFIX))} || exit 1`];
505
642
  if (lintStrict) {
506
- const lintJs = buildPreCommitResolverJs(LINT_SUFFIX, lintExtraArgvJs(absHypoDir));
643
+ const lintJs = build(LINT_SUFFIX, lintExtraArgvJs(absHypoDir));
507
644
  steps.push(`node -e ${shellSingleQuote(lintJs)} || exit 1`);
508
645
  }
509
646
  return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\n${steps.join('\n')}\nexit 0\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
510
647
  }
511
648
 
649
+ // The exact bytes a hook written with the previous commit-time resolver holds
650
+ // (what v1.10.0 shipped). upgrade compares a file on disk against this before
651
+ // it rewrites the whole file, for the same reason oldFormPreCommitContent
652
+ // exists: the marker span says nothing about what sits around it.
653
+ export function previousFormPreCommitContent(hypoDir, lintStrict) {
654
+ return wikiPreCommitContent(hypoDir, lintStrict, buildPreviousPreCommitResolverJs);
655
+ }
656
+
657
+ // Publishes `content` as the executable hook at `hookPath` through a complete
658
+ // candidate file: write the candidate beside the hook, mark it executable, then
659
+ // rename it over the hook. Git runs the hook at every commit, so a write torn
660
+ // halfway through the live file would leave a hook that fails or half-runs
661
+ // until someone repairs it by hand. With the rename as the only step that
662
+ // touches the live path, a failure anywhere leaves the previous hook whole.
663
+ // Ownership checks and the .bak copy are the caller's job.
664
+ //
665
+ // Returns `{ ok: true }`, or `{ ok: false, error, candidatePath, cleaned }`.
666
+ // `cleaned` is false when the candidate could not be removed after a failure; a
667
+ // leftover `pre-commit.<pid>.<rand>.tmp` is never run by git, so the caller
668
+ // reports its path rather than treating it as a second failure.
669
+ export function publishHookFile(hookPath, content) {
670
+ const candidatePath = `${hookPath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
671
+ try {
672
+ writeFileSync(candidatePath, content, { flag: 'wx' });
673
+ chmodSync(candidatePath, 0o755);
674
+ renameSync(candidatePath, hookPath);
675
+ return { ok: true };
676
+ } catch (error) {
677
+ let cleaned = false;
678
+ try {
679
+ rmSync(candidatePath, { force: true });
680
+ cleaned = true;
681
+ } catch {
682
+ // Reported through `cleaned: false`, not thrown over the original error.
683
+ }
684
+ return { ok: false, error, candidatePath, cleaned };
685
+ }
686
+ }
687
+
512
688
  // Rebuilds the exact bytes an OLD-form (version-pinned) pre-commit hook holds
513
689
  // for a given (root, hypoDir, lintStrict) — the shape a pre-issue release of
514
690
  // this writer would have produced, and the shape parseWikiPreCommitRoot reads
@@ -608,6 +784,11 @@ export function parseWikiPreCommitRoot(content) {
608
784
  const js = unescapeShellSingleQuoted(PRE_COMMIT_RESOLVER_LINE.exec(steps[1])[1]);
609
785
  hypoDir = extractLintHypoDir(js);
610
786
  }
787
+ // A body on the previous resolver also has no baked root, but it still needs
788
+ // upgrade to rewrite it, so it says so instead of looking like the live form.
789
+ if (isNewFormWorkerStep(steps[0], buildPreviousPreCommitResolverJs)) {
790
+ return { ok: true, hasMarker: true, root: null, previousResolver: true, lintStrict, hypoDir };
791
+ }
611
792
  return { ok: true, hasMarker: true, root: null, lintStrict, hypoDir };
612
793
  }
613
794
 
@@ -787,22 +968,37 @@ export function resolveGitHooksDir(repoRoot, { timeoutMs = 5000 } = {}) {
787
968
  * @returns {null | string} null when writing is safe, else a reason to log
788
969
  */
789
970
  export function unsafeHookTargetReason(hookPath) {
971
+ return unsafeHookTarget(hookPath)?.reason ?? null;
972
+ }
973
+
974
+ /**
975
+ * Same probe as unsafeHookTargetReason, with `operational` telling a refusal on purpose
976
+ * (a symlink, a non-regular file) from a probe that failed with an errno (EACCES, EIO, ...)
977
+ * and so says nothing about whether the entry is safe. Callers that must not carry on past a
978
+ * failed probe read this field instead of parsing the message.
979
+ *
980
+ * @returns {null | {reason: string, operational: boolean}}
981
+ */
982
+ export function unsafeHookTarget(hookPath) {
790
983
  let st;
791
984
  try {
792
985
  st = lstatSync(hookPath);
793
986
  } catch (e) {
794
987
  if (e && e.code === 'ENOENT') return null; // genuinely absent — safe to create
795
- return `cannot stat (${e.code || e.message})`;
988
+ return { reason: `cannot stat (${e.code || e.message})`, operational: true };
989
+ }
990
+ if (st.isSymbolicLink()) {
991
+ return { reason: 'is a symlink — refusing to write through it', operational: false };
796
992
  }
797
- if (st.isSymbolicLink()) return 'is a symlink — refusing to write through it';
798
- if (!st.isFile()) return 'exists but is not a regular file';
993
+ if (!st.isFile()) return { reason: 'exists but is not a regular file', operational: false };
799
994
  return null;
800
995
  }
801
996
 
802
997
  /**
803
998
  * Write-side wrapper: the hooks directory only if it is safe to install into.
804
999
  * Returns `{dir}` when installing is allowed, otherwise `{skip}` carrying a
805
- * human-readable reason for the caller to log.
1000
+ * human-readable reason for the caller to log. `operational: true` marks a skip that is a
1001
+ * failed probe (git could not resolve the hooks dir), not a deliberate refusal.
806
1002
  */
807
1003
  export function hooksDirForInstall(repoRoot, opts) {
808
1004
  const r = resolveGitHooksDir(repoRoot, opts);
@@ -815,7 +1011,8 @@ export function hooksDirForInstall(repoRoot, opts) {
815
1011
  return { skip: `hooks path is not a directory, so git runs no hooks (${r.path})` };
816
1012
  }
817
1013
  if (r.reason === 'git-unavailable') return { skip: 'git not available on PATH' };
818
- return { skip: `could not resolve hooks dir (${r.detail || r.reason})` };
1014
+ // operational: git or the filesystem failed, which is not a decision to leave the hook alone
1015
+ return { skip: `could not resolve hooks dir (${r.detail || r.reason})`, operational: true };
819
1016
  }
820
1017
  if (!r.owned) {
821
1018
  return {
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { readFileSync, existsSync } from 'node:fs';
19
19
  import { isAbsolute, join } from 'node:path';
20
+ import { compareSemver, parseSemver } from '../../hooks/version-check.mjs';
20
21
 
21
22
  /**
22
23
  * @param {string} settingsPath path to a Claude Code settings.json (e.g. ~/.claude/settings.json)
@@ -218,11 +219,14 @@ export function leafVersionDrift(pkgRoot) {
218
219
  // being a real Hypomnema package, must not be selected here. codex reproduction
219
220
  // (2026-09-11): a registry row's package.json carried no `name` field at all and
220
221
  // was still adopted as the durable root under the old, name-blind check.
221
- function selectEntry(entries, scope) {
222
+ function selectEntry(entries, scope, requireFile) {
222
223
  return (
223
224
  entries.find(
224
225
  (e) =>
225
- e && (scope === undefined || e.scope === scope) && isHypomnemaInstallRoot(e.installPath),
226
+ e &&
227
+ (scope === undefined || e.scope === scope) &&
228
+ isHypomnemaInstallRoot(e.installPath) &&
229
+ (requireFile === undefined || existsSync(join(e.installPath, requireFile))),
226
230
  ) ?? null
227
231
  );
228
232
  }
@@ -260,7 +264,10 @@ function selectEntry(entries, scope) {
260
264
  // comment exists to prevent). It also silences upgrade's dualSkipWouldCorrect
261
265
  // trigger, disabling the very self-heal that fixes a stale pointer. Drift is
262
266
  // surfaced by doctor's checkPluginLeafVersion instead.
263
- export function resolveEnabledPluginEntry(settingsPath, registryPath) {
267
+ //
268
+ // `requireFile` (a path relative to the install root, default none) makes a row
269
+ // eligible only when that file exists. Only resolveCommitTimeRoot passes it.
270
+ export function resolveEnabledPluginEntry(settingsPath, registryPath, requireFile) {
264
271
  const key = enabledHypomnemaPluginKey(settingsPath);
265
272
  if (!key) return { key: null, reason: 'not-enabled', entry: null, root: null };
266
273
  let reg;
@@ -273,7 +280,9 @@ export function resolveEnabledPluginEntry(settingsPath, registryPath) {
273
280
  reg && typeof reg.plugins === 'object' && !Array.isArray(reg.plugins) ? reg.plugins : null;
274
281
  const entries = plugins && Array.isArray(plugins[key]) ? plugins[key] : null;
275
282
  // Prefer the user-scope install, then any usable entry for this exact key.
276
- const entry = entries ? (selectEntry(entries, 'user') ?? selectEntry(entries)) : null;
283
+ const entry = entries
284
+ ? (selectEntry(entries, 'user', requireFile) ?? selectEntry(entries, undefined, requireFile))
285
+ : null;
277
286
  if (!entry) return { key, reason: 'unresolved', entry: null, root: null };
278
287
  return { key, reason: 'resolved', entry, root: entry.installPath };
279
288
  }
@@ -286,6 +295,50 @@ export function resolveEnabledPluginRoot(settingsPath, registryPath) {
286
295
  return resolveEnabledPluginEntry(settingsPath, registryPath).root;
287
296
  }
288
297
 
298
+ // The install root the vault's pre-commit hook should run when a vault
299
+ // operation fires it: the enabled plugin's root (resolveEnabledPluginEntry) and
300
+ // the sidecar `pkgRoot` from hypo-pkg.json (`pkgJsonPath`), whichever carries the
301
+ // higher package.json version, the plugin root on a tie. The sidecar is only a
302
+ // candidate when isHypomnemaInstallRoot accepts it. A root whose version
303
+ // parseSemver cannot read is dropped as a candidate, and null means neither
304
+ // root qualified. The comparison is hooks/version-check.mjs compareSemver, the
305
+ // same judgment SessionStart's downgrade guard makes: one question, one rule.
306
+ //
307
+ // A root is only eligible when `script` (relative to the root, default the
308
+ // pre-commit worker) exists in it, as in the generated hook: a plugin root
309
+ // without the worker must not win over a complete sidecar. For the plugin the
310
+ // check applies per registry row, so a later row that has the script is still
311
+ // found.
312
+ //
313
+ // The generated hook (scripts/lib/git-hooks-dir.mjs) runs standalone and carries
314
+ // its own copy of this choice, so this function is the reference its tests hold
315
+ // the script against.
316
+ export function resolveCommitTimeRoot(
317
+ settingsPath,
318
+ registryPath,
319
+ pkgJsonPath,
320
+ script = 'hooks/hypo-pre-commit.mjs',
321
+ ) {
322
+ const candidate = (root) => (root && parseSemver(readPkgVersionAt(root)) ? root : null);
323
+ const plugin = candidate(resolveEnabledPluginEntry(settingsPath, registryPath, script).root);
324
+ let sidecar = null;
325
+ try {
326
+ const pkgRoot = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))?.pkgRoot;
327
+ sidecar =
328
+ isHypomnemaInstallRoot(pkgRoot) && existsSync(join(pkgRoot, script))
329
+ ? candidate(pkgRoot)
330
+ : null;
331
+ } catch {
332
+ // unreadable or corrupt sidecar: no sidecar candidate
333
+ }
334
+ if (plugin && sidecar) {
335
+ return compareSemver(readPkgVersionAt(plugin), readPkgVersionAt(sidecar)) < 0
336
+ ? sidecar
337
+ : plugin;
338
+ }
339
+ return plugin ?? sidecar;
340
+ }
341
+
289
342
  // Single-computation answer to "is Claude's core hook/command surface
290
343
  // plugin-managed for THIS process, and if that requires a registry lookup, what
291
344
  // did it find?" doctor.mjs, init.mjs and upgrade.mjs each used to compute