hypomnema 1.7.3 → 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.
@@ -18,12 +18,21 @@
18
18
  * same rules as init/lint/query)
19
19
  * --shell-config=<path> Shell rc file to strip the shell block from (default: checks both
20
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
21
23
  *
22
24
  * The wiki's git pre-commit hook and the shell rc's `claude()` wrapper function are removed
23
25
  * only when they still carry the marker init.mjs wrote (WIKI_PRE_COMMIT_MARKER_START /
24
26
  * SHELL_MARKER_START, both from ./lib/git-hooks-dir.mjs). A user's own pre-commit hook, a
25
27
  * symlinked hook target, and any rc content outside the marker block are never touched.
26
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.
35
+ *
27
36
  * Extensions: hypo-ext-* hard-copies under
28
37
  * ~/.claude/{hooks,commands,skills,agents}/ and ~/.codex/{hooks,commands}/ (with
29
38
  * --codex) are removed when their on-disk SHA matches the recorded one in
@@ -69,6 +78,11 @@ import { removeProvenanceSidecar } from './lib/pkg-provenance.mjs';
69
78
  import {
70
79
  hooksDirForInstall,
71
80
  unsafeHookTargetReason,
81
+ findMarkerSpan,
82
+ isOwnedWikiPreCommitBody,
83
+ isOwnedShellFunctionBody,
84
+ canonicalize,
85
+ isInside,
72
86
  WIKI_PRE_COMMIT_MARKER_START,
73
87
  WIKI_PRE_COMMIT_MARKER_END,
74
88
  SHELL_MARKER_START,
@@ -455,54 +469,19 @@ function stripExtensionSettings(settingsPath, hooksDir, apply, ownedCommands = n
455
469
  return { stripped };
456
470
  }
457
471
 
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
472
  // ── wiki pre-commit hook removal ────────────────────────────────────────────
497
473
 
498
474
  // Mirrors init.mjs's own resolution (hooksDirForInstall) as the PRIMARY
499
475
  // candidate, so this finds the hook wherever init would put it today,
500
476
  // 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.
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.
506
485
  // A hook that still carries our marker is removed; a user's own pre-commit (no
507
486
  // marker), a symlinked/non-regular target, and a hook whose marker is
508
487
  // duplicated, swapped, or missing its shebang are all left standing.
@@ -512,7 +491,9 @@ function removeWikiPreCommitHook(hypoDir, apply) {
512
491
  const result = { removed: [], skipped: [], bakPresent: [] };
513
492
 
514
493
  if (!hypoDir || !existsSync(join(hypoDir, 'hypo-config.md'))) {
515
- result.skipped.push(`no Hypomnema vault found${hypoDir ? ` at ${hypoDir}` : ''} — nothing to remove`);
494
+ result.skipped.push(
495
+ `no Hypomnema vault found${hypoDir ? ` at ${hypoDir}` : ''} — nothing to remove`,
496
+ );
516
497
  return result;
517
498
  }
518
499
 
@@ -527,7 +508,23 @@ function removeWikiPreCommitHook(hypoDir, apply) {
527
508
  // correctly via the primary candidate above).
528
509
  const legacyGitDir = join(hypoDir, '.git');
529
510
  if (existsSync(legacyGitDir) && statSync(legacyGitDir).isDirectory()) {
530
- candidateDirs.push(join(legacyGitDir, 'hooks'));
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
+ }
531
528
  }
532
529
 
533
530
  const seen = new Set();
@@ -547,9 +544,6 @@ function removeWikiPreCommitHook(hypoDir, apply) {
547
544
  if (seen.has(key)) continue;
548
545
  seen.add(key);
549
546
 
550
- const bakPath = `${hookPath}.bak`;
551
- if (existsSync(bakPath)) result.bakPresent.push(bakPath);
552
-
553
547
  const unsafe = unsafeHookTargetReason(hookPath);
554
548
  if (unsafe) {
555
549
  result.skipped.push(`${hookPath} (${unsafe})`);
@@ -564,11 +558,22 @@ function removeWikiPreCommitHook(hypoDir, apply) {
564
558
  result.skipped.push(`${hookPath} (cannot read: ${e.code || e.message})`);
565
559
  continue;
566
560
  }
567
- if (!content.includes(WIKI_PRE_COMMIT_MARKER_START) || !content.includes(WIKI_PRE_COMMIT_MARKER_END)) {
561
+ if (
562
+ !content.includes(WIKI_PRE_COMMIT_MARKER_START) ||
563
+ !content.includes(WIKI_PRE_COMMIT_MARKER_END)
564
+ ) {
568
565
  result.skipped.push(`${hookPath} (not managed by Hypomnema — preserving)`);
569
566
  continue;
570
567
  }
571
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
+
572
577
  const span = findMarkerSpan(content, WIKI_PRE_COMMIT_MARKER_START, WIKI_PRE_COMMIT_MARKER_END);
573
578
  if (!span.ok) {
574
579
  result.skipped.push(`${hookPath} (${span.reason} — preserving)`);
@@ -589,11 +594,28 @@ function removeWikiPreCommitHook(hypoDir, apply) {
589
594
  const before = content.slice(0, span.startIdx);
590
595
  const after = content.slice(span.endIdx + WIKI_PRE_COMMIT_MARKER_END.length);
591
596
  if (!/^#![^\n]*\n$/.test(before)) {
592
- result.skipped.push(`${hookPath} (hook carries content before the Hypomnema block — preserving)`);
597
+ result.skipped.push(
598
+ `${hookPath} (hook carries content before the Hypomnema block — preserving)`,
599
+ );
593
600
  continue;
594
601
  }
595
602
  if (after.trim() !== '') {
596
- result.skipped.push(`${hookPath} (hook carries content after the Hypomnema block — preserving)`);
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
+ );
597
619
  continue;
598
620
  }
599
621
 
@@ -601,6 +623,26 @@ function removeWikiPreCommitHook(hypoDir, apply) {
601
623
  result.removed.push(hookPath);
602
624
  }
603
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
+
604
646
  return result;
605
647
  }
606
648
 
@@ -626,7 +668,23 @@ function removeShellFunctionBlock(shellConfigPath, apply) {
626
668
  return { path: shellConfigPath, removed: false, skipped: span.reason };
627
669
  }
628
670
 
629
- const updated = content.slice(0, span.startIdx) + content.slice(span.endIdx + SHELL_MARKER_END.length);
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);
630
688
  if (apply) writeFileSync(shellConfigPath, updated);
631
689
  return { path: shellConfigPath, removed: true, skipped: null };
632
690
  }
@@ -642,12 +700,16 @@ function parseArgs(argv) {
642
700
  forceExtensions: false,
643
701
  hypoDir: null,
644
702
  shellConfig: null,
703
+ keepShell: false,
704
+ keepWikiHook: false,
645
705
  };
646
706
  for (const arg of argv.slice(2)) {
647
707
  if (arg === '--apply') args.apply = true;
648
708
  else if (arg === '--codex') args.codex = true;
649
709
  else if (arg === '--force-commands') args.forceCommands = true;
650
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;
651
713
  else if (arg.startsWith('--hooks-dir=')) args.hooksDir = arg.slice(12);
652
714
  // expandHome mirrors init.mjs's own --hypo-dir/--shell-config parsing
653
715
  // (init.mjs's parseArgs) exactly, reusing the same function from
@@ -795,6 +857,22 @@ function stripSettingsJson(settingsPath, hooksDir, hookMap, apply) {
795
857
  const args = parseArgs(process.argv);
796
858
  const dryRun = !args.apply;
797
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
+
798
876
  const { hookMap, hookFiles } = loadHookFiles();
799
877
 
800
878
  const claudeHooksDir = args.hooksDir ?? join(HOME, '.claude', 'hooks');
@@ -805,16 +883,35 @@ const settingsResult = stripSettingsJson(claudeSettings, claudeHooksDir, hookMap
805
883
  const commandResult = removeCommands(args.apply, args.forceCommands);
806
884
 
807
885
  // 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);
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);
811
908
 
812
909
  const shellConfigCandidates = args.shellConfig
813
910
  ? [args.shellConfig]
814
911
  : [join(HOME, '.zshrc'), join(HOME, '.bashrc')];
815
- const shellBlockOutcomes = shellConfigCandidates
816
- .map((p) => removeShellFunctionBlock(p, args.apply))
817
- .filter(Boolean);
912
+ const shellBlockOutcomes = args.keepShell
913
+ ? []
914
+ : shellConfigCandidates.map((p) => removeShellFunctionBlock(p, args.apply)).filter(Boolean);
818
915
  const shellBlockResults = shellBlockOutcomes.filter((r) => r.removed).map((r) => r.path);
819
916
  const shellBlockSkipped = shellBlockOutcomes.filter((r) => !r.removed);
820
917
 
@@ -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.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.7.3"
4
+ version: "1.7.4"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7