hypomnema 1.6.2 → 1.7.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 (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +904 -128
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
package/scripts/graph.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { readFileSync } from 'fs';
18
18
  import { join } from 'path';
19
- import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
19
+ import { resolveHypoRootInfo, checkVaultOrExit, expandHome } from './lib/hypo-root.mjs';
20
20
  import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
21
21
  import { collectPagesGraph, extractWikilinks } from './lib/wikilink.mjs';
22
22
 
@@ -29,7 +29,11 @@ function parseArgs(argv) {
29
29
  else if (arg.startsWith('--format=')) args.format = arg.slice(9);
30
30
  else if (arg.startsWith('--min-edges=')) args.minEdges = parseInt(arg.slice(12), 10) || 0;
31
31
  }
32
- if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
32
+ if (!args.hypoDir) {
33
+ const info = resolveHypoRootInfo();
34
+ args.hypoDir = info.root;
35
+ args.hypoDirSource = info.source;
36
+ }
33
37
  return args;
34
38
  }
35
39
 
@@ -150,6 +154,9 @@ function formatDot(pages, graph, minEdges) {
150
154
  // ── main ──────────────────────────────────────────────────────────────────────
151
155
 
152
156
  const args = parseArgs(process.argv);
157
+ // Only validate the auto-resolved path (env/marker/default). An explicit
158
+ // --hypo-dir=<path> (tests, other tooling) is trusted as-is, valid or not.
159
+ if (args.hypoDirSource) checkVaultOrExit(args.hypoDir, args.hypoDirSource);
153
160
 
154
161
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
155
162
  const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
package/scripts/init.mjs CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  renameSync,
33
33
  unlinkSync,
34
34
  } from 'fs';
35
- import { join, basename, dirname } from 'path';
35
+ import { join, basename, dirname, resolve } from 'path';
36
36
  import { homedir } from 'os';
37
37
  import { execSync, spawnSync } from 'child_process';
38
38
  import { fileURLToPath } from 'url';
@@ -49,6 +49,11 @@ import {
49
49
  import { syncExtensions } from './lib/extensions.mjs';
50
50
  import { templateSchemaVersion } from './lib/template-schema-version.mjs';
51
51
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
52
+ import {
53
+ isHypomnemaPluginEnabled,
54
+ resolveEnabledPluginRoot as resolveEnabledPluginRootShared,
55
+ usablePkgRoot,
56
+ } from './lib/plugin-detect.mjs';
52
57
 
53
58
  const HOME = homedir();
54
59
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -93,6 +98,7 @@ const KNOWN_SUBCOMMANDS = new Set([
93
98
  'uninstall',
94
99
  'feedback-sync',
95
100
  'capture',
101
+ 'proposal',
96
102
  ]);
97
103
  const _verb = process.argv[2];
98
104
  if (_verb && KNOWN_SUBCOMMANDS.has(_verb) && _verb !== 'init') {
@@ -119,6 +125,7 @@ function parseArgs(argv) {
119
125
  shellSetup: true,
120
126
  shellConfig: null,
121
127
  allowDowngrade: false,
128
+ lintStrict: false,
122
129
  };
123
130
  for (const arg of argv.slice(2)) {
124
131
  if (arg === '--help' || arg === '-h') {
@@ -132,6 +139,9 @@ Commands:
132
139
  uninstall Remove hooks and registrations (dry-run by default; pass --apply)
133
140
  feedback-sync Project feedback (SoT) → MEMORY.md / CLAUDE.md learned-behaviors
134
141
  projection (--check default, --write to apply)
142
+ capture Reverse-capture ~/.claude/{commands,agents} extensions and
143
+ settings.json hooks into the wiki for cross-machine sync
144
+ (pass names or --all; --dry-run to preview)
135
145
 
136
146
  Running \`hypomnema\` with no command is equivalent to \`hypomnema init\`.
137
147
 
@@ -149,11 +159,15 @@ Init options:
149
159
  --shell-config=<path> Shell config file path (default: auto-detect)
150
160
  --allow-downgrade Override the guard that refuses to overwrite NEWER active
151
161
  hooks with an older package (stale-PATH-CLI footgun)
162
+ --lint-strict Opt-in: also gate the wiki pre-commit hook on
163
+ \`lint --strict\` (promotes STRICT_PROMOTE_IDS warnings
164
+ to a blocking error), sequenced after the .hypoignore
165
+ guard. Off by default; re-run init to add or drop it.
152
166
  --dry-run Show what would be done without making changes
153
167
  --help, -h Show this help message
154
168
 
155
- Subcommand-specific flags (upgrade/doctor/uninstall) live in the
156
- docstring at the top of scripts/<command>.mjs.`);
169
+ Subcommand-specific flags (upgrade/doctor/uninstall/capture) live in the
170
+ docstring at the top of scripts/<command>.mjs; capture also accepts \`--help\`.`);
157
171
  process.exit(0);
158
172
  } else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
159
173
  else if (arg === '--no-hooks') args.hooks = false;
@@ -174,6 +188,7 @@ docstring at the top of scripts/<command>.mjs.`);
174
188
  else if (arg === '--no-shell') args.shellSetup = false;
175
189
  else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
176
190
  else if (arg === '--allow-downgrade') args.allowDowngrade = true;
191
+ else if (arg === '--lint-strict') args.lintStrict = true;
177
192
  }
178
193
  return args;
179
194
  }
@@ -286,6 +301,23 @@ function writeWikiignore(hypoDir, dryRun) {
286
301
  log('created', dest);
287
302
  }
288
303
 
304
+ // ── .hyposcanignore ──────────────────────────────────────────────────────────
305
+ // Scan-only exclusion sibling to .hypoignore. NOT a privacy boundary — see
306
+ // templates/.hyposcanignore's header. Absence is fine: every scan-ignore
307
+ // reader treats a missing file as an empty pattern list.
308
+
309
+ function writeScanIgnore(hypoDir, dryRun) {
310
+ const dest = join(hypoDir, '.hyposcanignore');
311
+ if (existsSync(dest)) {
312
+ log('skipped', dest);
313
+ return;
314
+ }
315
+ const src = join(TEMPLATES, '.hyposcanignore');
316
+ const content = existsSync(src) ? readFileSync(src, 'utf-8') : '';
317
+ if (!dryRun) writeFileSync(dest, content);
318
+ log('created', dest);
319
+ }
320
+
289
321
  // ── .gitignore ───────────────────────────────────────────────────────────────
290
322
 
291
323
  function writeGitignore(hypoDir, dryRun) {
@@ -468,34 +500,77 @@ function pkgJsonPath() {
468
500
  * install (no metadata), unparseable versions, the same package re-running itself,
469
501
  * --allow-downgrade, or --dry-run.
470
502
  */
471
- function maybeRefuseDowngrade(op, allowDowngrade, dryRun) {
472
- if (allowDowngrade || dryRun) return;
503
+ function maybeRefuseDowngrade(op, allowDowngrade, dryRun, durableRoot) {
504
+ if (allowDowngrade || dryRun || !PKG_VERSION) return;
473
505
  const active = readPkgJsonSafe(pkgJsonPath());
474
- if (!active || !active.pkgVersion || !PKG_VERSION) return;
475
- const verdict = classifyInstall(
476
- { pkgRoot: PKG_ROOT, version: PKG_VERSION },
477
- { pkgRoot: active.pkgRoot, version: active.pkgVersion },
478
- );
479
- if (verdict === 'downgrade') {
480
- console.error(downgradeGuardMessage(PKG_VERSION, active.pkgVersion, op));
481
- process.exit(2);
506
+ const candidates = [];
507
+ // The recorded metadata: catches a stale bin trying to downgrade a newer
508
+ // recorded install (the original guard).
509
+ if (active && active.pkgVersion) {
510
+ candidates.push({ pkgRoot: active.pkgRoot, version: active.pkgVersion });
511
+ }
512
+ // The positively-resolved durable (plugin) install, when it is a DIFFERENT root
513
+ // than this package. In an npm-first dual install the recorded pkgVersion is the
514
+ // stale npm one, so comparing only against it lets an OLDER npm init run against a
515
+ // NEWER plugin; also compare against the plugin's own version. readPkgVersionAt
516
+ // yields a version only for a usable root.
517
+ if (durableRoot && durableRoot !== PKG_ROOT) {
518
+ const durableVersion = readPkgVersionAt(durableRoot);
519
+ if (durableVersion) candidates.push({ pkgRoot: durableRoot, version: durableVersion });
520
+ }
521
+ for (const active of candidates) {
522
+ const verdict = classifyInstall(
523
+ { pkgRoot: PKG_ROOT, version: PKG_VERSION },
524
+ { pkgRoot: active.pkgRoot, version: active.version },
525
+ );
526
+ if (verdict === 'downgrade') {
527
+ console.error(downgradeGuardMessage(PKG_VERSION, active.version, op));
528
+ process.exit(2);
529
+ }
530
+ }
531
+ }
532
+
533
+ function readPkgVersionAt(root) {
534
+ try {
535
+ const v = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version;
536
+ return typeof v === 'string' && v.length > 0 ? v : null;
537
+ } catch {
538
+ return null;
482
539
  }
483
540
  }
484
541
 
485
- function writePkgJson(dryRun, extraFields = {}) {
542
+ // usablePkgRoot now lives in ./lib/plugin-detect.mjs (imported above), shared with
543
+ // upgrade.mjs's dualSkip provenance correction so both agree on what is real.
544
+
545
+ function writePkgJson(dryRun, extraFields = {}, root = PKG_ROOT) {
486
546
  const dest = pkgJsonPath();
487
547
  const existing = readPkgJsonSafe(dest);
548
+ // `root` is the DURABLE install root the runtime resolves scripts through
549
+ // (hooks/hypo-shared.mjs reads pkgRoot): PKG_ROOT for a normal install, the
550
+ // positively-resolved plugin cache root for a dual install (see resolveDurableRoot).
551
+ // pkgVersion is read from `root` too — stamping a dual-install pointer at the
552
+ // plugin with THIS npm package's version would make doctor's own version-match
553
+ // check contradict itself. schemaVersion describes the templates init writes,
554
+ // which always come from PKG_ROOT regardless of where scripts resolve.
488
555
  const merged = {
489
556
  ...existing,
490
- pkgRoot: PKG_ROOT,
491
- pkgVersion: PKG_VERSION,
557
+ pkgRoot: root,
558
+ pkgVersion: readPkgVersionAt(root) ?? PKG_VERSION,
492
559
  schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.1',
493
560
  ...extraFields,
494
561
  };
495
- if (!dryRun) {
562
+ // Skip the write outright when the durable identity already matches what is on
563
+ // disk, rather than re-serialize an unchanged file. That keeps the dry-run and
564
+ // real write-sets identical: a real run must never silently rewrite a file a dry
565
+ // run reported as skipped. A genuine change (fresh identity, a version bump, or a
566
+ // dual-install correction from a stale npm pointer to the plugin root) is logged
567
+ // as "created" so it shows up in both write-sets. Log is emitted unconditionally
568
+ // (outside the dryRun guard) so a dry run previews exactly what a real run does.
569
+ const noWrite = JSON.stringify(merged) === JSON.stringify(existing);
570
+ if (!dryRun && !noWrite) {
496
571
  writePkgJsonAtomic(dest, merged);
497
- log('created', dest);
498
572
  }
573
+ log(noWrite ? 'skipped' : 'created', noWrite ? `${dest} (durable pkgRoot unchanged)` : dest);
499
574
  return merged;
500
575
  }
501
576
 
@@ -638,19 +713,56 @@ function installPkgGitHook(dryRun) {
638
713
  const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
639
714
  const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
640
715
 
641
- function wikiPreCommitContent() {
642
- const worker = join(PKG_ROOT, 'hooks', 'hypo-pre-commit.mjs');
643
- // Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
644
- const escaped = worker.replace(/'/g, "'\\''");
645
- return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\nnode '${escaped}'\nexit $?\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
716
+ // Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
717
+ function shellSingleQuote(p) {
718
+ return `'${p.replace(/'/g, "'\\''")}'`;
719
+ }
720
+
721
+ // `root` is the DURABLE install root the hook should resolve hypo-pre-commit.mjs
722
+ // (and, when opted in, lint.mjs) through — not necessarily PKG_ROOT. In a dual
723
+ // install (a manual/npm init while the plugin is enabled) PKG_ROOT is the
724
+ // manual/npm checkout the dual-install notice tells the user to uninstall;
725
+ // embedding it here would leave the vault's git hook dangling the moment they
726
+ // do, breaking every wiki commit. The caller passes the preserved plugin-owned
727
+ // root instead (see `durableRoot`), so the hook points at the install that
728
+ // actually persists.
729
+ //
730
+ // The block runs its steps sequentially rather than tail-calling `exit $?` on
731
+ // the first one, so a second step (the opt-in --lint-strict gate below) can
732
+ // run after the .hypoignore guard instead of being unreachable dead code.
733
+ // `lintStrict` is baked into the generated shim at install time — the same
734
+ // pattern already used for HYPOMNEMA_ROOT/HYPOMNEMA_GIT_DIR in
735
+ // install-git-hooks.mjs — so toggling it means re-running init with/without
736
+ // `--lint-strict`, not editing the hook by hand.
737
+ //
738
+ // `hypoDir` MUST be absolutized before it is baked in. Git runs a pre-commit
739
+ // hook with cwd set to the wiki's own working-tree root, so a relative
740
+ // `--hypo-dir` (e.g. from `hypo init --hypo-dir=wiki` run from its parent) is
741
+ // re-resolved AT COMMIT TIME against that root instead of the directory the
742
+ // caller meant — `wiki` becomes `<wiki-root>/wiki`, a path that doesn't exist,
743
+ // and lint.mjs falls through to its own default resolution (HYPO_DIR/
744
+ // hypo-config.md scan) and may silently lint an unrelated vault. `worker`
745
+ // needs no such treatment: `root` is already realpath'd upstream (see
746
+ // `durableRoot` / PKG_ROOT resolution), so it's absolute at every call site.
747
+ function wikiPreCommitContent(root, hypoDir, lintStrict) {
748
+ const absHypoDir = resolve(hypoDir);
749
+ const worker = join(root, 'hooks', 'hypo-pre-commit.mjs');
750
+ const steps = [`node ${shellSingleQuote(worker)} || exit 1`];
751
+ if (lintStrict) {
752
+ const lintScript = join(root, 'scripts', 'lint.mjs');
753
+ steps.push(
754
+ `node ${shellSingleQuote(lintScript)} --hypo-dir=${shellSingleQuote(absHypoDir)} --strict || exit 1`,
755
+ );
756
+ }
757
+ return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\n${steps.join('\n')}\nexit 0\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
646
758
  }
647
759
 
648
- function installWikiPreCommitHook(hypoDir, dryRun, force) {
760
+ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
649
761
  const gitDir = join(hypoDir, '.git');
650
762
  if (!existsSync(gitDir)) return; // no git repo — silently skip
651
763
  const hooksDir = join(gitDir, 'hooks');
652
764
  const hookPath = join(hooksDir, 'pre-commit');
653
- const newContent = wikiPreCommitContent();
765
+ const newContent = wikiPreCommitContent(root, hypoDir, lintStrict);
654
766
 
655
767
  if (existsSync(hookPath)) {
656
768
  const existing = readFileSync(hookPath, 'utf-8');
@@ -837,6 +949,66 @@ function firstCommit(hypoDir, remote, dryRun) {
837
949
 
838
950
  const args = parseArgs(process.argv);
839
951
 
952
+ // ── install channel ───────────────────────────────────────────────────────────
953
+ //
954
+ // A plugin-channel install already provides the 14 core hooks + slash commands
955
+ // through the package's own hooks/hooks.json + commands/ (Claude Code auto-wires
956
+ // them). Copying the same hooks into ~/.claude/hooks and registering the same
957
+ // events in settings.json on top makes every core hook fire TWICE, the same
958
+ // failure mode already fixed for `upgrade --apply`. `pluginMode` mirrors
959
+ // upgrade.mjs's own detector (this init.mjs itself running from a
960
+ // `.claude/plugins/…` root); `hypomnemaPluginEnabled` catches the dual-install
961
+ // variant (a manual/npm init.mjs run while the plugin is ALSO enabled — see
962
+ // lib/plugin-detect.mjs, fail-open on any uncertainty). Either signal means
963
+ // Claude's core hook/command surface is plugin-managed already.
964
+ const pluginMode = PKG_ROOT.replace(/\\/g, '/').includes('/.claude/plugins/');
965
+ const hypomnemaPluginEnabled =
966
+ !pluginMode && isHypomnemaPluginEnabled(join(HOME, '.claude', 'settings.json'));
967
+ const coreManagedByPlugin = pluginMode || hypomnemaPluginEnabled;
968
+
969
+ // resolveEnabledPluginRoot lives in ./lib/plugin-detect.mjs (shared with
970
+ // upgrade.mjs's dualSkip provenance correction, imported above as
971
+ // resolveEnabledPluginRootShared). This wrapper binds it to init's own HOME-derived
972
+ // settings/registry paths so callers below read the same as before the move.
973
+ function resolveEnabledPluginRoot(settingsPath) {
974
+ return resolveEnabledPluginRootShared(
975
+ settingsPath,
976
+ join(HOME, '.claude', 'plugins', 'installed_plugins.json'),
977
+ );
978
+ }
979
+
980
+ // Non-mutating read of the recorded pkgRoot. resolveDurableRoot runs before init's
981
+ // package validation and its "no writes before validation" guarantee, so it must
982
+ // NOT use readPkgJsonSafe (which renames a corrupt hypo-pkg.json aside — a write).
983
+ function recordedPkgRoot() {
984
+ try {
985
+ const v = JSON.parse(readFileSync(pkgJsonPath(), 'utf-8')).pkgRoot;
986
+ return typeof v === 'string' ? v : null;
987
+ } catch {
988
+ return null;
989
+ }
990
+ }
991
+
992
+ // The durable install root that persistent artifacts (the vault's git pre-commit
993
+ // hook, hypo-pkg.json) should point at. In a dual install PKG_ROOT is the
994
+ // manual/npm checkout the dual-install notice tells the user to uninstall, so
995
+ // point at the plugin's REAL cache root, positively resolved from the registry —
996
+ // NOT at whatever pkgRoot is already recorded, which after an npm-first sequence
997
+ // (npm init, enable plugin, npm init again) may itself be that soon-to-be-removed
998
+ // npm root. Only if the registry cannot positively identify the plugin do we fall
999
+ // back to a usable recorded pointer, then PKG_ROOT (never worse than pre-fix). In
1000
+ // true plugin mode PKG_ROOT already IS the plugin path; for npm-only it is the
1001
+ // only install.
1002
+ function resolveDurableRoot() {
1003
+ if (!hypomnemaPluginEnabled) return PKG_ROOT;
1004
+ const pluginRoot = resolveEnabledPluginRoot(join(HOME, '.claude', 'settings.json'));
1005
+ if (pluginRoot) return pluginRoot;
1006
+ const recorded = recordedPkgRoot();
1007
+ if (usablePkgRoot(recorded)) return recorded;
1008
+ return PKG_ROOT;
1009
+ }
1010
+ const durableRoot = resolveDurableRoot();
1011
+
840
1012
  // Validate hooks.json before any file writes so a bad package leaves no partial state
841
1013
  const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
842
1014
 
@@ -849,7 +1021,7 @@ const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
849
1021
  // and, with `--codex`, writes ~/.codex hooks/settings. The guard no-ops on a fresh
850
1022
  // install, same realpath'd pkgRoot (dev re-run / npm-link / post-commit sync),
851
1023
  // --allow-downgrade, or --dry-run, so legitimate flows are unaffected.
852
- maybeRefuseDowngrade('init', args.allowDowngrade, args.dryRun);
1024
+ maybeRefuseDowngrade('init', args.allowDowngrade, args.dryRun, durableRoot);
853
1025
 
854
1026
  if (args.fromRemote) {
855
1027
  // ── from-remote path: clone → read config → install hooks ──────────────────
@@ -917,24 +1089,38 @@ if (args.fromRemote) {
917
1089
  args.dryRun,
918
1090
  );
919
1091
 
920
- // 3. hypo-config.md + .hypoignore + .gitignore
1092
+ // 3. hypo-config.md + .hypoignore + .hyposcanignore + .gitignore
921
1093
  writeHypoConfig(args.hypoDir, args.dryRun);
922
1094
  writeWikiignore(args.hypoDir, args.dryRun);
1095
+ writeScanIgnore(args.hypoDir, args.dryRun);
923
1096
  writeGitignore(args.hypoDir, args.dryRun);
924
1097
  }
925
1098
 
926
1099
  // 4. hooks
927
1100
 
1101
+ if (coreManagedByPlugin) {
1102
+ log(
1103
+ 'skipped',
1104
+ `Claude core hooks/settings/commands — provided by the plugin loader (${
1105
+ pluginMode
1106
+ ? 'this script is running from a Claude Code plugin install'
1107
+ : 'the Hypomnema plugin is enabled in settings.json'
1108
+ }); installing them here would register every hook twice. Use \`claude plugin update\` to upgrade instead.`,
1109
+ );
1110
+ }
1111
+
928
1112
  let commandSHAs = null;
929
- if (args.commands) {
1113
+ if (args.commands && !coreManagedByPlugin) {
930
1114
  const claudeCommands = join(HOME, '.claude', 'commands', 'hypo');
931
1115
  commandSHAs = installCommands(claudeCommands, args.dryRun, args.forceCommands);
932
1116
  }
933
1117
 
934
1118
  if (args.hooks) {
935
- const claudeHooks = join(HOME, '.claude', 'hooks');
936
- installHooks(claudeHooks, args.dryRun);
937
- mergeSettingsJson(join(HOME, '.claude', 'settings.json'), claudeHooks, args.dryRun, HOOK_MAP);
1119
+ if (!coreManagedByPlugin) {
1120
+ const claudeHooks = join(HOME, '.claude', 'hooks');
1121
+ installHooks(claudeHooks, args.dryRun);
1122
+ mergeSettingsJson(join(HOME, '.claude', 'settings.json'), claudeHooks, args.dryRun, HOOK_MAP);
1123
+ }
938
1124
  }
939
1125
 
940
1126
  // Always record the active install identity (pkgRoot + pkgVersion), even for a
@@ -943,7 +1129,9 @@ if (args.hooks) {
943
1129
  // the downgrade guard has nothing to compare against, so a later stale
944
1130
  // sibling could repoint those surfaces unguarded. writePkgJson merges (...existing),
945
1131
  // so this never clobbers a commands/extensions map written elsewhere.
946
- writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {});
1132
+ // Record hypo-pkg.json against the durable root (the plugin's real cache root in a
1133
+ // dual install, PKG_ROOT otherwise), the same root the wiki pre-commit hook uses.
1134
+ writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {}, durableRoot);
947
1135
 
948
1136
  // 4b. user extensions companion sync. Runs after
949
1137
  // writePkgJson so the per-target SHA map is merged into the same hypo-pkg.json
@@ -1033,8 +1221,16 @@ if (args.hooks) {
1033
1221
  installPkgGitHook(args.dryRun);
1034
1222
  }
1035
1223
 
1036
- // 8b. wiki pre-commit hook (.hypoignore last-line-of-defence guard — §6.8)
1037
- installWikiPreCommitHook(args.hypoDir, args.dryRun, args.forceCommands);
1224
+ // 8b. wiki pre-commit hook (.hypoignore last-line-of-defence guard — §6.8).
1225
+ // Point it at the durable install root, not PKG_ROOT, so a dual install does not
1226
+ // embed a manual/npm path that dangles once the user uninstalls it.
1227
+ installWikiPreCommitHook(
1228
+ args.hypoDir,
1229
+ args.dryRun,
1230
+ args.forceCommands,
1231
+ durableRoot,
1232
+ args.lintStrict,
1233
+ );
1038
1234
 
1039
1235
  // 9. first commit + push (skip when cloned from remote — already has commits)
1040
1236
  if (args.gitInit && !args.fromRemote) {