hypomnema 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +141 -51
  4. package/README.ko.md +18 -17
  5. package/README.md +16 -15
  6. package/commands/crystallize.md +89 -24
  7. package/commands/doctor.md +3 -1
  8. package/docs/ARCHITECTURE.md +45 -14
  9. package/docs/CONTRIBUTING.md +26 -8
  10. package/hooks/atomic-write.mjs +44 -0
  11. package/hooks/base-store.mjs +409 -90
  12. package/hooks/close-gate-store.mjs +131 -16
  13. package/hooks/close-journal.mjs +250 -0
  14. package/hooks/close-receipt.mjs +559 -0
  15. package/hooks/hooks.json +3 -38
  16. package/hooks/hypo-auto-commit.mjs +80 -10
  17. package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
  18. package/hooks/hypo-auto-stage.mjs +13 -1
  19. package/hooks/hypo-close-guard.mjs +2 -1
  20. package/hooks/hypo-cwd-change.mjs +45 -20
  21. package/hooks/hypo-file-watch.mjs +44 -6
  22. package/hooks/hypo-first-prompt.mjs +15 -9
  23. package/hooks/hypo-hot-rebuild.mjs +169 -112
  24. package/hooks/hypo-personal-check.mjs +9 -1
  25. package/hooks/hypo-session-record.mjs +1 -1
  26. package/hooks/hypo-session-start.mjs +566 -20
  27. package/hooks/hypo-shared.mjs +2250 -206
  28. package/hooks/hypo-stop.mjs +207 -0
  29. package/hooks/proposal-store.mjs +201 -35
  30. package/hooks/shared.json +15 -0
  31. package/hooks/version-check.mjs +9 -7
  32. package/package.json +3 -1
  33. package/scripts/crystallize.mjs +3 -4
  34. package/scripts/doctor.mjs +549 -123
  35. package/scripts/init.mjs +86 -109
  36. package/scripts/lib/core-hooks.mjs +48 -22
  37. package/scripts/lib/crystallize-close-apply.mjs +2397 -188
  38. package/scripts/lib/crystallize-close-check.mjs +43 -23
  39. package/scripts/lib/design-history-stale.mjs +36 -9
  40. package/scripts/lib/frontmatter.mjs +39 -0
  41. package/scripts/lib/git-hooks-dir.mjs +427 -52
  42. package/scripts/lib/hook-inventory.mjs +180 -0
  43. package/scripts/lib/page-usage.mjs +1 -1
  44. package/scripts/lib/pkg-provenance.mjs +18 -1
  45. package/scripts/lib/plugin-detect.mjs +43 -9
  46. package/scripts/lib/project-create.mjs +40 -44
  47. package/scripts/lib/schema-vocab.mjs +22 -11
  48. package/scripts/lib/template-schema-version.mjs +48 -0
  49. package/scripts/lib/time.mjs +46 -0
  50. package/scripts/lint.mjs +66 -18
  51. package/scripts/proposal.mjs +423 -18
  52. package/scripts/resume.mjs +3 -2
  53. package/scripts/session-audit.mjs +2 -1
  54. package/scripts/uninstall.mjs +144 -66
  55. package/scripts/upgrade.mjs +523 -234
  56. package/scripts/verify.mjs +2 -1
  57. package/scripts/weekly-report.mjs +2 -1
  58. package/templates/SCHEMA.md +27 -7
  59. package/templates/gitignore +11 -0
  60. package/templates/hypo-automation.md +10 -13
  61. package/templates/hypo-config.md +1 -1
  62. package/templates/hypo-guide.md +53 -25
  63. package/templates/hot.md +0 -24
@@ -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,15 +55,18 @@ 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';
69
+ import { atomicWrite } from '../hooks/atomic-write.mjs';
66
70
 
67
71
  const HOME = homedir();
68
72
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -73,6 +77,14 @@ const PKG_ROOT = join(SCRIPT_DIR, '..');
73
77
  // so the only useful next step is a re-install of the package.
74
78
  const PKG_INTEGRITY_HINT =
75
79
  '→ This indicates a corrupt or incomplete install. Re-install with `npm install -g hypomnema` (or re-install the Claude Code plugin).';
80
+ // Where to send a person to verify after --apply. Named both ways, with no
81
+ // runtime guess: the `hypomnema` bin comes only with the npm install, so a
82
+ // plugin-only user has `/hypo:doctor` and nothing else, and a shell user with
83
+ // the npm CLI may have no slash-command surface at all. Same convention
84
+ // scripts/lib/crystallize-close-apply.mjs's own DOCTOR_HINT already uses for
85
+ // the same reason; kept as a separate local constant here rather than an
86
+ // import, since that file is not this task's to change.
87
+ const DOCTOR_HINT = '`/hypo:doctor` in Claude Code, or `hypomnema doctor` with the npm CLI';
76
88
  const HOOKS_SRC = join(PKG_ROOT, 'hooks');
77
89
  const COMMANDS_SRC = join(PKG_ROOT, 'commands');
78
90
  const TEMPLATES = join(PKG_ROOT, 'templates');
@@ -132,94 +144,24 @@ function bumpType(installed, current) {
132
144
  }
133
145
 
134
146
  // ── 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');
147
+ //
148
+ // loadHookInventory (scripts/lib/hook-inventory.mjs) is the one parser every
149
+ // hooks.json/shared.json consumer reads through — smoke-plugin, init, doctor,
150
+ // and uninstall all call the same function, so a command shape one of them
151
+ // would accept and another would silently misread cannot exist anymore. It also
152
+ // existence-checks every named file against THIS package's own hooks/ before
153
+ // returning ok:true, so a corrupt or incomplete package (a hook file dropped
154
+ // from `package.json`'s `files` allowlist) fails here, at startup, instead of
155
+ // upgrade reporting some files "src-missing" while still applying the rest.
156
+ const _hookInventory = loadHookInventory(PKG_ROOT);
157
+ if (!_hookInventory.ok) {
158
+ console.error(`Error: ${_hookInventory.error}`);
215
159
  console.error(PKG_INTEGRITY_HINT);
216
160
  process.exit(1);
217
161
  }
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 ?? [];
162
+ const HOOK_MAP = _hookInventory.hookMap;
163
+ const SHARED_FILES = _hookInventory.shared;
164
+ const INSTALL_ORDER = _hookInventory.ordered;
223
165
 
224
166
  // ── checks ───────────────────────────────────────────────────────────────────
225
167
 
@@ -281,7 +223,16 @@ function checkGuideVersion(hypoDir) {
281
223
  function checkHookFiles(hooksDir) {
282
224
  const results = [];
283
225
 
284
- const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
226
+ // The canonical order, shared by init and (reversed) by uninstall. Shared
227
+ // modules come first because applyHookFiles copies one file at a time and an
228
+ // entry hook imports its shared modules the moment the next event fires it:
229
+ // copy the entry hook first, get interrupted, and the next SessionStart dies
230
+ // with ERR_MODULE_NOT_FOUND, which is also the session the repair would have
231
+ // been run from.
232
+ //
233
+ // This is ordering, not atomicity. A half-copied set is still a mix of
234
+ // versions; what it is not is a set that cannot load.
235
+ const allFiles = INSTALL_ORDER;
285
236
  for (const file of allFiles) {
286
237
  const installedPath = join(hooksDir, file);
287
238
  const srcPath = join(HOOKS_SRC, file);
@@ -333,6 +284,51 @@ function checkSettingsJson(settingsPath, hooksDir) {
333
284
  return results;
334
285
  }
335
286
 
287
+ // Every .mjs basename this package still ships, entry hook or shared module.
288
+ // checkRetiredRegistrations uses it as the line between a registration we wrote
289
+ // and have since withdrawn, and a hook somebody else put in the same directory.
290
+ const SHIPPED_HOOK_FILES = new Set(INSTALL_ORDER);
291
+
292
+ // The reverse of checkSettingsJson. That walk asks whether every live
293
+ // HOOK_MAP entry is registered; it never asks whether a registration is still
294
+ // live, so a settings.json install that predates a hook moving out of an event
295
+ // keeps the old entry forever. The Stop orchestrator made that concrete: four
296
+ // Stop hooks became steps that hypo-stop.mjs spawns, and an install that kept
297
+ // their old Stop entries ran each of them twice per Stop.
298
+ //
299
+ // A registration is retired only when all three hold:
300
+ // - its command is exactly `node <hooksDir>/<file>` in the `$HOME` form
301
+ // init and checkSettingsJson write (no arguments, no subdirectory);
302
+ // - `<file>` is a basename this package still ships (SHIPPED_HOOK_FILES);
303
+ // - HOOK_MAP[event] no longer lists `<file>`.
304
+ // A hook this package never shipped, a hypo-ext-* extension, and anything with
305
+ // a different command shape fail the first two tests and are left alone.
306
+ function checkRetiredRegistrations(settingsPath, hooksDir) {
307
+ if (!existsSync(settingsPath)) return [];
308
+ let settings;
309
+ try {
310
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
311
+ } catch {
312
+ return [];
313
+ }
314
+ const prefix = `node ${hooksDir.replace(HOME, '$HOME')}/`;
315
+ const found = [];
316
+ for (const [event, groups] of Object.entries(settings.hooks || {})) {
317
+ if (!Array.isArray(groups)) continue;
318
+ const live = new Set(HOOK_MAP[event] || []);
319
+ for (const group of groups) {
320
+ if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) continue;
321
+ for (const hook of group.hooks) {
322
+ const cmd = hook?.command;
323
+ if (typeof cmd !== 'string' || !cmd.startsWith(prefix)) continue;
324
+ const file = cmd.slice(prefix.length);
325
+ if (SHIPPED_HOOK_FILES.has(file) && !live.has(file)) found.push({ event, file, cmd });
326
+ }
327
+ }
328
+ }
329
+ return found;
330
+ }
331
+
336
332
  // ── apply actions ────────────────────────────────────────────────────────────
337
333
 
338
334
  // `hooksDir` is the target the stale/missing paths in `hookResults` already point
@@ -351,41 +347,162 @@ function applyHookFiles(hookResults, hooksDir) {
351
347
  return applied;
352
348
  }
353
349
 
354
- function applySettingsJson(settingsResults, settingsPath) {
350
+ // ── settings.json apply: rename + add + retire as one atomic write ──────────
351
+ //
352
+ // These three used to be three separate `writeFileSync` calls (rename below,
353
+ // then this file's former applySettingsJson, then its former
354
+ // applyRetiredRegistrations), run one after another against the same file. A
355
+ // crash mid-write could truncate the JSON; a crash BETWEEN any two of them
356
+ // left VALID JSON that was only half-migrated. One example: a legacy Stop
357
+ // entry renamed to its new basename with nothing left to retire it, so both
358
+ // hypo-stop.mjs (the orchestrator) and the step it replaced fired on every
359
+ // Stop. The retire step made that window concrete when it was added: it is
360
+ // the step most likely to be skipped by exactly this kind of interruption.
361
+ //
362
+ // The fix is not three atomic writes in a row (that still leaves the
363
+ // between-writes window) but one in-memory transform committed once. Each
364
+ // step below reads the value the step before it just produced, never a
365
+ // precomputed check-time snapshot. That is what keeps the self-heal
366
+ // applySettingsJson used to need a fresh disk re-read for: a legacy
367
+ // wiki-auto-commit.mjs entry the rename step just turned into
368
+ // hypo-auto-commit.mjs is visible to the retire step as exactly that name,
369
+ // in the same pass, because it is reading the same object the rename step
370
+ // wrote into.
371
+
372
+ function migrateHookNamesInSettings(settings) {
373
+ const applied = [];
374
+ for (const [event, groups] of Object.entries(settings.hooks || {})) {
375
+ for (const group of Array.isArray(groups) ? groups : []) {
376
+ for (const hook of group.hooks || []) {
377
+ for (const [oldName, newName] of Object.entries(HOOK_RENAMES)) {
378
+ if ((hook.command || '').includes(oldName)) {
379
+ hook.command = hook.command.replace(oldName, newName);
380
+ applied.push(`${event}: ${oldName} → ${newName}`);
381
+ }
382
+ }
383
+ }
384
+ }
385
+ }
386
+ return applied;
387
+ }
388
+
389
+ // Copies a renamed hook's bytes onto its new basename when the old file is
390
+ // still on disk and the new one is not yet there. A file-system side effect,
391
+ // not a settings.json write, so it stays separate from the atomic commit
392
+ // below; existsSync guards make repeat runs a no-op.
393
+ function copyRenamedHookFiles(hooksDir) {
394
+ for (const [oldName, newName] of Object.entries(HOOK_RENAMES)) {
395
+ const oldPath = join(hooksDir, oldName);
396
+ const newPath = join(hooksDir, newName);
397
+ const srcPath = join(HOOKS_SRC, newName);
398
+ if (existsSync(oldPath) && !existsSync(newPath) && existsSync(srcPath)) {
399
+ copyFileSync(srcPath, newPath);
400
+ }
401
+ }
402
+ }
403
+
404
+ // Adds every HOOK_MAP entry `settings` does not yet register, judged against
405
+ // `settings` itself (already carrying whatever the rename step above just
406
+ // rewrote) rather than a precomputed checkSettingsJson() list. A name the
407
+ // rename just produced reads as already-present here, in the same pass.
408
+ function addMissingRegistrations(settings, hooksDir) {
409
+ if (!settings.hooks) settings.hooks = {};
410
+ const applied = [];
411
+ for (const [event, files] of Object.entries(HOOK_MAP)) {
412
+ if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
413
+ for (const file of files) {
414
+ const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
415
+ const alreadyPresent = settings.hooks[event]
416
+ .flatMap((g) => g.hooks || [])
417
+ .some((h) => h.command === cmd);
418
+ if (alreadyPresent) continue;
419
+ settings.hooks[event].push({ hooks: [{ type: 'command', command: cmd }] });
420
+ applied.push(`${event}: ${file}`);
421
+ }
422
+ }
423
+ return applied;
424
+ }
425
+
426
+ // The reverse of the add step above, judged the same way: against `settings`
427
+ // as the add step just left it, never a precomputed checkRetiredRegistrations()
428
+ // list computed before the rename ran. A registration is retired only when
429
+ // all three hold, mirroring checkRetiredRegistrations's contract:
430
+ // - its command is exactly `node <hooksDir>/<file>` in the `$HOME` form
431
+ // init and checkSettingsJson write (no arguments, no subdirectory);
432
+ // - `<file>` is a basename this package still ships (SHIPPED_HOOK_FILES);
433
+ // - HOOK_MAP[event] no longer lists `<file>`.
434
+ // This is why a legacy wiki-auto-commit.mjs entry the rename step turns into
435
+ // hypo-auto-commit.mjs is still caught here even though hypo-auto-commit.mjs
436
+ // never appeared in any precomputed retired list: this function never reads
437
+ // one, only the settings the two steps above already produced. Only the
438
+ // matching hook entries go; a group loses its whole entry only when nothing
439
+ // else is left in it, and other keys on the group (matcher) stay with what
440
+ // remains.
441
+ function removeRetiredRegistrations(settings, hooksDir) {
442
+ const prefix = `node ${hooksDir.replace(HOME, '$HOME')}/`;
443
+ const retired = [];
444
+ for (const [event, groups] of Object.entries(settings.hooks || {})) {
445
+ if (!Array.isArray(groups)) continue;
446
+ const live = new Set(HOOK_MAP[event] || []);
447
+ for (const group of groups) {
448
+ if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) continue;
449
+ for (const hook of group.hooks) {
450
+ const cmd = hook?.command;
451
+ if (typeof cmd !== 'string' || !cmd.startsWith(prefix)) continue;
452
+ const file = cmd.slice(prefix.length);
453
+ if (SHIPPED_HOOK_FILES.has(file) && !live.has(file)) retired.push({ event, file, cmd });
454
+ }
455
+ }
456
+ }
457
+ if (retired.length === 0) return [];
458
+ const doomed = new Set(retired.map((r) => `${r.event}\n${r.cmd}`));
459
+ for (const [event, groups] of Object.entries(settings.hooks)) {
460
+ if (!Array.isArray(groups)) continue;
461
+ const kept = [];
462
+ for (const group of groups) {
463
+ if (group && typeof group === 'object' && Array.isArray(group.hooks)) {
464
+ const before = group.hooks.length;
465
+ group.hooks = group.hooks.filter((h) => !doomed.has(`${event}\n${h?.command}`));
466
+ if (before > 0 && group.hooks.length === 0) continue;
467
+ }
468
+ kept.push(group);
469
+ }
470
+ if (kept.length === 0) delete settings.hooks[event];
471
+ else settings.hooks[event] = kept;
472
+ }
473
+ return retired.map((r) => `${r.event}: ${r.file}`);
474
+ }
475
+
476
+ // The one place settings.json is mutated by --apply: rename, add, and retire
477
+ // merged into a single in-memory transform, committed with exactly one
478
+ // atomicWrite (temp file plus rename, see hooks/atomic-write.mjs). So there is
479
+ // no window where the file on disk reflects only some of the three, and a
480
+ // crash mid-write lands on a throwaway temp name instead of a torn
481
+ // settings.json. Known gap this does not close: no lock guards settingsPath,
482
+ // so a user hand-editing it in another program at the same moment can still
483
+ // have their edit overwritten by whichever write lands last. That is the same
484
+ // exposure the old three-write version had, just narrowed from three windows
485
+ // to one.
486
+ function applySettingsJsonTransform(settingsPath, hooksDir) {
355
487
  let settings = {};
356
488
  if (existsSync(settingsPath)) {
357
489
  try {
358
490
  settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
359
491
  } catch {
360
- return [];
492
+ return { renamed: [], added: [], retired: [] };
361
493
  }
362
494
  }
363
495
  if (!settings.hooks) settings.hooks = {};
364
496
 
365
- const applied = [];
366
- for (const s of settingsResults) {
367
- if (s.status !== 'missing') continue;
368
- if (!Array.isArray(settings.hooks[s.event])) settings.hooks[s.event] = [];
369
- // re-check the current parsed settings before appending.
370
- // applyHookNameMigration may have rewritten a legacy wiki-*.mjs command to
371
- // exactly `s.cmd` between checkSettingsJson and now — appending without
372
- // this guard creates a duplicate registration (codex 2-worker review
373
- // reproduced 11 duplicate hypo-*.mjs entries on a wiki-only legacy settings
374
- // file). The precheck list is allowed to be stale; the apply path must
375
- // self-heal against the on-disk truth.
376
- const alreadyPresent = settings.hooks[s.event]
377
- .flatMap((g) => g.hooks || [])
378
- .some((h) => h.command === s.cmd);
379
- if (alreadyPresent) continue;
380
- settings.hooks[s.event].push({ hooks: [{ type: 'command', command: s.cmd }] });
381
- applied.push(`${s.event}: ${s.file}`);
382
- }
497
+ const renamed = migrateHookNamesInSettings(settings);
498
+ const added = addMissingRegistrations(settings, hooksDir);
499
+ const retired = removeRetiredRegistrations(settings, hooksDir);
383
500
 
384
- if (applied.length > 0) {
385
- mkdirSync(join(settingsPath, '..'), { recursive: true });
386
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
501
+ if (renamed.length > 0 || added.length > 0 || retired.length > 0) {
502
+ atomicWrite(settingsPath, JSON.stringify(settings, null, 2) + '\n');
387
503
  }
388
- return applied;
504
+ if (renamed.length > 0) copyRenamedHookFiles(hooksDir);
505
+ return { renamed, added, retired };
389
506
  }
390
507
 
391
508
  // Rename map: old wiki-*.mjs → new hypo-*.mjs
@@ -428,45 +545,12 @@ function checkOldHookNames(settingsPath) {
428
545
  return found;
429
546
  }
430
547
 
431
- function applyHookNameMigration(oldRefs, settingsPath, hooksDir) {
432
- if (!existsSync(settingsPath)) return [];
433
-
434
- let settings;
435
- try {
436
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
437
- } catch {
438
- return [];
439
- }
440
-
441
- const applied = [];
442
- for (const [event, groups] of Object.entries(settings.hooks || {})) {
443
- for (const group of Array.isArray(groups) ? groups : []) {
444
- for (const hook of group.hooks || []) {
445
- for (const [oldName, newName] of Object.entries(HOOK_RENAMES)) {
446
- if ((hook.command || '').includes(oldName)) {
447
- hook.command = hook.command.replace(oldName, newName);
448
- applied.push(`${event}: ${oldName} → ${newName}`);
449
- }
450
- }
451
- }
452
- }
453
- }
454
-
455
- if (applied.length > 0) {
456
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
457
- // Copy renamed hook files to the target hooks dir (~/.claude/hooks or
458
- // ~/.codex/hooks per the caller — codex mirror).
459
- for (const [oldName, newName] of Object.entries(HOOK_RENAMES)) {
460
- const oldPath = join(hooksDir, oldName);
461
- const newPath = join(hooksDir, newName);
462
- const srcPath = join(HOOKS_SRC, newName);
463
- if (existsSync(oldPath) && !existsSync(newPath) && existsSync(srcPath)) {
464
- copyFileSync(srcPath, newPath);
465
- }
466
- }
467
- }
468
- return applied;
469
- }
548
+ // The write path used to live here as its own function (its own
549
+ // writeFileSync). It is now the rename step inside applySettingsJsonTransform
550
+ // above (migrateHookNamesInSettings + copyRenamedHookFiles), merged with the
551
+ // add and retire steps into one atomic commit. checkOldHookNames above stays:
552
+ // it is read-only, used for the report and for deciding whether there is
553
+ // anything for --apply to do.
470
554
 
471
555
  // ── .hypoignore migration — ensure required runtime patterns are present ─────
472
556
  //
@@ -555,17 +639,42 @@ function applyGitignoreMigration(result) {
555
639
  return appended;
556
640
  }
557
641
 
558
- // ── wiki pre-commit hook: install-root self-heal ────────────────────────────
642
+ // ── wiki pre-commit hook: migrate the version-pinned form to the runtime
643
+ // resolver ───────────────────────────────────────────────────────
644
+ //
645
+ // init.mjs used to bake an absolute install root into the vault's pre-commit
646
+ // hook (see the OLD form documented above PRE_COMMIT_WORKER_LINE in
647
+ // lib/git-hooks-dir.mjs). A plugin-channel upgrade moves PKG_ROOT to a new
648
+ // version directory every release, but nothing ever re-ran init afterward, so
649
+ // the hook kept calling whatever release happened to be current the day
650
+ // /hypo:init last ran — silently, since the entry-point file the hook calls is
651
+ // often byte-identical across versions while the module graph it pulls in
652
+ // from its own directory is not. wikiPreCommitContent() no longer bakes a
653
+ // root in at all: the hook resolves it itself, at commit time. So the fix
654
+ // here is not "repoint the baked root at the current install" (there is
655
+ // nothing left to repoint once a hook is on the new form) — it is "rewrite an
656
+ // OLD-form hook onto the new form", once, unconditionally. That migration
657
+ // does not need to know the active install root, which is why it is safe even
658
+ // when the registry cannot positively resolve one right now (see
659
+ // wikiPreCommitNeedsMigration below): the resolver embedded in the rewritten
660
+ // hook will look that up itself, fresh, at every future commit.
661
+ //
662
+ // Read-only: locates the hook (if any) and, only when it carries our marker in
663
+ // a shape isOwnedWikiPreCommitBody() recognizes (old OR new — see
664
+ // lib/git-hooks-dir.mjs), what parseWikiPreCommitRoot() read back from it.
559
665
  //
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.
666
+ // Returns `null` when there is nothing of ours to report (no hook, or a hook
667
+ // with no Hypomnema marker at all: some other tool's or the user's own).
668
+ // Returns `{ hookPath, unrecognized: true, root: null }` when our marker IS
669
+ // present but parseWikiPreCommitRoot could not read a body it recognizes: a
670
+ // hand-edited or corrupted hook, or (before this fix) a hook whose OLD-form
671
+ // worker line named an install root that no longer exists. That case used to
672
+ // collapse into the same `null` a genuinely foreign hook gets, which made
673
+ // upgrade.mjs's report skip the "Wiki pre-commit" line entirely and left
674
+ // doctor.mjs reporting the marker-present pass with no root warning beside
675
+ // it: a broken hook (MODULE_NOT_FOUND on every commit) reads as installed
676
+ // and healthy either way. `root: null` here means the same thing it does in
677
+ // applyWikiPreCommitRoot's caller: never attempt the migration write.
569
678
  function checkWikiPreCommitRoot(hypoDir) {
570
679
  const resolved = resolveGitHooksDir(hypoDir);
571
680
  if (!resolved.ok || !resolved.owned) return null;
@@ -578,50 +687,105 @@ function checkWikiPreCommitRoot(hypoDir) {
578
687
  return null;
579
688
  }
580
689
  const parsed = parseWikiPreCommitRoot(content);
581
- if (!parsed.ok) return null;
690
+ if (!parsed.ok) {
691
+ return parsed.hasMarker ? { hookPath, unrecognized: true, root: null } : null;
692
+ }
582
693
  return { hookPath, root: parsed.root, lintStrict: parsed.lintStrict, hypoDir: parsed.hypoDir };
583
694
  }
584
695
 
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.
696
+ // Rewrites an OLD-form hook onto the runtime-resolving form, reusing the exact
697
+ // writer init.mjs itself uses so the regenerated hook is byte-identical to
698
+ // what a fresh init would produce today — the --lint-strict step count
699
+ // (info.lintStrict) is preserved exactly, never collapsed to one step, or the
700
+ // lint gate goes silently dark. The --hypo-dir baked into a --lint-strict hook
701
+ // is preserved from `info` (what parseWikiPreCommitRoot read back), never
702
+ // taken from this run's own --hypo-dir: upgrade can run against a vault with a
703
+ // different --hypo-dir than the one init baked in, and substituting the
704
+ // current run's value would silently repoint the lint gate at an unrelated
705
+ // directory. A non-absolute embedded value is refused rather than trusted
706
+ // (wikiPreCommitContent() re-resolves it against upgrade's own cwd, which is
707
+ // never what a relative value there was meant to mean), leaving the hook
708
+ // untouched for that run.
597
709
  //
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.
710
+ // `wantRoot` is accepted for call-site compatibility (the caller still
711
+ // computes it for the drift report and JSON output) but is otherwise unused
712
+ // here: the new form never bakes a root, so there is nothing to substitute it
713
+ // into. This only migrates the FORM. If the vault itself has actually moved,
714
+ // the embedded --hypo-dir is wrong too and no amount of --apply fixes that —
715
+ // only re-running `hypomnema init` rewrites --hypo-dir.
601
716
  //
602
717
  // Returns `{ ok: true }` on a successful rewrite, or `{ ok: false, reason }`
603
718
  // naming why the write was refused or failed, so the caller can surface that
604
719
  // reason instead of silently reporting "unchanged".
605
- function applyWikiPreCommitRoot(info, wantRoot) {
720
+ function applyWikiPreCommitRoot(info) {
606
721
  const unsafe = unsafeHookTargetReason(info.hookPath);
607
722
  if (unsafe) return { ok: false, reason: unsafe };
608
723
  if (info.lintStrict && !isAbsolute(info.hypoDir)) {
609
724
  return {
610
725
  ok: false,
611
- reason: `embedded --hypo-dir is not absolute (${info.hypoDir}) — refusing to repoint`,
726
+ reason: `embedded --hypo-dir is not absolute (${info.hypoDir}) — refusing to migrate`,
612
727
  };
613
728
  }
614
729
  // hypoDir only affects output when lintStrict is true (see
615
730
  // wikiPreCommitContent); the fallback here never reaches the written file.
616
731
  const hypoDir = info.lintStrict ? info.hypoDir : '.';
617
- const newContent = wikiPreCommitContent(wantRoot, hypoDir, info.lintStrict);
732
+
733
+ // Require the file ON DISK to already be, byte-for-byte, the exact OLD-form
734
+ // shape these SAME values (info.root, hypoDir, info.lintStrict) reconstruct.
735
+ // isOwnedWikiPreCommitBody upstream only validated the SPAN between the
736
+ // markers; it says nothing about content before the start marker or after
737
+ // the end one. A hand-crafted file pairing a forged-but-valid span (an
738
+ // old-form worker line naming a root that does not currently exist — exactly
739
+ // what isRewritableOldFormInstallRoot's "gone root" branch accepts) with real
740
+ // content outside that span would otherwise be silently discarded by the
741
+ // whole-file write below (codex reproduction, 2026-09-11). A legitimate
742
+ // install this codebase ever wrote IS exactly this reconstruction — nothing
743
+ // outside the span but the fixed shebang line — so this never refuses a real
744
+ // migration; see oldFormPreCommitContent's comment in lib/git-hooks-dir.mjs.
745
+ let currentContent;
746
+ try {
747
+ currentContent = readFileSync(info.hookPath, 'utf-8');
748
+ } catch (e) {
749
+ return {
750
+ ok: false,
751
+ reason: `cannot read hook to verify before migrating (${e.code || e.message})`,
752
+ };
753
+ }
754
+ const expectedOld = oldFormPreCommitContent(info.root, hypoDir, info.lintStrict);
755
+ if (currentContent !== expectedOld) {
756
+ return {
757
+ ok: false,
758
+ reason:
759
+ 'hook file has content outside the managed marker span, or does not exactly match ' +
760
+ 'the expected legacy shape — refusing to overwrite the whole file. Re-run ' +
761
+ "`hypomnema init --force-commands` (or the plugin's `/hypo:init --force-commands`) " +
762
+ 'to reinstall the guard by hand',
763
+ };
764
+ }
765
+
766
+ const newContent = wikiPreCommitContent(hypoDir, info.lintStrict);
767
+
768
+ // Safety-net backup of the exact bytes about to be replaced, mirroring
769
+ // init.mjs's --force-commands convention (a .bak beside the hook). Guarded
770
+ // through the same symlink/regular-file check as the hook itself: a
771
+ // pre-existing `.bak` (or `.bak.N`) that is a symlink or non-regular file
772
+ // must not be written through, matching init's own bak-write guard.
773
+ const bakPath = uniqueBakPath(info.hookPath);
774
+ if (!bakPath) {
775
+ return {
776
+ ok: false,
777
+ reason: `refusing to migrate without a safe backup path (a backup name is a symlink or not a regular file)`,
778
+ };
779
+ }
780
+ writeFileSync(bakPath, currentContent);
781
+
618
782
  // A REFUSAL (above) is a soft skip, matching init's contract that an unsafe
619
783
  // or unresolvable target is logged and stepped over. A real I/O failure is
620
784
  // not that: every other apply write in this file lets the exception through,
621
785
  // 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.
786
+ // migration quieter than the rest of --apply for no reason. It also reported
787
+ // the wrong thing: a chmod that fails AFTER the write says "still on the old
788
+ // form" while the file already holds the new one.
625
789
  writeFileSync(info.hookPath, newContent);
626
790
  chmodSync(info.hookPath, 0o755);
627
791
  return { ok: true };
@@ -697,7 +861,7 @@ checklist below is manual:
697
861
  hard-required fields above
698
862
  - [ ] Run \`hypomnema feedback-sync --check\` to verify the MEMORY/CLAUDE
699
863
  projection still resolves cleanly
700
- - [ ] Run \`hypomnema doctor\` to verify installation health
864
+ - [ ] Run doctor to verify installation health (\`/hypo:doctor\` in Claude Code, or \`hypomnema doctor\` with the npm CLI)
701
865
  - [ ] **Re-run \`hypomnema lint\` after backfilling — confirm 0 feedback errors
702
866
  remain (including the conditional \`claude-learned\` fields above)**
703
867
 
@@ -732,7 +896,7 @@ remaining steps are manual:
732
896
 
733
897
  - [ ] Compare your \`SCHEMA.md\` (v${fromVersion}) with the package template (v${toVersion}) and merge changes manually
734
898
  - [ ] Re-check all \`adr\` and \`learning\` pages for new required frontmatter fields once SCHEMA is updated
735
- - [ ] Run \`/hypo:doctor\` to verify installation health
899
+ - [ ] Run doctor to verify installation health (\`/hypo:doctor\` in Claude Code, or \`hypomnema doctor\` with the npm CLI)
736
900
  `;
737
901
 
738
902
  const content = `---
@@ -1080,12 +1244,21 @@ const dualSkipWouldCorrect =
1080
1244
  // dual-install notice tells the user to remove.
1081
1245
  const wikiHookWantRoot = hypomnemaPluginEnabled ? pluginRegistryRoot : PKG_ROOT;
1082
1246
  const wikiPreCommitInfo = checkWikiPreCommitRoot(args.hypoDir);
1083
- const wikiPreCommitDrift =
1084
- wikiPreCommitInfo !== null &&
1085
- wikiHookWantRoot !== null &&
1086
- wikiHookWantRoot !== wikiPreCommitInfo.root;
1247
+ // `wikiPreCommitInfo.root` is non-null only for a hook still on the
1248
+ // OLD, version-pinned form (see parseWikiPreCommitRoot in
1249
+ // lib/git-hooks-dir.mjs) — the NEW, runtime-resolving form never bakes one in,
1250
+ // so `root === null` there means "nothing to migrate", not "unresolved".
1251
+ // Migrating does not need wikiHookWantRoot at all: unlike the old repoint
1252
+ // (which had to pick the CORRECT root to bake in, and had to stay silent when
1253
+ // it could not positively resolve one — see the dual-install preserve case
1254
+ // below), rewriting onto the resolver form is always a strict improvement,
1255
+ // even when this run cannot resolve the active install itself, because the
1256
+ // rewritten hook resolves it fresh at every future commit instead of baking
1257
+ // in a guess now.
1258
+ const wikiPreCommitDrift = wikiPreCommitInfo !== null && wikiPreCommitInfo.root !== null;
1087
1259
  const commands = checkCommands();
1088
1260
  const oldHookRefs = checkOldHookNames(claudeSettingsPath);
1261
+ const retiredSettings = checkRetiredRegistrations(claudeSettingsPath, claudeHooksDir);
1089
1262
  const hypoignore = checkHypoignore(args.hypoDir);
1090
1263
  const gitignore = checkGitignore(args.hypoDir);
1091
1264
 
@@ -1095,6 +1268,9 @@ const gitignore = checkGitignore(args.hypoDir);
1095
1268
  const hooksCodex = args.codex ? checkHookFiles(codexHooksDir) : null;
1096
1269
  const settingsCodex = args.codex ? checkSettingsJson(codexSettingsPath, codexHooksDir) : null;
1097
1270
  const oldHookRefsCodex = args.codex ? checkOldHookNames(codexSettingsPath) : null;
1271
+ const retiredSettingsCodex = args.codex
1272
+ ? checkRetiredRegistrations(codexSettingsPath, codexHooksDir)
1273
+ : null;
1098
1274
 
1099
1275
  // Extensions companion. Read-only check; the apply
1100
1276
  // happens below, AFTER applyCommands, so the per-target SHA map merges into the
@@ -1198,6 +1374,8 @@ let appliedHookNameRenames = [];
1198
1374
  let appliedHooksCodex = [];
1199
1375
  let appliedSettingsCodex = [];
1200
1376
  let appliedHookNameRenamesCodex = [];
1377
+ let appliedRetiredSettings = [];
1378
+ let appliedRetiredSettingsCodex = [];
1201
1379
  let appliedCommands = [];
1202
1380
  let appliedHypoignore = [];
1203
1381
  let appliedGitignore = [];
@@ -1209,28 +1387,79 @@ let appliedExtensionsCodex = null;
1209
1387
  if (args.apply) {
1210
1388
  // Downgrade guard: an `--apply` from an OLDER package than the
1211
1389
  // 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.
1390
+ // rewrite the version record to the older one. Refuse before the first mutation.
1213
1391
  // A dev workspace re-running its own --apply (incl. the post-commit sync hook)
1214
1392
  // is exempt via realpath'd pkgRoot equality. Exit 2 = refused downgrade.
1215
- if (!args.allowDowngrade) {
1216
- const _active = readPkgJsonSafe(pkgJsonPath());
1393
+ //
1394
+ // Scoped to the two branches below that actually write a version-bearing
1395
+ // artifact this run does not own the comparison baseline for, and each
1396
+ // reads the baseline THAT branch actually writes, not a shared one:
1397
+ // - managesClaudeCore: applyHookFiles + writeProvenanceSidecar write this
1398
+ // run's own hooks and pkgVersion over the Claude-core hooks dir, whose
1399
+ // version of record is hypo-pkg.json (pkgJsonPath()) — the artifact
1400
+ // applyCommands below stamps. An older incoming version there really
1401
+ // would overwrite newer hooks.
1402
+ // - args.codex: reached even when managesClaudeCore is false (it is a
1403
+ // sibling branch, not nested under it — see :1383 below), and inside it
1404
+ // applyHookFiles(hooksCodex, ...) + writeProvenanceSidecar(codexHooksDir,
1405
+ // ..., readVersionAtRoot(PKG_ROOT), ...) copy codex hooks and stamp a
1406
+ // version into codexHooksDir's OWN `.hypo-provenance.json` sidecar, a
1407
+ // file distinct from hypo-pkg.json. A prior fix compared the codex
1408
+ // branch against hypo-pkg.json anyway (the Claude plugin's pointer,
1409
+ // unrelated to what codex hooks record their own version in) and so
1410
+ // never observed a downgrade there: in a dual install hypo-pkg.json
1411
+ // stays plugin-owned and un-bumped by a codex apply, so the "downgrade"
1412
+ // that guard could see was really just the plugin's untouched pointer,
1413
+ // leaving every SECOND `--apply --codex` refused forever (the codex
1414
+ // sidecar it should have compared against had already moved on).
1415
+ // Reading each branch's own artifact is the fix.
1416
+ // A dualSkip run with neither condition true (the plain `--apply` repeat
1417
+ // in a dual install, no `--codex`) copies no hook file and never writes its
1418
+ // own version: writeDualSkipProvenance stamps the REGISTRY's version, not
1419
+ // PKG_ROOT's, so there is nothing here for the guard to protect.
1420
+ if ((managesClaudeCore || args.codex) && !args.allowDowngrade) {
1217
1421
  let _incomingVersion = null;
1218
1422
  try {
1219
1423
  _incomingVersion = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
1220
1424
  } catch {
1221
1425
  /* unreadable own package.json — cannot prove a downgrade, allow */
1222
1426
  }
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);
1427
+ // `active` is { pkgRoot, pkgVersion } from whichever baseline the branch
1428
+ // actually writes to. `label` only feeds the error message.
1429
+ const refuseIfDowngrade = (active) => {
1430
+ if (
1431
+ active &&
1432
+ active.pkgRoot &&
1433
+ active.pkgVersion &&
1434
+ _incomingVersion &&
1435
+ classifyInstall(
1436
+ { pkgRoot: PKG_ROOT, version: _incomingVersion },
1437
+ { pkgRoot: active.pkgRoot, version: active.pkgVersion },
1438
+ ) === 'downgrade'
1439
+ ) {
1440
+ console.error(
1441
+ downgradeGuardMessage(_incomingVersion, active.pkgVersion, 'upgrade --apply'),
1442
+ );
1443
+ process.exit(2);
1444
+ }
1445
+ };
1446
+ if (managesClaudeCore) {
1447
+ refuseIfDowngrade(readPkgJsonSafe(pkgJsonPath()));
1448
+ }
1449
+ if (args.codex) {
1450
+ // readProvenanceSidecar returns null on both "no sidecar yet" (first-
1451
+ // ever `--apply --codex`, nothing to downgrade from) and "sidecar
1452
+ // exists but fails to parse" — deliberately treated the same as
1453
+ // absent, fail-open, rather than distinguished: a corrupt sidecar
1454
+ // carries no more of a trustworthy baseline than a missing one, and
1455
+ // the write immediately below this guard (writeProvenanceSidecar at
1456
+ // :~1419) heals it either way. Its {pkgRoot, pkgVersion} shape matches
1457
+ // what refuseIfDowngrade expects, unlike its `hypoSharedSha256`/
1458
+ // `copiedAt` fields, which classifyInstall never reads.
1459
+ const _codexActive = readProvenanceSidecar(codexHooksDir);
1460
+ refuseIfDowngrade(
1461
+ _codexActive && { pkgRoot: _codexActive.pkgRoot, pkgVersion: _codexActive.pkgVersion },
1462
+ );
1234
1463
  }
1235
1464
  }
1236
1465
  // Migration report is vault-side (writes into the Hypomnema root) and applies
@@ -1244,13 +1473,13 @@ if (args.apply) {
1244
1473
  });
1245
1474
  }
1246
1475
  if (managesClaudeCore) {
1247
- if (oldHookRefs.length > 0) {
1248
- appliedHookNameRenames = applyHookNameMigration(
1249
- oldHookRefs,
1250
- claudeSettingsPath,
1251
- claudeHooksDir,
1252
- );
1253
- }
1476
+ // Rename, add, and retire all commit through one atomicWrite inside this
1477
+ // call. See applySettingsJsonTransform's own comment for why they are no
1478
+ // longer three separate writeFileSync calls.
1479
+ const claudeSettingsResult = applySettingsJsonTransform(claudeSettingsPath, claudeHooksDir);
1480
+ appliedHookNameRenames = claudeSettingsResult.renamed;
1481
+ appliedSettings = claudeSettingsResult.added;
1482
+ appliedRetiredSettings = claudeSettingsResult.retired;
1254
1483
  appliedHooks = applyHookFiles(hooks, claudeHooksDir);
1255
1484
  // Refresh every run managesClaudeCore is true, not only when a stale hook
1256
1485
  // was actually copied above: applyHookFiles OVERWRITES (never skips) a
@@ -1258,7 +1487,6 @@ if (args.apply) {
1258
1487
  // must still keep the sidecar's pkgVersion truthful, same reasoning as
1259
1488
  // installHooks's own refresh-every-run comment.
1260
1489
  writeProvenanceSidecar(claudeHooksDir, PKG_ROOT, readVersionAtRoot(PKG_ROOT), HOOKS_SRC, false);
1261
- appliedSettings = applySettingsJson(settings, claudeSettingsPath);
1262
1490
  // applyCommands handles the single atomic hypo-pkg.json write (pkgRoot, version, schema, commands map)
1263
1491
  appliedCommands = applyCommands(commands, args.forceCommands);
1264
1492
  appliedPkgJson = true;
@@ -1350,23 +1578,19 @@ if (args.apply) {
1350
1578
  // wikiHookWantRoot is null only when dualSkip cannot positively resolve the
1351
1579
  // registry (preserve case), and wikiPreCommitDrift is already false then.
1352
1580
  if (wikiPreCommitDrift) {
1353
- const wikiPreCommitResult = applyWikiPreCommitRoot(wikiPreCommitInfo, wikiHookWantRoot);
1581
+ const wikiPreCommitResult = applyWikiPreCommitRoot(wikiPreCommitInfo);
1354
1582
  appliedWikiPreCommitRoot = wikiPreCommitResult.ok;
1355
1583
  if (!wikiPreCommitResult.ok) wikiPreCommitApplyFailReason = wikiPreCommitResult.reason;
1356
1584
  }
1357
- // codex core hooks + settings + wiki-*→hypo-* rename mirror. Same order
1358
- // as the claude side (rename first so subsequent hook copy can find renamed targets).
1585
+ // codex core hooks + settings + wiki-*→hypo-* rename mirror. Same merged,
1586
+ // single-write transform as the claude side above.
1359
1587
  if (args.codex) {
1360
- if (oldHookRefsCodex.length > 0) {
1361
- appliedHookNameRenamesCodex = applyHookNameMigration(
1362
- oldHookRefsCodex,
1363
- codexSettingsPath,
1364
- codexHooksDir,
1365
- );
1366
- }
1588
+ const codexSettingsResult = applySettingsJsonTransform(codexSettingsPath, codexHooksDir);
1589
+ appliedHookNameRenamesCodex = codexSettingsResult.renamed;
1590
+ appliedSettingsCodex = codexSettingsResult.added;
1591
+ appliedRetiredSettingsCodex = codexSettingsResult.retired;
1367
1592
  appliedHooksCodex = applyHookFiles(hooksCodex, codexHooksDir);
1368
1593
  writeProvenanceSidecar(codexHooksDir, PKG_ROOT, readVersionAtRoot(PKG_ROOT), HOOKS_SRC, false);
1369
- appliedSettingsCodex = applySettingsJson(settingsCodex, codexSettingsPath);
1370
1594
  }
1371
1595
  // After applyCommands wrote hypo-pkg.json — merges extensions.<target> alongside.
1372
1596
  appliedExtensions = syncExtensions({
@@ -1405,7 +1629,8 @@ const codexCoreDrift =
1405
1629
  (staleHooksCodex.length > 0 ||
1406
1630
  missingSettingsCodex.length > 0 ||
1407
1631
  invalidSettingsCodex ||
1408
- (oldHookRefsCodex?.length ?? 0) > 0);
1632
+ (oldHookRefsCodex?.length ?? 0) > 0 ||
1633
+ (retiredSettingsCodex?.length ?? 0) > 0);
1409
1634
 
1410
1635
  // Claude core-surface drift (hooks/settings/commands/rename/metadata). In plugin
1411
1636
  // mode these are plugin-managed, so they must NOT count as drift — otherwise the
@@ -1420,6 +1645,7 @@ const claudeCoreDrift =
1420
1645
  missingSettings.length > 0 ||
1421
1646
  invalidSettings ||
1422
1647
  oldHookRefs.length > 0 ||
1648
+ retiredSettings.length > 0 ||
1423
1649
  staleCommands.length > 0 ||
1424
1650
  userModifiedCommands.length > 0 ||
1425
1651
  orphanedCommands.length > 0 ||
@@ -1453,13 +1679,21 @@ if (args.json) {
1453
1679
  pkgJson,
1454
1680
  commands,
1455
1681
  oldHookRefs,
1682
+ retiredSettings,
1456
1683
  hypoignore,
1457
1684
  gitignore,
1458
1685
  wikiPreCommitRoot: wikiPreCommitInfo
1459
1686
  ? {
1460
1687
  current: wikiPreCommitInfo.root,
1688
+ // Not "where the hook will point": the migrated body resolves the
1689
+ // install itself at commit time. This is what THIS run resolved the
1690
+ // active install to, kept as diagnostic output.
1461
1691
  wantRoot: wikiHookWantRoot,
1462
1692
  drift: wikiPreCommitDrift,
1693
+ // true only for "marker present, body unreadable" (see
1694
+ // checkWikiPreCommitRoot above). Absent/false for both a clean
1695
+ // new-form hook and an old-form one this run can still parse.
1696
+ unrecognized: wikiPreCommitInfo.unrecognized === true,
1463
1697
  }
1464
1698
  : null,
1465
1699
  extensions: extCheck,
@@ -1468,6 +1702,7 @@ if (args.json) {
1468
1702
  hooksCodex,
1469
1703
  settingsCodex,
1470
1704
  oldHookRefsCodex,
1705
+ retiredSettingsCodex,
1471
1706
  applied: {
1472
1707
  hooks: appliedHooks,
1473
1708
  settings: appliedSettings,
@@ -1482,6 +1717,8 @@ if (args.json) {
1482
1717
  hooksCodex: appliedHooksCodex,
1483
1718
  settingsCodex: appliedSettingsCodex,
1484
1719
  hookNameRenamesCodex: appliedHookNameRenamesCodex,
1720
+ retiredSettings: appliedRetiredSettings,
1721
+ retiredSettingsCodex: appliedRetiredSettingsCodex,
1485
1722
  },
1486
1723
  migrationReport: migrationPath,
1487
1724
  },
@@ -1596,6 +1833,12 @@ if (schema.bump === 'none') {
1596
1833
  lines.push(
1597
1834
  `⚠ SCHEMA version ${schema.installed} → ${schema.current} [minor update — review and update SCHEMA.md manually]`,
1598
1835
  );
1836
+ // Name what actually changed in the versions between, when this map knows.
1837
+ // A version this map has no entry for (installed predates the map, or a
1838
+ // future bump forgot to add one) leaves the notice as it was above.
1839
+ for (const delta of schemaVersionDeltas(schema.installed, schema.current)) {
1840
+ lines.push(` - ${delta}`);
1841
+ }
1599
1842
  }
1600
1843
 
1601
1844
  // hypo-guide.md version stamp. Visibility only — a drift warning,
@@ -1616,12 +1859,18 @@ if (guide.bump === 'none') {
1616
1859
  // install) but predates the version stamp entirely. Counted as drift above
1617
1860
  // (guideDrift) — the message must be actionable, not "cannot compare",
1618
1861
  // since a pre-versioning copy silently reporting "up to date" was the bug.
1862
+ //
1863
+ // Name both sides by their ABSOLUTE path, not the package-relative
1864
+ // `templates/hypo-guide.md`. A person running upgrade from their vault root
1865
+ // (the common case) has no `templates/` there at all, since init never
1866
+ // copies that directory into the vault, so the old wording sent them
1867
+ // looking for a file that does not exist on the machine they are standing on.
1619
1868
  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`,
1869
+ `⚠ 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 compare and merge by hand: \`diff "${guide.pkgPath}" "${guide.hypoPath}"\`. --apply does not overwrite it`,
1621
1870
  );
1622
1871
  } else {
1623
1872
  lines.push(
1624
- `⚠ hypo-guide.md v${guide.installed} → v${guide.current} [package template changed — review templates/hypo-guide.md and update your copy manually; --apply does not overwrite it]`,
1873
+ `⚠ hypo-guide.md v${guide.installed} → v${guide.current} [package template changed, compare and merge by hand: \`diff "${guide.pkgPath}" "${guide.hypoPath}"\`; --apply does not overwrite it]`,
1625
1874
  );
1626
1875
  }
1627
1876
 
@@ -1683,6 +1932,28 @@ if (managesClaudeCore) {
1683
1932
  }
1684
1933
  if (settingsCodex) pushSettingsSummary(settingsCodex, ' (codex)', invalidSettingsCodex);
1685
1934
 
1935
+ // Registrations a settings.json install still carries for a hook this package
1936
+ // has since moved out of that event (checkRetiredRegistrations). Silent when
1937
+ // there are none: the settings line above already says the live set is whole.
1938
+ // Under --apply this reports what was removed, which can include an entry the
1939
+ // pre-apply check could not name (a legacy wiki-*.mjs rename that landed on a
1940
+ // retired hook).
1941
+ function pushRetiredSummary(checked, applied, label) {
1942
+ const entries = args.apply ? applied : checked.map((r) => `${r.event}: ${r.file}`);
1943
+ if (entries.length === 0) return;
1944
+ const colR = `settings.json${label}`.padEnd(20);
1945
+ lines.push(
1946
+ args.apply
1947
+ ? `✓ ${colR}removed ${entries.length} retired registration(s):`
1948
+ : `⚠ ${colR}${entries.length} retired registration(s) still fire. --apply removes them:`,
1949
+ );
1950
+ for (const e of entries) lines.push(` - ${e}`);
1951
+ }
1952
+ if (managesClaudeCore) pushRetiredSummary(retiredSettings, appliedRetiredSettings, '');
1953
+ if (retiredSettingsCodex) {
1954
+ pushRetiredSummary(retiredSettingsCodex, appliedRetiredSettingsCodex, ' (codex)');
1955
+ }
1956
+
1686
1957
  // Package metadata
1687
1958
  if (dualSkip && dualSkipCorrected) {
1688
1959
  // npm-first correction actually WROTE this run: the pre-apply pkgJson
@@ -1735,23 +2006,37 @@ if (dualSkip && dualSkipCorrected) {
1735
2006
  if (wikiPreCommitInfo && wikiPreCommitDrift) {
1736
2007
  if (appliedWikiPreCommitRoot) {
1737
2008
  lines.push(
1738
- `✓ Wiki pre-commit hook repointed from ${wikiPreCommitInfo.root} to ${wikiHookWantRoot}`,
2009
+ `✓ Wiki pre-commit hook migrated off the baked-in root ${wikiPreCommitInfo.root}` +
2010
+ ` — it now resolves the install at commit time and no longer goes stale on a release`,
1739
2011
  );
1740
2012
  } else if (args.apply) {
1741
2013
  // --apply ran but the write was refused or failed — say why, rather than
1742
2014
  // printing the same "run --apply" nudge that already ran and did nothing.
1743
2015
  lines.push(
1744
- `⚠ Wiki pre-commit could not repoint hook (${wikiPreCommitApplyFailReason}) — still` +
1745
- ` points at ${wikiPreCommitInfo.root}, active install is ${wikiHookWantRoot}`,
2016
+ `⚠ Wiki pre-commit could not migrate hook (${wikiPreCommitApplyFailReason}) —` +
2017
+ ` ${wikiPreCommitInfo.root} is still baked in and goes stale on the next release`,
1746
2018
  );
1747
2019
  } else {
1748
2020
  lines.push(
1749
- `⚠ Wiki pre-commit hook points at ${wikiPreCommitInfo.root}, active install is` +
1750
- ` ${wikiHookWantRoot} — run --apply to repoint it`,
2021
+ `⚠ Wiki pre-commit hook has ${wikiPreCommitInfo.root} baked in, so it goes stale on` +
2022
+ ` every release — run --apply to migrate it`,
1751
2023
  );
1752
2024
  }
2025
+ } else if (wikiPreCommitInfo && wikiPreCommitInfo.unrecognized) {
2026
+ // The marker is here but the body inside it is not one this codebase ever
2027
+ // wrote (hand-edited, corrupted, or an install root so far gone even the
2028
+ // rewrite-safe check above cannot read it). Nothing here is safe to
2029
+ // rewrite automatically, so name the break instead of staying silent about
2030
+ // it the way this branch used to (2026-09-11): a `--apply` that never
2031
+ // prints a Wiki pre-commit line at all reads as "nothing to do" when the
2032
+ // hook is actually failing every commit.
2033
+ lines.push(
2034
+ `⚠ Wiki pre-commit marker present but the hook body is not recognized (hand-edited,` +
2035
+ ` corrupted, or too stale to read). Run \`hypomnema init --force-commands\` (or the` +
2036
+ ` plugin's \`/hypo:init --force-commands\`) to reinstall the guard`,
2037
+ );
1753
2038
  } else if (wikiPreCommitInfo) {
1754
- lines.push(`✓ Wiki pre-commit hook root up to date`);
2039
+ lines.push(`✓ Wiki pre-commit hook resolves the install at commit time (nothing to migrate)`);
1755
2040
  }
1756
2041
 
1757
2042
  // Slash commands
@@ -1972,6 +2257,7 @@ const claudeCoreCount = managesClaudeCore
1972
2257
  missingSettings.length +
1973
2258
  (invalidSettings ? 1 : 0) +
1974
2259
  oldHookRefs.length +
2260
+ retiredSettings.length +
1975
2261
  staleCommands.length +
1976
2262
  userModifiedCommands.length +
1977
2263
  orphanedCommands.length +
@@ -2006,7 +2292,8 @@ const totalDrift =
2006
2292
  staleHooksCodex.length +
2007
2293
  missingSettingsCodex.length +
2008
2294
  (invalidSettingsCodex ? 1 : 0) +
2009
- (oldHookRefsCodex?.length ?? 0);
2295
+ (oldHookRefsCodex?.length ?? 0) +
2296
+ (retiredSettingsCodex?.length ?? 0);
2010
2297
  if (totalDrift === 0) {
2011
2298
  lines.push('Result: Hypomnema is up to date');
2012
2299
  } else if (args.apply) {
@@ -2028,8 +2315,10 @@ if (totalDrift === 0) {
2028
2315
  appliedExtCount +
2029
2316
  appliedHooksCodex.length +
2030
2317
  appliedSettingsCodex.length +
2031
- appliedHookNameRenamesCodex.length;
2032
- lines.push(`Result: ${total} update(s) applied. Run /hypo:doctor to verify.`);
2318
+ appliedHookNameRenamesCodex.length +
2319
+ appliedRetiredSettings.length +
2320
+ appliedRetiredSettingsCodex.length;
2321
+ lines.push(`Result: ${total} update(s) applied. Verify with ${DOCTOR_HINT}.`);
2033
2322
  } else {
2034
2323
  lines.push(`Result: ${totalDrift} item(s) need updating — run with --apply to install`);
2035
2324
  }