hypomnema 1.7.2 → 1.7.4

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.
@@ -48,11 +48,22 @@ import {
48
48
  chmodSync,
49
49
  rmSync,
50
50
  } from 'fs';
51
- import { join, basename, dirname, normalize, isAbsolute, sep } from 'path';
51
+ import { join, basename, dirname, normalize, isAbsolute } from 'path';
52
52
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
53
53
  import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
54
- import { collectPagesRename, slugForms } from './lib/wikilink.mjs';
54
+ import { collectPagesRename } from './lib/wikilink.mjs';
55
55
  import { RENAME_MARKER_REL, renameMarkerPath, readRenameMarker } from './lib/rename-marker.mjs';
56
+ import {
57
+ dirRelForm,
58
+ buildFormIndex,
59
+ classifyTarget,
60
+ newTargetFor,
61
+ maskNonWikilinkRegions,
62
+ splitLinkBody,
63
+ preservationClass,
64
+ isPreservedSource,
65
+ realContainedInVault,
66
+ } from './lib/slug-resolver.mjs';
56
67
 
57
68
  // ── arg parsing ───────────────────────────────────────────────────────────────
58
69
 
@@ -69,108 +80,16 @@ function parseArgs(argv) {
69
80
  return args;
70
81
  }
71
82
 
72
- // ── preservation class of a link-source path ───────────────────────────────────
73
- // Two distinct reasons a file is normally skipped as a link SOURCE, kept separate
74
- // because directory mode treats them differently (codex design BLOCKER):
75
- //
76
- // 'timerecord' — append-only snapshots (journal / session-log / weekly / archive
77
- // / postmortems + root log.md). Rewriting a [[old]] inside a past entry would
78
- // falsify that moment. BUT a directory move relocates the whole subtree, so a
79
- // time-record INSIDE the moved subtree that links a moving sibling must update
80
- // that intra-subtree path label (the page genuinely moved); see runDirectory.
81
- //
82
- // 'sources' — sources/* immutable CAPTURED material. Never rewritten, not even
83
- // inside a moved subtree: a directory move must not claim ownership over the
84
- // bytes of an external source we transcribed verbatim.
85
- //
86
- // Matches a path SEGMENT so `pages/journal/x.md` and `projects/p/session-log/y.md`
87
- // both qualify. Returns null for ordinary live pages.
88
- function preservationClass(rel) {
89
- const p = rel.replace(/\\/g, '/');
90
- if (/(^|\/)sources(\/|$)/.test(p)) return 'sources';
91
- if (p === 'log.md') return 'timerecord';
92
- if (/(^|\/)(journal|session-log|weekly|archive|postmortems)(\/|$)/.test(p)) return 'timerecord';
93
- return null;
94
- }
95
-
96
- // Single-page mode preserves BOTH classes as link sources (unchanged behavior): a
97
- // rename elsewhere in the vault must never churn a frozen snapshot or a source.
98
- function isPreservedSource(rel) {
99
- return preservationClass(rel) !== null;
100
- }
101
-
102
- // ── slug-form index (resolution with collision detection) ──────────────────────
103
- // Unlike lint's buildSlugMap (a Set that silently dedups collisions), rename
104
- // needs to KNOW when a form is shared, so it maps each form → the set of page
105
- // rels that expose it. precedence forms per page: full noExt slug, bare
106
- // basename, dir-relative (drop the leading scan-dir segment).
107
- const dirRelForm = (slug) => slugForms(slug).dirRel;
108
-
109
- function buildFormIndex(pages) {
110
- const index = new Map(); // form → Set<rel>
111
- const add = (form, rel) => {
112
- if (!form) return;
113
- if (!index.has(form)) index.set(form, new Set());
114
- index.get(form).add(rel);
115
- };
116
- for (const p of pages) {
117
- add(p.slug, p.rel);
118
- // sources/* are full-slug-only link targets, exactly as lint's
119
- // collectLinkTargets treats them: a bare `[[name]]` must NOT resolve to a
120
- // source file. Adding their bare/dir-relative aliases here would make a real
121
- // page's bare link look ambiguous and skip a legitimate rewrite.
122
- if (/(^|\/)sources(\/|$)/.test(p.rel)) continue;
123
- add(p.bare, p.rel);
124
- add(dirRelForm(p.slug), p.rel);
125
- }
126
- return index;
127
- }
128
-
129
- // Classify a link target against the from-page. Returns the form KIND when the
130
- // target points at from-page, plus whether that form is ambiguous (shared with
131
- // another page → unsafe to auto-rewrite).
132
- function classifyTarget(target, fromPage, formIndex) {
133
- const owners = formIndex.get(target);
134
- if (!owners || !owners.has(fromPage.rel)) return { kind: null, ambiguous: false };
135
- const ambiguous = owners.size > 1;
136
- let kind = null;
137
- if (target === fromPage.slug) kind = 'full';
138
- else if (target === dirRelForm(fromPage.slug)) kind = 'dirrel';
139
- else if (target === fromPage.bare) kind = 'bare';
140
- return { kind, ambiguous };
141
- }
142
-
143
- // The new target string for a matched form kind — same kind, new page.
144
- function newTargetFor(kind, toPage) {
145
- if (kind === 'full') return toPage.slug;
146
- if (kind === 'dirrel') return dirRelForm(toPage.slug) ?? toPage.bare;
147
- return toPage.bare; // bare
148
- }
149
-
150
- // ── wikilink masking (mirror lint.mjs stripNonWikilinkRegions) ──────────────────
151
- // Blank out fenced code, inline code, and HTML comments WITHOUT changing length,
152
- // so a [[ref]] match index in the mask aligns with the same index in the source.
153
- // Rewriting then edits the source at those exact spans, never touching a link
154
- // that only appears inside a code sample.
155
- function maskNonWikilinkRegions(content) {
156
- let out = content;
157
- out = out.replace(/^[ \t]{0,3}```[\s\S]*?^[ \t]{0,3}```/gm, (m) => m.replace(/[^\n]/g, ' '));
158
- out = out.replace(/^[ \t]{0,3}~~~[\s\S]*?^[ \t]{0,3}~~~/gm, (m) => m.replace(/[^\n]/g, ' '));
159
- out = out.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
160
- out = out.replace(/``[^`\n]*``/g, (m) => ' '.repeat(m.length));
161
- out = out.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
162
- return out;
163
- }
164
-
165
- // Parse the inside of a `[[ ... ]]` into { target, suffix } where suffix is the
166
- // alias/anchor tail to preserve verbatim (including a table-escaped `\|`). The
167
- // target capture stops before an optional `\` preceding the `|`/`#` delimiter,
168
- // matching lint's extractor exactly.
169
- function splitLinkBody(body) {
170
- const m = body.match(/^([^|#\\]+?)(\\?[|#][\s\S]*)?$/);
171
- if (!m) return null;
172
- return { target: m[1].trim(), suffix: m[2] || '' };
173
- }
83
+ // ── preservation class of a link-source path, form index, masking, and link-body
84
+ // parsing (preservationClass / isPreservedSource / dirRelForm / buildFormIndex /
85
+ // classifyTarget / newTargetFor / maskNonWikilinkRegions / splitLinkBody) now
86
+ // live in ./lib/slug-resolver.mjs, shared with lint.mjs's existence-check mode.
87
+ // Directory mode's own notes on the two preservation classes: 'timerecord' is
88
+ // skipped as a link SOURCE outside the moved subtree, but a time-record INSIDE
89
+ // the moved subtree that links a moving sibling must update that intra-subtree
90
+ // path label (the page genuinely moved); see runDirectory. 'sources' is never
91
+ // rewritten even inside a moved subtree: a directory move must not claim
92
+ // ownership over the bytes of an external source transcribed verbatim.
174
93
 
175
94
  // Rewrite every inbound reference to fromPage in `content`. Returns
176
95
  // { content, rewrites, ambiguous } where rewrites/ambiguous list the links
@@ -316,7 +235,9 @@ function guardExistingMarker(args, thisMarker) {
316
235
  );
317
236
  }
318
237
  const same =
319
- marker.mode === thisMarker.mode && marker.from === thisMarker.from && marker.to === thisMarker.to;
238
+ marker.mode === thisMarker.mode &&
239
+ marker.from === thisMarker.from &&
240
+ marker.to === thisMarker.to;
320
241
  if (!same) {
321
242
  fail(
322
243
  args,
@@ -468,40 +389,10 @@ function rewriteContentDir(content, movedByRel, formIndex, aliasPreserve) {
468
389
  return { content: out, rewrites, ambiguous };
469
390
  }
470
391
 
471
- // Verify a path resolves — following any symlink ANCESTOR — to a location inside
472
- // the real vault root. A lexical ../-check cannot catch this: `projects/link/new`
473
- // where `projects/link` → /tmp/outside is lexically in-vault, yet renameSync would
474
- // follow the symlink and write across the vault boundary. The destination may not
475
- // exist, so the deepest existing prefix is resolved (its realpath is where a write
476
- // would actually land). Fail-closed: returns false on any resolution error.
477
- //
478
- // The walk uses lstat (NOT existsSync) so a DANGLING symlink prefix is detected as
479
- // present-but-unresolvable rather than skipped as absent — otherwise the walk would
480
- // step past it to an in-vault parent and wrongly report containment, letting an
481
- // external rewrite land before renameSync crashes on the dangling target.
482
- function realContainedInVault(absPath, realRoot) {
483
- let probe = absPath;
484
- // Walk up to the deepest path component that exists as a link-or-real entry.
485
- for (;;) {
486
- let exists = true;
487
- try {
488
- lstatSync(probe);
489
- } catch {
490
- exists = false;
491
- }
492
- if (exists) break;
493
- const parent = dirname(probe);
494
- if (parent === probe) return false;
495
- probe = parent;
496
- }
497
- let real;
498
- try {
499
- real = realpathSync(probe); // follows links; throws on a dangling symlink
500
- } catch {
501
- return false;
502
- }
503
- return real === realRoot || real.startsWith(realRoot + sep);
504
- }
392
+ // realContainedInVault now lives in ./lib/slug-resolver.mjs (imported above),
393
+ // reused as-is: `projects/link/new` where `projects/link` → /tmp/outside is
394
+ // lexically in-vault, yet renameSync would follow the symlink and write
395
+ // across the vault boundary, which is exactly what it guards against below.
505
396
 
506
397
  // The destination's deepest existing ancestor must be a directory. Otherwise
507
398
  // mkdirSync(dirname, {recursive}) fails with ENOTDIR — but only AFTER the inbound
@@ -760,7 +651,10 @@ function runDirectory(args, fromDirRel, ignorePatterns) {
760
651
  // instead of an unsafe write-then-remove fallback; every write above is
761
652
  // already atomic, so a re-run picks up cleanly.
762
653
  if (err.code !== 'EXDEV') throw err;
763
- fail(args, `--from and --to ended up on different filesystems mid-run — refusing an unsafe fallback move.`);
654
+ fail(
655
+ args,
656
+ `--from and --to ended up on different filesystems mid-run — refusing an unsafe fallback move.`,
657
+ );
764
658
  }
765
659
  clearRenameMarker(args);
766
660
  moved = true;
@@ -974,7 +868,10 @@ function run(args) {
974
868
  // removes. fromPage.path still holds the valid (possibly rewritten)
975
869
  // body, so a re-run picks up cleanly once the device issue is gone.
976
870
  if (err.code !== 'EXDEV') throw err;
977
- fail(args, `--from and --to ended up on different filesystems mid-run — refusing an unsafe fallback move.`);
871
+ fail(
872
+ args,
873
+ `--from and --to ended up on different filesystems mid-run — refusing an unsafe fallback move.`,
874
+ );
978
875
  }
979
876
  }
980
877
  clearRenameMarker(args);
@@ -14,6 +14,24 @@
14
14
  * --force-commands Remove user-modified slash commands instead of preserving them
15
15
  * --force-extensions Remove user-modified extension files (hypo-ext-*) instead of preserving them
16
16
  * --hooks-dir=<path> Override Claude hooks directory (default: ~/.claude/hooks)
17
+ * --hypo-dir=<path> Wiki vault to remove the pre-commit hook from (default: auto-resolve,
18
+ * same rules as init/lint/query)
19
+ * --shell-config=<path> Shell rc file to strip the shell block from (default: checks both
20
+ * ~/.zshrc and ~/.bashrc, since init may have run under either shell)
21
+ * --keep-shell Skip the shell rc `claude()` block removal entirely
22
+ * --keep-wiki-hook Skip the wiki pre-commit hook removal entirely
23
+ *
24
+ * The wiki's git pre-commit hook and the shell rc's `claude()` wrapper function are removed
25
+ * only when they still carry the marker init.mjs wrote (WIKI_PRE_COMMIT_MARKER_START /
26
+ * SHELL_MARKER_START, both from ./lib/git-hooks-dir.mjs). A user's own pre-commit hook, a
27
+ * symlinked hook target, and any rc content outside the marker block are never touched.
28
+ *
29
+ * --hooks-dir only redirects where the ~/.claude/hooks/*.mjs removal looks; it does NOT bound
30
+ * the rc-block and wiki-hook removals above, since those live outside ~/.claude entirely and a
31
+ * hypo-dir override already exists for the latter. A caller that wants an uninstall run scoped
32
+ * to a throwaway hooks dir (a sandboxed CI check, for instance) and NOT touching the real
33
+ * machine's shell rc files or scanning for a real vault must say so explicitly with
34
+ * --keep-shell --keep-wiki-hook.
17
35
  *
18
36
  * Extensions: hypo-ext-* hard-copies under
19
37
  * ~/.claude/{hooks,commands,skills,agents}/ and ~/.codex/{hooks,commands}/ (with
@@ -23,7 +41,16 @@
23
41
  * does not follow them). The wiki source (~/hypomnema/extensions/) is preserved.
24
42
  */
25
43
 
26
- import { existsSync, readFileSync, writeFileSync, rmSync, rmdirSync, readdirSync } from 'fs';
44
+ import {
45
+ existsSync,
46
+ readFileSync,
47
+ writeFileSync,
48
+ rmSync,
49
+ rmdirSync,
50
+ readdirSync,
51
+ statSync,
52
+ realpathSync,
53
+ } from 'fs';
27
54
  import { join } from 'path';
28
55
  import { homedir } from 'os';
29
56
  import { fileURLToPath } from 'url';
@@ -48,6 +75,20 @@ import {
48
75
  buildHookCommand,
49
76
  } from './lib/extensions.mjs';
50
77
  import { removeProvenanceSidecar } from './lib/pkg-provenance.mjs';
78
+ import {
79
+ hooksDirForInstall,
80
+ unsafeHookTargetReason,
81
+ findMarkerSpan,
82
+ isOwnedWikiPreCommitBody,
83
+ isOwnedShellFunctionBody,
84
+ canonicalize,
85
+ isInside,
86
+ WIKI_PRE_COMMIT_MARKER_START,
87
+ WIKI_PRE_COMMIT_MARKER_END,
88
+ SHELL_MARKER_START,
89
+ SHELL_MARKER_END,
90
+ } from './lib/git-hooks-dir.mjs';
91
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
51
92
 
52
93
  const HOME = homedir();
53
94
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -428,6 +469,226 @@ function stripExtensionSettings(settingsPath, hooksDir, apply, ownedCommands = n
428
469
  return { stripped };
429
470
  }
430
471
 
472
+ // ── wiki pre-commit hook removal ────────────────────────────────────────────
473
+
474
+ // Mirrors init.mjs's own resolution (hooksDirForInstall) as the PRIMARY
475
+ // candidate, so this finds the hook wherever init would put it today,
476
+ // including under a core.hooksPath override. A second, best-effort candidate
477
+ // (the vault's plain .git/hooks/pre-commit) is checked too, but it only
478
+ // recovers ONE specific drift: an install that went to the default .git/hooks
479
+ // and had core.hooksPath pointed elsewhere afterward. It does not recover the
480
+ // general case (an install that went to a non-default core.hooksPath which was
481
+ // then repointed somewhere else again), since the only fallback candidate is
482
+ // the plain .git/hooks path, never the original custom one. Both candidates go
483
+ // through the same marker/ownership gate below, so widening the search costs
484
+ // nothing in safety, only in how many places we bother to look.
485
+ // A hook that still carries our marker is removed; a user's own pre-commit (no
486
+ // marker), a symlinked/non-regular target, and a hook whose marker is
487
+ // duplicated, swapped, or missing its shebang are all left standing.
488
+ // `pre-commit.bak` (init's --force-commands backup of the user's original
489
+ // hook) is never removed here, only reported so the user knows it exists.
490
+ function removeWikiPreCommitHook(hypoDir, apply) {
491
+ const result = { removed: [], skipped: [], bakPresent: [] };
492
+
493
+ if (!hypoDir || !existsSync(join(hypoDir, 'hypo-config.md'))) {
494
+ result.skipped.push(
495
+ `no Hypomnema vault found${hypoDir ? ` at ${hypoDir}` : ''} — nothing to remove`,
496
+ );
497
+ return result;
498
+ }
499
+
500
+ const candidateDirs = [];
501
+ const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
502
+ if (hooksDir) candidateDirs.push(hooksDir);
503
+ else if (skip) result.skipped.push(skip);
504
+
505
+ // Best-effort fallback: only when `.git` is a real directory here (a plain
506
+ // checkout, not a linked worktree's gitdir-pointer FILE, which this join
507
+ // would misread entirely — resolveGitHooksDir already handles that layout
508
+ // correctly via the primary candidate above).
509
+ const legacyGitDir = join(hypoDir, '.git');
510
+ if (existsSync(legacyGitDir) && statSync(legacyGitDir).isDirectory()) {
511
+ const legacyHooksDir = join(legacyGitDir, 'hooks');
512
+ // Canonicalize before trusting this candidate. The primary path above
513
+ // already refuses to write through a `.git/hooks` that resolves outside
514
+ // the repo (resolveGitHooksDir's `owned` check), but that refusal does
515
+ // nothing for THIS fallback, which built its candidate by string join,
516
+ // not by resolving anything. If `.git/hooks` (or any ancestor of it) is
517
+ // itself a symlink to an external directory, the join above still points
518
+ // there, and the leaf-only unsafeHookTargetReason() below cannot see it:
519
+ // lstat on the FINAL path component says nothing about a symlink the OS
520
+ // already followed to reach that component. Codex reproduced exactly
521
+ // this (2026-08-27): a symlinked `.git/hooks` pointing at an external
522
+ // directory let this fallback delete a file the primary path had
523
+ // already, correctly, refused to touch.
524
+ const resolvedCandidate = canonicalize(legacyHooksDir);
525
+ if (isInside(resolvedCandidate, canonicalize(legacyGitDir))) {
526
+ candidateDirs.push(legacyHooksDir);
527
+ }
528
+ }
529
+
530
+ const seen = new Set();
531
+ for (const dir of candidateDirs) {
532
+ const hookPath = join(dir, 'pre-commit');
533
+ // Dedupe by the resolved real path, not the string: the primary candidate
534
+ // is realpath'd internally (resolveGitHooksDir's canonicalize) while the
535
+ // legacy join above is not, so the same physical file can arrive under two
536
+ // different-looking paths. Falling back to the raw path when the file does
537
+ // not exist is fine — two distinct absent candidates never collide.
538
+ let key = hookPath;
539
+ try {
540
+ key = realpathSync(hookPath);
541
+ } catch {
542
+ // leave key as hookPath
543
+ }
544
+ if (seen.has(key)) continue;
545
+ seen.add(key);
546
+
547
+ const unsafe = unsafeHookTargetReason(hookPath);
548
+ if (unsafe) {
549
+ result.skipped.push(`${hookPath} (${unsafe})`);
550
+ continue;
551
+ }
552
+ if (!existsSync(hookPath)) continue; // already absent, nothing to report
553
+
554
+ let content;
555
+ try {
556
+ content = readFileSync(hookPath, 'utf-8');
557
+ } catch (e) {
558
+ result.skipped.push(`${hookPath} (cannot read: ${e.code || e.message})`);
559
+ continue;
560
+ }
561
+ if (
562
+ !content.includes(WIKI_PRE_COMMIT_MARKER_START) ||
563
+ !content.includes(WIKI_PRE_COMMIT_MARKER_END)
564
+ ) {
565
+ result.skipped.push(`${hookPath} (not managed by Hypomnema — preserving)`);
566
+ continue;
567
+ }
568
+
569
+ // Report the backup only past the ownership gate above: a vault with no
570
+ // Hypomnema hook at all (or a symlinked/unreadable one) can still happen
571
+ // to have a stray pre-commit.bak lying around, and attributing that to
572
+ // "from --force-commands" before confirming this IS a Hypomnema-managed
573
+ // hook would misdescribe someone else's file.
574
+ const bakPath = `${hookPath}.bak`;
575
+ if (existsSync(bakPath)) result.bakPresent.push(bakPath);
576
+
577
+ const span = findMarkerSpan(content, WIKI_PRE_COMMIT_MARKER_START, WIKI_PRE_COMMIT_MARKER_END);
578
+ if (!span.ok) {
579
+ result.skipped.push(`${hookPath} (${span.reason} — preserving)`);
580
+ continue;
581
+ }
582
+
583
+ // init writes the marker as the ENTIRE hook body: a bare "#!/bin/sh\n"
584
+ // right before WIKI_PRE_COMMIT_MARKER_START, nothing after
585
+ // WIKI_PRE_COMMIT_MARKER_END. A file with no shebang there was never
586
+ // written by init even if it happens to carry a well-formed marker span
587
+ // (hand-authored or copy-pasted) — deleting it on marker presence alone
588
+ // would remove code we do not own. A user who appended their own check
589
+ // after the block turned this into a file we only partly own. init's own
590
+ // --force-commands path handles the analogous case by OVERWRITING with
591
+ // equivalent content (a safe merge); uninstall has no such repair, only
592
+ // rmSync, so treating "extra content" the same as "fully ours" would
593
+ // silently delete the user's check with no way back.
594
+ const before = content.slice(0, span.startIdx);
595
+ const after = content.slice(span.endIdx + WIKI_PRE_COMMIT_MARKER_END.length);
596
+ if (!/^#![^\n]*\n$/.test(before)) {
597
+ result.skipped.push(
598
+ `${hookPath} (hook carries content before the Hypomnema block — preserving)`,
599
+ );
600
+ continue;
601
+ }
602
+ if (after.trim() !== '') {
603
+ result.skipped.push(
604
+ `${hookPath} (hook carries content after the Hypomnema block — preserving)`,
605
+ );
606
+ continue;
607
+ }
608
+
609
+ // A well-formed span (one start, one end, in order) with a bare shebang
610
+ // before it and nothing after proves only the SHAPE around the block is
611
+ // ours. It says nothing about what is INSIDE the block — a marker pair
612
+ // can be hand-copied around arbitrary content, including a user's own
613
+ // check (codex BLOCKER, 2026-08-27). Refuse unless the body itself is
614
+ // recognizable as what wikiPreCommitContent() writes.
615
+ if (!isOwnedWikiPreCommitBody(content, span)) {
616
+ result.skipped.push(
617
+ `${hookPath} (marker span present but its body does not match the hook Hypomnema writes — preserving)`,
618
+ );
619
+ continue;
620
+ }
621
+
622
+ if (apply) rmSync(hookPath);
623
+ result.removed.push(hookPath);
624
+ }
625
+
626
+ // Every candidate came back plain-absent (existsSync(hookPath) was false for
627
+ // all of them): nothing was removed, and nothing was skipped-with-a-reason
628
+ // either, so silence here would read as "there was nothing to say" when it
629
+ // actually means "the fallback above did not find a marked hook anywhere it
630
+ // looked". Report that explicitly instead of just going quiet — and name the
631
+ // one drift this cannot recover from: if core.hooksPath pointed somewhere
632
+ // else at install time and has since been repointed AGAIN (custom to
633
+ // custom, not the default-to-custom case the fallback above does cover),
634
+ // the hook Hypomnema wrote is still sitting at that first custom path,
635
+ // still executable, and can fail a future commit there with no cleanup
636
+ // path from this run.
637
+ if (candidateDirs.length > 0 && result.removed.length === 0 && result.skipped.length === 0) {
638
+ result.skipped.push(
639
+ `no Hypomnema-marked pre-commit hook found in ${candidateDirs.join(' or ')} — if core.hooksPath ` +
640
+ `pointed somewhere else at install time and has since changed again, the hook Hypomnema wrote ` +
641
+ `may still be sitting at that earlier path; it will keep running on every commit there and can ` +
642
+ `fail commits until it is removed by hand`,
643
+ );
644
+ }
645
+
646
+ return result;
647
+ }
648
+
649
+ // ── shell function block removal ────────────────────────────────────────────
650
+
651
+ // init picks ONE rc file at install time from $SHELL (or --shell-config), but
652
+ // $SHELL by uninstall time may point somewhere else, or init may have run in
653
+ // a different shell session altogether — so both common rc files are checked
654
+ // by default rather than guessing one. Only the marker span itself is
655
+ // stripped; every other byte in the file, including surrounding blank lines,
656
+ // is left exactly as it was. A malformed span (duplicated or swapped markers,
657
+ // see findMarkerSpan above) leaves the file completely untouched: an rc file
658
+ // is the user's own, and this script has no backup to restore it from.
659
+ function removeShellFunctionBlock(shellConfigPath, apply) {
660
+ if (!existsSync(shellConfigPath)) return null;
661
+ const content = readFileSync(shellConfigPath, 'utf-8');
662
+ if (!content.includes(SHELL_MARKER_START) && !content.includes(SHELL_MARKER_END)) {
663
+ return null; // block not present here at all
664
+ }
665
+
666
+ const span = findMarkerSpan(content, SHELL_MARKER_START, SHELL_MARKER_END);
667
+ if (!span.ok) {
668
+ return { path: shellConfigPath, removed: false, skipped: span.reason };
669
+ }
670
+
671
+ // A well-formed span proves only that a start and an end marker exist in
672
+ // order — nothing about what sits between them. A marker pair copy-pasted
673
+ // around a user's own function (or appended to, inside the same span) would
674
+ // pass every check above and get removed along with that user's code
675
+ // (codex BLOCKER, 2026-08-27). Refuse unless the body between the markers
676
+ // is byte-identical to what init.mjs installs.
677
+ if (!isOwnedShellFunctionBody(content, span)) {
678
+ return {
679
+ path: shellConfigPath,
680
+ removed: false,
681
+ skipped:
682
+ 'marker span present but its body does not match the shell function Hypomnema installs',
683
+ };
684
+ }
685
+
686
+ const updated =
687
+ content.slice(0, span.startIdx) + content.slice(span.endIdx + SHELL_MARKER_END.length);
688
+ if (apply) writeFileSync(shellConfigPath, updated);
689
+ return { path: shellConfigPath, removed: true, skipped: null };
690
+ }
691
+
431
692
  // ── arg parsing ──────────────────────────────────────────────────────────────
432
693
 
433
694
  function parseArgs(argv) {
@@ -437,13 +698,29 @@ function parseArgs(argv) {
437
698
  hooksDir: null,
438
699
  forceCommands: false,
439
700
  forceExtensions: false,
701
+ hypoDir: null,
702
+ shellConfig: null,
703
+ keepShell: false,
704
+ keepWikiHook: false,
440
705
  };
441
706
  for (const arg of argv.slice(2)) {
442
707
  if (arg === '--apply') args.apply = true;
443
708
  else if (arg === '--codex') args.codex = true;
444
709
  else if (arg === '--force-commands') args.forceCommands = true;
445
710
  else if (arg === '--force-extensions') args.forceExtensions = true;
711
+ else if (arg === '--keep-shell') args.keepShell = true;
712
+ else if (arg === '--keep-wiki-hook') args.keepWikiHook = true;
446
713
  else if (arg.startsWith('--hooks-dir=')) args.hooksDir = arg.slice(12);
714
+ // expandHome mirrors init.mjs's own --hypo-dir/--shell-config parsing
715
+ // (init.mjs's parseArgs) exactly, reusing the same function from
716
+ // ./lib/hypo-root.mjs rather than re-deriving it. Uninstall must undo
717
+ // whatever path init actually wrote to disk, and init resolves a leading
718
+ // "~/" itself (the shell never does, since the value arrives already
719
+ // quoted inside "--flag=value"); skipping that step here would silently
720
+ // fail to find the vault or rc file a user installed with "~/..." to
721
+ // begin with.
722
+ else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
723
+ else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
447
724
  }
448
725
  return args;
449
726
  }
@@ -580,6 +857,22 @@ function stripSettingsJson(settingsPath, hooksDir, hookMap, apply) {
580
857
  const args = parseArgs(process.argv);
581
858
  const dryRun = !args.apply;
582
859
 
860
+ // --hooks-dir only redirects the ~/.claude/hooks/*.mjs cleanup below (see the
861
+ // module doc comment). A caller who passes it alone, expecting the run to
862
+ // stay confined to that throwaway directory, is surprised when the real
863
+ // machine's shell rc files and auto-resolved wiki vault get touched too
864
+ // (codex CONCERN, 2026-08-27). Warn before any of the removal functions run,
865
+ // not just in the final report, since by the time that report prints under
866
+ // --apply the files are already gone.
867
+ if (args.hooksDir && !args.keepShell && !args.keepWikiHook) {
868
+ console.error(
869
+ `⚠ --hooks-dir only redirects the ~/.claude/hooks/*.mjs cleanup. This run will still ` +
870
+ `${dryRun ? 'inspect' : 'modify'} the real shell rc files (~/.zshrc, ~/.bashrc, or --shell-config) ` +
871
+ `and the auto-resolved wiki vault's pre-commit hook. Pass --keep-shell and/or --keep-wiki-hook to ` +
872
+ `scope this run to --hooks-dir only.`,
873
+ );
874
+ }
875
+
583
876
  const { hookMap, hookFiles } = loadHookFiles();
584
877
 
585
878
  const claudeHooksDir = args.hooksDir ?? join(HOME, '.claude', 'hooks');
@@ -589,6 +882,39 @@ const hookResult = removeHookFiles(claudeHooksDir, hookFiles, args.apply);
589
882
  const settingsResult = stripSettingsJson(claudeSettings, claudeHooksDir, hookMap, args.apply);
590
883
  const commandResult = removeCommands(args.apply, args.forceCommands);
591
884
 
885
+ // Wiki-side cleanup: the git pre-commit hook and the shell rc block init.mjs
886
+ // installs outside ~/.claude entirely. Both are independent of --codex/--hooks-dir:
887
+ // --hooks-dir only redirects the ~/.claude/hooks/*.mjs removal above, it does not
888
+ // bound these two, since they live outside ~/.claude and already have their own
889
+ // scoping flags (--hypo-dir, --shell-config). A caller that wants an uninstall run
890
+ // confined to a throwaway --hooks-dir and NOT touching the real machine's shell rc
891
+ // files or scanning for a real vault (a sandboxed CI check, for instance) says so
892
+ // explicitly with --keep-shell / --keep-wiki-hook rather than relying on
893
+ // --hooks-dir to imply it.
894
+ // resolveHypoRoot() scans a fixed list of candidate directories under HOME
895
+ // (see ./lib/hypo-root.mjs). --keep-wiki-hook says the caller does not want
896
+ // this run touching a vault at all, so the scan itself is skipped rather than
897
+ // run and then discarded — matching the module doc comment above ("--keep-
898
+ // wiki-hook" is described as skipping the cleanup outright, not as skipping
899
+ // only the removal after still resolving a root).
900
+ const hypoDir = args.keepWikiHook ? null : (args.hypoDir ?? resolveHypoRoot());
901
+ const preCommitResult = args.keepWikiHook
902
+ ? {
903
+ removed: [],
904
+ skipped: ['--keep-wiki-hook passed: wiki pre-commit hook cleanup skipped'],
905
+ bakPresent: [],
906
+ }
907
+ : removeWikiPreCommitHook(hypoDir, args.apply);
908
+
909
+ const shellConfigCandidates = args.shellConfig
910
+ ? [args.shellConfig]
911
+ : [join(HOME, '.zshrc'), join(HOME, '.bashrc')];
912
+ const shellBlockOutcomes = args.keepShell
913
+ ? []
914
+ : shellConfigCandidates.map((p) => removeShellFunctionBlock(p, args.apply)).filter(Boolean);
915
+ const shellBlockResults = shellBlockOutcomes.filter((r) => r.removed).map((r) => r.path);
916
+ const shellBlockSkipped = shellBlockOutcomes.filter((r) => !r.removed);
917
+
592
918
  // Extensions. Order matters: remove files first, then strip
593
919
  // settings, then surgically clear the per-target SHA map. The SHA strip uses
594
920
  // removedKeys so a user-modified file we left in place keeps its recorded SHA
@@ -736,6 +1062,27 @@ if (hookResult.missing.length)
736
1062
  lines.push(
737
1063
  `⊘ Already absent (${hookResult.missing.length}):\n${hookResult.missing.map((p) => ` ${p}`).join('\n')}`,
738
1064
  );
1065
+ if (preCommitResult.removed.length)
1066
+ lines.push(
1067
+ `✓ Wiki pre-commit hook ${dryRun ? 'to remove' : 'removed'} (${preCommitResult.removed.length}):\n${preCommitResult.removed.map((p) => ` ${p}`).join('\n')}`,
1068
+ );
1069
+ if (preCommitResult.skipped.length)
1070
+ lines.push(
1071
+ `⊘ Wiki pre-commit hook preserved:\n${preCommitResult.skipped.map((p) => ` ${p}`).join('\n')}`,
1072
+ );
1073
+ if (preCommitResult.bakPresent.length)
1074
+ lines.push(
1075
+ `ⓘ Pre-commit backup left in place (from --force-commands, never touched by uninstall):\n${preCommitResult.bakPresent.map((p) => ` ${p}`).join('\n')}`,
1076
+ );
1077
+ if (shellBlockResults.length)
1078
+ lines.push(
1079
+ `✓ Shell function block ${dryRun ? 'to remove' : 'removed'} (${shellBlockResults.length}):\n${shellBlockResults.map((p) => ` ${p}`).join('\n')}`,
1080
+ );
1081
+ if (shellBlockSkipped.length)
1082
+ lines.push(
1083
+ `⊘ Shell function block preserved:\n${shellBlockSkipped.map((r) => ` ${r.path} (${r.skipped})`).join('\n')}`,
1084
+ );
1085
+
739
1086
  if (settingsResult.error) lines.push(`⚠ ${settingsResult.error}`);
740
1087
  if (claudeExtSettings.error) lines.push(`⚠ ${claudeExtSettings.error}`);
741
1088
  if (codexExtSettings.error) lines.push(`⚠ ${codexExtSettings.error}`);
@@ -750,7 +1097,9 @@ if (
750
1097
  !pkgJsonRemoved &&
751
1098
  !commandResult.skippedUserModified.length &&
752
1099
  !extSkippedUserModified.length &&
753
- !extSkippedNonRegular.length
1100
+ !extSkippedNonRegular.length &&
1101
+ !preCommitResult.removed.length &&
1102
+ !shellBlockResults.length
754
1103
  ) {
755
1104
  lines.push('Nothing to uninstall — Hypomnema does not appear to be installed.');
756
1105
  }
@@ -50,8 +50,8 @@ If `/hypo:crystallize` was invoked as a session-close action, run through this c
50
50
  Surface each of these four to the user first. Every one is **advisory** (identity guard): the user confirms or declines, and none performs an automatic action, writes a file on its own, or bypasses the mandatory gate.
51
51
 
52
52
  - **Trivial-session check (#44)** — Was this session trivial (a single bug fix, a single-file edit, or Q&A with no durable artifact)? If so, recommend skipping session-close: *"이 세션은 trivial해 보입니다 — session-close를 건너뛸까요?"* A trivial skip is a recommendation, **not** a bypass: it must not mark the session closed, must not run `--mark-session-closed`, and must not claim `/compact` can pass. Any real close still requires all 5 mandatory files.
53
- - **ADR-candidate check (#41)** — Did this session make an architectural or design decision (a new pattern, a tradeoff, a convention)? If yes, ask whether it warrants an ADR and capture that intent in the session-log entry. If nothing rose to ADR level, you may record the literal marker `ADR 없음 — <one-line reason>` in that same session-log entry — but gate it on #42's bar, not this one: the marker is machine-read and W8 treats a session-log entry carrying `ADR 없음` (and no ADR reference) as a *no-design* session, excluding it from the design-history staleness check. So write `ADR 없음` only when the session had no design change at all. If it had a sub-ADR design shift (background / tradeoff / differentiation), append to design-history (#42a) instead — writing the marker there would suppress the W8 nudge that shift needs. **Never auto-write an ADR file** — the session-log note is the only action here.
54
- - **design-history staleness check (#42)** — Two branches, so a stale W8 never blocks a clean close: (a) if this session changed design decisions that `projects/<name>/design-history.md` does not yet reflect — including background / tradeoff / differentiation shifts that are below ADR level but still belong in the ledger — recommend appending to it now (W8 flags this mechanically; an active-project W8 hard-blocks at PreCompact — append before you commit, not after the gate fires). (b) only if this session made **no** design change at all does the `ADR 없음` marker from #41 exempt the entry from W8 — do **not** touch design-history. Caution: `ADR 없음` means "no design change," which is a stricter bar than "no ADR-level decision." A session with a sub-ADR design shift should take branch (a) and append; writing `ADR 없음` there would suppress the W8 nudge it actually needs. If the file does not exist, skip silently — do **not** create it just for this check. Never auto-update it.
53
+ - **ADR-candidate check (#41).** Did this session make an architectural or design decision (a new pattern, a tradeoff, a convention)? If yes, ask whether it warrants an ADR and capture that intent in the session-log entry. If nothing rose to ADR level, you may record the literal marker `ADR 없음: <one-line reason>` in that same session-log entry, but gate it on #42's bar, not this one: the marker is machine-read and W8 treats a session-log entry carrying `ADR 없음` (and no ADR reference) as a *no-design* session, excluding it from the design-history staleness check. So write `ADR 없음` only when the session had no design change at all. If it had a sub-ADR design shift (background, tradeoff, or differentiation), append to design-history (#42a) instead: writing the marker there would suppress the W8 nudge that shift needs. **Never auto-write an ADR file.** The session-log note is the only action here. This check carries no `decisions/` directory precondition: run it whether or not that directory exists.
54
+ - **design-history staleness check (#42).** Two branches, so a stale W8 never blocks a clean close: (a) if this session changed design decisions that `projects/<name>/design-history.md` does not yet reflect (including background, tradeoff, or differentiation shifts that are below ADR level but still belong in the ledger), recommend appending to it now: W8 flags this mechanically, and an active-project W8 hard-blocks at PreCompact, so append before you commit, not after the gate fires. **If the file does not exist yet and this session had a design change, recommend creating it now** with that change as the first entry; lint separately flags a missing-but-needed file as W14, a warning that never blocks. (b) only if this session made **no** design change at all does the `ADR 없음` marker from #41 exempt the entry from W8; do **not** touch design-history, and do not create the file just to satisfy this branch's check. Caution: `ADR 없음` means "no design change," which is a stricter bar than "no ADR-level decision." A session with a sub-ADR design shift should take branch (a) and append; writing `ADR 없음` there would suppress the W8 nudge it actually needs. Never auto-write the file yourself in either branch: recommend it, and let the user decide.
55
55
  - **Ingest check (#43)** — Did this session consume trustworthy external knowledge (a fetched URL, official docs, or code you verified directly)? If so, recommend running `/hypo:ingest` to capture it under `sources/`. Proceed only on the user's confirmation.
56
56
 
57
57
  When uncertain, surface the question rather than skip it. None of the four blocks the close or writes on its own.