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
@@ -18,6 +18,7 @@ import { homedir } from 'os';
18
18
  import { spawnSync } from 'child_process';
19
19
  import { fileURLToPath } from 'url';
20
20
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
+ import { loadHookInventory } from './lib/hook-inventory.mjs';
21
22
  import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
22
23
  import { readRenameMarker, renameMarkerPath, RENAME_MARKER_REL } from './lib/rename-marker.mjs';
23
24
  import {
@@ -36,10 +37,12 @@ import {
36
37
  localAndUtcDates,
37
38
  normalizeVerifiedScope,
38
39
  SESSION_CLOSED_MARKER_STALE_MS,
40
+ sessionClosedMarkerPath,
39
41
  isUsablePkgRootLocal,
40
42
  selfLocationPkgRootFrom,
41
43
  } from '../hooks/hypo-shared.mjs';
42
- import { listProposals } from '../hooks/proposal-store.mjs';
44
+ import { classifyProposals } from './proposal.mjs';
45
+ import { listProposalsChecked, orphanTmpTargetName } from '../hooks/proposal-store.mjs';
43
46
  import {
44
47
  discoverExtensions,
45
48
  parseManifest,
@@ -64,6 +67,7 @@ import {
64
67
  computeHooksDigest,
65
68
  } from './lib/pkg-provenance.mjs';
66
69
  import { resolveCliOnPath, classifyInstall, upgradeApplyHint } from '../hooks/version-check.mjs';
70
+ import { closeCheckpointState, isCloseComplete } from '../hooks/close-receipt.mjs';
67
71
  import {
68
72
  enabledHypomnemaPluginKey,
69
73
  usablePkgRoot,
@@ -154,94 +158,22 @@ function fail(label, detail = '') {
154
158
  }
155
159
 
156
160
  // ── hook map (loaded from hooks/hooks.json — single source of truth) ─────────
157
-
158
- let _hookConfig;
159
- try {
160
- _hookConfig = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
161
- } catch {
162
- console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
163
- console.error(PKG_INTEGRITY_HINT);
164
- process.exit(1);
165
- }
166
- if (!_hookConfig || typeof _hookConfig !== 'object' || Array.isArray(_hookConfig)) {
167
- console.error('Error: hooks/hooks.json must be a JSON object');
168
- console.error(PKG_INTEGRITY_HINT);
169
- process.exit(1);
170
- }
171
- if (
172
- !_hookConfig.hooks ||
173
- typeof _hookConfig.hooks !== 'object' ||
174
- Array.isArray(_hookConfig.hooks)
175
- ) {
176
- console.error('Error: hooks/hooks.json must contain a "hooks" object');
177
- console.error(PKG_INTEGRITY_HINT);
178
- process.exit(1);
179
- }
180
- function _extractCommandFileName(command) {
181
- if (typeof command !== 'string') return null;
182
- const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
183
- if (matches.length > 0) return matches[matches.length - 1][1];
184
- const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
185
- return bare ? bare[1] : null;
186
- }
187
-
188
- function _isHookFileName(file) {
189
- return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
190
- }
191
-
192
- function _isHookGroup(group) {
193
- return (
194
- group &&
195
- typeof group === 'object' &&
196
- !Array.isArray(group) &&
197
- Array.isArray(group.hooks) &&
198
- group.hooks.length > 0 &&
199
- group.hooks.every(
200
- (hook) =>
201
- hook &&
202
- typeof hook === 'object' &&
203
- !Array.isArray(hook) &&
204
- hook.type === 'command' &&
205
- _extractCommandFileName(hook.command),
206
- )
207
- );
208
- }
209
-
210
- // Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
211
- function _extractFileNames(groups) {
212
- return groups.flatMap((group) => {
213
- if (typeof group === 'string') return [group.trim()];
214
- return group.hooks.map((hook) => _extractCommandFileName(hook.command));
215
- });
216
- }
217
-
218
- for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
219
- const valid =
220
- Array.isArray(groups) &&
221
- groups.length > 0 &&
222
- groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
223
- _extractFileNames(groups).length > 0;
224
- if (!valid) {
225
- console.error(
226
- `Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
227
- );
228
- console.error(PKG_INTEGRITY_HINT);
229
- process.exit(1);
230
- }
231
- }
232
- if (
233
- _hookConfig.shared !== undefined &&
234
- (!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every((f) => _isHookFileName(f)))
235
- ) {
236
- console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
161
+ //
162
+ // loadHookInventory (scripts/lib/hook-inventory.mjs) is the one parser every
163
+ // hooks.json/shared.json consumer reads through — smoke-plugin, init, upgrade,
164
+ // and uninstall all call the same function. It existence-checks every named
165
+ // file against THIS package's own hooks/ before returning ok:true, so a
166
+ // corrupt or incomplete doctor.mjs's OWN package (never the vault being
167
+ // audited — that stays checkHooks()'s job below) fails here rather than
168
+ // auditing an install against a hook list doctor cannot trust.
169
+ const _hookInventory = loadHookInventory(PKG_ROOT);
170
+ if (!_hookInventory.ok) {
171
+ console.error(`Error: ${_hookInventory.error}`);
237
172
  console.error(PKG_INTEGRITY_HINT);
238
173
  process.exit(1);
239
174
  }
240
-
241
- const HOOK_MAP = Object.fromEntries(
242
- Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]),
243
- );
244
- const SHARED_FILES = _hookConfig.shared ?? [];
175
+ const HOOK_MAP = _hookInventory.hookMap;
176
+ const SHARED_FILES = _hookInventory.shared;
245
177
 
246
178
  // ── checks ───────────────────────────────────────────────────────────────────
247
179
 
@@ -338,10 +270,16 @@ function checkHooks(coreManagedByPlugin) {
338
270
  const claudeHooks = join(HOME, '.claude', 'hooks');
339
271
  const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
340
272
 
341
- let missing = 0;
342
- for (const file of allFiles) {
343
- if (!existsSync(join(claudeHooks, file))) missing++;
344
- }
273
+ // A partial install used to only count the missing files, then stay at
274
+ // `warn` (rc=0). A real QA session hit exactly this: one hook file
275
+ // deleted, and neither the terminal output nor the exit code said which
276
+ // one, so an rc-gated CI job could not catch it and a user could not act
277
+ // on it without re-running upgrade just to find out. Naming the files
278
+ // reuses the same computation upgrade.mjs already does (checkHookFiles's
279
+ // per-file `status: 'missing'`), and `fail` gives the exit code something
280
+ // real to report.
281
+ const missingFiles = allFiles.filter((file) => !existsSync(join(claudeHooks, file)));
282
+ const missing = missingFiles.length;
345
283
 
346
284
  if (missing === 0) {
347
285
  pass('Hook files installed', claudeHooks);
@@ -353,7 +291,36 @@ function checkHooks(coreManagedByPlugin) {
353
291
  `provided by the plugin loader (hooks/hooks.json) — none copied to ${claudeHooks} (expected)`,
354
292
  );
355
293
  } else if (missing < allFiles.length) {
356
- warn('Hook files installed', `${missing}/${allFiles.length} missing in ${claudeHooks}`);
294
+ // Naming the files is the part the QA incident actually asked for, so both
295
+ // channels get it. The exit code is not: on the plugin channel nothing the
296
+ // user can run copies these files (init.mjs gates installHooks on
297
+ // `!coreManagedByPlugin`, upgrade.mjs gates applyHookFiles on
298
+ // `managesClaudeCore`), so a `fail` there is a red the user cannot clear by
299
+ // doing anything, on every run, forever. Adding one file to
300
+ // hooks/shared.json is enough to put every dual install in this branch at
301
+ // once, which is exactly how this release found the problem.
302
+ //
303
+ // So the severity follows whether the exit code has an answer. A partial
304
+ // set on the plugin channel is leftovers from an earlier non-plugin
305
+ // install: harmless, since the loader supplies the real hooks, and worth
306
+ // saying out loud without failing over. checkSettingsJson below splits on
307
+ // the same flag for the same reason.
308
+ //
309
+ // The cost of this split: on the plugin channel a partial set stays rc=0,
310
+ // so an rc-gated check there still cannot see it. The file names are the
311
+ // only signal in that case, which is why they are printed either way.
312
+ const examples = missingFiles.slice(0, 3).join(', ');
313
+ const rest = missing > 3 ? `, +${missing - 3} more` : '';
314
+ const detail = `${missing}/${allFiles.length} missing in ${claudeHooks}: ${examples}${rest}`;
315
+ if (coreManagedByPlugin) {
316
+ warn(
317
+ 'Hook files installed',
318
+ `${detail} — the plugin loader supplies these, so this copy is leftovers from an ` +
319
+ `earlier non-plugin install; nothing to fix unless you want the directory clean`,
320
+ );
321
+ } else {
322
+ fail('Hook files installed', `${detail}. Run /hypo:init`);
323
+ }
357
324
  } else {
358
325
  fail('Hook files installed', `No hook files found in ${claudeHooks} — run /hypo:init`);
359
326
  }
@@ -361,6 +328,49 @@ function checkHooks(coreManagedByPlugin) {
361
328
  checkProvenanceSidecar(claudeHooks, 'hooks/.hypo-provenance.json');
362
329
  }
363
330
 
331
+ // Registrations for a hook this package still ships, under an event hooks.json
332
+ // no longer maps it to. The forward walk in checkSettingsJson only asks whether
333
+ // each live hook is registered, so an install that kept the four pre-orchestrator
334
+ // Stop entries next to hypo-stop.mjs read as fully registered while each of those
335
+ // steps ran twice per Stop. Same boundary as upgrade.mjs's
336
+ // checkRetiredRegistrations, which is what removes them: the exact `$HOME`-form
337
+ // command init writes, a basename in this package's hooks.json or shared.json,
338
+ // and an event whose HOOK_MAP entry does not list it. Anything else (a user's own
339
+ // hook, a hypo-ext-* extension, a command with arguments) is not ours to name.
340
+ function retiredRegistrations(settings, hooksDir) {
341
+ const shipped = new Set([...Object.values(HOOK_MAP).flat(), ...SHARED_FILES]);
342
+ const prefix = `node ${hooksDir.replace(HOME, '$HOME')}/`;
343
+ const found = [];
344
+ for (const [event, groups] of Object.entries(settings.hooks || {})) {
345
+ if (!Array.isArray(groups)) continue;
346
+ const live = new Set(HOOK_MAP[event] || []);
347
+ for (const g of groups) {
348
+ if (!g || typeof g !== 'object' || !Array.isArray(g.hooks)) continue;
349
+ for (const h of g.hooks) {
350
+ if (typeof h?.command !== 'string' || !h.command.startsWith(prefix)) continue;
351
+ const file = h.command.slice(prefix.length);
352
+ if (shipped.has(file) && !live.has(file)) found.push({ event, file, cmd: h.command });
353
+ }
354
+ }
355
+ }
356
+ return found;
357
+ }
358
+
359
+ function reportRetiredRegistrations(label, retired, remedy) {
360
+ if (retired.length === 0) {
361
+ pass(label, 'None');
362
+ return;
363
+ }
364
+ const names = retired.map((r) => `${r.event}: ${r.file}`);
365
+ warn(
366
+ label,
367
+ `${retired.length} registration(s) for a hook hooks/hooks.json no longer registers under ` +
368
+ `that event. Each still fires on its own, on top of whatever runs it now (the Stop steps ` +
369
+ `are spawned by hypo-stop.mjs), so it runs twice. ${remedy}: ` +
370
+ `${names.slice(0, 4).join(', ')}${names.length > 4 ? `, +${names.length - 4} more` : ''}`,
371
+ );
372
+ }
373
+
364
374
  function checkSettingsJson(coreManagedByPlugin) {
365
375
  const settingsPath = join(HOME, '.claude', 'settings.json');
366
376
  if (!existsSync(settingsPath)) {
@@ -398,7 +408,32 @@ function checkSettingsJson(coreManagedByPlugin) {
398
408
  }
399
409
  }
400
410
 
401
- if (registered === total) {
411
+ if (coreManagedByPlugin && registered > 0) {
412
+ // The plugin loader registers these hooks itself, from hooks/hooks.json.
413
+ // Entries in settings.json on top of that are not a second half of one
414
+ // install: they are a whole second registration, left over from an earlier
415
+ // npm or manual install, and every hook they name fires twice per event.
416
+ //
417
+ // This branch has to come before the `registered === total` pass below,
418
+ // which would otherwise read a full duplicate set as the healthiest
419
+ // possible state and print "15/15 registered". It also has to come before
420
+ // the partial-warn, whose `/hypo:init` prescription does nothing here:
421
+ // init.mjs skips mergeSettingsJson entirely on this channel.
422
+ //
423
+ // Left as warn rather than fail: doctor has never removed a settings entry
424
+ // and guessing which hook group belongs to Hypomnema is how it would start
425
+ // deleting someone else's. Saying the state out loud, with the exact
426
+ // command prefix to look for, is what doctor can do honestly. The cost is
427
+ // that an rc-gated check still cannot see a double-firing install.
428
+ warn(
429
+ 'settings.json hook registrations',
430
+ `${registered}/${total} Hypomnema hook(s) also registered in settings.json while the plugin ` +
431
+ `loader is active — these fire on top of the plugin's own, so each one runs twice. ` +
432
+ `They are leftovers from an earlier npm or manual install: remove the entries whose ` +
433
+ `command starts with \`node ${hooksDir.replace(HOME, '$HOME')}/hypo-\` from settings.json, ` +
434
+ `or run /hypo:uninstall before reinstalling the plugin`,
435
+ );
436
+ } else if (registered === total) {
402
437
  pass('settings.json hook registrations', `${registered}/${total} registered`);
403
438
  } else if (registered > 0) {
404
439
  warn(
@@ -421,6 +456,11 @@ function checkSettingsJson(coreManagedByPlugin) {
421
456
  // so they are intentionally absent from HOOK_MAP. Excluded here; their
422
457
  // integrity (SHA + manifest + entry match) is checked separately in E5.
423
458
  const isExtCommand = (cmd) => /(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(cmd);
459
+ // Retired registrations get their own line with a prescription that works
460
+ // (upgrade --apply removes them); listing them here as well would print the
461
+ // same entry twice under a remedy that does not.
462
+ const retired = retiredRegistrations(settings, hooksDir);
463
+ const retiredCmds = new Set(retired.map((r) => r.cmd));
424
464
  const expectedCmds = new Set(
425
465
  Object.entries(HOOK_MAP).flatMap(([, files]) =>
426
466
  files.map((f) => `node ${hooksDir.replace(HOME, '$HOME')}/${f}`),
@@ -436,7 +476,8 @@ function checkSettingsJson(coreManagedByPlugin) {
436
476
  typeof h.command === 'string' &&
437
477
  /hypo-[^/]+\.mjs/.test(h.command) &&
438
478
  !isExtCommand(h.command) &&
439
- !expectedCmds.has(h.command)
479
+ !expectedCmds.has(h.command) &&
480
+ !retiredCmds.has(h.command)
440
481
  ) {
441
482
  stale.push(h.command);
442
483
  }
@@ -451,6 +492,15 @@ function checkSettingsJson(coreManagedByPlugin) {
451
492
  } else {
452
493
  pass('settings.json stale hypo-* entries', 'None');
453
494
  }
495
+ reportRetiredRegistrations(
496
+ 'settings.json retired hook registrations',
497
+ retired,
498
+ // upgrade --apply leaves settings.json alone on the plugin channel, so it
499
+ // is not a remedy there.
500
+ coreManagedByPlugin
501
+ ? 'Remove them from settings.json by hand'
502
+ : 'Run /hypo:upgrade (or `hypomnema upgrade --apply`) to remove them',
503
+ );
454
504
 
455
505
  // duplicate hypo-* entries per event
456
506
  const dupes = [];
@@ -477,6 +527,71 @@ function checkSettingsJson(coreManagedByPlugin) {
477
527
  }
478
528
  }
479
529
 
530
+ // ── dual install (plugin + manual/npm core hook conflict) ────────────────────
531
+ //
532
+ // A plugin-channel install and a manual/npm install can both register
533
+ // the SAME core hooks from two different PKG_ROOTs, and each hooks/hypo-shared.mjs
534
+ // copy resolves its own root by self-location, so nothing forces the two to agree
535
+ // — one hook can call v1.7.0's scripts/ while another in the same session calls
536
+ // v1.7.1's. upgrade.mjs already detects this (dualInstallCoreConflict/dualSkip)
537
+ // and skips double-registering on --apply; the 2026-09-08 policy investigation's
538
+ // recommendation was for doctor to report the same state on every run, not only
539
+ // when upgrade happens to be invoked. `pluginChannel` above is computed once via
540
+ // the SAME resolvePluginChannel() upgrade.mjs and init.mjs call
541
+ // (lib/plugin-detect.mjs) — this reuses that judgment rather than re-deriving it.
542
+ // `hypomnemaPluginEnabled` is `reason !== 'not-enabled'`, so it stays true when the
543
+ // registry could not be read or the enabled key resolved to no install on disk. Both
544
+ // mean settings NAMES a plugin whose files were not located, which is a different
545
+ // state from "two installs are really present". checkPkgIntegrity below asks the same
546
+ // question, so the two share this name rather than each spelling the pair out.
547
+ function channelJudgmentFailed(pluginChannel) {
548
+ return (
549
+ pluginChannel.hypomnemaPluginEnabled &&
550
+ (pluginChannel.rootReason === 'registry-unreadable' ||
551
+ pluginChannel.rootReason === 'unresolved')
552
+ );
553
+ }
554
+
555
+ function checkDualInstall(pluginChannel) {
556
+ const { pluginMode, hypomnemaPluginEnabled } = pluginChannel;
557
+ if (pluginMode) {
558
+ pass(
559
+ 'Dual install (plugin + manual/npm)',
560
+ 'Running as the plugin install — no manual/npm registration to conflict with',
561
+ );
562
+ return;
563
+ }
564
+ if (!hypomnemaPluginEnabled) {
565
+ pass('Dual install (plugin + manual/npm)', 'Manual/npm install only — plugin not enabled');
566
+ return;
567
+ }
568
+ // Not a dual install: telling this user to `npm uninstall -g hypomnema` would remove
569
+ // the only copy they actually have. A stale `enabledPlugins` entry left behind by an
570
+ // uninstall is exactly this shape.
571
+ if (channelJudgmentFailed(pluginChannel)) {
572
+ warn(
573
+ 'Dual install (plugin + manual/npm)',
574
+ `~/.claude/settings.json enables the Hypomnema plugin, but the registry says ` +
575
+ `\`${pluginChannel.rootReason}\` — the plugin's files could not be located, so whether this is a ` +
576
+ `real dual install is UNDETERMINED. Do not remove the npm/manual copy on the strength ` +
577
+ `of this line: if the plugin is not actually installed, that copy is the only one you ` +
578
+ `have. Check ~/.claude/plugins/installed_plugins.json, then either re-install the ` +
579
+ `plugin or drop the stale entry from enabledPlugins.`,
580
+ );
581
+ return;
582
+ }
583
+ warn(
584
+ 'Dual install (plugin + manual/npm)',
585
+ 'The Hypomnema plugin is ALSO enabled in ~/.claude/settings.json while this is the ' +
586
+ 'manual/npm install — the plugin loader already provides the core hooks, slash ' +
587
+ 'commands, and settings.json wiring, and /hypo:upgrade skips re-registering them by ' +
588
+ 'default (doing so would fire every core hook twice). Pick one install: remove the ' +
589
+ 'npm/manual copy (`npm uninstall -g hypomnema`) and upgrade via `/plugin marketplace ' +
590
+ 'update hypomnema`, or run `hypomnema upgrade --apply --allow-dual-install` to ' +
591
+ 'knowingly register both.',
592
+ );
593
+ }
594
+
480
595
  function checkGit(hypoDir) {
481
596
  if (!existsSync(join(hypoDir, '.git'))) {
482
597
  warn(
@@ -543,15 +658,38 @@ function checkGit(hypoDir) {
543
658
  // plugin-cache leaf-drift precedent: reporting must never turn into
544
659
  // filtering, or a still-real, still-resolvable root looks like it vanished).
545
660
  const parsedRoot = parseWikiPreCommitRoot(content);
546
- const wantRoot = resolveDurableHookRoot();
547
- if (parsedRoot.ok && wantRoot !== null && wantRoot !== parsedRoot.root) {
661
+ if (parsedRoot.ok && parsedRoot.root === null) {
662
+ // The runtime-resolving form: the hook looks up its own
663
+ // install root at commit time, so there is no baked value to compare
664
+ // against the active install — it cannot structurally go stale the way
665
+ // the old, version-pinned form could. Report that fact in place of the
666
+ // stale-root comparison below, rather than falling silent.
667
+ pass(`${label} root`, 'Resolved at commit time by the hook itself — never goes stale');
668
+ } else if (!parsedRoot.ok) {
669
+ // The pass above only checked that our START marker is present, not
670
+ // that the body it wraps is one this codebase can actually run. This is
671
+ // the fail-open this branch used to leave: a hand-edited body, a
672
+ // corrupted one, or (before the rewrite-safe root check) an OLD-form
673
+ // hook whose baked-in install root no longer existed all read as a
674
+ // clean "guard installed" pass while the hook fails MODULE_NOT_FOUND on
675
+ // every real commit.
548
676
  warn(
549
677
  `${label} root`,
550
- `Points at an old install root (${parsedRoot.root}) — the active install is ` +
551
- `${wantRoot}. To repoint it, run ${upgradeApplyHint(
552
- pluginMode || hypomnemaPluginEnabled,
553
- )}.`,
678
+ 'Guard marker present but the hook body is not recognized, it may be failing on ' +
679
+ `every commit. Run \`hypomnema init --force-commands\` (or the plugin's ` +
680
+ `\`/hypo:init --force-commands\`) to reinstall it.`,
554
681
  );
682
+ } else {
683
+ const wantRoot = resolveDurableHookRoot();
684
+ if (wantRoot !== null && wantRoot !== parsedRoot.root) {
685
+ warn(
686
+ `${label} root`,
687
+ `Points at an old install root (${parsedRoot.root}) — the active install is ` +
688
+ `${wantRoot}. To repoint it, run ${upgradeApplyHint(
689
+ pluginMode || hypomnemaPluginEnabled,
690
+ )}.`,
691
+ );
692
+ }
555
693
  }
556
694
  } else {
557
695
  warn(label, 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore');
@@ -1033,6 +1171,10 @@ function checkSessionCloseArtifacts(hypoDir) {
1033
1171
  // already-expired marker can't vouch for anything.
1034
1172
  const cacheDir = join(hypoDir, '.cache');
1035
1173
  const markers = [];
1174
+ // Markers closeCheckpointState does not call a finished close (the receipt
1175
+ // they name is missing, invalid or another generation, or a valid receipt
1176
+ // disagrees with them). They cover nothing, and the warning says why.
1177
+ const brokenMarkers = [];
1036
1178
  let cacheEntries = [];
1037
1179
  if (existsSync(cacheDir)) {
1038
1180
  try {
@@ -1047,6 +1189,19 @@ function checkSessionCloseArtifacts(hypoDir) {
1047
1189
  const data = JSON.parse(readFileSync(join(cacheDir, file), 'utf-8'));
1048
1190
  const ts = Date.parse(data?.closed_at || '');
1049
1191
  if (!Number.isFinite(ts) || Date.now() - ts > SESSION_CLOSED_MARKER_STALE_MS) continue;
1192
+ // The same verdict Stop blocks on, fed the marker parsed above so the
1193
+ // reader never unlinks anything. The file name carries the sanitized id,
1194
+ // which can differ from the real one; the marker body carries the real
1195
+ // one, and the receipt's own session id check needs exactly that.
1196
+ const sessionId =
1197
+ typeof data?.session_id === 'string' && data.session_id
1198
+ ? data.session_id
1199
+ : file.slice('session-closed-'.length, -'.marker'.length);
1200
+ const checkpoint = closeCheckpointState(hypoDir, sessionId, { marker: data });
1201
+ if (!isCloseComplete(checkpoint)) {
1202
+ brokenMarkers.push({ sessionId, reason: checkpoint.reason });
1203
+ continue;
1204
+ }
1050
1205
  // ONLY the v4 `projects` array counts as project-scope evidence. A
1051
1206
  // legacy flat `project` field can be a recency-derived misattribution
1052
1207
  // (the P1 bug resolveCloseScope's own doc comment describes) — the
@@ -1086,6 +1241,15 @@ function checkSessionCloseArtifacts(hypoDir) {
1086
1241
  .map((a) => `${a.where}${a.date ? ` (${a.date})` : ''}`)
1087
1242
  .join(', ');
1088
1243
  const extra = unmatched.length > 5 ? ` (+${unmatched.length - 5} more)` : '';
1244
+ const brokenNote =
1245
+ brokenMarkers.length > 0
1246
+ ? `. ${brokenMarkers.length} session-closed marker(s) did not count because their close ` +
1247
+ `checkpoint is broken: ` +
1248
+ brokenMarkers
1249
+ .slice(0, 3)
1250
+ .map((b) => `${b.sessionId} (${b.reason})`)
1251
+ .join('; ')
1252
+ : '';
1089
1253
  // Not necessarily UNAPPROVED — an approved log-only marker (deliberately
1090
1254
  // empty `projects`) or one scoped to a different project would ALSO land
1091
1255
  // here, since neither covers a specific project's artifact. Both readings
@@ -1095,7 +1259,65 @@ function checkSessionCloseArtifacts(hypoDir) {
1095
1259
  'Session-close artifacts',
1096
1260
  `${unmatched.length} close artifact(s) with no session-closed marker covering their ` +
1097
1261
  `date and project scope — may be an unapproved hand-made close, or an approved close ` +
1098
- `whose marker doesn't attribute this project: ${sample}${extra}`,
1262
+ `whose marker doesn't attribute this project: ${sample}${extra}${brokenNote}`,
1263
+ );
1264
+ }
1265
+
1266
+ // A close receipt with no session-closed marker beside it is a half-finished
1267
+ // close (the run stopped before the marker landed, or only the marker was
1268
+ // invalidated). checkSessionCloseArtifacts starts from close artifacts and
1269
+ // then walks markers, so this state is invisible to it. Enumerate the
1270
+ // receipts instead, judge each markerless one with the verdict Stop blocks
1271
+ // on, and stay silent otherwise. Same 7-day window as the marker walk, read
1272
+ // off the receipt file's mtime.
1273
+ function checkHalfClosedReceipts(hypoDir) {
1274
+ const sessionsDir = join(hypoDir, '.cache', 'sessions');
1275
+ let dirs = [];
1276
+ try {
1277
+ dirs = readdirSync(sessionsDir);
1278
+ } catch {
1279
+ return; // no sessions directory, or unreadable: nothing to judge
1280
+ }
1281
+ const broken = [];
1282
+ for (const name of dirs) {
1283
+ const file = join(sessionsDir, name, 'close-receipt.json');
1284
+ try {
1285
+ if (Date.now() - statSync(file).mtimeMs > SESSION_CLOSED_MARKER_STALE_MS) continue;
1286
+ // The directory name is the sanitized id; the receipt body carries the real one.
1287
+ const body = JSON.parse(readFileSync(file, 'utf-8'));
1288
+ const sessionId =
1289
+ typeof body?.sessionId === 'string' && body.sessionId ? body.sessionId : name;
1290
+ if (existsSync(sessionClosedMarkerPath(hypoDir, sessionId))) continue;
1291
+ const checkpoint = closeCheckpointState(hypoDir, sessionId, { marker: null });
1292
+ if (checkpoint.state !== 'broken') continue;
1293
+ // --mark-session-closed refuses a receipt whose commit is no longer an
1294
+ // ancestor of HEAD (history rewritten), so do not offer it for that case.
1295
+ const commit = typeof body?.commit === 'string' ? body.commit : '';
1296
+ const rewritten =
1297
+ /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(commit) &&
1298
+ spawnSync('git', ['-C', hypoDir, 'rev-parse', '--verify', '--quiet', 'HEAD']).status ===
1299
+ 0 &&
1300
+ spawnSync('git', ['-C', hypoDir, 'merge-base', '--is-ancestor', commit, 'HEAD']).status !==
1301
+ 0;
1302
+ broken.push({ sessionId, reason: checkpoint.reason, rewritten });
1303
+ } catch {
1304
+ // unreadable or corrupt receipt: not this check's job to diagnose
1305
+ }
1306
+ }
1307
+ if (broken.length === 0) return;
1308
+ const crystallize = join(PKG_ROOT, 'scripts', 'crystallize.mjs');
1309
+ warn(
1310
+ 'Session-close checkpoint',
1311
+ `${broken.length} session(s) hold a close receipt but no session-closed marker: ` +
1312
+ broken
1313
+ .slice(0, 3)
1314
+ .map((b) =>
1315
+ b.rewritten
1316
+ ? `${b.sessionId} (${b.reason}; the commit it certified is no longer in this branch's history). Restore that commit, or ask the user to close again and apply it with /hypo:crystallize`
1317
+ : `${b.sessionId} (${b.reason}). Recover with: node "${crystallize}" --mark-session-closed --session-id=${b.sessionId}`,
1318
+ )
1319
+ .join('; ') +
1320
+ (broken.length > 3 ? ` (+${broken.length - 3} more)` : ''),
1099
1321
  );
1100
1322
  }
1101
1323
 
@@ -1272,38 +1494,195 @@ function checkProjectSuggestions(hypoDir) {
1272
1494
  }
1273
1495
 
1274
1496
  function checkProposals(hypoDir) {
1275
- // Vault-wide count of parked write-proposals (T8). listProposals is the
1276
- // count source rather than a raw readdir: it already skips malformed and
1277
- // spoofed-id artifacts, so the number matches exactly what `hypomnema proposal
1278
- // list/apply/discard` can act on. Surface only: warn (never fail), pass at 0,
1279
- // because a pending proposal is a normal state awaiting review, not a broken
1280
- // install, and the check discovers without changing any state.
1281
- const count = listProposals(hypoDir).length;
1282
- if (count === 0) {
1283
- pass('Pending proposals', 'No parked write-proposals');
1284
- } else {
1497
+ // Surface only: warn (never fail), pass at 0, because a parked proposal is a
1498
+ // normal state awaiting a human rather than a broken install, and this check
1499
+ // discovers without changing anything.
1500
+ //
1501
+ // Ask the store directly FIRST, before classifying anything. `ok: false`
1502
+ // means `.cache/proposals` itself could not be listed (permission denied,
1503
+ // or a file sitting where the directory should be): the backlog is
1504
+ // unmeasured, not zero. classifyProposals reads listProposals underneath,
1505
+ // which folds that same readdir failure into `[]` (right for its own
1506
+ // callers, which want a best-effort listing, wrong for a health check), so
1507
+ // classifying first and asking the store second let an unmeasured backlog
1508
+ // slip through the `classified.length === 0` gate below and report a
1509
+ // lying pass. This warn branch used to read
1510
+ // `inventory.ok ? inventory.unreadable : []`, which threw away `ok: false`
1511
+ // entirely and fell straight into that same lying pass.
1512
+ const inventory = listProposalsChecked(hypoDir);
1513
+ if (!inventory.ok) {
1285
1514
  warn(
1286
1515
  'Pending proposals',
1287
- `${count} parked write-proposal(s) awaiting review; inspect with \`hypomnema proposal list\``,
1516
+ `${join(hypoDir, '.cache', 'proposals')} could not be listed (permission denied, or a ` +
1517
+ `file sitting where the directory should be): the parked write-proposal count is ` +
1518
+ `unmeasured here, not zero. Run \`ls -la ${join(hypoDir, '.cache', 'proposals')}\` to ` +
1519
+ `see what is blocking it, then fix the permissions or remove the stray file.`,
1520
+ );
1521
+ return;
1522
+ }
1523
+ const unreadable = inventory.unreadable;
1524
+
1525
+ // Not a count of artifacts. Three different states share that shape and want
1526
+ // three different things from the user, and telling them apart needs the
1527
+ // audit log and the page's current hash, which is exactly the judgment
1528
+ // `proposal reconcile` makes. Reuse that judgment rather than derive a second
1529
+ // one here: the failure this closes is a doctor that told the user to review
1530
+ // a write a human had already approved and that was already on the page.
1531
+ const classified = classifyProposals(hypoDir);
1532
+ const pending = classified.filter((c) => c.kind === 'pending');
1533
+ const recoverable = classified.filter((c) => c.kind === 'recoverable');
1534
+ const broken = classified.filter((c) => c.kind === 'evidence-broken');
1535
+
1536
+ if (classified.length === 0 && unreadable.length === 0) {
1537
+ pass('Pending proposals', 'No parked write-proposals');
1538
+ return;
1539
+ }
1540
+ const parts = [];
1541
+ if (recoverable.length > 0) {
1542
+ parts.push(
1543
+ `${recoverable.length} approved and already written, but their close handoff receipt ` +
1544
+ `never landed: run \`hypomnema proposal reconcile\` (it recovers the receipt from ` +
1545
+ `evidence and writes no page). Do NOT re-run the close first`,
1546
+ );
1547
+ }
1548
+ if (broken.length > 0) {
1549
+ parts.push(
1550
+ `${broken.length} whose audit entry no longer matches the page (${broken
1551
+ .slice(0, 3)
1552
+ .map((c) => c.target)
1553
+ .join(', ')}): neither reconcile nor a re-close can settle these without a human`,
1554
+ );
1555
+ }
1556
+ if (pending.length > 0) {
1557
+ // No drain to advertise: reconcile never removes a 'pending' artifact (see the
1558
+ // note above reconcileProposals). `list` shows age and target activity so a
1559
+ // human can triage, and every removal is their own `discard`.
1560
+ parts.push(
1561
+ `${pending.length} awaiting review; inspect with \`hypomnema proposal list\` ` +
1562
+ `(it shows each one's age and whether its target moved since)`,
1563
+ );
1564
+ }
1565
+ if (unreadable.length > 0) {
1566
+ parts.push(
1567
+ `${unreadable.length} candidate file(s) in .cache/proposals could not be read or ` +
1568
+ `parsed (corrupt, permission-denied, or hand-edited): ${unreadable
1569
+ .slice(0, 3)
1570
+ .join(', ')}${unreadable.length > 3 ? `, +${unreadable.length - 3} more` : ''}; ` +
1571
+ `neither \`proposal list\` nor \`proposal reconcile\` sees these. Run ` +
1572
+ `\`ls -la ${join(hypoDir, '.cache', 'proposals')}\` to see the rest and open each file ` +
1573
+ `directly`,
1288
1574
  );
1289
1575
  }
1576
+ // Separate sentence, not folded into the wording above: an orphan tmp file
1577
+ // is a subset of `unreadable` (a name can sit in both arrays, see
1578
+ // scanProposalFiles), but it is not corrupt and not hand-edited. It is a
1579
+ // complete proposal body whose rename from `<id>.json.<pid>.<rand>.tmp` to
1580
+ // `<id>.json` never landed, so the fix is `mv`, not deletion.
1581
+ const orphanTmp = inventory.orphanTmp || [];
1582
+ if (orphanTmp.length > 0) {
1583
+ const proposalsDir = join(hypoDir, '.cache', 'proposals');
1584
+ const hints = orphanTmp
1585
+ .slice(0, 3)
1586
+ .map(
1587
+ (name) =>
1588
+ `mv "${join(proposalsDir, name)}" "${join(proposalsDir, orphanTmpTargetName(name))}"`,
1589
+ )
1590
+ .join('; ');
1591
+ parts.push(
1592
+ `${orphanTmp.length} of the file(s) above are recoverable, not corrupt: a writer finished ` +
1593
+ `serializing a proposal body but crashed before the rename to its \`.json\` name landed. ` +
1594
+ `Restore with, e.g., \`${hints}\`${orphanTmp.length > 3 ? ` (+${orphanTmp.length - 3} more, same pattern: drop the \`.<pid>.<rand>.tmp\` suffix)` : ''}`,
1595
+ );
1596
+ }
1597
+ warn('Pending proposals', parts.join('. '));
1598
+ }
1599
+
1600
+ /**
1601
+ * Surface leftover `.cache/close-intent/` records: crystallize-close-apply's
1602
+ * torn-set witness (see closeIntentPath's doc comment there). A record
1603
+ * outlives its close only when that close crashed between writeCloseIntent
1604
+ * and clearCloseIntent, so every one found here means an interrupted apply,
1605
+ * never a normal state to leave silent. Read-only: whether an aged-out
1606
+ * record gets deleted is hasTornCloseIntent's call, made the next time a
1607
+ * close actually runs the probe, not doctor's.
1608
+ */
1609
+ function checkCloseIntent(hypoDir) {
1610
+ const dir = join(hypoDir, '.cache', 'close-intent');
1611
+ if (!existsSync(dir)) {
1612
+ pass('Close-intent records', 'No leftover .cache/close-intent/ records');
1613
+ return;
1614
+ }
1615
+ let names;
1616
+ try {
1617
+ names = readdirSync(dir);
1618
+ } catch (e) {
1619
+ // The same failure hasTornCloseIntent treats as 'unreadable' rather than
1620
+ // silently torn: this check cannot tell either, so it says so instead of
1621
+ // reporting a clean count that might be a lie.
1622
+ warn(
1623
+ 'Close-intent records',
1624
+ `${dir} could not be listed (${e.code || e.message}): whether a leftover record is sitting ` +
1625
+ `there is unmeasured, not zero, and every no-payload close probe against this vault treats ` +
1626
+ `an unreadable directory as unresolved (payload required) until this is fixed. Run ` +
1627
+ `\`ls -la ${dir}\` to see what is blocking it.`,
1628
+ );
1629
+ return;
1630
+ }
1631
+ const records = names.filter((n) => n.endsWith('.json'));
1632
+ if (records.length === 0) {
1633
+ pass('Close-intent records', 'No leftover .cache/close-intent/ records');
1634
+ return;
1635
+ }
1636
+ let unreadableCount = 0;
1637
+ let oldestMinutes = null;
1638
+ for (const name of records) {
1639
+ try {
1640
+ const parsed = JSON.parse(readFileSync(join(dir, name), 'utf-8'));
1641
+ const ts = Date.parse(parsed?.startedAt);
1642
+ if (Number.isFinite(ts)) {
1643
+ const minutes = Math.round((Date.now() - ts) / 60_000);
1644
+ if (oldestMinutes === null || minutes > oldestMinutes) oldestMinutes = minutes;
1645
+ continue;
1646
+ }
1647
+ } catch {
1648
+ // falls through to unreadableCount below
1649
+ }
1650
+ unreadableCount++;
1651
+ }
1652
+ const parts = [
1653
+ `${records.length} record(s) in .cache/close-intent/, left behind by a close that began ` +
1654
+ `writing and never called clearCloseIntent (a crash or a killed session mid-apply, or a close that ended ok:false before its commit landed)`,
1655
+ ];
1656
+ if (oldestMinutes !== null) parts.push(`oldest is ${oldestMinutes} minute(s) old`);
1657
+ if (unreadableCount > 0) {
1658
+ parts.push(
1659
+ `${unreadableCount} could not be read or parsed, so its age is unknown; every no-payload ` +
1660
+ `close probe for this vault treats an unreadable record the same as a torn one (payload ` +
1661
+ `required) until it is removed`,
1662
+ );
1663
+ }
1664
+ warn('Close-intent records', parts.join('; '));
1290
1665
  }
1291
1666
 
1292
1667
  function checkCodexPaths() {
1293
1668
  const codexHooks = join(HOME, '.codex', 'hooks');
1294
1669
  const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
1295
1670
 
1296
- let missing = 0;
1297
- for (const file of allFiles) {
1298
- if (!existsSync(join(codexHooks, file))) missing++;
1299
- }
1671
+ // Names the files for the same reason checkHooks above does: a count alone
1672
+ // leaves the user re-running upgrade just to find out which one. The severity
1673
+ // stays warn here rather than following checkHooks to fail, because this
1674
+ // channel is opt-in and nothing reads its exit code.
1675
+ const missingFiles = allFiles.filter((file) => !existsSync(join(codexHooks, file)));
1676
+ const missing = missingFiles.length;
1300
1677
 
1301
1678
  if (missing === 0) {
1302
1679
  pass('Codex hook files installed', codexHooks);
1303
1680
  } else if (missing < allFiles.length) {
1681
+ const examples = missingFiles.slice(0, 3).join(', ');
1682
+ const rest = missing > 3 ? `, +${missing - 3} more` : '';
1304
1683
  warn(
1305
1684
  'Codex hook files installed',
1306
- `${missing}/${allFiles.length} missing in ${codexHooks} — run /hypo:init --codex`,
1685
+ `${missing}/${allFiles.length} missing in ${codexHooks}: ${examples}${rest} — run /hypo:init --codex`,
1307
1686
  );
1308
1687
  } else {
1309
1688
  fail(
@@ -1359,6 +1738,11 @@ function checkCodexPaths() {
1359
1738
  `0/${total} registered — run /hypo:init --codex`,
1360
1739
  );
1361
1740
  }
1741
+ reportRetiredRegistrations(
1742
+ 'Codex settings.json retired hook registrations',
1743
+ retiredRegistrations(settings, hooksDir),
1744
+ 'Run `hypomnema upgrade --apply --codex` to remove them',
1745
+ );
1362
1746
  }
1363
1747
 
1364
1748
  // ── extensions integrity (E5) ─────────────────────────────────────
@@ -2281,13 +2665,56 @@ function checkProvenanceSidecar(hooksDir, label) {
2281
2665
  // trusts.
2282
2666
  //
2283
2667
  // Returns null when there is nothing to report (field absent, or digests
2284
- // match), or a ready-to-append reason string naming a few diverged files.
2668
+ // match), or a ready-to-append reason string: either a diverged-files
2669
+ // mismatch, or the "cannot verify" case below.
2285
2670
  function hooksDigestMismatch(hooksDir, recordedDigest) {
2286
2671
  if (typeof recordedDigest !== 'string' || !recordedDigest) return null;
2287
2672
  const actualDigest = computeHooksDigest(PKG_ROOT, hooksDir);
2288
- if (actualDigest === null || actualDigest === recordedDigest) return null;
2673
+ if (actualDigest === recordedDigest) return null;
2289
2674
 
2290
2675
  const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
2676
+
2677
+ // computeHooksDigest returns null for two different reasons, and neither is
2678
+ // "the digests matched": either hooks.json itself could not be read from
2679
+ // PKG_ROOT, or one of the files it lists could not be read out of hooksDir.
2680
+ // This branch used to be folded into the early return as
2681
+ // `if (actualDigest === null || actualDigest === recordedDigest) return null`,
2682
+ // which reported a failed recompute as nothing-to-report, so a deleted hook
2683
+ // file still printed `verified`. "Could not verify" has to stay a distinct,
2684
+ // non-silent state from "verified". It is not promoted to fail here because
2685
+ // checkProvenanceSidecar keeps this label warn-or-pass, never fail, same as
2686
+ // every other branch in that function, and checkHooks() above already
2687
+ // names the same deletion as a fail, so the exit code is covered there.
2688
+ //
2689
+ // Of those two causes only the second can reach this line: loadHookInventory
2690
+ // reads the same two JSON files at module load and exits 1 when they are bad,
2691
+ // so a doctor that got this far has a readable config. Naming the files is
2692
+ // still best-effort, because it re-reads them a moment after
2693
+ // computeHooksDigest did: a file that vanished in between shows up in the
2694
+ // digest failure and not in this list. When the list comes back empty the
2695
+ // message says that instead of asserting a count of zero unreadable files,
2696
+ // which would read as "nothing is wrong" on the one line that exists to say
2697
+ // otherwise.
2698
+ if (actualDigest === null) {
2699
+ const unreadable = allFiles.filter((file) => {
2700
+ try {
2701
+ readFileSync(join(hooksDir, file));
2702
+ return false;
2703
+ } catch {
2704
+ return true;
2705
+ }
2706
+ });
2707
+ const which = unreadable.length
2708
+ ? `${unreadable.length} hook file(s) unreadable in ${hooksDir} ` +
2709
+ `(e.g. ${unreadable.slice(0, 3).join(', ')})`
2710
+ : `every listed hook file reads back from ${hooksDir} now, so whatever the recompute ` +
2711
+ `could not read has already changed underneath it`;
2712
+ return (
2713
+ `hooksDigest cannot be recomputed. ${which}. Run ` +
2714
+ `${upgradeApplyHint(pluginMode || hypomnemaPluginEnabled)}`
2715
+ );
2716
+ }
2717
+
2291
2718
  const diverged = allFiles.filter((file) => {
2292
2719
  try {
2293
2720
  return !readFileSync(join(hooksDir, file)).equals(readFileSync(join(HOOKS_SRC, file)));
@@ -2329,13 +2756,9 @@ function checkPkgIntegrity(claudeHome) {
2329
2756
  // failed ('registry-unreadable'/'unresolved'), init/upgrade skip the very
2330
2757
  // same write on every re-run until the registry is fixed first (see
2331
2758
  // resolveDurableRoot in init.mjs and the dualSkip branch in upgrade.mjs).
2332
- const channelJudgmentFailed =
2333
- hypomnemaPluginEnabled &&
2334
- (pluginChannel.rootReason === 'registry-unreadable' ||
2335
- pluginChannel.rootReason === 'unresolved');
2336
2759
  warn(
2337
2760
  'hypo-pkg.json pkgRoot',
2338
- channelJudgmentFailed
2761
+ channelJudgmentFailed(pluginChannel)
2339
2762
  ? `hypo-pkg.json has no pkgRoot field because the enabled plugin's channel judgment failed ` +
2340
2763
  `(${pluginChannel.rootReason}): re-running /hypo:init or /hypo:upgrade will skip the same ` +
2341
2764
  `write until this resolves. Fix: repair or refresh ~/.claude/plugins/installed_plugins.json ` +
@@ -2451,6 +2874,7 @@ if (rootOk) {
2451
2874
  }
2452
2875
  checkHooks(coreManagedByPlugin);
2453
2876
  checkSettingsJson(coreManagedByPlugin);
2877
+ checkDualInstall(pluginChannel);
2454
2878
  checkPkgIntegrity(args.claudeHome);
2455
2879
  checkStaleSibling();
2456
2880
  checkPluginLeafVersion();
@@ -2460,9 +2884,11 @@ if (rootOk) checkExtensions(args.hypoDir, args.claudeHome, 'claude');
2460
2884
  if (rootOk && args.codex) checkExtensions(args.hypoDir, args.claudeHome, 'codex');
2461
2885
  if (rootOk) checkProjectIndexAnchors(args.hypoDir);
2462
2886
  if (rootOk) checkSessionCloseArtifacts(args.hypoDir);
2887
+ if (rootOk) checkHalfClosedReceipts(args.hypoDir);
2463
2888
  if (rootOk) checkSyncState(args.hypoDir);
2464
2889
  if (rootOk) checkProjectSuggestions(args.hypoDir);
2465
2890
  if (rootOk) checkProposals(args.hypoDir);
2891
+ if (rootOk) checkCloseIntent(args.hypoDir);
2466
2892
  if (rootOk) checkFeedbackProjection(args.hypoDir, args.claudeHome, args.projectId);
2467
2893
  checkGit(args.hypoDir);
2468
2894