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.
@@ -42,8 +42,9 @@ import { homedir } from 'os';
42
42
  import { fileURLToPath } from 'url';
43
43
  import { createHash } from 'crypto';
44
44
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
45
+ import { loadHookInventory } from './lib/hook-inventory.mjs';
45
46
  import { parseFrontmatter } from './lib/frontmatter.mjs';
46
- import { templateSchemaVersion } from './lib/template-schema-version.mjs';
47
+ import { templateSchemaVersion, schemaVersionDeltas } from './lib/template-schema-version.mjs';
47
48
  import {
48
49
  readPkgJson as readPkgJsonSafe,
49
50
  writePkgJsonAtomic,
@@ -54,13 +55,15 @@ import {
54
55
  writeDualSkipProvenance,
55
56
  } from './lib/pkg-json.mjs';
56
57
  import { syncExtensions } from './lib/extensions.mjs';
57
- import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
58
+ import { writeProvenanceSidecar, readProvenanceSidecar } from './lib/pkg-provenance.mjs';
58
59
  import { resolvePluginChannel } from './lib/plugin-detect.mjs';
59
60
  import {
60
61
  resolveGitHooksDir,
61
62
  unsafeHookTargetReason,
62
63
  parseWikiPreCommitRoot,
63
64
  wikiPreCommitContent,
65
+ oldFormPreCommitContent,
66
+ uniqueBakPath,
64
67
  } from './lib/git-hooks-dir.mjs';
65
68
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
66
69
 
@@ -132,94 +135,23 @@ function bumpType(installed, current) {
132
135
  }
133
136
 
134
137
  // ── hook map (loaded from hooks/hooks.json — single source of truth) ─────────
135
-
136
- let _hookConfig;
137
- try {
138
- _hookConfig = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
139
- } catch {
140
- console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
141
- console.error(PKG_INTEGRITY_HINT);
142
- process.exit(1);
143
- }
144
- if (!_hookConfig || typeof _hookConfig !== 'object' || Array.isArray(_hookConfig)) {
145
- console.error('Error: hooks/hooks.json must be a JSON object');
146
- console.error(PKG_INTEGRITY_HINT);
147
- process.exit(1);
148
- }
149
- if (
150
- !_hookConfig.hooks ||
151
- typeof _hookConfig.hooks !== 'object' ||
152
- Array.isArray(_hookConfig.hooks)
153
- ) {
154
- console.error('Error: hooks/hooks.json must contain a "hooks" object');
155
- console.error(PKG_INTEGRITY_HINT);
156
- process.exit(1);
157
- }
158
- function _extractCommandFileName(command) {
159
- if (typeof command !== 'string') return null;
160
- const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
161
- if (matches.length > 0) return matches[matches.length - 1][1];
162
- const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
163
- return bare ? bare[1] : null;
164
- }
165
-
166
- function _isHookFileName(file) {
167
- return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
168
- }
169
-
170
- function _isHookGroup(group) {
171
- return (
172
- group &&
173
- typeof group === 'object' &&
174
- !Array.isArray(group) &&
175
- Array.isArray(group.hooks) &&
176
- group.hooks.length > 0 &&
177
- group.hooks.every(
178
- (hook) =>
179
- hook &&
180
- typeof hook === 'object' &&
181
- !Array.isArray(hook) &&
182
- hook.type === 'command' &&
183
- _extractCommandFileName(hook.command),
184
- )
185
- );
186
- }
187
-
188
- // Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
189
- function _extractFileNames(groups) {
190
- return groups.flatMap((group) => {
191
- if (typeof group === 'string') return [group.trim()];
192
- return group.hooks.map((hook) => _extractCommandFileName(hook.command));
193
- });
194
- }
195
-
196
- for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
197
- const valid =
198
- Array.isArray(groups) &&
199
- groups.length > 0 &&
200
- groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
201
- _extractFileNames(groups).length > 0;
202
- if (!valid) {
203
- console.error(
204
- `Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
205
- );
206
- console.error(PKG_INTEGRITY_HINT);
207
- process.exit(1);
208
- }
209
- }
210
- if (
211
- _hookConfig.shared !== undefined &&
212
- (!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every((f) => _isHookFileName(f)))
213
- ) {
214
- console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
138
+ //
139
+ // loadHookInventory (scripts/lib/hook-inventory.mjs) is the one parser every
140
+ // hooks.json/shared.json consumer reads through — smoke-plugin, init, doctor,
141
+ // and uninstall all call the same function, so a command shape one of them
142
+ // would accept and another would silently misread cannot exist anymore. It also
143
+ // existence-checks every named file against THIS package's own hooks/ before
144
+ // returning ok:true, so a corrupt or incomplete package (a hook file dropped
145
+ // from `package.json`'s `files` allowlist) fails here, at startup, instead of
146
+ // upgrade reporting some files "src-missing" while still applying the rest.
147
+ const _hookInventory = loadHookInventory(PKG_ROOT);
148
+ if (!_hookInventory.ok) {
149
+ console.error(`Error: ${_hookInventory.error}`);
215
150
  console.error(PKG_INTEGRITY_HINT);
216
151
  process.exit(1);
217
152
  }
218
-
219
- const HOOK_MAP = Object.fromEntries(
220
- Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]),
221
- );
222
- const SHARED_FILES = _hookConfig.shared ?? [];
153
+ const HOOK_MAP = _hookInventory.hookMap;
154
+ const SHARED_FILES = _hookInventory.shared;
223
155
 
224
156
  // ── checks ───────────────────────────────────────────────────────────────────
225
157
 
@@ -555,17 +487,42 @@ function applyGitignoreMigration(result) {
555
487
  return appended;
556
488
  }
557
489
 
558
- // ── wiki pre-commit hook: install-root self-heal ────────────────────────────
490
+ // ── wiki pre-commit hook: migrate the version-pinned form to the runtime
491
+ // resolver ───────────────────────────────────────────────────────
492
+ //
493
+ // init.mjs used to bake an absolute install root into the vault's pre-commit
494
+ // hook (see the OLD form documented above PRE_COMMIT_WORKER_LINE in
495
+ // lib/git-hooks-dir.mjs). A plugin-channel upgrade moves PKG_ROOT to a new
496
+ // version directory every release, but nothing ever re-ran init afterward, so
497
+ // the hook kept calling whatever release happened to be current the day
498
+ // /hypo:init last ran — silently, since the entry-point file the hook calls is
499
+ // often byte-identical across versions while the module graph it pulls in
500
+ // from its own directory is not. wikiPreCommitContent() no longer bakes a
501
+ // root in at all: the hook resolves it itself, at commit time. So the fix
502
+ // here is not "repoint the baked root at the current install" (there is
503
+ // nothing left to repoint once a hook is on the new form) — it is "rewrite an
504
+ // OLD-form hook onto the new form", once, unconditionally. That migration
505
+ // does not need to know the active install root, which is why it is safe even
506
+ // when the registry cannot positively resolve one right now (see
507
+ // wikiPreCommitNeedsMigration below): the resolver embedded in the rewritten
508
+ // hook will look that up itself, fresh, at every future commit.
509
+ //
510
+ // Read-only: locates the hook (if any) and, only when it carries our marker in
511
+ // a shape isOwnedWikiPreCommitBody() recognizes (old OR new — see
512
+ // lib/git-hooks-dir.mjs), what parseWikiPreCommitRoot() read back from it.
559
513
  //
560
- // init.mjs bakes an absolute install root into the vault's pre-commit hook
561
- // (see wikiPreCommitContent in lib/git-hooks-dir.mjs). A plugin-channel upgrade
562
- // moves PKG_ROOT to a new version directory every release, but nothing ever
563
- // re-ran init afterward, so the hook keeps calling whatever release happened
564
- // to be current the day /hypo:init last ran — silently, since the entry-point
565
- // file the hook calls is often byte-identical across versions while the
566
- // module graph it pulls in from its own directory is not. Read-only: locates
567
- // the hook (if any) and, only when it carries our marker in a shape
568
- // wikiPreCommitContent() could have written, the root currently baked into it.
514
+ // Returns `null` when there is nothing of ours to report (no hook, or a hook
515
+ // with no Hypomnema marker at all: some other tool's or the user's own).
516
+ // Returns `{ hookPath, unrecognized: true, root: null }` when our marker IS
517
+ // present but parseWikiPreCommitRoot could not read a body it recognizes: a
518
+ // hand-edited or corrupted hook, or (before this fix) a hook whose OLD-form
519
+ // worker line named an install root that no longer exists. That case used to
520
+ // collapse into the same `null` a genuinely foreign hook gets, which made
521
+ // upgrade.mjs's report skip the "Wiki pre-commit" line entirely and left
522
+ // doctor.mjs reporting the marker-present pass with no root warning beside
523
+ // it: a broken hook (MODULE_NOT_FOUND on every commit) reads as installed
524
+ // and healthy either way. `root: null` here means the same thing it does in
525
+ // applyWikiPreCommitRoot's caller: never attempt the migration write.
569
526
  function checkWikiPreCommitRoot(hypoDir) {
570
527
  const resolved = resolveGitHooksDir(hypoDir);
571
528
  if (!resolved.ok || !resolved.owned) return null;
@@ -578,50 +535,105 @@ function checkWikiPreCommitRoot(hypoDir) {
578
535
  return null;
579
536
  }
580
537
  const parsed = parseWikiPreCommitRoot(content);
581
- if (!parsed.ok) return null;
538
+ if (!parsed.ok) {
539
+ return parsed.hasMarker ? { hookPath, unrecognized: true, root: null } : null;
540
+ }
582
541
  return { hookPath, root: parsed.root, lintStrict: parsed.lintStrict, hypoDir: parsed.hypoDir };
583
542
  }
584
543
 
585
- // Rewrites ONLY the embedded root, reusing the exact writer init.mjs itself
586
- // uses so the regenerated hook is byte-identical to what a fresh init would
587
- // produce — the --lint-strict step count (info.lintStrict) is preserved
588
- // exactly, never collapsed to one step, or the lint gate goes silently dark.
589
- // The --hypo-dir baked into a --lint-strict hook is preserved from `info`
590
- // (what parseWikiPreCommitRoot read back), never taken from this run's own
591
- // --hypo-dir: upgrade can run against a vault with a different --hypo-dir than
592
- // the one init baked in, and substituting the current run's value would
593
- // silently repoint the lint gate at an unrelated directory. A non-absolute
594
- // embedded value is refused rather than trusted (wikiPreCommitContent()
595
- // re-resolves it against upgrade's own cwd, which is never what a relative
596
- // value there was meant to mean), leaving the hook untouched for that run.
544
+ // Rewrites an OLD-form hook onto the runtime-resolving form, reusing the exact
545
+ // writer init.mjs itself uses so the regenerated hook is byte-identical to
546
+ // what a fresh init would produce today — the --lint-strict step count
547
+ // (info.lintStrict) is preserved exactly, never collapsed to one step, or the
548
+ // lint gate goes silently dark. The --hypo-dir baked into a --lint-strict hook
549
+ // is preserved from `info` (what parseWikiPreCommitRoot read back), never
550
+ // taken from this run's own --hypo-dir: upgrade can run against a vault with a
551
+ // different --hypo-dir than the one init baked in, and substituting the
552
+ // current run's value would silently repoint the lint gate at an unrelated
553
+ // directory. A non-absolute embedded value is refused rather than trusted
554
+ // (wikiPreCommitContent() re-resolves it against upgrade's own cwd, which is
555
+ // never what a relative value there was meant to mean), leaving the hook
556
+ // untouched for that run.
597
557
  //
598
- // This only self-heals the ROOT. If the vault itself has actually moved, the
599
- // embedded --hypo-dir is wrong too and no amount of --apply repoints it — only
600
- // re-running `hypomnema init` rewrites --hypo-dir.
558
+ // `wantRoot` is accepted for call-site compatibility (the caller still
559
+ // computes it for the drift report and JSON output) but is otherwise unused
560
+ // here: the new form never bakes a root, so there is nothing to substitute it
561
+ // into. This only migrates the FORM. If the vault itself has actually moved,
562
+ // the embedded --hypo-dir is wrong too and no amount of --apply fixes that —
563
+ // only re-running `hypomnema init` rewrites --hypo-dir.
601
564
  //
602
565
  // Returns `{ ok: true }` on a successful rewrite, or `{ ok: false, reason }`
603
566
  // naming why the write was refused or failed, so the caller can surface that
604
567
  // reason instead of silently reporting "unchanged".
605
- function applyWikiPreCommitRoot(info, wantRoot) {
568
+ function applyWikiPreCommitRoot(info) {
606
569
  const unsafe = unsafeHookTargetReason(info.hookPath);
607
570
  if (unsafe) return { ok: false, reason: unsafe };
608
571
  if (info.lintStrict && !isAbsolute(info.hypoDir)) {
609
572
  return {
610
573
  ok: false,
611
- reason: `embedded --hypo-dir is not absolute (${info.hypoDir}) — refusing to repoint`,
574
+ reason: `embedded --hypo-dir is not absolute (${info.hypoDir}) — refusing to migrate`,
612
575
  };
613
576
  }
614
577
  // hypoDir only affects output when lintStrict is true (see
615
578
  // wikiPreCommitContent); the fallback here never reaches the written file.
616
579
  const hypoDir = info.lintStrict ? info.hypoDir : '.';
617
- const newContent = wikiPreCommitContent(wantRoot, hypoDir, info.lintStrict);
580
+
581
+ // Require the file ON DISK to already be, byte-for-byte, the exact OLD-form
582
+ // shape these SAME values (info.root, hypoDir, info.lintStrict) reconstruct.
583
+ // isOwnedWikiPreCommitBody upstream only validated the SPAN between the
584
+ // markers; it says nothing about content before the start marker or after
585
+ // the end one. A hand-crafted file pairing a forged-but-valid span (an
586
+ // old-form worker line naming a root that does not currently exist — exactly
587
+ // what isRewritableOldFormInstallRoot's "gone root" branch accepts) with real
588
+ // content outside that span would otherwise be silently discarded by the
589
+ // whole-file write below (codex reproduction, 2026-09-11). A legitimate
590
+ // install this codebase ever wrote IS exactly this reconstruction — nothing
591
+ // outside the span but the fixed shebang line — so this never refuses a real
592
+ // migration; see oldFormPreCommitContent's comment in lib/git-hooks-dir.mjs.
593
+ let currentContent;
594
+ try {
595
+ currentContent = readFileSync(info.hookPath, 'utf-8');
596
+ } catch (e) {
597
+ return {
598
+ ok: false,
599
+ reason: `cannot read hook to verify before migrating (${e.code || e.message})`,
600
+ };
601
+ }
602
+ const expectedOld = oldFormPreCommitContent(info.root, hypoDir, info.lintStrict);
603
+ if (currentContent !== expectedOld) {
604
+ return {
605
+ ok: false,
606
+ reason:
607
+ 'hook file has content outside the managed marker span, or does not exactly match ' +
608
+ 'the expected legacy shape — refusing to overwrite the whole file. Re-run ' +
609
+ "`hypomnema init --force-commands` (or the plugin's `/hypo:init --force-commands`) " +
610
+ 'to reinstall the guard by hand',
611
+ };
612
+ }
613
+
614
+ const newContent = wikiPreCommitContent(hypoDir, info.lintStrict);
615
+
616
+ // Safety-net backup of the exact bytes about to be replaced, mirroring
617
+ // init.mjs's --force-commands convention (a .bak beside the hook). Guarded
618
+ // through the same symlink/regular-file check as the hook itself: a
619
+ // pre-existing `.bak` (or `.bak.N`) that is a symlink or non-regular file
620
+ // must not be written through, matching init's own bak-write guard.
621
+ const bakPath = uniqueBakPath(info.hookPath);
622
+ if (!bakPath) {
623
+ return {
624
+ ok: false,
625
+ reason: `refusing to migrate without a safe backup path (a backup name is a symlink or not a regular file)`,
626
+ };
627
+ }
628
+ writeFileSync(bakPath, currentContent);
629
+
618
630
  // A REFUSAL (above) is a soft skip, matching init's contract that an unsafe
619
631
  // or unresolvable target is logged and stepped over. A real I/O failure is
620
632
  // not that: every other apply write in this file lets the exception through,
621
633
  // and init does the same, so swallowing EACCES here would make this one
622
- // self-heal quieter than the rest of --apply for no reason. It also reported
623
- // the wrong thing: a chmod that fails AFTER the write says "still points at
624
- // old" while the file already holds the new root.
634
+ // migration quieter than the rest of --apply for no reason. It also reported
635
+ // the wrong thing: a chmod that fails AFTER the write says "still on the old
636
+ // form" while the file already holds the new one.
625
637
  writeFileSync(info.hookPath, newContent);
626
638
  chmodSync(info.hookPath, 0o755);
627
639
  return { ok: true };
@@ -1080,10 +1092,18 @@ const dualSkipWouldCorrect =
1080
1092
  // dual-install notice tells the user to remove.
1081
1093
  const wikiHookWantRoot = hypomnemaPluginEnabled ? pluginRegistryRoot : PKG_ROOT;
1082
1094
  const wikiPreCommitInfo = checkWikiPreCommitRoot(args.hypoDir);
1083
- const wikiPreCommitDrift =
1084
- wikiPreCommitInfo !== null &&
1085
- wikiHookWantRoot !== null &&
1086
- wikiHookWantRoot !== wikiPreCommitInfo.root;
1095
+ // `wikiPreCommitInfo.root` is non-null only for a hook still on the
1096
+ // OLD, version-pinned form (see parseWikiPreCommitRoot in
1097
+ // lib/git-hooks-dir.mjs) — the NEW, runtime-resolving form never bakes one in,
1098
+ // so `root === null` there means "nothing to migrate", not "unresolved".
1099
+ // Migrating does not need wikiHookWantRoot at all: unlike the old repoint
1100
+ // (which had to pick the CORRECT root to bake in, and had to stay silent when
1101
+ // it could not positively resolve one — see the dual-install preserve case
1102
+ // below), rewriting onto the resolver form is always a strict improvement,
1103
+ // even when this run cannot resolve the active install itself, because the
1104
+ // rewritten hook resolves it fresh at every future commit instead of baking
1105
+ // in a guess now.
1106
+ const wikiPreCommitDrift = wikiPreCommitInfo !== null && wikiPreCommitInfo.root !== null;
1087
1107
  const commands = checkCommands();
1088
1108
  const oldHookRefs = checkOldHookNames(claudeSettingsPath);
1089
1109
  const hypoignore = checkHypoignore(args.hypoDir);
@@ -1209,28 +1229,79 @@ let appliedExtensionsCodex = null;
1209
1229
  if (args.apply) {
1210
1230
  // Downgrade guard: an `--apply` from an OLDER package than the
1211
1231
  // active install would overwrite newer hooks (upgrade.mjs:287 copyFileSync) and
1212
- // rewrite hypo-pkg.json to the older version. Refuse before the first mutation.
1232
+ // rewrite the version record to the older one. Refuse before the first mutation.
1213
1233
  // A dev workspace re-running its own --apply (incl. the post-commit sync hook)
1214
1234
  // is exempt via realpath'd pkgRoot equality. Exit 2 = refused downgrade.
1215
- if (!args.allowDowngrade) {
1216
- const _active = readPkgJsonSafe(pkgJsonPath());
1235
+ //
1236
+ // Scoped to the two branches below that actually write a version-bearing
1237
+ // artifact this run does not own the comparison baseline for, and each
1238
+ // reads the baseline THAT branch actually writes, not a shared one:
1239
+ // - managesClaudeCore: applyHookFiles + writeProvenanceSidecar write this
1240
+ // run's own hooks and pkgVersion over the Claude-core hooks dir, whose
1241
+ // version of record is hypo-pkg.json (pkgJsonPath()) — the artifact
1242
+ // applyCommands below stamps. An older incoming version there really
1243
+ // would overwrite newer hooks.
1244
+ // - args.codex: reached even when managesClaudeCore is false (it is a
1245
+ // sibling branch, not nested under it — see :1383 below), and inside it
1246
+ // applyHookFiles(hooksCodex, ...) + writeProvenanceSidecar(codexHooksDir,
1247
+ // ..., readVersionAtRoot(PKG_ROOT), ...) copy codex hooks and stamp a
1248
+ // version into codexHooksDir's OWN `.hypo-provenance.json` sidecar, a
1249
+ // file distinct from hypo-pkg.json. A prior fix compared the codex
1250
+ // branch against hypo-pkg.json anyway (the Claude plugin's pointer,
1251
+ // unrelated to what codex hooks record their own version in) and so
1252
+ // never observed a downgrade there: in a dual install hypo-pkg.json
1253
+ // stays plugin-owned and un-bumped by a codex apply, so the "downgrade"
1254
+ // that guard could see was really just the plugin's untouched pointer,
1255
+ // leaving every SECOND `--apply --codex` refused forever (the codex
1256
+ // sidecar it should have compared against had already moved on).
1257
+ // Reading each branch's own artifact is the fix.
1258
+ // A dualSkip run with neither condition true (the plain `--apply` repeat
1259
+ // in a dual install, no `--codex`) copies no hook file and never writes its
1260
+ // own version: writeDualSkipProvenance stamps the REGISTRY's version, not
1261
+ // PKG_ROOT's, so there is nothing here for the guard to protect.
1262
+ if ((managesClaudeCore || args.codex) && !args.allowDowngrade) {
1217
1263
  let _incomingVersion = null;
1218
1264
  try {
1219
1265
  _incomingVersion = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
1220
1266
  } catch {
1221
1267
  /* unreadable own package.json — cannot prove a downgrade, allow */
1222
1268
  }
1223
- if (
1224
- _active &&
1225
- _active.pkgVersion &&
1226
- _incomingVersion &&
1227
- classifyInstall(
1228
- { pkgRoot: PKG_ROOT, version: _incomingVersion },
1229
- { pkgRoot: _active.pkgRoot, version: _active.pkgVersion },
1230
- ) === 'downgrade'
1231
- ) {
1232
- console.error(downgradeGuardMessage(_incomingVersion, _active.pkgVersion, 'upgrade --apply'));
1233
- process.exit(2);
1269
+ // `active` is { pkgRoot, pkgVersion } from whichever baseline the branch
1270
+ // actually writes to. `label` only feeds the error message.
1271
+ const refuseIfDowngrade = (active) => {
1272
+ if (
1273
+ active &&
1274
+ active.pkgRoot &&
1275
+ active.pkgVersion &&
1276
+ _incomingVersion &&
1277
+ classifyInstall(
1278
+ { pkgRoot: PKG_ROOT, version: _incomingVersion },
1279
+ { pkgRoot: active.pkgRoot, version: active.pkgVersion },
1280
+ ) === 'downgrade'
1281
+ ) {
1282
+ console.error(
1283
+ downgradeGuardMessage(_incomingVersion, active.pkgVersion, 'upgrade --apply'),
1284
+ );
1285
+ process.exit(2);
1286
+ }
1287
+ };
1288
+ if (managesClaudeCore) {
1289
+ refuseIfDowngrade(readPkgJsonSafe(pkgJsonPath()));
1290
+ }
1291
+ if (args.codex) {
1292
+ // readProvenanceSidecar returns null on both "no sidecar yet" (first-
1293
+ // ever `--apply --codex`, nothing to downgrade from) and "sidecar
1294
+ // exists but fails to parse" — deliberately treated the same as
1295
+ // absent, fail-open, rather than distinguished: a corrupt sidecar
1296
+ // carries no more of a trustworthy baseline than a missing one, and
1297
+ // the write immediately below this guard (writeProvenanceSidecar at
1298
+ // :~1419) heals it either way. Its {pkgRoot, pkgVersion} shape matches
1299
+ // what refuseIfDowngrade expects, unlike its `hypoSharedSha256`/
1300
+ // `copiedAt` fields, which classifyInstall never reads.
1301
+ const _codexActive = readProvenanceSidecar(codexHooksDir);
1302
+ refuseIfDowngrade(
1303
+ _codexActive && { pkgRoot: _codexActive.pkgRoot, pkgVersion: _codexActive.pkgVersion },
1304
+ );
1234
1305
  }
1235
1306
  }
1236
1307
  // Migration report is vault-side (writes into the Hypomnema root) and applies
@@ -1350,7 +1421,7 @@ if (args.apply) {
1350
1421
  // wikiHookWantRoot is null only when dualSkip cannot positively resolve the
1351
1422
  // registry (preserve case), and wikiPreCommitDrift is already false then.
1352
1423
  if (wikiPreCommitDrift) {
1353
- const wikiPreCommitResult = applyWikiPreCommitRoot(wikiPreCommitInfo, wikiHookWantRoot);
1424
+ const wikiPreCommitResult = applyWikiPreCommitRoot(wikiPreCommitInfo);
1354
1425
  appliedWikiPreCommitRoot = wikiPreCommitResult.ok;
1355
1426
  if (!wikiPreCommitResult.ok) wikiPreCommitApplyFailReason = wikiPreCommitResult.reason;
1356
1427
  }
@@ -1458,8 +1529,15 @@ if (args.json) {
1458
1529
  wikiPreCommitRoot: wikiPreCommitInfo
1459
1530
  ? {
1460
1531
  current: wikiPreCommitInfo.root,
1532
+ // Not "where the hook will point": the migrated body resolves the
1533
+ // install itself at commit time. This is what THIS run resolved the
1534
+ // active install to, kept as diagnostic output.
1461
1535
  wantRoot: wikiHookWantRoot,
1462
1536
  drift: wikiPreCommitDrift,
1537
+ // true only for "marker present, body unreadable" (see
1538
+ // checkWikiPreCommitRoot above). Absent/false for both a clean
1539
+ // new-form hook and an old-form one this run can still parse.
1540
+ unrecognized: wikiPreCommitInfo.unrecognized === true,
1463
1541
  }
1464
1542
  : null,
1465
1543
  extensions: extCheck,
@@ -1596,6 +1674,12 @@ if (schema.bump === 'none') {
1596
1674
  lines.push(
1597
1675
  `⚠ SCHEMA version ${schema.installed} → ${schema.current} [minor update — review and update SCHEMA.md manually]`,
1598
1676
  );
1677
+ // Name what actually changed in the versions between, when this map knows.
1678
+ // A version this map has no entry for (installed predates the map, or a
1679
+ // future bump forgot to add one) leaves the notice as it was above.
1680
+ for (const delta of schemaVersionDeltas(schema.installed, schema.current)) {
1681
+ lines.push(` - ${delta}`);
1682
+ }
1599
1683
  }
1600
1684
 
1601
1685
  // hypo-guide.md version stamp. Visibility only — a drift warning,
@@ -1617,7 +1701,7 @@ if (guide.bump === 'none') {
1617
1701
  // (guideDrift) — the message must be actionable, not "cannot compare",
1618
1702
  // since a pre-versioning copy silently reporting "up to date" was the bug.
1619
1703
  lines.push(
1620
- `⚠ hypo-guide.md installed copy has no version stamp (pre-versioning stale copy; package=v${guide.current}) — review templates/hypo-guide.md and update your copy manually; --apply does not overwrite it`,
1704
+ `⚠ hypo-guide.md installed copy has no version stamp (pre-versioning stale copy; package=v${guide.current}) — no stamp means there is no base version to diff a changelog from, so review templates/hypo-guide.md and update your copy manually; --apply does not overwrite it`,
1621
1705
  );
1622
1706
  } else {
1623
1707
  lines.push(
@@ -1735,23 +1819,37 @@ if (dualSkip && dualSkipCorrected) {
1735
1819
  if (wikiPreCommitInfo && wikiPreCommitDrift) {
1736
1820
  if (appliedWikiPreCommitRoot) {
1737
1821
  lines.push(
1738
- `✓ Wiki pre-commit hook repointed from ${wikiPreCommitInfo.root} to ${wikiHookWantRoot}`,
1822
+ `✓ Wiki pre-commit hook migrated off the baked-in root ${wikiPreCommitInfo.root}` +
1823
+ ` — it now resolves the install at commit time and no longer goes stale on a release`,
1739
1824
  );
1740
1825
  } else if (args.apply) {
1741
1826
  // --apply ran but the write was refused or failed — say why, rather than
1742
1827
  // printing the same "run --apply" nudge that already ran and did nothing.
1743
1828
  lines.push(
1744
- `⚠ Wiki pre-commit could not repoint hook (${wikiPreCommitApplyFailReason}) — still` +
1745
- ` points at ${wikiPreCommitInfo.root}, active install is ${wikiHookWantRoot}`,
1829
+ `⚠ Wiki pre-commit could not migrate hook (${wikiPreCommitApplyFailReason}) —` +
1830
+ ` ${wikiPreCommitInfo.root} is still baked in and goes stale on the next release`,
1746
1831
  );
1747
1832
  } else {
1748
1833
  lines.push(
1749
- `⚠ Wiki pre-commit hook points at ${wikiPreCommitInfo.root}, active install is` +
1750
- ` ${wikiHookWantRoot} — run --apply to repoint it`,
1834
+ `⚠ Wiki pre-commit hook has ${wikiPreCommitInfo.root} baked in, so it goes stale on` +
1835
+ ` every release — run --apply to migrate it`,
1751
1836
  );
1752
1837
  }
1838
+ } else if (wikiPreCommitInfo && wikiPreCommitInfo.unrecognized) {
1839
+ // The marker is here but the body inside it is not one this codebase ever
1840
+ // wrote (hand-edited, corrupted, or an install root so far gone even the
1841
+ // rewrite-safe check above cannot read it). Nothing here is safe to
1842
+ // rewrite automatically, so name the break instead of staying silent about
1843
+ // it the way this branch used to (2026-09-11): a `--apply` that never
1844
+ // prints a Wiki pre-commit line at all reads as "nothing to do" when the
1845
+ // hook is actually failing every commit.
1846
+ lines.push(
1847
+ `⚠ Wiki pre-commit marker present but the hook body is not recognized (hand-edited,` +
1848
+ ` corrupted, or too stale to read). Run \`hypomnema init --force-commands\` (or the` +
1849
+ ` plugin's \`/hypo:init --force-commands\`) to reinstall the guard`,
1850
+ );
1753
1851
  } else if (wikiPreCommitInfo) {
1754
- lines.push(`✓ Wiki pre-commit hook root up to date`);
1852
+ lines.push(`✓ Wiki pre-commit hook resolves the install at commit time (nothing to migrate)`);
1755
1853
  }
1756
1854
 
1757
1855
  // Slash commands
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.8.2"
4
+ version: "1.8.4"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7