hypomnema 1.7.1 → 1.7.3

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.
@@ -28,6 +28,8 @@ import {
28
28
  unlinkSync,
29
29
  mkdirSync,
30
30
  lstatSync,
31
+ statSync,
32
+ chmodSync,
31
33
  rmdirSync,
32
34
  } from 'fs';
33
35
  import { join, dirname, relative, resolve, posix, sep } from 'path';
@@ -894,9 +896,26 @@ export function readExtensionPkgStateNoMutate(pkgPath, target) {
894
896
 
895
897
  // ── sync orchestration ─────────────────────────────────────────────────────────
896
898
 
897
- function writeFreshAtomic(dest, content) {
899
+ /** Carry only src's execute bits (owner/group/other) onto a mode value; every
900
+ * other permission bit (dest's own read/write, however umask shaped it) is
901
+ * left alone. This is the one place forward-sync decides "should this file be
902
+ * executable", so every writer of dest routes through it. Exported so
903
+ * capture.mjs's own atomic writer (a captured file's FIRST write into the wiki)
904
+ * applies the identical rule; the wiki copy must not lose the bit before
905
+ * forward-sync ever gets a chance to carry it further. */
906
+ export function withSrcExecBits(destMode, srcMode) {
907
+ return (destMode & ~0o111) | (srcMode & 0o111);
908
+ }
909
+
910
+ function writeFreshAtomic(dest, content, srcMode) {
898
911
  const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
899
912
  writeFileSync(tmp, content);
913
+ if (srcMode != null) {
914
+ // writeFileSync has no mode option that survives umask, so the execute bit
915
+ // has to be applied explicitly, and before the rename: otherwise dest is
916
+ // briefly visible with the wrong mode to anything racing this write.
917
+ chmodSync(tmp, withSrcExecBits(statSync(tmp).mode, srcMode));
918
+ }
900
919
  try {
901
920
  renameSync(tmp, dest);
902
921
  } catch (err) {
@@ -914,13 +933,19 @@ function writeFreshAtomic(dest, content) {
914
933
  * overwrites user-modified / unowned files; without it those are left untouched and
915
934
  * surface as drift/conflict. A symlink/non-regular dest is never followed even under
916
935
  * force (the isRegularFile guard precedes the force branch).
936
+ *
937
+ * The executable bit is not tracked anywhere (no mode column in the SHA map's
938
+ * ownership model), so src's mode is the only source of truth for it and gets
939
+ * carried onto dest on every branch that (re)writes it, including the
940
+ * content-identical `up-to-date` branch below.
917
941
  */
918
942
  function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
919
943
  const srcContent = readFileSync(srcPath);
920
944
  const srcSHA = sha256(srcContent);
945
+ const srcMode = statSync(srcPath).mode;
921
946
 
922
947
  if (!existsSync(destPath)) {
923
- if (apply) writeFreshAtomic(destPath, srcContent);
948
+ if (apply) writeFreshAtomic(destPath, srcContent, srcMode);
924
949
  return { action: 'create', sha: srcSHA };
925
950
  }
926
951
  if (!isRegularFile(destPath)) {
@@ -933,6 +958,20 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
933
958
  }
934
959
  const onDiskSHA = sha256(onDisk);
935
960
  if (onDiskSHA === srcSHA) {
961
+ // Content already matches, but the exec bit can still be stale: this used to
962
+ // be a pure no-op, which is exactly why a mismatched bit here never healed.
963
+ // Reported as 'update' (not a new action) so every existing "N to sync" /
964
+ // "N synced" count and log line already keyed on create/update/force-update
965
+ // picks it up for free.
966
+ // ponytail: this also overwrites an exec bit a user deliberately flipped on a
967
+ // file whose content happens to still match src (chmod -x'd a script they
968
+ // like read-only). Upgrade path: record mode next to sha in the pkg-json map
969
+ // so a user's own bit can be told apart from our default and left alone.
970
+ const destMode = statSync(destPath).mode;
971
+ if ((destMode & 0o111) !== (srcMode & 0o111)) {
972
+ if (apply) chmodSync(destPath, withSrcExecBits(destMode, srcMode));
973
+ return { action: 'update', sha: srcSHA };
974
+ }
936
975
  return { action: 'up-to-date', sha: srcSHA };
937
976
  }
938
977
  if (recordedSHA && onDiskSHA === recordedSHA) {
@@ -942,7 +981,7 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
942
981
  if (!verify || sha256(verify) !== recordedSHA) {
943
982
  return { action: 'skip-changed', sha: recordedSHA };
944
983
  }
945
- writeFreshAtomic(destPath, srcContent);
984
+ writeFreshAtomic(destPath, srcContent, srcMode);
946
985
  }
947
986
  return { action: 'update', sha: srcSHA };
948
987
  }
@@ -950,7 +989,7 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
950
989
  if (force) {
951
990
  if (apply) {
952
991
  writeFreshAtomic(`${destPath}.bak`, onDisk);
953
- writeFreshAtomic(destPath, srcContent);
992
+ writeFreshAtomic(destPath, srcContent, srcMode);
954
993
  }
955
994
  return { action: 'force-update', sha: srcSHA };
956
995
  }
@@ -38,6 +38,16 @@ import { execFileSync } from 'child_process';
38
38
  import { existsSync, lstatSync, realpathSync, statSync } from 'fs';
39
39
  import { basename, dirname, isAbsolute, join, resolve, sep } from 'path';
40
40
 
41
+ // ── shared install/uninstall markers ────────────────────────────────────────
42
+ // init.mjs writes these when it installs the wiki's git pre-commit hook and
43
+ // the shell rc block; uninstall.mjs reads them back to remove exactly what
44
+ // init created. Defined once here, imported by both, so the two scripts can
45
+ // never drift into recognizing different markers.
46
+ export const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
47
+ export const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
48
+ export const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
49
+ export const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
50
+
41
51
  // Fallback scrub list for git versions without `rev-parse --local-env-vars`.
42
52
  // Mirrors scripts/install-git-hooks.mjs, which established this trust model.
43
53
  const STATIC_LOCAL_ENV_VARS = [
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `.hypo-provenance.json` sidecar: copy-time producer proof for the
3
+ * standalone (manual/npm) hooks channel.
4
+ *
5
+ * hooks/hypo-shared.mjs's resolvePkgRoot() falls back to this file, verified
6
+ * against its own package name + a SHA-256 of the running hypo-shared.mjs,
7
+ * whenever self-location can't confirm a package root — the standalone hooks
8
+ * copy has no package.json alongside it to walk up to, by design (hooks
9
+ * import only Node built-ins, nothing outside the hooks dir).
10
+ *
11
+ * This is accidental-staleness protection, not a security boundary: any
12
+ * process running as this OS user can edit this JSON file freely, same as it
13
+ * could edit hooks/hypo-shared.mjs itself. It only guards against the
14
+ * accidental drift init/upgrade left unguarded before this fix — a hook
15
+ * file skip (already-present) that still let hypo-pkg.json's cached pointer
16
+ * move on to a newer version, silently mismatching the code actually copied.
17
+ *
18
+ * The filename and the producer-name check ("hypomnema") here must stay
19
+ * byte-identical with hooks/hypo-shared.mjs's own copy of this contract —
20
+ * hooks can't import scripts/, so the two sides are duplicated, not shared.
21
+ */
22
+
23
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'fs';
24
+ import { join } from 'path';
25
+ import { sha256 } from './pkg-json.mjs';
26
+ import { readCoreHooksConfig, deriveCoreHookBasenames } from './core-hooks.mjs';
27
+
28
+ export const PROVENANCE_FILENAME = '.hypo-provenance.json';
29
+ export const EXPECTED_PKG_NAME = 'hypomnema';
30
+
31
+ // Field name for the aggregate hook-set hash (BLOCKER A). Exported so
32
+ // scripts/doctor.mjs reads/writes the same key instead of duplicating the
33
+ // string literal — doctor already imports this module for
34
+ // PROVENANCE_FILENAME/EXPECTED_PKG_NAME, so importing one more constant costs
35
+ // nothing and closes the duplication CONCERN 1 flagged for the filename/name
36
+ // pair (those two stay duplicated only because hooks/hypo-shared.mjs can't
37
+ // import this file at all).
38
+ export const HOOKS_DIGEST_FIELD = 'hooksDigest';
39
+
40
+ /**
41
+ * Deterministic aggregate hash over every hook file hooks/hooks.json wires up
42
+ * (every event handler's file plus every `shared` entry) — the exact set
43
+ * installHooks/applyHookFiles copy, derived from hooks.json itself rather
44
+ * than hand-listed here so this can never drift from what actually gets
45
+ * copied. `dir` is read for CONTENT: pass hooksSrcDir at write time (the
46
+ * package's own hooks/, immediately after files were copied out of it — see
47
+ * writeProvenanceSidecar) and the installed hooksDir at doctor-check time
48
+ * (mirrors how hypoSharedSha256 below is written from source but verified
49
+ * against the installed copy).
50
+ *
51
+ * Deliberately NOT what the runtime resolver reads: hooks/hypo-shared.mjs's
52
+ * readVerifiedProvenancePkgRoot() only ever compares hypoSharedSha256, one
53
+ * file, because hook modules load on every single hook event and hashing an
54
+ * entire directory on every load would tax the hot path for a check only
55
+ * `hypomnema doctor` needs to run once per invocation. This digest is that
56
+ * doctor-only, directory-wide check: it catches a hooks/ dir where some OTHER
57
+ * file (e.g. hypo-personal-check.mjs) went stale while hypo-shared.mjs itself
58
+ * stayed byte-identical, which the single-file SHA the runtime trusts cannot
59
+ * see at all.
60
+ *
61
+ * Sorted basenames, `<file>\n<sha256-of-file-hex>\n` per file concatenated,
62
+ * then SHA-256 of that string — order and content only, never mtime/size.
63
+ * Returns null if hooks.json can't be read/parsed or any listed file can't be
64
+ * read, so a caller can treat "couldn't compute" the same as "nothing to
65
+ * compare" rather than writing/trusting a bogus digest.
66
+ */
67
+ export function computeHooksDigest(pkgRoot, dir) {
68
+ const cfgRes = readCoreHooksConfig(pkgRoot);
69
+ if (!cfgRes.ok) return null;
70
+ const files = [...deriveCoreHookBasenames(cfgRes.cfg)].sort();
71
+ try {
72
+ let acc = '';
73
+ for (const file of files) {
74
+ acc += `${file}\n${sha256(readFileSync(join(dir, file)))}\n`;
75
+ }
76
+ return sha256(acc);
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ export function provenancePath(hooksDir) {
83
+ return join(hooksDir, PROVENANCE_FILENAME);
84
+ }
85
+
86
+ /**
87
+ * Write/refresh the sidecar next to a standalone hooks copy at `hooksDir`.
88
+ * `hooksSrcDir` is the package's own hooks/ (the source hypo-shared.mjs was
89
+ * just copied FROM) — its content is what gets hashed, since that hash is
90
+ * what the copy left at `hooksDir` will actually match.
91
+ *
92
+ * Callers must invoke this every run that touches — or even just re-verifies
93
+ * — the hook file set, EVEN when every individual .mjs file was skipped as
94
+ * already-present. A skipped hook copy that still lets hypo-pkg.json's
95
+ * pkgVersion move on to a newer release is exactly the bug this sidecar
96
+ * exists to catch; refreshing it only when files actually changed would
97
+ * reintroduce that gap under a new name.
98
+ *
99
+ * Returns the sidecar path written, or null if the source couldn't be
100
+ * hashed (leaves any pre-existing sidecar untouched rather than write a
101
+ * broken one).
102
+ *
103
+ * `hooksDigest` (BLOCKER A) is best-effort: computed from the same
104
+ * `hooksSrcDir`, over the file set hooks.json actually wires up. Unlike
105
+ * hypoSharedSha256 above, a failure to compute it does not abort the write —
106
+ * it is a doctor-only freshness signal, not part of the runtime contract
107
+ * (hooks/hypo-shared.mjs never reads this field), so a sidecar the runtime
108
+ * can verify should not be withheld just because the digest step failed.
109
+ */
110
+ export function writeProvenanceSidecar(hooksDir, pkgRoot, pkgVersion, hooksSrcDir, dryRun) {
111
+ let hypoSharedSha256;
112
+ try {
113
+ hypoSharedSha256 = sha256(readFileSync(join(hooksSrcDir, 'hypo-shared.mjs')));
114
+ } catch {
115
+ return null;
116
+ }
117
+ const hooksDigest = computeHooksDigest(pkgRoot, hooksSrcDir);
118
+ const dest = provenancePath(hooksDir);
119
+ const data = {
120
+ pkgRoot,
121
+ pkgVersion,
122
+ hypoSharedSha256,
123
+ ...(hooksDigest ? { [HOOKS_DIGEST_FIELD]: hooksDigest } : {}),
124
+ copiedAt: new Date().toISOString(),
125
+ };
126
+ if (!dryRun) atomicWriteJson(dest, data);
127
+ return dest;
128
+ }
129
+
130
+ // Tmp file in the SAME directory as `dest`, then rename over it (CONCERN 2):
131
+ // a reader (readVerifiedProvenancePkgRoot in hooks/hypo-shared.mjs) that
132
+ // races a plain truncate-then-write can observe a half-written file and
133
+ // JSON.parse throws, degrading PKG_ROOT to null for that hook invocation.
134
+ // Same directory is required for the rename to be atomic — crossing a
135
+ // filesystem boundary would fall back to a non-atomic copy+delete.
136
+ function atomicWriteJson(dest, data) {
137
+ const tmp = `${dest}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
138
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
139
+ try {
140
+ renameSync(tmp, dest);
141
+ } catch (err) {
142
+ try {
143
+ unlinkSync(tmp);
144
+ } catch {
145
+ /* best-effort cleanup */
146
+ }
147
+ throw err;
148
+ }
149
+ }
150
+
151
+ /** Remove the sidecar at `hooksDir`, if present. Returns the path removed, or null. */
152
+ export function removeProvenanceSidecar(hooksDir, apply) {
153
+ const dest = provenancePath(hooksDir);
154
+ if (!existsSync(dest)) return null;
155
+ if (apply) unlinkSync(dest);
156
+ return dest;
157
+ }
158
+
159
+ /** Non-mutating read. Returns the parsed sidecar, or null on absence/corruption. */
160
+ export function readProvenanceSidecar(hooksDir) {
161
+ try {
162
+ return JSON.parse(readFileSync(provenancePath(hooksDir), 'utf-8'));
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
package/scripts/lint.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
20
20
  import { join, extname, basename } from 'path';
21
21
  import { resolveHypoRootInfo, checkVaultOrExit, expandHome } from './lib/hypo-root.mjs';
22
- import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
22
+ import { SESSION_STATE_NEXT_HEADINGS, closeFileTargetsGlobal } from '../hooks/hypo-shared.mjs';
23
23
  import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
24
24
  import {
25
25
  parseSchemaVocab,
@@ -554,6 +554,50 @@ const validTypes = new Set([...VALID_TYPES, ...parseSchemaTypes(args.hypoDir)]);
554
554
 
555
555
  for (const page of pages) lintPage(page, slugMap, tagVocab, pageDirs, validTypes);
556
556
 
557
+ // W4 (broken-wikilink only, NOT the full lintPage) for close's root-level
558
+ // write targets that fall outside pages/projects/journal: hot.md and log.md
559
+ // today. closeFileTargetsGlobal is the one list of what close writes
560
+ // (hooks/hypo-shared.mjs), reused here instead of re-derived, so a future
561
+ // close target is covered automatically. Only the entries NOT already under
562
+ // a scanDir are new; closeFileTargetsGlobal's projects/<slug>/* entries are
563
+ // already linted in full above.
564
+ //
565
+ // Why link-only and not the full lintPage: measured, not assumed. On the
566
+ // packaged templates/ (hot.md type:reference, log.md type:log, both declared
567
+ // in templates/SCHEMA.md's taxonomy), full lintPage on both files comes back
568
+ // completely clean, so "these types trip W2" is not the reason to hold back.
569
+ // The real reason is that live vaults do not all match the template. A vault
570
+ // whose SCHEMA.md predates the `log` type row (or has none) gets a fresh
571
+ // "Unknown type: log" W2 the moment log.md is run through lintPage, and a
572
+ // vault whose root log.md predates the frontmatter convention entirely (no
573
+ // leading `---` block at all, confirmed against a real maintainer vault)
574
+ // gets a fresh "No frontmatter found" W1. Both W1 and W2 are in
575
+ // STRICT_PROMOTE_IDS, so under --strict a vault that lint has always passed
576
+ // would start failing on a file whose content this fix never touched. Full
577
+ // lintPage stays reserved for pages/projects/journal, where every file was
578
+ // already being linted before this change and there is no such newly-exposed
579
+ // vault. W4 itself stays warn-only outside --strict, and postApply
580
+ // (crystallize.mjs) gates only on errors, so this addition can add a new
581
+ // warning to a close's lint output but can never fail one.
582
+ const closeRootTargets = [...closeFileTargetsGlobal(args.hypoDir)].filter(
583
+ (f) => !f.startsWith('pages/') && !f.startsWith('projects/') && !f.startsWith('journal/'),
584
+ );
585
+ for (const rel of closeRootTargets) {
586
+ const full = join(args.hypoDir, rel);
587
+ if (!existsSync(full) || isScanIgnored(full, args.hypoDir, ignorePatterns)) continue;
588
+ let content;
589
+ try {
590
+ content = readFileSync(full, 'utf-8');
591
+ } catch {
592
+ continue;
593
+ }
594
+ for (const link of extractWikilinks(content)) {
595
+ if (!slugMap.has(link)) {
596
+ issue('warn', rel, `Broken wikilink: [[${link}]]`, null, 'W4');
597
+ }
598
+ }
599
+ }
600
+
557
601
  // W8: design-history.md stale relative to session-log.md. Emitted once per
558
602
  // project (not per page) — runs outside the page loop. POSIX-separated path
559
603
  // literal (not path.join) so consumers can rely on `file.split('/')` shape
@@ -14,6 +14,15 @@
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
+ *
22
+ * The wiki's git pre-commit hook and the shell rc's `claude()` wrapper function are removed
23
+ * only when they still carry the marker init.mjs wrote (WIKI_PRE_COMMIT_MARKER_START /
24
+ * SHELL_MARKER_START, both from ./lib/git-hooks-dir.mjs). A user's own pre-commit hook, a
25
+ * symlinked hook target, and any rc content outside the marker block are never touched.
17
26
  *
18
27
  * Extensions: hypo-ext-* hard-copies under
19
28
  * ~/.claude/{hooks,commands,skills,agents}/ and ~/.codex/{hooks,commands}/ (with
@@ -23,7 +32,16 @@
23
32
  * does not follow them). The wiki source (~/hypomnema/extensions/) is preserved.
24
33
  */
25
34
 
26
- import { existsSync, readFileSync, writeFileSync, rmSync, rmdirSync, readdirSync } from 'fs';
35
+ import {
36
+ existsSync,
37
+ readFileSync,
38
+ writeFileSync,
39
+ rmSync,
40
+ rmdirSync,
41
+ readdirSync,
42
+ statSync,
43
+ realpathSync,
44
+ } from 'fs';
27
45
  import { join } from 'path';
28
46
  import { homedir } from 'os';
29
47
  import { fileURLToPath } from 'url';
@@ -47,6 +65,16 @@ import {
47
65
  hasSymlinkAncestor,
48
66
  buildHookCommand,
49
67
  } from './lib/extensions.mjs';
68
+ import { removeProvenanceSidecar } from './lib/pkg-provenance.mjs';
69
+ import {
70
+ hooksDirForInstall,
71
+ unsafeHookTargetReason,
72
+ WIKI_PRE_COMMIT_MARKER_START,
73
+ WIKI_PRE_COMMIT_MARKER_END,
74
+ SHELL_MARKER_START,
75
+ SHELL_MARKER_END,
76
+ } from './lib/git-hooks-dir.mjs';
77
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
50
78
 
51
79
  const HOME = homedir();
52
80
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -427,6 +455,182 @@ function stripExtensionSettings(settingsPath, hooksDir, apply, ownedCommands = n
427
455
  return { stripped };
428
456
  }
429
457
 
458
+ // ── marker-span validation (shared by both removal paths below) ────────────
459
+
460
+ // Two independent indexOf() calls cannot tell "well-formed" apart from
461
+ // "duplicated" or "swapped": if a file happens to hold two full copies of the
462
+ // block, indexOf finds only the first END, so slicing [firstStart, firstEnd]
463
+ // leaves the second copy's install behind with no report of it. If END
464
+ // precedes START (a hand-edited or corrupted file), slicing [start, end) with
465
+ // start > end does not error, it silently duplicates whatever sits between
466
+ // them into the "removed" span. Neither this script nor the file it is
467
+ // touching has a way back from either outcome, so a span is only trusted when
468
+ // both markers appear EXACTLY once and START comes before END.
469
+ function countOccurrences(content, needle) {
470
+ let count = 0;
471
+ let idx = 0;
472
+ while ((idx = content.indexOf(needle, idx)) !== -1) {
473
+ count++;
474
+ idx += needle.length;
475
+ }
476
+ return count;
477
+ }
478
+
479
+ function findMarkerSpan(content, startMarker, endMarker) {
480
+ const startCount = countOccurrences(content, startMarker);
481
+ const endCount = countOccurrences(content, endMarker);
482
+ if (startCount !== 1 || endCount !== 1) {
483
+ return {
484
+ ok: false,
485
+ reason: `expected exactly one start and one end marker, found ${startCount} start / ${endCount} end`,
486
+ };
487
+ }
488
+ const startIdx = content.indexOf(startMarker);
489
+ const endIdx = content.indexOf(endMarker);
490
+ if (!(startIdx < endIdx)) {
491
+ return { ok: false, reason: 'the end marker appears before the start marker' };
492
+ }
493
+ return { ok: true, startIdx, endIdx };
494
+ }
495
+
496
+ // ── wiki pre-commit hook removal ────────────────────────────────────────────
497
+
498
+ // Mirrors init.mjs's own resolution (hooksDirForInstall) as the PRIMARY
499
+ // candidate, so this finds the hook wherever init would put it today,
500
+ // including under a core.hooksPath override. A second, best-effort candidate
501
+ // (the vault's plain .git/hooks/pre-commit) is checked too: if core.hooksPath
502
+ // changed after install, the current resolution no longer points at the file
503
+ // init actually wrote, and that file would otherwise never be found. Both
504
+ // candidates go through the same marker/ownership gate below, so widening the
505
+ // search costs nothing in safety, only in how many places we bother to look.
506
+ // A hook that still carries our marker is removed; a user's own pre-commit (no
507
+ // marker), a symlinked/non-regular target, and a hook whose marker is
508
+ // duplicated, swapped, or missing its shebang are all left standing.
509
+ // `pre-commit.bak` (init's --force-commands backup of the user's original
510
+ // hook) is never removed here, only reported so the user knows it exists.
511
+ function removeWikiPreCommitHook(hypoDir, apply) {
512
+ const result = { removed: [], skipped: [], bakPresent: [] };
513
+
514
+ if (!hypoDir || !existsSync(join(hypoDir, 'hypo-config.md'))) {
515
+ result.skipped.push(`no Hypomnema vault found${hypoDir ? ` at ${hypoDir}` : ''} — nothing to remove`);
516
+ return result;
517
+ }
518
+
519
+ const candidateDirs = [];
520
+ const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
521
+ if (hooksDir) candidateDirs.push(hooksDir);
522
+ else if (skip) result.skipped.push(skip);
523
+
524
+ // Best-effort fallback: only when `.git` is a real directory here (a plain
525
+ // checkout, not a linked worktree's gitdir-pointer FILE, which this join
526
+ // would misread entirely — resolveGitHooksDir already handles that layout
527
+ // correctly via the primary candidate above).
528
+ const legacyGitDir = join(hypoDir, '.git');
529
+ if (existsSync(legacyGitDir) && statSync(legacyGitDir).isDirectory()) {
530
+ candidateDirs.push(join(legacyGitDir, 'hooks'));
531
+ }
532
+
533
+ const seen = new Set();
534
+ for (const dir of candidateDirs) {
535
+ const hookPath = join(dir, 'pre-commit');
536
+ // Dedupe by the resolved real path, not the string: the primary candidate
537
+ // is realpath'd internally (resolveGitHooksDir's canonicalize) while the
538
+ // legacy join above is not, so the same physical file can arrive under two
539
+ // different-looking paths. Falling back to the raw path when the file does
540
+ // not exist is fine — two distinct absent candidates never collide.
541
+ let key = hookPath;
542
+ try {
543
+ key = realpathSync(hookPath);
544
+ } catch {
545
+ // leave key as hookPath
546
+ }
547
+ if (seen.has(key)) continue;
548
+ seen.add(key);
549
+
550
+ const bakPath = `${hookPath}.bak`;
551
+ if (existsSync(bakPath)) result.bakPresent.push(bakPath);
552
+
553
+ const unsafe = unsafeHookTargetReason(hookPath);
554
+ if (unsafe) {
555
+ result.skipped.push(`${hookPath} (${unsafe})`);
556
+ continue;
557
+ }
558
+ if (!existsSync(hookPath)) continue; // already absent, nothing to report
559
+
560
+ let content;
561
+ try {
562
+ content = readFileSync(hookPath, 'utf-8');
563
+ } catch (e) {
564
+ result.skipped.push(`${hookPath} (cannot read: ${e.code || e.message})`);
565
+ continue;
566
+ }
567
+ if (!content.includes(WIKI_PRE_COMMIT_MARKER_START) || !content.includes(WIKI_PRE_COMMIT_MARKER_END)) {
568
+ result.skipped.push(`${hookPath} (not managed by Hypomnema — preserving)`);
569
+ continue;
570
+ }
571
+
572
+ const span = findMarkerSpan(content, WIKI_PRE_COMMIT_MARKER_START, WIKI_PRE_COMMIT_MARKER_END);
573
+ if (!span.ok) {
574
+ result.skipped.push(`${hookPath} (${span.reason} — preserving)`);
575
+ continue;
576
+ }
577
+
578
+ // init writes the marker as the ENTIRE hook body: a bare "#!/bin/sh\n"
579
+ // right before WIKI_PRE_COMMIT_MARKER_START, nothing after
580
+ // WIKI_PRE_COMMIT_MARKER_END. A file with no shebang there was never
581
+ // written by init even if it happens to carry a well-formed marker span
582
+ // (hand-authored or copy-pasted) — deleting it on marker presence alone
583
+ // would remove code we do not own. A user who appended their own check
584
+ // after the block turned this into a file we only partly own. init's own
585
+ // --force-commands path handles the analogous case by OVERWRITING with
586
+ // equivalent content (a safe merge); uninstall has no such repair, only
587
+ // rmSync, so treating "extra content" the same as "fully ours" would
588
+ // silently delete the user's check with no way back.
589
+ const before = content.slice(0, span.startIdx);
590
+ const after = content.slice(span.endIdx + WIKI_PRE_COMMIT_MARKER_END.length);
591
+ if (!/^#![^\n]*\n$/.test(before)) {
592
+ result.skipped.push(`${hookPath} (hook carries content before the Hypomnema block — preserving)`);
593
+ continue;
594
+ }
595
+ if (after.trim() !== '') {
596
+ result.skipped.push(`${hookPath} (hook carries content after the Hypomnema block — preserving)`);
597
+ continue;
598
+ }
599
+
600
+ if (apply) rmSync(hookPath);
601
+ result.removed.push(hookPath);
602
+ }
603
+
604
+ return result;
605
+ }
606
+
607
+ // ── shell function block removal ────────────────────────────────────────────
608
+
609
+ // init picks ONE rc file at install time from $SHELL (or --shell-config), but
610
+ // $SHELL by uninstall time may point somewhere else, or init may have run in
611
+ // a different shell session altogether — so both common rc files are checked
612
+ // by default rather than guessing one. Only the marker span itself is
613
+ // stripped; every other byte in the file, including surrounding blank lines,
614
+ // is left exactly as it was. A malformed span (duplicated or swapped markers,
615
+ // see findMarkerSpan above) leaves the file completely untouched: an rc file
616
+ // is the user's own, and this script has no backup to restore it from.
617
+ function removeShellFunctionBlock(shellConfigPath, apply) {
618
+ if (!existsSync(shellConfigPath)) return null;
619
+ const content = readFileSync(shellConfigPath, 'utf-8');
620
+ if (!content.includes(SHELL_MARKER_START) && !content.includes(SHELL_MARKER_END)) {
621
+ return null; // block not present here at all
622
+ }
623
+
624
+ const span = findMarkerSpan(content, SHELL_MARKER_START, SHELL_MARKER_END);
625
+ if (!span.ok) {
626
+ return { path: shellConfigPath, removed: false, skipped: span.reason };
627
+ }
628
+
629
+ const updated = content.slice(0, span.startIdx) + content.slice(span.endIdx + SHELL_MARKER_END.length);
630
+ if (apply) writeFileSync(shellConfigPath, updated);
631
+ return { path: shellConfigPath, removed: true, skipped: null };
632
+ }
633
+
430
634
  // ── arg parsing ──────────────────────────────────────────────────────────────
431
635
 
432
636
  function parseArgs(argv) {
@@ -436,6 +640,8 @@ function parseArgs(argv) {
436
640
  hooksDir: null,
437
641
  forceCommands: false,
438
642
  forceExtensions: false,
643
+ hypoDir: null,
644
+ shellConfig: null,
439
645
  };
440
646
  for (const arg of argv.slice(2)) {
441
647
  if (arg === '--apply') args.apply = true;
@@ -443,6 +649,16 @@ function parseArgs(argv) {
443
649
  else if (arg === '--force-commands') args.forceCommands = true;
444
650
  else if (arg === '--force-extensions') args.forceExtensions = true;
445
651
  else if (arg.startsWith('--hooks-dir=')) args.hooksDir = arg.slice(12);
652
+ // expandHome mirrors init.mjs's own --hypo-dir/--shell-config parsing
653
+ // (init.mjs's parseArgs) exactly, reusing the same function from
654
+ // ./lib/hypo-root.mjs rather than re-deriving it. Uninstall must undo
655
+ // whatever path init actually wrote to disk, and init resolves a leading
656
+ // "~/" itself (the shell never does, since the value arrives already
657
+ // quoted inside "--flag=value"); skipping that step here would silently
658
+ // fail to find the vault or rc file a user installed with "~/..." to
659
+ // begin with.
660
+ else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
661
+ else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
446
662
  }
447
663
  return args;
448
664
  }
@@ -510,6 +726,17 @@ function removeHookFiles(hooksDir, hookFiles, apply) {
510
726
  missing.push(p);
511
727
  }
512
728
  }
729
+ // .hypo-provenance.json (scripts/lib/pkg-provenance.mjs) is written next to
730
+ // this exact hooksDir by installHooks/applyHookFiles — same lifecycle as the
731
+ // hook files themselves, so it is removed in the same pass rather than left
732
+ // behind to describe a package root that no longer has any hooks pointing
733
+ // through it. Unlike hookFiles above, this is an optional file (only the
734
+ // manual/npm channel ever writes one) — so, mirroring the loop's own
735
+ // present/absent split, it is only added to `removed` (dry-run: "to remove")
736
+ // when it actually exists; a channel that never wrote one gets no line at
737
+ // all, not a spurious "already absent".
738
+ const sidecar = removeProvenanceSidecar(hooksDir, apply);
739
+ if (sidecar) removed.push(sidecar);
513
740
  return { removed, missing };
514
741
  }
515
742
 
@@ -577,6 +804,20 @@ const hookResult = removeHookFiles(claudeHooksDir, hookFiles, args.apply);
577
804
  const settingsResult = stripSettingsJson(claudeSettings, claudeHooksDir, hookMap, args.apply);
578
805
  const commandResult = removeCommands(args.apply, args.forceCommands);
579
806
 
807
+ // Wiki-side cleanup: the git pre-commit hook and the shell rc block init.mjs
808
+ // installs outside ~/.claude entirely. Both are independent of --codex/--hooks-dir.
809
+ const hypoDir = args.hypoDir ?? resolveHypoRoot();
810
+ const preCommitResult = removeWikiPreCommitHook(hypoDir, args.apply);
811
+
812
+ const shellConfigCandidates = args.shellConfig
813
+ ? [args.shellConfig]
814
+ : [join(HOME, '.zshrc'), join(HOME, '.bashrc')];
815
+ const shellBlockOutcomes = shellConfigCandidates
816
+ .map((p) => removeShellFunctionBlock(p, args.apply))
817
+ .filter(Boolean);
818
+ const shellBlockResults = shellBlockOutcomes.filter((r) => r.removed).map((r) => r.path);
819
+ const shellBlockSkipped = shellBlockOutcomes.filter((r) => !r.removed);
820
+
580
821
  // Extensions. Order matters: remove files first, then strip
581
822
  // settings, then surgically clear the per-target SHA map. The SHA strip uses
582
823
  // removedKeys so a user-modified file we left in place keeps its recorded SHA
@@ -724,6 +965,27 @@ if (hookResult.missing.length)
724
965
  lines.push(
725
966
  `⊘ Already absent (${hookResult.missing.length}):\n${hookResult.missing.map((p) => ` ${p}`).join('\n')}`,
726
967
  );
968
+ if (preCommitResult.removed.length)
969
+ lines.push(
970
+ `✓ Wiki pre-commit hook ${dryRun ? 'to remove' : 'removed'} (${preCommitResult.removed.length}):\n${preCommitResult.removed.map((p) => ` ${p}`).join('\n')}`,
971
+ );
972
+ if (preCommitResult.skipped.length)
973
+ lines.push(
974
+ `⊘ Wiki pre-commit hook preserved:\n${preCommitResult.skipped.map((p) => ` ${p}`).join('\n')}`,
975
+ );
976
+ if (preCommitResult.bakPresent.length)
977
+ lines.push(
978
+ `ⓘ Pre-commit backup left in place (from --force-commands, never touched by uninstall):\n${preCommitResult.bakPresent.map((p) => ` ${p}`).join('\n')}`,
979
+ );
980
+ if (shellBlockResults.length)
981
+ lines.push(
982
+ `✓ Shell function block ${dryRun ? 'to remove' : 'removed'} (${shellBlockResults.length}):\n${shellBlockResults.map((p) => ` ${p}`).join('\n')}`,
983
+ );
984
+ if (shellBlockSkipped.length)
985
+ lines.push(
986
+ `⊘ Shell function block preserved:\n${shellBlockSkipped.map((r) => ` ${r.path} (${r.skipped})`).join('\n')}`,
987
+ );
988
+
727
989
  if (settingsResult.error) lines.push(`⚠ ${settingsResult.error}`);
728
990
  if (claudeExtSettings.error) lines.push(`⚠ ${claudeExtSettings.error}`);
729
991
  if (codexExtSettings.error) lines.push(`⚠ ${codexExtSettings.error}`);
@@ -738,7 +1000,9 @@ if (
738
1000
  !pkgJsonRemoved &&
739
1001
  !commandResult.skippedUserModified.length &&
740
1002
  !extSkippedUserModified.length &&
741
- !extSkippedNonRegular.length
1003
+ !extSkippedNonRegular.length &&
1004
+ !preCommitResult.removed.length &&
1005
+ !shellBlockResults.length
742
1006
  ) {
743
1007
  lines.push('Nothing to uninstall — Hypomnema does not appear to be installed.');
744
1008
  }