hypomnema 1.6.2 → 1.7.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 (70) 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 +18 -1
  11. package/hooks/hypo-auto-commit.mjs +92 -15
  12. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  13. package/hooks/hypo-auto-stage.mjs +41 -1
  14. package/hooks/hypo-close-guard.mjs +246 -0
  15. package/hooks/hypo-cwd-change.mjs +31 -2
  16. package/hooks/hypo-file-watch.mjs +21 -2
  17. package/hooks/hypo-first-prompt.mjs +19 -3
  18. package/hooks/hypo-hot-rebuild.mjs +43 -5
  19. package/hooks/hypo-lookup.mjs +86 -29
  20. package/hooks/hypo-personal-check.mjs +24 -3
  21. package/hooks/hypo-session-record.mjs +2 -3
  22. package/hooks/hypo-session-start.mjs +199 -20
  23. package/hooks/hypo-shared.mjs +2080 -176
  24. package/hooks/proposal-store.mjs +513 -0
  25. package/hooks/version-check.mjs +45 -0
  26. package/package.json +42 -14
  27. package/scripts/capture.mjs +556 -37
  28. package/scripts/crystallize.mjs +751 -110
  29. package/scripts/doctor.mjs +787 -26
  30. package/scripts/feedback-sync.mjs +515 -44
  31. package/scripts/graph.mjs +35 -13
  32. package/scripts/init.mjs +287 -41
  33. package/scripts/lib/extensions.mjs +656 -1
  34. package/scripts/lib/git-hooks-dir.mjs +229 -0
  35. package/scripts/lib/hypo-ignore.mjs +54 -6
  36. package/scripts/lib/hypo-root.mjs +56 -6
  37. package/scripts/lib/page-usage.mjs +15 -2
  38. package/scripts/lib/pkg-json.mjs +40 -0
  39. package/scripts/lib/plugin-detect.mjs +96 -6
  40. package/scripts/lib/project-create.mjs +5 -1
  41. package/scripts/lib/rename-marker.mjs +39 -0
  42. package/scripts/lib/wd-match.mjs +23 -5
  43. package/scripts/lib/wikilink.mjs +32 -6
  44. package/scripts/lint.mjs +103 -5
  45. package/scripts/proposal.mjs +1032 -0
  46. package/scripts/query.mjs +25 -4
  47. package/scripts/rename.mjs +223 -18
  48. package/scripts/resume.mjs +34 -12
  49. package/scripts/stats.mjs +41 -9
  50. package/scripts/uninstall.mjs +141 -6
  51. package/scripts/upgrade.mjs +197 -15
  52. package/skills/crystallize/SKILL.md +44 -7
  53. package/skills/debate/SKILL.md +88 -0
  54. package/skills/debate/references/orchestration-patterns.md +83 -0
  55. package/templates/.hyposcanignore +10 -0
  56. package/templates/SCHEMA.md +12 -0
  57. package/templates/gitignore +9 -0
  58. package/templates/hypo-config.md +1 -1
  59. package/templates/hypo-guide.md +6 -0
  60. package/scripts/.gitkeep +0 -0
  61. package/scripts/check-bilingual.mjs +0 -153
  62. package/scripts/check-readme-version.mjs +0 -126
  63. package/scripts/check-tracker-ids.mjs +0 -426
  64. package/scripts/check-versions.mjs +0 -171
  65. package/scripts/install-git-hooks.mjs +0 -293
  66. package/scripts/lib/changelog-classify.mjs +0 -216
  67. package/scripts/lib/check-bilingual.mjs +0 -244
  68. package/scripts/lib/check-tracker-ids.mjs +0 -217
  69. package/scripts/lib/pre-commit-format.mjs +0 -251
  70. 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
 
@@ -87,7 +91,7 @@ function escapeDot(s) {
87
91
 
88
92
  // ── formatters ────────────────────────────────────────────────────────────────
89
93
 
90
- function formatJson(pages, graph, minEdges) {
94
+ function formatJson(pages, graph, minEdges, vaultFound) {
91
95
  const nodes = pages
92
96
  .map((p) => ({
93
97
  slug: p.slug,
@@ -103,7 +107,9 @@ function formatJson(pages, graph, minEdges) {
103
107
  return fn && tn;
104
108
  });
105
109
 
106
- return JSON.stringify({ nodes, edges }, null, 2);
110
+ // vaultFound: same reasoning as lint.mjs/stats.mjs — an empty graph from a
111
+ // vault that was never found must not read the same as a real, linkless one.
112
+ return JSON.stringify({ nodes, edges, vaultFound }, null, 2);
107
113
  }
108
114
 
109
115
  function formatMermaid(pages, graph, minEdges) {
@@ -150,6 +156,13 @@ function formatDot(pages, graph, minEdges) {
150
156
  // ── main ──────────────────────────────────────────────────────────────────────
151
157
 
152
158
  const args = parseArgs(process.argv);
159
+ // Only validate the auto-resolved path (env/marker/default). An explicit
160
+ // --hypo-dir=<path> (tests, other tooling) is trusted as-is, valid or not.
161
+ // See lint.mjs's matching comment: source 'default'/stale-'marker' stays
162
+ // exit-0 (CI-safe).
163
+ const vaultMissing = args.hypoDirSource
164
+ ? checkVaultOrExit(args.hypoDir, args.hypoDirSource)
165
+ : false;
153
166
 
154
167
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
155
168
  const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
@@ -157,13 +170,22 @@ const pages = scanDirs.flatMap((d) => collectPagesGraph(d, args.hypoDir, ignoreP
157
170
  const slugIndex = buildSlugIndex(pages);
158
171
  const graph = buildGraph(pages, slugIndex);
159
172
 
160
- switch (args.format) {
161
- case 'mermaid':
162
- console.log(formatMermaid(pages, graph, args.minEdges));
163
- break;
164
- case 'dot':
165
- console.log(formatDot(pages, graph, args.minEdges));
166
- break;
167
- default:
168
- console.log(formatJson(pages, graph, args.minEdges));
173
+ if (vaultMissing && (args.format === 'mermaid' || args.format === 'dot')) {
174
+ // Same reasoning as lint.mjs/stats.mjs: mermaid/dot render straight to a
175
+ // diagram tool with no JSON envelope to carry `vaultFound`, so a valid but
176
+ // linkless diagram from a real (empty) vault and "no vault was found at
177
+ // all" would otherwise render identically. checkVaultOrExit already put the
178
+ // "No Hypomnema vault found" notice on stderr; suppress the stdout diagram
179
+ // rather than hand the renderer an empty-but-"real"-looking graph.
180
+ } else {
181
+ switch (args.format) {
182
+ case 'mermaid':
183
+ console.log(formatMermaid(pages, graph, args.minEdges));
184
+ break;
185
+ case 'dot':
186
+ console.log(formatDot(pages, graph, args.minEdges));
187
+ break;
188
+ default:
189
+ console.log(formatJson(pages, graph, args.minEdges, !vaultMissing));
190
+ }
169
191
  }
package/scripts/init.mjs CHANGED
@@ -32,12 +32,13 @@ 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';
39
39
  import { createHash } from 'crypto';
40
40
  import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
41
+ import { hooksDirForInstall, unsafeHookTargetReason } from './lib/git-hooks-dir.mjs';
41
42
  import { readCoreHooksConfig } from './lib/core-hooks.mjs';
42
43
  import {
43
44
  readPkgJson as readPkgJsonSafe,
@@ -49,6 +50,11 @@ import {
49
50
  import { syncExtensions } from './lib/extensions.mjs';
50
51
  import { templateSchemaVersion } from './lib/template-schema-version.mjs';
51
52
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
53
+ import {
54
+ isHypomnemaPluginEnabled,
55
+ resolveEnabledPluginRoot as resolveEnabledPluginRootShared,
56
+ usablePkgRoot,
57
+ } from './lib/plugin-detect.mjs';
52
58
 
53
59
  const HOME = homedir();
54
60
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -93,6 +99,7 @@ const KNOWN_SUBCOMMANDS = new Set([
93
99
  'uninstall',
94
100
  'feedback-sync',
95
101
  'capture',
102
+ 'proposal',
96
103
  ]);
97
104
  const _verb = process.argv[2];
98
105
  if (_verb && KNOWN_SUBCOMMANDS.has(_verb) && _verb !== 'init') {
@@ -119,6 +126,7 @@ function parseArgs(argv) {
119
126
  shellSetup: true,
120
127
  shellConfig: null,
121
128
  allowDowngrade: false,
129
+ lintStrict: false,
122
130
  };
123
131
  for (const arg of argv.slice(2)) {
124
132
  if (arg === '--help' || arg === '-h') {
@@ -132,6 +140,12 @@ Commands:
132
140
  uninstall Remove hooks and registrations (dry-run by default; pass --apply)
133
141
  feedback-sync Project feedback (SoT) → MEMORY.md / CLAUDE.md learned-behaviors
134
142
  projection (--check default, --write to apply)
143
+ capture Reverse-capture ~/.claude/{commands,agents} extensions and
144
+ settings.json hooks into the wiki for cross-machine sync
145
+ (pass names or --all; --dry-run to preview)
146
+ proposal Manage staged crystallize proposals: list, apply, or
147
+ discard a parked write (also challenge/resolve for the
148
+ no-TTY agent approval channel)
135
149
 
136
150
  Running \`hypomnema\` with no command is equivalent to \`hypomnema init\`.
137
151
 
@@ -149,11 +163,19 @@ Init options:
149
163
  --shell-config=<path> Shell config file path (default: auto-detect)
150
164
  --allow-downgrade Override the guard that refuses to overwrite NEWER active
151
165
  hooks with an older package (stale-PATH-CLI footgun)
166
+ --lint-strict Opt-in: also gate the wiki pre-commit hook on
167
+ \`lint --strict\` (promotes STRICT_PROMOTE_IDS warnings
168
+ to a blocking error), sequenced after the .hypoignore
169
+ guard. Off by default; re-run init to add or drop it.
152
170
  --dry-run Show what would be done without making changes
171
+ --version Print the installed package version and exit
153
172
  --help, -h Show this help message
154
173
 
155
- Subcommand-specific flags (upgrade/doctor/uninstall) live in the
156
- docstring at the top of scripts/<command>.mjs.`);
174
+ Subcommand-specific flags (upgrade/doctor/uninstall/capture) live in the
175
+ docstring at the top of scripts/<command>.mjs; capture also accepts \`--help\`.`);
176
+ process.exit(0);
177
+ } else if (arg === '--version') {
178
+ console.log(PKG_VERSION ?? 'unknown');
157
179
  process.exit(0);
158
180
  } else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
159
181
  else if (arg === '--no-hooks') args.hooks = false;
@@ -174,6 +196,19 @@ docstring at the top of scripts/<command>.mjs.`);
174
196
  else if (arg === '--no-shell') args.shellSetup = false;
175
197
  else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
176
198
  else if (arg === '--allow-downgrade') args.allowDowngrade = true;
199
+ else if (arg === '--lint-strict') args.lintStrict = true;
200
+ else {
201
+ // An unrecognized argument used to fall through silently and run the
202
+ // default init flow (scaffold + hook install + settings.json merge) —
203
+ // a destructive write triggered by what looked like a query. `--version`
204
+ // was the concrete case that bit a user: it reads like an inspection
205
+ // flag, was never implemented, and its typo/mistake landed on a live
206
+ // wiki's pre-commit hook instead of printing anything. Fail loud and
207
+ // do nothing, rather than guess what the caller meant.
208
+ console.error(`Unknown option: ${arg}`);
209
+ console.error('Run `hypomnema --help` for usage.');
210
+ process.exit(2);
211
+ }
177
212
  }
178
213
  return args;
179
214
  }
@@ -286,6 +321,23 @@ function writeWikiignore(hypoDir, dryRun) {
286
321
  log('created', dest);
287
322
  }
288
323
 
324
+ // ── .hyposcanignore ──────────────────────────────────────────────────────────
325
+ // Scan-only exclusion sibling to .hypoignore. NOT a privacy boundary — see
326
+ // templates/.hyposcanignore's header. Absence is fine: every scan-ignore
327
+ // reader treats a missing file as an empty pattern list.
328
+
329
+ function writeScanIgnore(hypoDir, dryRun) {
330
+ const dest = join(hypoDir, '.hyposcanignore');
331
+ if (existsSync(dest)) {
332
+ log('skipped', dest);
333
+ return;
334
+ }
335
+ const src = join(TEMPLATES, '.hyposcanignore');
336
+ const content = existsSync(src) ? readFileSync(src, 'utf-8') : '';
337
+ if (!dryRun) writeFileSync(dest, content);
338
+ log('created', dest);
339
+ }
340
+
289
341
  // ── .gitignore ───────────────────────────────────────────────────────────────
290
342
 
291
343
  function writeGitignore(hypoDir, dryRun) {
@@ -468,34 +520,77 @@ function pkgJsonPath() {
468
520
  * install (no metadata), unparseable versions, the same package re-running itself,
469
521
  * --allow-downgrade, or --dry-run.
470
522
  */
471
- function maybeRefuseDowngrade(op, allowDowngrade, dryRun) {
472
- if (allowDowngrade || dryRun) return;
523
+ function maybeRefuseDowngrade(op, allowDowngrade, dryRun, durableRoot) {
524
+ if (allowDowngrade || dryRun || !PKG_VERSION) return;
473
525
  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);
526
+ const candidates = [];
527
+ // The recorded metadata: catches a stale bin trying to downgrade a newer
528
+ // recorded install (the original guard).
529
+ if (active && active.pkgVersion) {
530
+ candidates.push({ pkgRoot: active.pkgRoot, version: active.pkgVersion });
531
+ }
532
+ // The positively-resolved durable (plugin) install, when it is a DIFFERENT root
533
+ // than this package. In an npm-first dual install the recorded pkgVersion is the
534
+ // stale npm one, so comparing only against it lets an OLDER npm init run against a
535
+ // NEWER plugin; also compare against the plugin's own version. readPkgVersionAt
536
+ // yields a version only for a usable root.
537
+ if (durableRoot && durableRoot !== PKG_ROOT) {
538
+ const durableVersion = readPkgVersionAt(durableRoot);
539
+ if (durableVersion) candidates.push({ pkgRoot: durableRoot, version: durableVersion });
540
+ }
541
+ for (const active of candidates) {
542
+ const verdict = classifyInstall(
543
+ { pkgRoot: PKG_ROOT, version: PKG_VERSION },
544
+ { pkgRoot: active.pkgRoot, version: active.version },
545
+ );
546
+ if (verdict === 'downgrade') {
547
+ console.error(downgradeGuardMessage(PKG_VERSION, active.version, op));
548
+ process.exit(2);
549
+ }
482
550
  }
483
551
  }
484
552
 
485
- function writePkgJson(dryRun, extraFields = {}) {
553
+ function readPkgVersionAt(root) {
554
+ try {
555
+ const v = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version;
556
+ return typeof v === 'string' && v.length > 0 ? v : null;
557
+ } catch {
558
+ return null;
559
+ }
560
+ }
561
+
562
+ // usablePkgRoot now lives in ./lib/plugin-detect.mjs (imported above), shared with
563
+ // upgrade.mjs's dualSkip provenance correction so both agree on what is real.
564
+
565
+ function writePkgJson(dryRun, extraFields = {}, root = PKG_ROOT) {
486
566
  const dest = pkgJsonPath();
487
567
  const existing = readPkgJsonSafe(dest);
568
+ // `root` is the DURABLE install root the runtime resolves scripts through
569
+ // (hooks/hypo-shared.mjs reads pkgRoot): PKG_ROOT for a normal install, the
570
+ // positively-resolved plugin cache root for a dual install (see resolveDurableRoot).
571
+ // pkgVersion is read from `root` too — stamping a dual-install pointer at the
572
+ // plugin with THIS npm package's version would make doctor's own version-match
573
+ // check contradict itself. schemaVersion describes the templates init writes,
574
+ // which always come from PKG_ROOT regardless of where scripts resolve.
488
575
  const merged = {
489
576
  ...existing,
490
- pkgRoot: PKG_ROOT,
491
- pkgVersion: PKG_VERSION,
577
+ pkgRoot: root,
578
+ pkgVersion: readPkgVersionAt(root) ?? PKG_VERSION,
492
579
  schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.1',
493
580
  ...extraFields,
494
581
  };
495
- if (!dryRun) {
582
+ // Skip the write outright when the durable identity already matches what is on
583
+ // disk, rather than re-serialize an unchanged file. That keeps the dry-run and
584
+ // real write-sets identical: a real run must never silently rewrite a file a dry
585
+ // run reported as skipped. A genuine change (fresh identity, a version bump, or a
586
+ // dual-install correction from a stale npm pointer to the plugin root) is logged
587
+ // as "created" so it shows up in both write-sets. Log is emitted unconditionally
588
+ // (outside the dryRun guard) so a dry run previews exactly what a real run does.
589
+ const noWrite = JSON.stringify(merged) === JSON.stringify(existing);
590
+ if (!dryRun && !noWrite) {
496
591
  writePkgJsonAtomic(dest, merged);
497
- log('created', dest);
498
592
  }
593
+ log(noWrite ? 'skipped' : 'created', noWrite ? `${dest} (durable pkgRoot unchanged)` : dest);
499
594
  return merged;
500
595
  }
501
596
 
@@ -618,10 +713,19 @@ function installCommands(targetDir, dryRun, force) {
618
713
  }
619
714
 
620
715
  function installPkgGitHook(dryRun) {
621
- const gitDir = join(PKG_ROOT, '.git');
622
- if (!existsSync(gitDir)) return;
623
- const hooksDir = join(gitDir, 'hooks');
716
+ const { dir: hooksDir, skip } = hooksDirForInstall(PKG_ROOT);
717
+ if (!hooksDir) {
718
+ if (skip) log('skipped', `${PKG_ROOT} post-commit (${skip})`);
719
+ return;
720
+ }
624
721
  const hookPath = join(hooksDir, 'post-commit');
722
+ // Checked before existsSync: a dangling symlink reads as absent there, and
723
+ // writing would create its external target.
724
+ const unsafe = unsafeHookTargetReason(hookPath);
725
+ if (unsafe) {
726
+ log('skipped', `${hookPath} (${unsafe})`);
727
+ return;
728
+ }
625
729
  if (existsSync(hookPath)) {
626
730
  log('skipped', hookPath);
627
731
  return;
@@ -638,19 +742,67 @@ function installPkgGitHook(dryRun) {
638
742
  const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
639
743
  const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
640
744
 
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`;
745
+ // Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
746
+ function shellSingleQuote(p) {
747
+ return `'${p.replace(/'/g, "'\\''")}'`;
748
+ }
749
+
750
+ // `root` is the DURABLE install root the hook should resolve hypo-pre-commit.mjs
751
+ // (and, when opted in, lint.mjs) through — not necessarily PKG_ROOT. In a dual
752
+ // install (a manual/npm init while the plugin is enabled) PKG_ROOT is the
753
+ // manual/npm checkout the dual-install notice tells the user to uninstall;
754
+ // embedding it here would leave the vault's git hook dangling the moment they
755
+ // do, breaking every wiki commit. The caller passes the preserved plugin-owned
756
+ // root instead (see `durableRoot`), so the hook points at the install that
757
+ // actually persists.
758
+ //
759
+ // The block runs its steps sequentially rather than tail-calling `exit $?` on
760
+ // the first one, so a second step (the opt-in --lint-strict gate below) can
761
+ // run after the .hypoignore guard instead of being unreachable dead code.
762
+ // `lintStrict` is baked into the generated shim at install time — the same
763
+ // pattern already used for HYPOMNEMA_ROOT/HYPOMNEMA_GIT_DIR in
764
+ // install-git-hooks.mjs — so toggling it means re-running init with/without
765
+ // `--lint-strict`, not editing the hook by hand.
766
+ //
767
+ // `hypoDir` MUST be absolutized before it is baked in. Git runs a pre-commit
768
+ // hook with cwd set to the wiki's own working-tree root, so a relative
769
+ // `--hypo-dir` (e.g. from `hypo init --hypo-dir=wiki` run from its parent) is
770
+ // re-resolved AT COMMIT TIME against that root instead of the directory the
771
+ // caller meant — `wiki` becomes `<wiki-root>/wiki`, a path that doesn't exist,
772
+ // and lint.mjs falls through to its own default resolution (HYPO_DIR/
773
+ // hypo-config.md scan) and may silently lint an unrelated vault. `worker`
774
+ // needs no such treatment: `root` is already realpath'd upstream (see
775
+ // `durableRoot` / PKG_ROOT resolution), so it's absolute at every call site.
776
+ function wikiPreCommitContent(root, hypoDir, lintStrict) {
777
+ const absHypoDir = resolve(hypoDir);
778
+ const worker = join(root, 'hooks', 'hypo-pre-commit.mjs');
779
+ const steps = [`node ${shellSingleQuote(worker)} || exit 1`];
780
+ if (lintStrict) {
781
+ const lintScript = join(root, 'scripts', 'lint.mjs');
782
+ steps.push(
783
+ `node ${shellSingleQuote(lintScript)} --hypo-dir=${shellSingleQuote(absHypoDir)} --strict || exit 1`,
784
+ );
785
+ }
786
+ return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\n${steps.join('\n')}\nexit 0\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
646
787
  }
647
788
 
648
- function installWikiPreCommitHook(hypoDir, dryRun, force) {
649
- const gitDir = join(hypoDir, '.git');
650
- if (!existsSync(gitDir)) return; // no git repo — silently skip
651
- const hooksDir = join(gitDir, 'hooks');
789
+ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
790
+ const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
791
+ if (!hooksDir) {
792
+ // no git repo — silently skip, as before; anything else is worth surfacing
793
+ if (skip) log('skipped', `${hypoDir} pre-commit (${skip})`);
794
+ return;
795
+ }
652
796
  const hookPath = join(hooksDir, 'pre-commit');
653
- const newContent = wikiPreCommitContent();
797
+ const newContent = wikiPreCommitContent(root, hypoDir, lintStrict);
798
+
799
+ // Before every branch below, including --force: a symlink here would send the
800
+ // write through to an arbitrary external file.
801
+ const unsafe = unsafeHookTargetReason(hookPath);
802
+ if (unsafe) {
803
+ log('skipped', `${hookPath} (${unsafe})`);
804
+ return;
805
+ }
654
806
 
655
807
  if (existsSync(hookPath)) {
656
808
  const existing = readFileSync(hookPath, 'utf-8');
@@ -665,8 +817,18 @@ function installWikiPreCommitHook(hypoDir, dryRun, force) {
665
817
  }
666
818
  log('merged', `${hookPath} (pre-commit updated)`);
667
819
  } else if (force) {
820
+ // The .bak is a SECOND write to a DIFFERENT path, so the guard on
821
+ // hookPath above says nothing about it. Left unchecked, a pre-commit.bak
822
+ // symlink turns --force-commands into an overwrite of whatever it points
823
+ // at — deterministically, not as a race.
824
+ const bakPath = hookPath + '.bak';
825
+ const unsafeBak = unsafeHookTargetReason(bakPath);
826
+ if (unsafeBak) {
827
+ log('skipped', `${bakPath} (${unsafeBak}) — not force-overwriting without a safe backup`);
828
+ return;
829
+ }
668
830
  if (!dryRun) {
669
- writeFileSync(hookPath + '.bak', existing);
831
+ writeFileSync(bakPath, existing);
670
832
  writeFileSync(hookPath, newContent);
671
833
  chmodSync(hookPath, 0o755);
672
834
  }
@@ -837,6 +999,66 @@ function firstCommit(hypoDir, remote, dryRun) {
837
999
 
838
1000
  const args = parseArgs(process.argv);
839
1001
 
1002
+ // ── install channel ───────────────────────────────────────────────────────────
1003
+ //
1004
+ // A plugin-channel install already provides the 14 core hooks + slash commands
1005
+ // through the package's own hooks/hooks.json + commands/ (Claude Code auto-wires
1006
+ // them). Copying the same hooks into ~/.claude/hooks and registering the same
1007
+ // events in settings.json on top makes every core hook fire TWICE, the same
1008
+ // failure mode already fixed for `upgrade --apply`. `pluginMode` mirrors
1009
+ // upgrade.mjs's own detector (this init.mjs itself running from a
1010
+ // `.claude/plugins/…` root); `hypomnemaPluginEnabled` catches the dual-install
1011
+ // variant (a manual/npm init.mjs run while the plugin is ALSO enabled — see
1012
+ // lib/plugin-detect.mjs, fail-open on any uncertainty). Either signal means
1013
+ // Claude's core hook/command surface is plugin-managed already.
1014
+ const pluginMode = PKG_ROOT.replace(/\\/g, '/').includes('/.claude/plugins/');
1015
+ const hypomnemaPluginEnabled =
1016
+ !pluginMode && isHypomnemaPluginEnabled(join(HOME, '.claude', 'settings.json'));
1017
+ const coreManagedByPlugin = pluginMode || hypomnemaPluginEnabled;
1018
+
1019
+ // resolveEnabledPluginRoot lives in ./lib/plugin-detect.mjs (shared with
1020
+ // upgrade.mjs's dualSkip provenance correction, imported above as
1021
+ // resolveEnabledPluginRootShared). This wrapper binds it to init's own HOME-derived
1022
+ // settings/registry paths so callers below read the same as before the move.
1023
+ function resolveEnabledPluginRoot(settingsPath) {
1024
+ return resolveEnabledPluginRootShared(
1025
+ settingsPath,
1026
+ join(HOME, '.claude', 'plugins', 'installed_plugins.json'),
1027
+ );
1028
+ }
1029
+
1030
+ // Non-mutating read of the recorded pkgRoot. resolveDurableRoot runs before init's
1031
+ // package validation and its "no writes before validation" guarantee, so it must
1032
+ // NOT use readPkgJsonSafe (which renames a corrupt hypo-pkg.json aside — a write).
1033
+ function recordedPkgRoot() {
1034
+ try {
1035
+ const v = JSON.parse(readFileSync(pkgJsonPath(), 'utf-8')).pkgRoot;
1036
+ return typeof v === 'string' ? v : null;
1037
+ } catch {
1038
+ return null;
1039
+ }
1040
+ }
1041
+
1042
+ // The durable install root that persistent artifacts (the vault's git pre-commit
1043
+ // hook, hypo-pkg.json) should point at. In a dual install PKG_ROOT is the
1044
+ // manual/npm checkout the dual-install notice tells the user to uninstall, so
1045
+ // point at the plugin's REAL cache root, positively resolved from the registry —
1046
+ // NOT at whatever pkgRoot is already recorded, which after an npm-first sequence
1047
+ // (npm init, enable plugin, npm init again) may itself be that soon-to-be-removed
1048
+ // npm root. Only if the registry cannot positively identify the plugin do we fall
1049
+ // back to a usable recorded pointer, then PKG_ROOT (never worse than pre-fix). In
1050
+ // true plugin mode PKG_ROOT already IS the plugin path; for npm-only it is the
1051
+ // only install.
1052
+ function resolveDurableRoot() {
1053
+ if (!hypomnemaPluginEnabled) return PKG_ROOT;
1054
+ const pluginRoot = resolveEnabledPluginRoot(join(HOME, '.claude', 'settings.json'));
1055
+ if (pluginRoot) return pluginRoot;
1056
+ const recorded = recordedPkgRoot();
1057
+ if (usablePkgRoot(recorded)) return recorded;
1058
+ return PKG_ROOT;
1059
+ }
1060
+ const durableRoot = resolveDurableRoot();
1061
+
840
1062
  // Validate hooks.json before any file writes so a bad package leaves no partial state
841
1063
  const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
842
1064
 
@@ -849,7 +1071,7 @@ const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
849
1071
  // and, with `--codex`, writes ~/.codex hooks/settings. The guard no-ops on a fresh
850
1072
  // install, same realpath'd pkgRoot (dev re-run / npm-link / post-commit sync),
851
1073
  // --allow-downgrade, or --dry-run, so legitimate flows are unaffected.
852
- maybeRefuseDowngrade('init', args.allowDowngrade, args.dryRun);
1074
+ maybeRefuseDowngrade('init', args.allowDowngrade, args.dryRun, durableRoot);
853
1075
 
854
1076
  if (args.fromRemote) {
855
1077
  // ── from-remote path: clone → read config → install hooks ──────────────────
@@ -917,24 +1139,38 @@ if (args.fromRemote) {
917
1139
  args.dryRun,
918
1140
  );
919
1141
 
920
- // 3. hypo-config.md + .hypoignore + .gitignore
1142
+ // 3. hypo-config.md + .hypoignore + .hyposcanignore + .gitignore
921
1143
  writeHypoConfig(args.hypoDir, args.dryRun);
922
1144
  writeWikiignore(args.hypoDir, args.dryRun);
1145
+ writeScanIgnore(args.hypoDir, args.dryRun);
923
1146
  writeGitignore(args.hypoDir, args.dryRun);
924
1147
  }
925
1148
 
926
1149
  // 4. hooks
927
1150
 
1151
+ if (coreManagedByPlugin) {
1152
+ log(
1153
+ 'skipped',
1154
+ `Claude core hooks/settings/commands — provided by the plugin loader (${
1155
+ pluginMode
1156
+ ? 'this script is running from a Claude Code plugin install'
1157
+ : 'the Hypomnema plugin is enabled in settings.json'
1158
+ }); installing them here would register every hook twice. Use \`claude plugin update\` to upgrade instead.`,
1159
+ );
1160
+ }
1161
+
928
1162
  let commandSHAs = null;
929
- if (args.commands) {
1163
+ if (args.commands && !coreManagedByPlugin) {
930
1164
  const claudeCommands = join(HOME, '.claude', 'commands', 'hypo');
931
1165
  commandSHAs = installCommands(claudeCommands, args.dryRun, args.forceCommands);
932
1166
  }
933
1167
 
934
1168
  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);
1169
+ if (!coreManagedByPlugin) {
1170
+ const claudeHooks = join(HOME, '.claude', 'hooks');
1171
+ installHooks(claudeHooks, args.dryRun);
1172
+ mergeSettingsJson(join(HOME, '.claude', 'settings.json'), claudeHooks, args.dryRun, HOOK_MAP);
1173
+ }
938
1174
  }
939
1175
 
940
1176
  // Always record the active install identity (pkgRoot + pkgVersion), even for a
@@ -943,7 +1179,9 @@ if (args.hooks) {
943
1179
  // the downgrade guard has nothing to compare against, so a later stale
944
1180
  // sibling could repoint those surfaces unguarded. writePkgJson merges (...existing),
945
1181
  // so this never clobbers a commands/extensions map written elsewhere.
946
- writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {});
1182
+ // Record hypo-pkg.json against the durable root (the plugin's real cache root in a
1183
+ // dual install, PKG_ROOT otherwise), the same root the wiki pre-commit hook uses.
1184
+ writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {}, durableRoot);
947
1185
 
948
1186
  // 4b. user extensions companion sync. Runs after
949
1187
  // writePkgJson so the per-target SHA map is merged into the same hypo-pkg.json
@@ -1033,8 +1271,16 @@ if (args.hooks) {
1033
1271
  installPkgGitHook(args.dryRun);
1034
1272
  }
1035
1273
 
1036
- // 8b. wiki pre-commit hook (.hypoignore last-line-of-defence guard — §6.8)
1037
- installWikiPreCommitHook(args.hypoDir, args.dryRun, args.forceCommands);
1274
+ // 8b. wiki pre-commit hook (.hypoignore last-line-of-defence guard — §6.8).
1275
+ // Point it at the durable install root, not PKG_ROOT, so a dual install does not
1276
+ // embed a manual/npm path that dangles once the user uninstalls it.
1277
+ installWikiPreCommitHook(
1278
+ args.hypoDir,
1279
+ args.dryRun,
1280
+ args.forceCommands,
1281
+ durableRoot,
1282
+ args.lintStrict,
1283
+ );
1038
1284
 
1039
1285
  // 9. first commit + push (skip when cloned from remote — already has commits)
1040
1286
  if (args.gitInit && !args.fromRemote) {