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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/commands/capture.md +1 -1
- package/commands/crystallize.md +7 -7
- package/commands/uninstall.md +16 -4
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONTRIBUTING.md +13 -4
- package/hooks/close-gate-store.mjs +435 -0
- package/hooks/hooks.json +2 -1
- package/hooks/hypo-close-guard.mjs +24 -4
- package/hooks/hypo-hot-rebuild.mjs +22 -2
- package/hooks/hypo-personal-check.mjs +1 -1
- package/hooks/hypo-session-end.mjs +21 -2
- package/hooks/hypo-shared.mjs +434 -192
- package/package.json +2 -1
- package/scripts/capture.mjs +26 -20
- package/scripts/crystallize.mjs +153 -20
- package/scripts/doctor.mjs +2 -2
- package/scripts/init.mjs +34 -18
- package/scripts/lib/design-history-stale.mjs +26 -7
- package/scripts/lib/extensions.mjs +89 -6
- package/scripts/lib/git-hooks-dir.mjs +139 -2
- package/scripts/lib/slug-resolver.mjs +181 -0
- package/scripts/lint.mjs +72 -24
- package/scripts/rename.mjs +38 -141
- package/scripts/uninstall.mjs +351 -2
- package/skills/crystallize/SKILL.md +2 -2
- package/templates/hypo-config.md +1 -1
package/scripts/rename.mjs
CHANGED
|
@@ -48,11 +48,22 @@ import {
|
|
|
48
48
|
chmodSync,
|
|
49
49
|
rmSync,
|
|
50
50
|
} from 'fs';
|
|
51
|
-
import { join, basename, dirname, normalize, isAbsolute
|
|
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
|
|
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
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
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 &&
|
|
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
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
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(
|
|
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(
|
|
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);
|
package/scripts/uninstall.mjs
CHANGED
|
@@ -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 {
|
|
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)
|
|
54
|
-
- **design-history staleness check (#42)
|
|
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.
|