hypomnema 1.7.4 → 1.8.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 (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +1202 -0
  4. package/README.ko.md +4 -4
  5. package/README.md +5 -5
  6. package/commands/audit.md +1 -1
  7. package/commands/capture.md +1 -1
  8. package/commands/crystallize.md +3 -3
  9. package/commands/doctor.md +15 -1
  10. package/commands/feedback.md +1 -1
  11. package/commands/graph.md +1 -1
  12. package/commands/ingest.md +1 -1
  13. package/commands/init.md +2 -2
  14. package/commands/lint.md +1 -1
  15. package/commands/query.md +1 -1
  16. package/commands/rename.md +1 -1
  17. package/commands/resume.md +1 -1
  18. package/commands/stats.md +1 -1
  19. package/commands/uninstall.md +1 -1
  20. package/commands/upgrade.md +2 -2
  21. package/commands/verify.md +1 -1
  22. package/docs/ARCHITECTURE.md +8 -3
  23. package/docs/CONTRIBUTING.md +2 -1
  24. package/hooks/base-store.mjs +198 -0
  25. package/hooks/close-gate-store.mjs +3 -2
  26. package/hooks/hypo-auto-minimal-crystallize.mjs +10 -0
  27. package/hooks/hypo-compact-guard.mjs +5 -3
  28. package/hooks/hypo-cwd-change.mjs +8 -0
  29. package/hooks/hypo-personal-check.mjs +127 -67
  30. package/hooks/hypo-session-start.mjs +91 -10
  31. package/hooks/hypo-shared.mjs +241 -19
  32. package/hooks/version-check.mjs +44 -6
  33. package/package.json +9 -3
  34. package/scripts/capture.mjs +49 -17
  35. package/scripts/crystallize.mjs +20 -2092
  36. package/scripts/doctor.mjs +334 -14
  37. package/scripts/init.mjs +106 -81
  38. package/scripts/lib/crystallize-args.mjs +50 -0
  39. package/scripts/lib/crystallize-close-apply.mjs +1830 -0
  40. package/scripts/lib/crystallize-close-check.mjs +238 -0
  41. package/scripts/lib/crystallize-close-gate.mjs +58 -0
  42. package/scripts/lib/crystallize-helpers.mjs +55 -0
  43. package/scripts/lib/extensions.mjs +175 -49
  44. package/scripts/lib/git-hooks-dir.mjs +88 -3
  45. package/scripts/lib/plugin-detect.mjs +176 -21
  46. package/scripts/lint.mjs +12 -6
  47. package/scripts/proposal.mjs +3 -3
  48. package/scripts/upgrade.mjs +243 -20
  49. package/skills/crystallize/SKILL.md +15 -13
  50. package/skills/graph/SKILL.md +3 -3
  51. package/skills/ingest/SKILL.md +2 -2
  52. package/skills/lint/SKILL.md +3 -3
  53. package/skills/query/SKILL.md +3 -3
  54. package/skills/verify/SKILL.md +3 -3
  55. package/templates/hypo-automation.md +59 -17
  56. package/templates/hypo-config.md +1 -1
  57. package/templates/hypo-guide.md +12 -9
  58. package/templates/hypo-help.md +1 -1
@@ -20,7 +20,11 @@ import { fileURLToPath } from 'url';
20
20
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
21
  import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
22
22
  import { readRenameMarker, renameMarkerPath, RENAME_MARKER_REL } from './lib/rename-marker.mjs';
23
- import { resolveGitHooksDir, WIKI_PRE_COMMIT_MARKER_START } from './lib/git-hooks-dir.mjs';
23
+ import {
24
+ resolveGitHooksDir,
25
+ WIKI_PRE_COMMIT_MARKER_START,
26
+ parseWikiPreCommitRoot,
27
+ } from './lib/git-hooks-dir.mjs';
24
28
  import { parseFrontmatter } from './lib/frontmatter.mjs';
25
29
  import {
26
30
  readSyncState,
@@ -58,8 +62,14 @@ import {
58
62
  HOOKS_DIGEST_FIELD,
59
63
  computeHooksDigest,
60
64
  } from './lib/pkg-provenance.mjs';
61
- import { resolveCliOnPath, classifyInstall } from '../hooks/version-check.mjs';
62
- import { isHypomnemaPluginEnabled } from './lib/plugin-detect.mjs';
65
+ import { resolveCliOnPath, classifyInstall, upgradeApplyHint } from '../hooks/version-check.mjs';
66
+ import {
67
+ enabledHypomnemaPluginKey,
68
+ usablePkgRoot,
69
+ leafVersionDrift,
70
+ resolvePluginChannel,
71
+ resolveEnabledPluginEntry,
72
+ } from './lib/plugin-detect.mjs';
63
73
 
64
74
  const HOME = homedir();
65
75
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -68,7 +78,7 @@ const HOOKS_SRC = join(PKG_ROOT, 'hooks');
68
78
 
69
79
  // ── install channel ───────────────────────────────────────────────────────────
70
80
  //
71
- // A plugin-channel install registers its 14 core hooks straight out of the
81
+ // A plugin-channel install registers the core hooks named in hooks/hooks.json straight out of the
72
82
  // package's own hooks/hooks.json (CLAUDE_PLUGIN_ROOT) — Claude Code auto-wires
73
83
  // them, they are never copied into ~/.claude/hooks and never listed in
74
84
  // ~/.claude/settings.json. checkHooks/checkSettingsJson used to treat that
@@ -80,11 +90,28 @@ const HOOKS_SRC = join(PKG_ROOT, 'hooks');
80
90
  // dual-install variant — a manual/npm doctor.mjs run while the plugin is ALSO
81
91
  // enabled in settings.json (see lib/plugin-detect.mjs; fails open on any
82
92
  // uncertainty). Either signal means Claude's core hook surface is
83
- // plugin-managed, not missing.
84
- const pluginMode = PKG_ROOT.replace(/\\/g, '/').includes('/.claude/plugins/');
85
- const hypomnemaPluginEnabled =
86
- !pluginMode && isHypomnemaPluginEnabled(join(HOME, '.claude', 'settings.json'));
87
- const coreManagedByPlugin = pluginMode || hypomnemaPluginEnabled;
93
+ // plugin-managed, not missing. resolvePluginChannel is the single place this
94
+ // judgment is made — init.mjs and upgrade.mjs call the same function with their
95
+ // own PKG_ROOT so all three tools agree.
96
+ const pluginChannel = resolvePluginChannel({
97
+ pkgRoot: PKG_ROOT,
98
+ settingsPath: join(HOME, '.claude', 'settings.json'),
99
+ registryPath: join(HOME, '.claude', 'plugins', 'installed_plugins.json'),
100
+ });
101
+ const { pluginMode, hypomnemaPluginEnabled, coreManagedByPlugin } = pluginChannel;
102
+
103
+ // The durable root the vault's pre-commit hook SHOULD embed, mirroring the same
104
+ // rule upgrade.mjs's self-heal uses: in normal operation (plain plugin mode or
105
+ // plain npm/manual) doctor's own PKG_ROOT already IS the active install, no
106
+ // registry lookup needed or even possible to get wrong. In a dual install
107
+ // (manual/npm doctor run while the plugin is also enabled) the active runtime
108
+ // is the PLUGIN's, so only the registry's positive resolution can be trusted —
109
+ // null means "cannot verify", never "matches", so checkGit below must skip the
110
+ // comparison rather than report a false pass or a false drift.
111
+ function resolveDurableHookRoot() {
112
+ if (!hypomnemaPluginEnabled) return PKG_ROOT;
113
+ return pluginChannel.root;
114
+ }
88
115
 
89
116
  // Shown after every fatal package-integrity error. These conditions mean the
90
117
  // shipped hooks/hooks.json is missing or malformed — never a user mistake —
@@ -508,6 +535,23 @@ function checkGit(hypoDir) {
508
535
  );
509
536
  } else if (content.includes(WIKI_PRE_COMMIT_MARKER_START)) {
510
537
  pass(label, 'Hypomnema .hypoignore guard installed');
538
+ // A stale root never trips the marker check above (the marker itself never
539
+ // moves), so it is a SEPARATE, additive report, never a replacement for the
540
+ // pass — excluding a drifted root from consideration here would leave the
541
+ // exact silent-staleness failure this check exists to catch (see the
542
+ // plugin-cache leaf-drift precedent: reporting must never turn into
543
+ // filtering, or a still-real, still-resolvable root looks like it vanished).
544
+ const parsedRoot = parseWikiPreCommitRoot(content);
545
+ const wantRoot = resolveDurableHookRoot();
546
+ if (parsedRoot.ok && wantRoot !== null && wantRoot !== parsedRoot.root) {
547
+ warn(
548
+ `${label} root`,
549
+ `Points at an old install root (${parsedRoot.root}) — the active install is ` +
550
+ `${wantRoot}. To repoint it, run ${upgradeApplyHint(
551
+ pluginMode || hypomnemaPluginEnabled,
552
+ )}.`,
553
+ );
554
+ }
511
555
  } else {
512
556
  warn(label, 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore');
513
557
  }
@@ -1839,6 +1883,264 @@ function checkStaleSibling() {
1839
1883
  }
1840
1884
  }
1841
1885
 
1886
+ // ── plugin cache leaf version (plugin channel) ──────────────────────────────
1887
+ //
1888
+ // `/plugin marketplace update` names its cache directory after the release it
1889
+ // copies in (`cache/<marketplace>/hypo/<version>/`, per the plugin-channel
1890
+ // invariant in this repo's CLAUDE.md) and skips the copy whenever that version
1891
+ // string has not moved, so a leaf can end up naming an older release than the
1892
+ // package.json actually sitting inside it (2026-08-29 QA constructed that shape:
1893
+ // leaf "1.7.3", package.json "1.7.4"). This is the ONLY surface for that signal.
1894
+ // resolveEnabledPluginRoot (lib/plugin-detect.mjs) deliberately does NOT exclude a
1895
+ // drifted candidate: an earlier revision did, and excluding it makes that function
1896
+ // return null. init's resolveDurableRoot then tries the recorded pkgRoot and, when that
1897
+ // is unusable too, PKG_ROOT, which in a dual install is a removable npm checkout that
1898
+ // would get baked into the vault's git hook. So resolution stays permissive
1899
+ // and reporting lives here, named per-entry so a user can see exactly which registry
1900
+ // path disagrees with itself and what it actually contains.
1901
+ //
1902
+ // Only meaningful when the Hypomnema plugin is actually enabled: an entry in
1903
+ // installed_plugins.json for a disabled/other plugin proves nothing about this
1904
+ // install, so the check is silent (not even a pass) when the plugin key is not
1905
+ // enabled in settings.json.
1906
+ function checkPluginLeafVersion() {
1907
+ const settingsPath = join(HOME, '.claude', 'settings.json');
1908
+ const key = enabledHypomnemaPluginKey(settingsPath);
1909
+ if (!key) return; // plugin not enabled, nothing to verify
1910
+
1911
+ const registryPath = join(HOME, '.claude', 'plugins', 'installed_plugins.json');
1912
+ let reg;
1913
+ try {
1914
+ reg = JSON.parse(readFileSync(registryPath, 'utf-8'));
1915
+ } catch {
1916
+ warn(
1917
+ 'Plugin cache leaf version',
1918
+ `Cannot read ${registryPath}, so the plugin cache directory's identity cannot be verified`,
1919
+ );
1920
+ return;
1921
+ }
1922
+ const entries =
1923
+ reg &&
1924
+ typeof reg.plugins === 'object' &&
1925
+ !Array.isArray(reg.plugins) &&
1926
+ Array.isArray(reg.plugins[key])
1927
+ ? reg.plugins[key]
1928
+ : null;
1929
+ if (!entries || entries.length === 0) {
1930
+ warn(
1931
+ 'Plugin cache leaf version',
1932
+ `No registry entry for ${key} in ${registryPath}, so the plugin cache directory's identity cannot be verified`,
1933
+ );
1934
+ return;
1935
+ }
1936
+
1937
+ // Deduped by installPath: one plugin key routinely carries several registry
1938
+ // entries (a user-scope and a project-scope row) that name the SAME directory.
1939
+ // Counting rows instead of paths reports "2 plugin cache path(s)" and lists the
1940
+ // identical path twice — both the count and the list mislead about how many
1941
+ // installs are actually drifted.
1942
+ const driftedByPath = new Map();
1943
+ const seen = new Set();
1944
+ let compared = 0;
1945
+ for (const e of entries) {
1946
+ const installPath = e && typeof e.installPath === 'string' ? e.installPath : null;
1947
+ if (!installPath || seen.has(installPath)) continue;
1948
+ seen.add(installPath);
1949
+ if (!usablePkgRoot(installPath)) continue;
1950
+ const d = leafVersionDrift(installPath);
1951
+ if (!d.checked) continue;
1952
+ compared += 1;
1953
+ if (d.drift) {
1954
+ driftedByPath.set(
1955
+ installPath,
1956
+ `${installPath} (leaf says v${d.leaf}, package.json says v${d.manifestVersion})`,
1957
+ );
1958
+ }
1959
+ }
1960
+ const drifted = [...driftedByPath.values()];
1961
+
1962
+ // "Nothing drifted" and "nothing was comparable" are different answers, and this check
1963
+ // used to give the same one for both. A path that is unreadable, or whose leaf is not
1964
+ // version-named, was never compared — reporting "matches" there states a fact nobody
1965
+ // established. The function already warns on its other two unverifiable states (an
1966
+ // unreadable registry, a key with no entries), so passing here was the odd one out.
1967
+ if (compared === 0) {
1968
+ warn(
1969
+ 'Plugin cache leaf version',
1970
+ `The plugin is enabled and the registry names ${key}, but no path it names could be ` +
1971
+ `compared: none is a readable package dir with a version-named leaf. Common for a ` +
1972
+ `local or dev marketplace checkout. The cache directory's identity is unverified; ` +
1973
+ `this is not a statement that the installed version matches.`,
1974
+ );
1975
+ } else if (drifted.length === 0) {
1976
+ pass(
1977
+ 'Plugin cache leaf version',
1978
+ `Cache directory name matches the package installed inside it (${compared} path(s) compared)`,
1979
+ );
1980
+ } else {
1981
+ // Scoped to the paths themselves. resolveEnabledPluginRoot picks ONE entry
1982
+ // (user scope first, then any usable one), so a drifted project-scope row beside a
1983
+ // healthy user-scope row is not the root Hypomnema actually runs from. Claiming
1984
+ // "Hypomnema is silently running a different version" for every drifted row
1985
+ // overstates what was measured.
1986
+ warn(
1987
+ 'Plugin cache leaf version',
1988
+ `${drifted.length} plugin cache path(s) whose directory name disagrees with the release ` +
1989
+ `installed inside it: ${drifted.join(', ')}. Anything resolving through such a path ` +
1990
+ `runs a different version than the name claims. Run ` +
1991
+ `\`/plugin marketplace update hypomnema\` then \`/reload-plugins\`, or reinstall the plugin.`,
1992
+ );
1993
+ }
1994
+ }
1995
+
1996
+ // ── installed cache vs marketplace clone (plugin channel) ──────────────────
1997
+ //
1998
+ // `/plugin marketplace update <marketplace>` pulls the marketplace repo to its
1999
+ // own clone at `~/.claude/plugins/marketplaces/<marketplace>/`, but only
2000
+ // re-copies that clone into the plugin CACHE (the directory hooks actually run
2001
+ // from) when plugin.json's version string has moved, the same skip-the-copy
2002
+ // behavior `checkPluginLeafVersion` above reports on. When the version string
2003
+ // has not moved, the marketplace clone can sit commits ahead of the cache with
2004
+ // no symptom anywhere: hooks run, slash commands resolve, the version string
2005
+ // still reads correctly, and a bug already fixed on `main` keeps reproducing.
2006
+ // Measured 2026-08-27: marketplace clone at one commit, install cache three
2007
+ // merges behind, same version string, no surface said so.
2008
+ //
2009
+ // `installed_plugins.json` records the exact commit each cache entry was
2010
+ // populated from (`gitCommitSha`); the marketplace clone's own `git rev-parse
2011
+ // HEAD` is the other half. Compared against the SAME row
2012
+ // `resolveEnabledPluginEntry` picks (user scope preferred, matching what
2013
+ // actually runs), this is the only place the two can be told apart. Never a
2014
+ // hard failure: a developer's checkout running ahead of an install is the
2015
+ // normal state while iterating, so this only makes the gap visible (warn),
2016
+ // with what the user can actually do about it (wait for the next version
2017
+ // bump, or replace the cache directory by hand knowing the next real update
2018
+ // overwrites it anyway); there is no way to force the copy to happen sooner.
2019
+ //
2020
+ // Reads the row through resolveEnabledPluginEntry rather than resolving a path
2021
+ // and then re-filtering the registry for the row that produced it (an earlier
2022
+ // revision of this function did exactly that, in the opposite order — filter
2023
+ // for a sha first, prefer user scope second — which silently swaps rows
2024
+ // whenever the user row has no gitCommitSha; see resolveEnabledPluginEntry's
2025
+ // own comment in lib/plugin-detect.mjs). One resolver call means one selection
2026
+ // rule and one registry read; there is no second row to disagree with.
2027
+ function checkPluginInstallDrift() {
2028
+ const settingsPath = join(HOME, '.claude', 'settings.json');
2029
+ const registryPath = join(HOME, '.claude', 'plugins', 'installed_plugins.json');
2030
+ const resolved = resolveEnabledPluginEntry(settingsPath, registryPath);
2031
+ if (resolved.reason === 'not-enabled') return; // plugin not enabled, nothing to verify
2032
+
2033
+ if (resolved.reason === 'registry-unreadable') {
2034
+ warn(
2035
+ 'Installed cache vs marketplace HEAD',
2036
+ `Cannot read ${registryPath}, so the installed cache's commit cannot be verified`,
2037
+ );
2038
+ return;
2039
+ }
2040
+ if (resolved.reason === 'unresolved') {
2041
+ warn(
2042
+ 'Installed cache vs marketplace HEAD',
2043
+ `${resolved.key} is enabled but no usable install root could be positively resolved from ` +
2044
+ `${registryPath}, so the installed cache's commit cannot be verified`,
2045
+ );
2046
+ return;
2047
+ }
2048
+ const { key, entry, root: installRoot } = resolved;
2049
+ if (!(typeof entry.gitCommitSha === 'string' && entry.gitCommitSha)) {
2050
+ warn(
2051
+ 'Installed cache vs marketplace HEAD',
2052
+ `The registry row for ${installRoot} carries no gitCommitSha, so the installed cache's ` +
2053
+ `commit cannot be verified. Another row on the same path may have one, but it describes ` +
2054
+ `a different install`,
2055
+ );
2056
+ return;
2057
+ }
2058
+ const installedSha = entry.gitCommitSha;
2059
+
2060
+ // `hypo@<marketplace>` / legacy `hypomnema@<marketplace>` → the marketplace
2061
+ // clone lives at `~/.claude/plugins/marketplaces/<marketplace>/`.
2062
+ const marketplaceName = key.slice(key.indexOf('@') + 1);
2063
+ const marketplaceDir = join(HOME, '.claude', 'plugins', 'marketplaces', marketplaceName);
2064
+ const headResult = spawnSync('git', ['-C', marketplaceDir, 'rev-parse', 'HEAD'], {
2065
+ encoding: 'utf-8',
2066
+ });
2067
+ if (headResult.status !== 0 || !headResult.stdout.trim()) {
2068
+ warn(
2069
+ 'Installed cache vs marketplace HEAD',
2070
+ `Cannot read the marketplace clone's HEAD at ${marketplaceDir}, so the installed cache ` +
2071
+ `cannot be compared to it`,
2072
+ );
2073
+ return;
2074
+ }
2075
+ const marketplaceHead = headResult.stdout.trim();
2076
+
2077
+ if (installedSha === marketplaceHead) {
2078
+ pass(
2079
+ 'Installed cache vs marketplace HEAD',
2080
+ `matches marketplace HEAD (${marketplaceHead.slice(0, 7)})`,
2081
+ );
2082
+ return;
2083
+ }
2084
+
2085
+ // Which way the two shas sit is a question about ancestry, so ask git that
2086
+ // directly instead of inferring it from a count. `A..B` returns 0 both when
2087
+ // the install is ahead and when history is truncated so the walk cannot
2088
+ // reach back, and those two want opposite advice: telling someone to
2089
+ // overwrite a newer cache with an older clone is a downgrade. The
2090
+ // marketplace clone on this machine is shallow (`.git/shallow` present), so
2091
+ // the truncated case is the normal one here, not an edge.
2092
+ const ancestor = (older, newer) =>
2093
+ spawnSync('git', ['-C', marketplaceDir, 'merge-base', '--is-ancestor', older, newer], {
2094
+ encoding: 'utf-8',
2095
+ }).status === 0;
2096
+
2097
+ if (ancestor(marketplaceHead, installedSha)) {
2098
+ warn(
2099
+ 'Installed cache vs marketplace HEAD',
2100
+ `The installed cache (${installRoot}) is running ${installedSha.slice(0, 7)}, which is ` +
2101
+ `ahead of the marketplace clone's HEAD (${marketplaceHead.slice(0, 7)}). That is the ` +
2102
+ `normal state while developing against a local checkout; nothing to do.`,
2103
+ );
2104
+ return;
2105
+ }
2106
+
2107
+ if (!ancestor(installedSha, marketplaceHead)) {
2108
+ // Neither reaches the other. Divergent histories, a force-pushed branch, a
2109
+ // commit that predates a shallow clone's graft point. The shas differ and
2110
+ // that is worth saying, but any "N commits behind" or "replace it by hand"
2111
+ // would be a guess about a direction git just declined to give.
2112
+ warn(
2113
+ 'Installed cache vs marketplace HEAD',
2114
+ `The installed cache (${installRoot}) is running ${installedSha.slice(0, 7)} and the ` +
2115
+ `marketplace clone's HEAD is ${marketplaceHead.slice(0, 7)}. Neither commit reaches the ` +
2116
+ `other in this clone, so which one is newer cannot be determined here (the clone may be ` +
2117
+ `shallow, or the branch force-pushed). Compare them against the upstream repository ` +
2118
+ `before changing anything.`,
2119
+ );
2120
+ return;
2121
+ }
2122
+
2123
+ // Behind, established by ancestry. The count is a nice-to-have on top; a
2124
+ // shallow clone can refuse to produce one and the sentence still holds.
2125
+ const countResult = spawnSync(
2126
+ 'git',
2127
+ ['-C', marketplaceDir, 'rev-list', '--count', `${installedSha}..${marketplaceHead}`],
2128
+ { encoding: 'utf-8' },
2129
+ );
2130
+ const behindCount =
2131
+ countResult.status === 0 && countResult.stdout.trim() !== '' ? countResult.stdout.trim() : null;
2132
+ const behindText = behindCount !== null ? `${behindCount} commit(s) behind` : 'behind';
2133
+ warn(
2134
+ 'Installed cache vs marketplace HEAD',
2135
+ `The installed cache (${installRoot}) is running ${installedSha.slice(0, 7)}, ${behindText} ` +
2136
+ `the marketplace clone's HEAD (${marketplaceHead.slice(0, 7)}). The version string did not ` +
2137
+ `move, so \`/plugin marketplace update ${marketplaceName}\` pulled the marketplace clone ` +
2138
+ `forward but skipped re-copying the cache hooks actually run from. Wait for the next ` +
2139
+ `version bump, or replace ${installRoot} by hand with a copy of ${marketplaceDir} (a real ` +
2140
+ `update will overwrite it anyway).`,
2141
+ );
2142
+ }
2143
+
1842
2144
  // ── provenance sidecar (manual/npm channel) ────────────────────────────────────
1843
2145
  //
1844
2146
  // `.hypo-provenance.json` lives next to a standalone-copied hooks/ dir
@@ -1868,7 +2170,7 @@ function checkProvenanceSidecar(hooksDir, label) {
1868
2170
  label,
1869
2171
  `no provenance sidecar, and this install's hooks cannot self-locate their ` +
1870
2172
  `package — PKG_ROOT resolves to null for every hook running from ${hooksDir}; ` +
1871
- `run \`hypomnema upgrade --apply\` to write one`,
2173
+ `run ${upgradeApplyHint(pluginMode || hypomnemaPluginEnabled)} to write one`,
1872
2174
  );
1873
2175
  }
1874
2176
  return;
@@ -1880,7 +2182,7 @@ function checkProvenanceSidecar(hooksDir, label) {
1880
2182
  } catch {
1881
2183
  warn(
1882
2184
  label,
1883
- `${sidecarPath} is not valid JSON — run \`hypomnema upgrade --apply\` to rewrite it`,
2185
+ `${sidecarPath} is not valid JSON — run ${upgradeApplyHint(pluginMode || hypomnemaPluginEnabled)} to rewrite it`,
1884
2186
  );
1885
2187
  return;
1886
2188
  }
@@ -1934,7 +2236,7 @@ function checkProvenanceSidecar(hooksDir, label) {
1934
2236
  warn(
1935
2237
  label,
1936
2238
  `${sidecarPath} does not verify (${reasons.join('; ')}) — resolvePkgRoot() will treat ` +
1937
- `PKG_ROOT as unresolved this session; run \`hypomnema upgrade --apply\` to refresh it`,
2239
+ `PKG_ROOT as unresolved this session; run ${upgradeApplyHint(pluginMode || hypomnemaPluginEnabled)} to refresh it`,
1938
2240
  );
1939
2241
  }
1940
2242
 
@@ -1968,7 +2270,7 @@ function hooksDigestMismatch(hooksDir, recordedDigest) {
1968
2270
  return (
1969
2271
  `hooksDigest mismatch: ${diverged.length}/${allFiles.length} hook file(s) differ from ` +
1970
2272
  `the current package source${examples ? ` (e.g. ${examples})` : ''} — run ` +
1971
- `\`hypomnema upgrade --apply\``
2273
+ `${upgradeApplyHint(pluginMode || hypomnemaPluginEnabled)}`
1972
2274
  );
1973
2275
  }
1974
2276
 
@@ -1992,9 +2294,25 @@ function checkPkgIntegrity(claudeHome) {
1992
2294
  // no longer exists and this correctly falls into the silent fresh-install branch.
1993
2295
  if (!existsSync(pkgPath)) return;
1994
2296
  if (!meta || !meta.pkgRoot) {
2297
+ // A missing pkgRoot has two distinct causes with different fixes. Re-running
2298
+ // /hypo:init or /hypo:upgrade only helps the ordinary one (a corrupt or
2299
+ // never-written file). When the enabled plugin's channel judgment itself
2300
+ // failed ('registry-unreadable'/'unresolved'), init/upgrade skip the very
2301
+ // same write on every re-run until the registry is fixed first (see
2302
+ // resolveDurableRoot in init.mjs and the dualSkip branch in upgrade.mjs).
2303
+ const channelJudgmentFailed =
2304
+ hypomnemaPluginEnabled &&
2305
+ (pluginChannel.rootReason === 'registry-unreadable' ||
2306
+ pluginChannel.rootReason === 'unresolved');
1995
2307
  warn(
1996
2308
  'hypo-pkg.json pkgRoot',
1997
- `hypo-pkg.json has no pkgRoot field — metadata is incomplete; re-run /hypo:init or /hypo:upgrade`,
2309
+ channelJudgmentFailed
2310
+ ? `hypo-pkg.json has no pkgRoot field because the enabled plugin's channel judgment failed ` +
2311
+ `(${pluginChannel.rootReason}): re-running /hypo:init or /hypo:upgrade will skip the same ` +
2312
+ `write until this resolves. Fix: repair or refresh ~/.claude/plugins/installed_plugins.json ` +
2313
+ '(reinstall the plugin, or run `/plugin marketplace update hypomnema` then `/reload-plugins`), ' +
2314
+ 'then re-run /hypo:init or /hypo:upgrade to record the confirmed root.'
2315
+ : `hypo-pkg.json has no pkgRoot field — metadata is incomplete; re-run /hypo:init or /hypo:upgrade`,
1998
2316
  );
1999
2317
  return;
2000
2318
  }
@@ -2106,6 +2424,8 @@ checkHooks(coreManagedByPlugin);
2106
2424
  checkSettingsJson(coreManagedByPlugin);
2107
2425
  checkPkgIntegrity(args.claudeHome);
2108
2426
  checkStaleSibling();
2427
+ checkPluginLeafVersion();
2428
+ checkPluginInstallDrift();
2109
2429
  if (args.codex) checkCodexPaths();
2110
2430
  if (rootOk) checkExtensions(args.hypoDir, args.claudeHome, 'claude');
2111
2431
  if (rootOk && args.codex) checkExtensions(args.hypoDir, args.claudeHome, 'codex');