hypomnema 1.2.1 → 1.3.1

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 (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +4 -2
  4. package/README.md +4 -2
  5. package/commands/crystallize.md +23 -6
  6. package/commands/feedback.md +1 -1
  7. package/commands/upgrade.md +2 -0
  8. package/docs/CONTRIBUTING.md +96 -11
  9. package/hooks/hypo-auto-commit.mjs +3 -3
  10. package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
  11. package/hooks/hypo-cwd-change.mjs +2 -2
  12. package/hooks/hypo-first-prompt.mjs +1 -1
  13. package/hooks/hypo-personal-check.mjs +57 -7
  14. package/hooks/hypo-session-start.mjs +73 -19
  15. package/hooks/hypo-shared.mjs +206 -16
  16. package/hooks/version-check.mjs +204 -6
  17. package/package.json +5 -2
  18. package/scripts/bump-version.mjs +9 -3
  19. package/scripts/check-bilingual.mjs +115 -0
  20. package/scripts/crystallize.mjs +130 -16
  21. package/scripts/doctor.mjs +45 -9
  22. package/scripts/feedback-sync.mjs +44 -15
  23. package/scripts/feedback.mjs +5 -5
  24. package/scripts/fix-status-verify.mjs +256 -0
  25. package/scripts/init.mjs +45 -4
  26. package/scripts/install-git-hooks.mjs +258 -0
  27. package/scripts/lib/adr-corpus.mjs +79 -0
  28. package/scripts/lib/check-bilingual.mjs +141 -0
  29. package/scripts/lib/extensions.mjs +3 -3
  30. package/scripts/lib/feedback-scope.mjs +21 -0
  31. package/scripts/lib/fix-manifest.mjs +109 -0
  32. package/scripts/lib/fix-status-verify.mjs +438 -0
  33. package/scripts/lib/plugin-detect.mjs +51 -0
  34. package/scripts/lib/pre-commit-format.mjs +251 -0
  35. package/scripts/lib/project-create.mjs +2 -2
  36. package/scripts/lint.mjs +48 -8
  37. package/scripts/pre-commit-format.mjs +198 -0
  38. package/scripts/resume.mjs +61 -3
  39. package/scripts/smoke-pack.mjs +39 -2
  40. package/scripts/upgrade.mjs +308 -58
  41. package/skills/crystallize/SKILL.md +13 -2
  42. package/templates/hypo-config.md +1 -1
  43. package/templates/hypo-guide.md +4 -0
@@ -32,6 +32,24 @@ import { fileURLToPath } from 'node:url';
32
32
  const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..');
33
33
  const KEEP = process.argv.includes('--keep');
34
34
 
35
+ // When this script runs inside `npm publish --dry-run` (the release workflow's
36
+ // publish-credential pre-check), npm exports `npm_config_dry_run=true` into the
37
+ // lifecycle environment. spawnSync inherits process.env, so the nested
38
+ // `npm pack` below would ALSO run in dry-run mode — reporting a tarball
39
+ // filename/size it never actually writes to disk — and the subsequent
40
+ // `npm install <tarball>` then fails with ENOENT (npm maps errno -2 → exit 254).
41
+ // Strip the flag so the nested npm commands always perform real writes,
42
+ // matching the real tag-push publish job (which has no outer `--dry-run`). The
43
+ // outer workflow stays dry-run and still skips the registry PUT.
44
+ // (This was the precheck-254 root cause; the earlier "missing NODE_AUTH_TOKEN"
45
+ // theory was wrong — it is file absence, not auth.)
46
+ const npmEnv = { ...process.env };
47
+ for (const key of Object.keys(npmEnv)) {
48
+ if (key.toLowerCase().replaceAll('-', '_') === 'npm_config_dry_run') {
49
+ delete npmEnv[key];
50
+ }
51
+ }
52
+
35
53
  const PKG = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf-8'));
36
54
  const PKG_NAME = PKG.name;
37
55
 
@@ -56,8 +74,15 @@ const wikiDir = join(work, 'wiki');
56
74
  let cleanupOk = false;
57
75
 
58
76
  try {
77
+ // Capture pre-commit hook contents (if any) BEFORE pack so we can prove
78
+ // `npm pack` didn't mutate it. The `prepare` lifecycle script runs during
79
+ // `npm pack` and could theoretically touch .git/hooks/pre-commit; the
80
+ // installer's CI/lifecycle guards must prevent that.
81
+ const preCommitPath = join(REPO, '.git', 'hooks', 'pre-commit');
82
+ const preCommitBefore = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : null;
83
+
59
84
  step('npm pack');
60
- const pack = run('npm', ['pack', '--json'], { cwd: REPO });
85
+ const pack = run('npm', ['pack', '--json'], { cwd: REPO, env: npmEnv });
61
86
  const meta = JSON.parse(pack.stdout)[0];
62
87
  const tarball = join(REPO, meta.filename);
63
88
  console.log(` → ${meta.filename} (${meta.size} bytes, ${meta.entryCount} entries)`);
@@ -72,7 +97,10 @@ try {
72
97
  private: true,
73
98
  }) + '\n',
74
99
  );
75
- run('npm', ['install', '--no-audit', '--no-fund', '--silent', tarball], { cwd: installRoot });
100
+ // No `--silent`: run() only prints npm's stdout/stderr on a non-zero exit, so
101
+ // a real nested-install failure must not be muted (the precheck-254 error was
102
+ // masked by `--silent` for two release cycles).
103
+ run('npm', ['install', '--no-audit', '--no-fund', tarball], { cwd: installRoot, env: npmEnv });
76
104
 
77
105
  // Move tarball into the work dir so it's not left in the repo.
78
106
  renameSync(tarball, join(work, meta.filename));
@@ -203,6 +231,15 @@ try {
203
231
  );
204
232
  }
205
233
 
234
+ step('verify pre-commit hook was not mutated by npm pack');
235
+ const preCommitAfter = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : null;
236
+ if (preCommitBefore !== preCommitAfter) {
237
+ throw new Error(
238
+ 'npm pack mutated .git/hooks/pre-commit — the prepare lifecycle ' +
239
+ 'guard (npm_command=pack) is not firing.',
240
+ );
241
+ }
242
+
206
243
  console.log('\n✓ smoke-pack passed');
207
244
  cleanupOk = true;
208
245
  } catch (err) {
@@ -19,6 +19,11 @@
19
19
  * wiki-*.mjs → hypo-*.mjs rename migration (fix #48), and
20
20
  * the user-extensions companion sync (E4 / fix #32).
21
21
  * --json Output results as JSON
22
+ * --allow-downgrade Override the guard that refuses to overwrite a NEWER
23
+ * active install with an older package (ADR 0038)
24
+ * --allow-dual-install Override the ISSUE-8 guard: register the Claude core
25
+ * surface even though the Hypomnema plugin is also enabled
26
+ * (knowingly accept the double-registration risk)
22
27
  */
23
28
 
24
29
  import {
@@ -45,6 +50,8 @@ import {
45
50
  readFileIfRegular,
46
51
  } from './lib/pkg-json.mjs';
47
52
  import { syncExtensions } from './lib/extensions.mjs';
53
+ import { isHypomnemaPluginEnabled } from './lib/plugin-detect.mjs';
54
+ import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
48
55
 
49
56
  const HOME = homedir();
50
57
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -76,6 +83,8 @@ function parseArgs(argv) {
76
83
  forceCommands: false,
77
84
  forceExtensions: false,
78
85
  codex: false,
86
+ allowDowngrade: false,
87
+ allowDualInstall: false,
79
88
  };
80
89
  for (const arg of argv.slice(2)) {
81
90
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
@@ -84,6 +93,8 @@ function parseArgs(argv) {
84
93
  else if (arg === '--force-extensions') args.forceExtensions = true;
85
94
  else if (arg === '--codex') args.codex = true;
86
95
  else if (arg === '--json') args.json = true;
96
+ else if (arg === '--allow-downgrade') args.allowDowngrade = true;
97
+ else if (arg === '--allow-dual-install') args.allowDualInstall = true;
87
98
  }
88
99
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
89
100
  return args;
@@ -312,7 +323,7 @@ function applySettingsJson(settingsResults, settingsPath) {
312
323
  for (const s of settingsResults) {
313
324
  if (s.status !== 'missing') continue;
314
325
  if (!Array.isArray(settings.hooks[s.event])) settings.hooks[s.event] = [];
315
- // fix #48 BLOCKER: re-check the current parsed settings before appending.
326
+ // re-check the current parsed settings before appending.
316
327
  // applyHookNameMigration may have rewritten a legacy wiki-*.mjs command to
317
328
  // exactly `s.cmd` between checkSettingsJson and now — appending without
318
329
  // this guard creates a duplicate registration (codex 2-worker review
@@ -453,7 +464,7 @@ function applyHypoignoreMigration(result) {
453
464
  return appended;
454
465
  }
455
466
 
456
- function writeMigrationReport(hypoDir, fromVersion, toVersion) {
467
+ function writeMigrationReport(hypoDir, fromVersion, toVersion, { pluginMode = false } = {}) {
457
468
  const today = new Date().toISOString().slice(0, 10);
458
469
  const filename = `MIGRATION-v${toVersion}.md`;
459
470
  const dest = join(hypoDir, filename);
@@ -521,15 +532,16 @@ checklist below is manual:
521
532
  - [ ] **Re-run \`hypomnema lint\` after backfilling — confirm 0 feedback errors
522
533
  remain (including the conditional \`claude-learned\` fields above)**
523
534
 
524
- ## Caveat — \`scope: project:<project-id>\` and slug regex
535
+ ## Note — \`scope: project:<project-id>\` and the scope regex
525
536
 
526
- The lint regex \`^project:[a-z0-9][a-z0-9-]*$\` accepts only short slugs, but
527
- \`feedback-sync\`'s default resolved project-id is cwd-derived (e.g.
528
- \`-Users-you-Workspace-Project\`), which the regex rejects. To use a
529
- \`scope: project:*\` page in v1.2.0 you must override with
530
- \`--project-id=<slug>\` so the resolved id is slug-safe. The full resolved-id
531
- wiki-slug reconciliation is deferred to v1.3.0; \`commands/feedback.md\`
532
- documents the interim pattern.
537
+ As of v1.3.0 the feedback scope regex \`^(global|project:[A-Za-z0-9_-]+)\$\`
538
+ accepts cwd-derived project-ids directly (e.g.
539
+ \`-Users-you-Workspace-Project\`), so a \`scope: project:*\` page no longer needs
540
+ a \`--project-id=<slug>\` override just to pass \`lint\`. The resolved id must
541
+ still exact-match \`feedback-sync\`'s project-id for projection (default: cwd
542
+ with \`/\` and \`.\` replaced by \`-\`). Known limit: a cwd containing spaces or
543
+ other characters outside \`[A-Za-z0-9_-]\` still derives an id the regex
544
+ rejects — pass \`--project-id=<id>\` for those.
533
545
  `
534
546
  : `## What changed
535
547
 
@@ -538,9 +550,15 @@ Review the SCHEMA diff and update your wiki pages accordingly.
538
550
 
539
551
  ## Action items
540
552
 
541
- This report was generated during \`/hypo:upgrade --apply\`. Hook files and settings.json
542
- entries were applied by that run (or skipped with a warning if the target was malformed —
543
- see the upgrade output). \`SCHEMA.md\` is intentionally **not** overwritten by upgrade the
553
+ This report was generated during \`/hypo:upgrade --apply\`. ${
554
+ pluginMode
555
+ ? 'You are on a **plugin install**, so the core hook files and settings.json hook ' +
556
+ 'registrations were NOT touched — the Claude Code plugin loader owns them (upgrade the ' +
557
+ 'plugin via `/plugin marketplace update hypomnema` then `/reload-plugins`). Vault ' +
558
+ 'extensions, if any, were still synced.'
559
+ : 'Hook files and settings.json entries were applied by that run (or skipped with a ' +
560
+ 'warning if the target was malformed — see the upgrade output).'
561
+ } \`SCHEMA.md\` is intentionally **not** overwritten by upgrade — the
544
562
  remaining steps are manual:
545
563
 
546
564
  - [ ] Compare your \`SCHEMA.md\` (v${fromVersion}) with the package template (v${toVersion}) and merge changes manually
@@ -743,6 +761,31 @@ function applyCommands(commandResults, force) {
743
761
  return applied;
744
762
  }
745
763
 
764
+ // ISSUE-6: in plugin mode `applyCommands` is skipped (no command copy), but the
765
+ // runtime still needs hypo-pkg.json to resolve PKG_ROOT for lint/feedback scripts
766
+ // (hooks/hypo-shared.mjs → hypo-personal-check). Write minimal metadata pointing
767
+ // at the plugin's package root, preserving any existing fields (e.g. `extensions`)
768
+ // but DROPPING any prior `commands` map (no commands were copied, so a stale map
769
+ // would falsely assert ownership of ~/.claude/commands/hypo).
770
+ function writePluginModeMetadata() {
771
+ const path = pkgJsonPath();
772
+ // Drop any prior top-level `commands` SHA map: no commands were copied in plugin
773
+ // mode, so keeping a manual install's map would falsely assert ownership of
774
+ // ~/.claude/commands/hypo. Preserve every other field (e.g. `extensions`).
775
+ const { commands: _droppedCommands, ...existing } = readPkgJsonSafe(path) || {};
776
+ let pkgVersion = null;
777
+ try {
778
+ pkgVersion = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
779
+ } catch {}
780
+ writePkgJsonAtomic(path, {
781
+ ...existing,
782
+ pkgRoot: PKG_ROOT,
783
+ pkgVersion,
784
+ schemaVersion: '2.0',
785
+ });
786
+ return true;
787
+ }
788
+
746
789
  // ── main ─────────────────────────────────────────────────────────────────────
747
790
 
748
791
  const args = parseArgs(process.argv);
@@ -754,6 +797,43 @@ const claudeSettingsPath = join(HOME, '.claude', 'settings.json');
754
797
  const codexHooksDir = join(HOME, '.codex', 'hooks');
755
798
  const codexSettingsPath = join(HOME, '.codex', 'settings.json');
756
799
 
800
+ // ISSUE-6: when `/hypo:upgrade` runs as the Claude Code PLUGIN, the 15 core hooks
801
+ // and 14 slash commands are provided by the plugin's hooks.json + commands/
802
+ // (auto-wired by Claude Code), NOT copied into ~/.claude/. The manual/npm health
803
+ // check below would then report all of them "missing" and recommend `--apply`,
804
+ // which copies the hooks into ~/.claude/hooks/ and registers 14 settings.json
805
+ // events → Claude Code runs BOTH the plugin hooks.json AND user settings.json, so
806
+ // every hook fires TWICE. The decisive signal: the plugin command runs the
807
+ // PLUGIN's upgrade.mjs, so PKG_ROOT lives under ~/.claude/plugins/. (A manual/npm
808
+ // upgrade.mjs run while the plugin is ALSO enabled is a different failure mode —
809
+ // dual install — tracked as ISSUE-8.)
810
+ // Match the Claude plugin cache shape specifically (`~/.claude/plugins/…`), NOT a
811
+ // generic `/plugins/` substring — this flag now GATES install behavior, so a
812
+ // legitimate npm/dev checkout under some unrelated `…/plugins/…` path must not be
813
+ // misclassified and silently stop managing its hooks. (detectChannel's broad
814
+ // `/plugins/` test is fine for the notifier's display-only use, but too loose here.)
815
+ const pluginMode = PKG_ROOT.replace(/\\/g, '/').includes('/.claude/plugins/');
816
+ // ISSUE-8 (dual install): the OTHER way the same double-registration can happen.
817
+ // Here the MANUAL/npm upgrade.mjs is running (pluginMode=false), but the Hypomnema
818
+ // plugin is ALSO enabled in ~/.claude/settings.json — so the plugin loader already
819
+ // provides the core hooks/commands/settings. A manual/npm `--apply` would copy and
820
+ // register them on top, and every core hook fires twice. The detector is fail-open
821
+ // (see lib/plugin-detect.mjs): a false positive would wrongly alter a legitimate
822
+ // npm-only user's upgrade, so it only fires on an exact `hypomnema@<mp>: true`.
823
+ const hypomnemaPluginEnabled = !pluginMode && isHypomnemaPluginEnabled(claudeSettingsPath);
824
+ const dualInstallCoreConflict = hypomnemaPluginEnabled;
825
+ // Surface policy: the Claude core surface (hooks/settings/commands/hook-name
826
+ // migration) is skipped when EITHER the plugin runs this script (pluginMode) OR a
827
+ // manual/npm run detects the plugin is enabled (dualInstallCoreConflict) — unless
828
+ // the user knowingly overrides with --allow-dual-install. The codex core surface
829
+ // (--codex) and vault-defined extensions are NOT plugin-provided, so they stay
830
+ // managed in every case.
831
+ const managesClaudeCore = !pluginMode && (!dualInstallCoreConflict || args.allowDualInstall);
832
+ // dualSkip = core was skipped specifically because of the dual install (not a true
833
+ // plugin-mode run, and not overridden). Drives the warning banner and the metadata
834
+ // preservation below.
835
+ const dualSkip = dualInstallCoreConflict && !args.allowDualInstall;
836
+
757
837
  const schema = checkSchemaVersion(args.hypoDir);
758
838
  const hooks = checkHookFiles(claudeHooksDir);
759
839
  const settings = checkSettingsJson(claudeSettingsPath, claudeHooksDir);
@@ -762,7 +842,7 @@ const commands = checkCommands();
762
842
  const oldHookRefs = checkOldHookNames(claudeSettingsPath);
763
843
  const hypoignore = checkHypoignore(args.hypoDir);
764
844
 
765
- // fix #48: when --codex is set, mirror the same core-hook checks against ~/.codex/
845
+ // when --codex is set, mirror the same core-hook checks against ~/.codex/
766
846
  // so `hypomnema upgrade --codex` reports drift symmetrically and `--apply --codex`
767
847
  // updates both targets in one pass (matching init.mjs behaviour).
768
848
  const hooksCodex = args.codex ? checkHookFiles(codexHooksDir) : null;
@@ -784,7 +864,7 @@ const extCheck = syncExtensions({
784
864
  force: args.forceExtensions,
785
865
  });
786
866
 
787
- // E4 (#32): --codex mirrors the extensions sync into ~/.codex (hooks + commands
867
+ // E4 (fix #32): --codex mirrors the extensions sync into ~/.codex (hooks + commands
788
868
  // only; skills/agents skipped with a notice). The per-target SHA map lives in the
789
869
  // same ~/.claude/hypo-pkg.json under extensions.codex, so pkgPath is unchanged.
790
870
  const extCodexSettingsPath = codexSettingsPath;
@@ -817,7 +897,14 @@ const invalidSettingsCodex = settingsCodex
817
897
  ? settingsCodex.some((s) => s.status === 'invalid-json')
818
898
  : false;
819
899
  const schemaDrift = schema.bump !== 'none' && schema.bump !== 'unknown' && schema.bump !== 'ahead';
820
- const pkgJsonDrift = pkgJson.status !== 'up-to-date';
900
+ // ISSUE-8 dual-install: when core is skipped, hypo-pkg.json is deliberately left
901
+ // pointing at the PLUGIN's package root (preserved identity), so checkPkgJson()
902
+ // reports it 'stale' relative to this npm/manual PKG_ROOT. That mismatch is
903
+ // INTENTIONAL — `--apply` will not (and must not) rewrite it — so it must not
904
+ // count as actionable drift, or the user would be nagged to run --apply forever.
905
+ // A genuinely missing/corrupt file (status 'missing') is still surfaced (warning
906
+ // below), because the runtime then cannot resolve its package root at all.
907
+ const pkgJsonDrift = pkgJson.status !== 'up-to-date' && !(dualSkip && pkgJson.status === 'stale');
821
908
  const staleCommands = commands.filter((c) => c.status === 'stale' || c.status === 'missing');
822
909
  const userModifiedCommands = commands.filter((c) => c.status === 'user-modified');
823
910
  const orphanedCommands = commands.filter((c) => c.status === 'orphaned');
@@ -839,23 +926,87 @@ let appliedExtensions = null;
839
926
  let appliedExtensionsCodex = null;
840
927
 
841
928
  if (args.apply) {
842
- if (oldHookRefs.length > 0) {
843
- appliedHookNameRenames = applyHookNameMigration(
844
- oldHookRefs,
845
- claudeSettingsPath,
846
- claudeHooksDir,
847
- );
929
+ // Downgrade guard (ADR 0038, P): an `--apply` from an OLDER package than the
930
+ // active install would overwrite newer hooks (upgrade.mjs:287 copyFileSync) and
931
+ // rewrite hypo-pkg.json to the older version. Refuse before the first mutation.
932
+ // A dev workspace re-running its own --apply (incl. the post-commit sync hook)
933
+ // is exempt via realpath'd pkgRoot equality. Exit 2 = refused downgrade.
934
+ if (!args.allowDowngrade) {
935
+ const _active = readPkgJsonSafe(pkgJsonPath());
936
+ let _incomingVersion = null;
937
+ try {
938
+ _incomingVersion = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
939
+ } catch {
940
+ /* unreadable own package.json — cannot prove a downgrade, allow */
941
+ }
942
+ if (
943
+ _active &&
944
+ _active.pkgVersion &&
945
+ _incomingVersion &&
946
+ classifyInstall(
947
+ { pkgRoot: PKG_ROOT, version: _incomingVersion },
948
+ { pkgRoot: _active.pkgRoot, version: _active.pkgVersion },
949
+ ) === 'downgrade'
950
+ ) {
951
+ console.error(downgradeGuardMessage(_incomingVersion, _active.pkgVersion, 'upgrade --apply'));
952
+ process.exit(2);
953
+ }
848
954
  }
955
+ // Migration report is vault-side (writes into the Hypomnema root) and applies
956
+ // in both install models.
849
957
  if (schema.bump === 'major' && schema.installed && schema.current && existsSync(args.hypoDir)) {
850
- migrationPath = writeMigrationReport(args.hypoDir, schema.installed, schema.current);
958
+ migrationPath = writeMigrationReport(args.hypoDir, schema.installed, schema.current, {
959
+ // Use the core-skipped predicate, not raw pluginMode: in a dual-install skip
960
+ // the core surface is plugin-owned too, so the report must not claim the
961
+ // core hooks/settings were applied (ISSUE-8, W2 review note).
962
+ pluginMode: !managesClaudeCore,
963
+ });
964
+ }
965
+ if (managesClaudeCore) {
966
+ if (oldHookRefs.length > 0) {
967
+ appliedHookNameRenames = applyHookNameMigration(
968
+ oldHookRefs,
969
+ claudeSettingsPath,
970
+ claudeHooksDir,
971
+ );
972
+ }
973
+ appliedHooks = applyHookFiles(hooks, claudeHooksDir);
974
+ appliedSettings = applySettingsJson(settings, claudeSettingsPath);
975
+ // applyCommands handles the single atomic hypo-pkg.json write (pkgRoot, version, schema, commands map)
976
+ appliedCommands = applyCommands(commands, args.forceCommands);
977
+ appliedPkgJson = true;
978
+ } else if (pluginMode) {
979
+ // ISSUE-6 plugin mode: the plugin loader owns the core hooks/commands and
980
+ // settings.json wiring — copying them here would double-register. Skip those,
981
+ // but STILL write minimal package metadata so the runtime can resolve PKG_ROOT
982
+ // for lint/feedback scripts (hooks/hypo-shared.mjs → hypo-personal-check). The
983
+ // commands SHA map is intentionally omitted (no command copy happened). PKG_ROOT
984
+ // is the plugin's own path here, so this metadata is authoritative.
985
+ appliedPkgJson = writePluginModeMetadata();
986
+ } else {
987
+ // ISSUE-8 dual-install skip: a manual/npm run while the plugin is enabled. We
988
+ // skip the core surface (the plugin owns it), but — unlike true plugin mode —
989
+ // PKG_ROOT here is the npm/manual path while the ACTIVE runtime hooks are the
990
+ // PLUGIN's. Rewriting a VALID hypo-pkg.json.pkgRoot to this npm path would
991
+ // mis-point the plugin runtime's lint/feedback resolution, so we PRESERVE an
992
+ // existing plugin-written identity (pkgJson.status 'stale'/'up-to-date' both
993
+ // mean a usable pkgRoot is already on disk) and do not touch it.
994
+ //
995
+ // If the metadata is MISSING or corrupt (status 'missing'; corrupt files are
996
+ // renamed to *.corrupt-*.json by readPkgJson and then read as absent), there is
997
+ // no plugin identity to preserve. Write minimal fallback metadata pointing at
998
+ // this (same-version) npm copy so the plugin runtime can resolve a package root
999
+ // at all — strictly better than the pkgRoot-less file extension sync would
1000
+ // otherwise create, or no file at all. The dual-install banner still tells the
1001
+ // user to resolve the dual install.
1002
+ if (pkgJson.status === 'missing') {
1003
+ appliedPkgJson = writePluginModeMetadata();
1004
+ } else {
1005
+ appliedPkgJson = false;
1006
+ }
851
1007
  }
852
- appliedHooks = applyHookFiles(hooks, claudeHooksDir);
853
- appliedSettings = applySettingsJson(settings, claudeSettingsPath);
854
- // applyCommands handles the single atomic hypo-pkg.json write (pkgRoot, version, schema, commands map)
855
- appliedCommands = applyCommands(commands, args.forceCommands);
856
- appliedPkgJson = true;
857
1008
  appliedHypoignore = applyHypoignoreMigration(hypoignore);
858
- // fix #48: codex core hooks + settings + wiki-*→hypo-* rename mirror. Same order
1009
+ // codex core hooks + settings + wiki-*→hypo-* rename mirror. Same order
859
1010
  // as the claude side (rename first so subsequent hook copy can find renamed targets).
860
1011
  if (args.codex) {
861
1012
  if (oldHookRefsCodex.length > 0) {
@@ -878,7 +1029,7 @@ if (args.apply) {
878
1029
  apply: true,
879
1030
  force: args.forceExtensions,
880
1031
  });
881
- // E4 (#32): codex apply runs AFTER the claude apply so it reads the freshly
1032
+ // E4 (fix #32): codex apply runs AFTER the claude apply so it reads the freshly
882
1033
  // written hypo-pkg.json and merges extensions.codex alongside extensions.claude
883
1034
  // (the per-target spread in syncExtensions preserves the other target's map).
884
1035
  if (args.codex) {
@@ -898,7 +1049,7 @@ if (args.apply) {
898
1049
 
899
1050
  const extDrift = extCheck.needsWork || (extCheckCodex?.needsWork ?? false);
900
1051
 
901
- // fix #48: codex drift only counts when --codex is set — without the flag the codex
1052
+ // codex drift only counts when --codex is set — without the flag the codex
902
1053
  // target is intentionally unobserved (parity with the existing extensions pattern).
903
1054
  const codexCoreDrift =
904
1055
  args.codex &&
@@ -907,17 +1058,28 @@ const codexCoreDrift =
907
1058
  invalidSettingsCodex ||
908
1059
  (oldHookRefsCodex?.length ?? 0) > 0);
909
1060
 
910
- const hasDrift =
1061
+ // Claude core-surface drift (hooks/settings/commands/rename/metadata). In plugin
1062
+ // mode these are plugin-managed, so they must NOT count as drift — otherwise the
1063
+ // report nags "N items need updating" and recommends a double-registering --apply.
1064
+ // Plugin-provided surface (hooks/settings/commands/rename) — excluded from drift
1065
+ // in plugin mode. pkgJsonDrift is intentionally NOT here: hypo-pkg.json is written
1066
+ // in BOTH install models (plugin mode writes minimal metadata so the runtime can
1067
+ // resolve PKG_ROOT for lint/feedback), so a missing/stale metadata file should
1068
+ // still prompt a (safe, metadata-only) --apply.
1069
+ const claudeCoreDrift =
911
1070
  staleHooks.length > 0 ||
912
1071
  missingSettings.length > 0 ||
913
- schemaDrift ||
914
1072
  invalidSettings ||
915
- pkgJsonDrift ||
916
1073
  oldHookRefs.length > 0 ||
917
1074
  staleCommands.length > 0 ||
918
1075
  userModifiedCommands.length > 0 ||
919
1076
  orphanedCommands.length > 0 ||
920
- nonRegularCommands.length > 0 ||
1077
+ nonRegularCommands.length > 0;
1078
+
1079
+ const hasDrift =
1080
+ (managesClaudeCore && claudeCoreDrift) ||
1081
+ pkgJsonDrift ||
1082
+ schemaDrift ||
921
1083
  hypoignore.status === 'needs-migration' ||
922
1084
  extDrift ||
923
1085
  codexCoreDrift;
@@ -926,6 +1088,12 @@ if (args.json) {
926
1088
  console.log(
927
1089
  JSON.stringify(
928
1090
  {
1091
+ pluginMode,
1092
+ // ISSUE-8 dual-install signals.
1093
+ hypomnemaPluginEnabled,
1094
+ dualInstallCoreConflict,
1095
+ coreManagedBy: managesClaudeCore ? 'self' : pluginMode ? 'plugin' : 'plugin-enabled',
1096
+ dualInstallOverride: args.allowDualInstall,
929
1097
  schema,
930
1098
  hooks,
931
1099
  settings,
@@ -935,7 +1103,7 @@ if (args.json) {
935
1103
  hypoignore,
936
1104
  extensions: extCheck,
937
1105
  extensionsCodex: extCheckCodex,
938
- // fix #48: codex core mirror (null when --codex absent).
1106
+ // codex core mirror (null when --codex absent).
939
1107
  hooksCodex,
940
1108
  settingsCodex,
941
1109
  oldHookRefsCodex,
@@ -970,6 +1138,49 @@ if (args.json) {
970
1138
  // Human-readable report
971
1139
  const lines = [];
972
1140
 
1141
+ // ISSUE-6: lead with the plugin-mode banner so the user understands why the core
1142
+ // hook/command/settings sections read "managed by plugin" and that `--apply` will
1143
+ // NOT touch them (only vault-side migrations + package metadata).
1144
+ if (pluginMode) {
1145
+ lines.push(
1146
+ 'ℹ Plugin install detected — Hypomnema is loaded via the Claude Code plugin.',
1147
+ ' Core hooks, slash commands, and settings.json wiring are provided by the',
1148
+ ' plugin loader, so `/hypo:upgrade` does NOT manage them (and `--apply` will',
1149
+ ' not copy/register them — that would double-register every hook).',
1150
+ ' → To upgrade the plugin: `/plugin marketplace update hypomnema` then `/reload-plugins`.',
1151
+ ' → `/hypo:upgrade --apply` here applies vault-side migrations (SCHEMA,',
1152
+ ' .hypoignore), refreshes package metadata, and still syncs any vault',
1153
+ ' extensions — but does NOT install the core hooks/commands/settings.',
1154
+ '',
1155
+ );
1156
+ }
1157
+
1158
+ // ISSUE-8: a manual/npm upgrade.mjs is running while the Hypomnema plugin is ALSO
1159
+ // enabled — a dual install. Lead with a loud banner: the core surface is owned by
1160
+ // the plugin and is intentionally skipped, so `--apply` will not double-register.
1161
+ if (dualSkip) {
1162
+ lines.push(
1163
+ '⚠ Dual install detected — you are running the MANUAL/npm `upgrade.mjs`, but the',
1164
+ ' Hypomnema plugin is ALSO enabled in ~/.claude/settings.json. The plugin loader',
1165
+ ' already provides the core hooks, slash commands, and settings.json wiring, so',
1166
+ ' this run does NOT copy/register them (doing so would double-register every hook).',
1167
+ ' → Recommended: pick ONE install. To keep the plugin, remove the npm/manual copy',
1168
+ ' (`npm uninstall -g hypomnema`) and upgrade via `/plugin marketplace update',
1169
+ ' hypomnema` + `/reload-plugins`. Vault extensions + codex (if any) are still synced.',
1170
+ ' → To register the core surface here anyway (knowingly accept the double-register',
1171
+ ' risk), re-run with `--allow-dual-install`.',
1172
+ '',
1173
+ );
1174
+ } else if (dualInstallCoreConflict && args.allowDualInstall) {
1175
+ // Override path: the user forced core registration despite the enabled plugin.
1176
+ lines.push(
1177
+ '⚠ Dual install — `--allow-dual-install` set: registering the Claude core surface',
1178
+ ' even though the Hypomnema plugin is enabled. Every core hook may now fire TWICE',
1179
+ ' (plugin loader + ~/.claude registration) until one install is removed.',
1180
+ '',
1181
+ );
1182
+ }
1183
+
973
1184
  // Schema version
974
1185
  if (schema.bump === 'none') {
975
1186
  lines.push(`✓ SCHEMA version ${schema.installed} (up to date)`);
@@ -1015,7 +1226,13 @@ function pushHookSummary(hookList, label, targetPath) {
1015
1226
  }
1016
1227
  }
1017
1228
  }
1018
- pushHookSummary(hooks, '', '~/.claude/hooks/');
1229
+ if (managesClaudeCore) {
1230
+ pushHookSummary(hooks, '', '~/.claude/hooks/');
1231
+ } else {
1232
+ lines.push(
1233
+ '✓ Hook files provided by the plugin loader (not managed in ~/.claude/hooks/)',
1234
+ );
1235
+ }
1019
1236
  if (hooksCodex) pushHookSummary(hooksCodex, ' (codex)', '~/.codex/hooks/');
1020
1237
 
1021
1238
  // settings.json registrations (target-aware mirror; fix #48).
@@ -1034,11 +1251,33 @@ function pushSettingsSummary(sList, label, invalidFlag) {
1034
1251
  }
1035
1252
  }
1036
1253
  }
1037
- pushSettingsSummary(settings, '', invalidSettings);
1254
+ if (managesClaudeCore) {
1255
+ pushSettingsSummary(settings, '', invalidSettings);
1256
+ } else {
1257
+ lines.push(
1258
+ '✓ settings.json hook wiring provided by the plugin (no ~/.claude registration)',
1259
+ );
1260
+ }
1038
1261
  if (settingsCodex) pushSettingsSummary(settingsCodex, ' (codex)', invalidSettingsCodex);
1039
1262
 
1040
1263
  // Package metadata
1041
- if (pkgJson.status === 'up-to-date') {
1264
+ if (dualSkip && pkgJson.status === 'stale') {
1265
+ // ISSUE-8: the 'stale' here is the preserved plugin identity (pkgRoot points at
1266
+ // the plugin, not this npm/manual copy). That is intentional — not actionable.
1267
+ lines.push(
1268
+ `✓ Package metadata hypo-pkg.json plugin-owned (preserved — not rewritten in a dual install)`,
1269
+ );
1270
+ } else if (dualSkip && pkgJson.status === 'missing') {
1271
+ // Missing/corrupt in a dual install: there is no plugin identity to preserve, so
1272
+ // `--apply` writes minimal fallback metadata (pointing at this same-version npm
1273
+ // copy) — enough for the plugin runtime to resolve its scripts. The real fix is
1274
+ // still to resolve the dual install.
1275
+ lines.push(
1276
+ `⚠ Package metadata hypo-pkg.json missing/unreadable — \`--apply\` writes fallback metadata`,
1277
+ ` for this npm copy so the plugin runtime can resolve its scripts.`,
1278
+ ` Better: resolve the dual install (remove the npm/manual copy).`,
1279
+ );
1280
+ } else if (pkgJson.status === 'up-to-date') {
1042
1281
  lines.push(`✓ Package metadata hypo-pkg.json up to date`);
1043
1282
  } else if (pkgJson.status === 'stale') {
1044
1283
  lines.push(
@@ -1055,7 +1294,9 @@ const cmdMissCount = commands.filter((c) => c.status === 'missing').length;
1055
1294
  const cmdUserCount = userModifiedCommands.length;
1056
1295
  const cmdOrphanCount = orphanedCommands.length;
1057
1296
  const cmdNonRegCount = nonRegularCommands.length;
1058
- if (commands.length === 0) {
1297
+ if (!managesClaudeCore) {
1298
+ lines.push('✓ Slash commands provided by the plugin loader (not ~/.claude/commands/hypo/)');
1299
+ } else if (commands.length === 0) {
1059
1300
  lines.push(`⚠ Slash commands package commands/ is empty`);
1060
1301
  } else if (
1061
1302
  cmdStaleCount === 0 &&
@@ -1090,7 +1331,7 @@ if (commands.length === 0) {
1090
1331
  }
1091
1332
 
1092
1333
  // Old hook names (wiki-*.mjs → hypo-*.mjs rename migration). Target-aware so
1093
- // fix #48 surfaces codex settings.json that still references the v1.0/v1.1 names.
1334
+ // codex settings.json entries that still reference the v1.0/v1.1 names are surfaced.
1094
1335
  function pushHookNameSummary(refs, label) {
1095
1336
  if (refs.length > 0) {
1096
1337
  lines.push(
@@ -1102,7 +1343,10 @@ function pushHookNameSummary(refs, label) {
1102
1343
  lines.push(`✓ ${colN}All hook references use current hypo-*.mjs names`);
1103
1344
  }
1104
1345
  }
1105
- pushHookNameSummary(oldHookRefs, '');
1346
+ // In plugin mode the Claude settings.json is plugin-owned and --apply skips the
1347
+ // rename migration, so do not print a "run --apply to rename" instruction it will
1348
+ // not honor. (codex hook-name migration is unaffected by pluginMode.)
1349
+ if (managesClaudeCore) pushHookNameSummary(oldHookRefs, '');
1106
1350
  if (oldHookRefsCodex) pushHookNameSummary(oldHookRefsCodex, ' (codex)');
1107
1351
 
1108
1352
  // .hypoignore migration (ensure required runtime patterns are present)
@@ -1142,7 +1386,7 @@ function pushExtSummary(check, label) {
1142
1386
  for (const c of check.conflicts) lines.push(` ✗ ${c.file} [${c.action} — left untouched]`);
1143
1387
  for (const d of check.drifts) lines.push(` ⚠ ${d.file} [drift — left untouched]`);
1144
1388
  }
1145
- // E3 (#31): a hard conflict blocks install (exit 1, even under --apply); drift is
1389
+ // E3 (fix #31): a hard conflict blocks install (exit 1, even under --apply); drift is
1146
1390
  // resolvable advisory. Emit the spec'd WIKI messages so the user knows the recovery.
1147
1391
  if (nConflicts > 0) {
1148
1392
  lines.push(' [WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
@@ -1200,7 +1444,7 @@ if (
1200
1444
  lines.push(`✓ Appended .hypoignore entries (${appliedHypoignore.length}):`);
1201
1445
  for (const e of appliedHypoignore) lines.push(` → ${e}`);
1202
1446
  }
1203
- // fix #48: codex-target applied actions (mirrors claude blocks above).
1447
+ // codex-target applied actions (mirrors claude blocks above).
1204
1448
  if (appliedHookNameRenamesCodex.length > 0) {
1205
1449
  lines.push(`✓ Renamed legacy hook references (codex) (${appliedHookNameRenamesCodex.length}):`);
1206
1450
  for (const r of appliedHookNameRenamesCodex) lines.push(` → ${r}`);
@@ -1237,26 +1481,32 @@ pushAppliedExt(appliedExtensionsCodex, ' (codex)');
1237
1481
 
1238
1482
  // Summary
1239
1483
  lines.push('');
1484
+ // Claude core-surface item count — zeroed in plugin mode (plugin-managed), so the
1485
+ // summary never reads "N items need updating" for hooks/settings/commands.
1486
+ const claudeCoreCount = managesClaudeCore
1487
+ ? staleHooks.length +
1488
+ missingSettings.length +
1489
+ (invalidSettings ? 1 : 0) +
1490
+ oldHookRefs.length +
1491
+ staleCommands.length +
1492
+ userModifiedCommands.length +
1493
+ orphanedCommands.length +
1494
+ nonRegularCommands.length
1495
+ : 0;
1496
+
1240
1497
  const totalDrift =
1241
- staleHooks.length +
1242
- missingSettings.length +
1243
- (schemaDrift ? 1 : 0) +
1244
- (invalidSettings ? 1 : 0) +
1498
+ claudeCoreCount +
1245
1499
  (pkgJsonDrift ? 1 : 0) +
1246
- oldHookRefs.length +
1247
- staleCommands.length +
1248
- userModifiedCommands.length +
1249
- orphanedCommands.length +
1250
- nonRegularCommands.length +
1500
+ (schemaDrift ? 1 : 0) +
1251
1501
  (hypoignore.status === 'needs-migration' ? hypoignore.missing.length : 0) +
1252
1502
  extCheck.actions.filter(
1253
1503
  (a) => a.action === 'create' || a.action === 'update' || a.action === 'force-update',
1254
1504
  ).length +
1255
- // E3 (#31): unresolved drift/conflict is pending work too — without these the
1256
- // summary printed "up to date" while the exit code was 1 (codex review).
1505
+ // E3 (fix #31): unresolved drift/conflict is pending work too — without these the
1506
+ // summary printed "up to date" while the exit code was 1.
1257
1507
  extCheck.conflicts.length +
1258
1508
  extCheck.drifts.length +
1259
- // E4 (#32): codex-target pending work counts identically (same message/exit
1509
+ // E4 (fix #32): codex-target pending work counts identically (same message/exit
1260
1510
  // consistency the E3 review caught — a codex conflict must not read "up to date").
1261
1511
  (extCheckCodex
1262
1512
  ? extCheckCodex.actions.filter(
@@ -1265,7 +1515,7 @@ const totalDrift =
1265
1515
  extCheckCodex.conflicts.length +
1266
1516
  extCheckCodex.drifts.length
1267
1517
  : 0) +
1268
- // fix #48: codex core mirror counts the same way as the claude side.
1518
+ // codex core mirror counts the same way as the claude side.
1269
1519
  staleHooksCodex.length +
1270
1520
  missingSettingsCodex.length +
1271
1521
  (invalidSettingsCodex ? 1 : 0) +
@@ -1297,7 +1547,7 @@ if (totalDrift === 0) {
1297
1547
 
1298
1548
  console.log(lines.join('\n'));
1299
1549
 
1300
- // E3 (#31): a hard extension conflict blocks even under --apply (unlike ordinary
1550
+ // E3 (fix #31): a hard extension conflict blocks even under --apply (unlike ordinary
1301
1551
  // drift, which only fails check mode). --force-extensions clears the resolvable
1302
1552
  // cases; an unfollowable symlink/non-regular dest still counts and stays exit 1.
1303
1553
  const extBlocked = extCheck.conflicts.length > 0 || (extCheckCodex?.conflicts.length ?? 0) > 0;