hypomnema 1.8.2 → 1.8.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/CHANGELOG.md +105 -51
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/commands/crystallize.md +14 -3
- package/docs/ARCHITECTURE.md +13 -5
- package/docs/CONTRIBUTING.md +21 -7
- package/hooks/close-journal.mjs +128 -0
- package/hooks/hooks.json +1 -9
- package/hooks/hypo-session-start.mjs +194 -11
- package/hooks/hypo-shared.mjs +117 -39
- package/hooks/proposal-store.mjs +35 -1
- package/hooks/shared.json +9 -0
- package/package.json +2 -1
- package/scripts/doctor.mjs +43 -91
- package/scripts/init.mjs +54 -106
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +569 -79
- package/scripts/lib/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +150 -0
- package/scripts/lib/pkg-provenance.mjs +11 -0
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/template-schema-version.mjs +47 -0
- package/scripts/uninstall.mjs +130 -66
- package/scripts/upgrade.mjs +243 -145
- package/templates/hypo-config.md +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// hook-inventory.mjs — the one grammar every hooks.json/shared.json consumer reads
|
|
2
|
+
// through: smoke-plugin.mjs, init.mjs, upgrade.mjs, doctor.mjs, uninstall.mjs.
|
|
3
|
+
//
|
|
4
|
+
// Before this file existed, five scripts each grew their own regex for pulling a
|
|
5
|
+
// .mjs basename out of a hooks.json `command` string, and the five disagreed:
|
|
6
|
+
// - smoke-plugin accepted ANY path after `${CLAUDE_PLUGIN_ROOT}/`, so a command
|
|
7
|
+
// pointing at `${CLAUDE_PLUGIN_ROOT}/scripts/foo.mjs` (the wrong directory —
|
|
8
|
+
// init only ever copies hooks/) passed smoke as long as that file happened to
|
|
9
|
+
// exist, while init/upgrade/doctor/uninstall all resolve the same command to
|
|
10
|
+
// `hooks/foo.mjs` and never look in scripts/ at all.
|
|
11
|
+
// - init/upgrade/doctor took the LAST `.mjs` path segment in the command, so a
|
|
12
|
+
// trailing argument like `--config bar.mjs` would have picked `bar.mjs` up as
|
|
13
|
+
// the hook file instead of the hook itself.
|
|
14
|
+
// - uninstall anchored on `/hooks/([^/\s]+\.mjs)$` — the command had to literally
|
|
15
|
+
// END in `.mjs`, so the same trailing-argument shape it would silently ignore
|
|
16
|
+
// instead of misreading.
|
|
17
|
+
// One parser closes all three gaps by only ever accepting one shape.
|
|
18
|
+
//
|
|
19
|
+
// This module also folds in the existence check that used to be missing entirely:
|
|
20
|
+
// loadHookInventory() only returns ok:true once every name on both lists resolves
|
|
21
|
+
// to a real regular file under `<pkgRoot>/hooks/`. A caller that runs this BEFORE
|
|
22
|
+
// its first write (as init.mjs now does) can never install a partial hook set from
|
|
23
|
+
// a package a file was dropped out of.
|
|
24
|
+
//
|
|
25
|
+
// Deliberately does NOT accept the legacy bare-filename group format
|
|
26
|
+
// (`"hooks.json"`: `{ "SomeEvent": ["foo.mjs"] }`) that ../core-hooks.mjs's
|
|
27
|
+
// readCoreHooksConfig still tolerates for reverse-capture's basename reservation.
|
|
28
|
+
// That tolerance exists so a stale or hand-edited hooks.json never lets a core
|
|
29
|
+
// hook slip into a user's captured extensions; it has nothing to do with what
|
|
30
|
+
// install/uninstall are willing to act on, so narrowing the grammar here does not
|
|
31
|
+
// need to touch core-hooks.mjs at all.
|
|
32
|
+
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { readCoreHooksConfig } from './core-hooks.mjs';
|
|
35
|
+
import { isRegularFile } from './pkg-json.mjs';
|
|
36
|
+
|
|
37
|
+
// A plain .mjs basename: word characters, dots, and hyphens only, no path
|
|
38
|
+
// separator (so a joined path can never climb out of the directory it is joined
|
|
39
|
+
// onto) and no leading dot (rules out a hidden file and `..`, which does not
|
|
40
|
+
// match `\w` but a bare `.` prefix like `.mjs` alone still would). Mirrors the
|
|
41
|
+
// basename check smoke-plugin.mjs already applied to hooks/shared.json entries.
|
|
42
|
+
const SAFE_MJS_BASENAME = /^[\w.-]+\.mjs$/;
|
|
43
|
+
|
|
44
|
+
export function isSafeMjsBasename(name) {
|
|
45
|
+
return typeof name === 'string' && SAFE_MJS_BASENAME.test(name) && !name.startsWith('.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The one command shape this module accepts: `${CLAUDE_PLUGIN_ROOT}/hooks/<basename>.mjs`,
|
|
49
|
+
// optionally followed by more of the command (a trailing argument, a redirect —
|
|
50
|
+
// anything after whitespace or a closing quote). No sub-directory under hooks/,
|
|
51
|
+
// no other top-level directory, no bare filename with no `${CLAUDE_PLUGIN_ROOT}/hooks/`
|
|
52
|
+
// prefix at all.
|
|
53
|
+
const PLUGIN_HOOKS_TARGET = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^\s/"'`]+)(?=$|[\s"'`])/;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract the hooks/<basename>.mjs a command string targets, or null when the
|
|
57
|
+
* command does not match the one accepted shape (wrong directory, a
|
|
58
|
+
* sub-directory, a basename that fails isSafeMjsBasename, or no
|
|
59
|
+
* `${CLAUDE_PLUGIN_ROOT}/hooks/` segment at all).
|
|
60
|
+
*/
|
|
61
|
+
export function extractPluginHookBasename(command) {
|
|
62
|
+
if (typeof command !== 'string') return null;
|
|
63
|
+
const m = command.match(PLUGIN_HOOKS_TARGET);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
return isSafeMjsBasename(m[1]) ? m[1] : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load, narrowly validate, and existence-check the hook inventory rooted at
|
|
70
|
+
* `pkgRoot`: hooks/hooks.json's per-event basenames plus hooks/shared.json's
|
|
71
|
+
* shared-module basenames. Fails closed on the FIRST problem, whether that is a
|
|
72
|
+
* read/parse failure (delegated to readCoreHooksConfig), an empty hooks map, a
|
|
73
|
+
* command that does not match the one accepted grammar, an unsafe shared.json
|
|
74
|
+
* basename, or a named file that is not actually a regular file under
|
|
75
|
+
* `<pkgRoot>/hooks/`.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} pkgRoot
|
|
78
|
+
* @returns {{ ok: true, hookMap: Record<string, string[]>, shared: string[] }
|
|
79
|
+
* | { ok: false, error: string }}
|
|
80
|
+
*/
|
|
81
|
+
export function loadHookInventory(pkgRoot) {
|
|
82
|
+
const res = readCoreHooksConfig(pkgRoot);
|
|
83
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
84
|
+
const cfg = res.cfg;
|
|
85
|
+
|
|
86
|
+
if (Object.keys(cfg.hooks).length === 0) {
|
|
87
|
+
return { ok: false, error: 'hooks/hooks.json "hooks" must not be empty' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hookMap = {};
|
|
91
|
+
for (const [event, groups] of Object.entries(cfg.hooks)) {
|
|
92
|
+
const basenames = [];
|
|
93
|
+
for (const group of groups) {
|
|
94
|
+
// readCoreHooksConfig still accepts a bare-filename group (the legacy
|
|
95
|
+
// shape reverse-capture's basename reservation tolerates on purpose — see
|
|
96
|
+
// the module header). Every real install/uninstall consumer of THIS
|
|
97
|
+
// function only ever shipped the hook-group object form, so a bare string
|
|
98
|
+
// reaching here is not something any of the five knows how to act on.
|
|
99
|
+
if (typeof group === 'string') {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
error: `hooks.${event}: a bare filename group ("${group}") is not accepted here — wrap it in a hook-group object with an explicit command`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (!Array.isArray(group.hooks) || group.hooks.length === 0) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: `hooks.${event}: a hook group must have a non-empty "hooks" array`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
for (const hook of group.hooks) {
|
|
112
|
+
if (hook.type !== 'command') {
|
|
113
|
+
return { ok: false, error: `hooks.${event}: hook entry "type" must be "command"` };
|
|
114
|
+
}
|
|
115
|
+
const base = extractPluginHookBasename(hook.command);
|
|
116
|
+
if (!base) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: `hooks.${event}: command does not match "\${CLAUDE_PLUGIN_ROOT}/hooks/<basename>.mjs": ${hook.command}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
basenames.push(base);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (basenames.length === 0) {
|
|
126
|
+
return { ok: false, error: `hooks.${event} yields no hook files` };
|
|
127
|
+
}
|
|
128
|
+
hookMap[event] = basenames;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const file of cfg.shared) {
|
|
132
|
+
if (!isSafeMjsBasename(file)) {
|
|
133
|
+
return { ok: false, error: `hooks/shared.json: "${file}" is not a plain .mjs basename` };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Existence, checked last and over the UNION of both lists: a caller that
|
|
138
|
+
// reaches this point has already paid for the grammar checks above, and a
|
|
139
|
+
// missing file (dropped from `package.json`'s `files` allowlist, or from a
|
|
140
|
+
// corrupted copy) is exactly the gap major C closed — nothing may be
|
|
141
|
+
// installed, refreshed, or reported healthy on the strength of a name alone.
|
|
142
|
+
const allBasenames = new Set([...Object.values(hookMap).flat(), ...cfg.shared]);
|
|
143
|
+
for (const base of allBasenames) {
|
|
144
|
+
if (!isRegularFile(join(pkgRoot, 'hooks', base))) {
|
|
145
|
+
return { ok: false, error: `hooks/${base} is not a file` };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { ok: true, hookMap, shared: cfg.shared };
|
|
150
|
+
}
|
|
@@ -106,6 +106,14 @@ export function provenancePath(hooksDir) {
|
|
|
106
106
|
* it is a doctor-only freshness signal, not part of the runtime contract
|
|
107
107
|
* (hooks/hypo-shared.mjs never reads this field), so a sidecar the runtime
|
|
108
108
|
* can verify should not be withheld just because the digest step failed.
|
|
109
|
+
*
|
|
110
|
+
* `managedFiles` (major D) is the same best-effort shape: the sorted basename
|
|
111
|
+
* list this run's own copy targeted (every hooks.json event command plus every
|
|
112
|
+
* hooks/shared.json entry, derived from `pkgRoot` the same way the digest
|
|
113
|
+
* above is). scripts/uninstall.mjs reads it back as a fallback source of truth
|
|
114
|
+
* when THIS package's own hooks.json can no longer be read at uninstall time —
|
|
115
|
+
* the sidecar was written by whatever package last actually ran the copy, so it
|
|
116
|
+
* names the truth of what is on disk even after the current package rots.
|
|
109
117
|
*/
|
|
110
118
|
export function writeProvenanceSidecar(hooksDir, pkgRoot, pkgVersion, hooksSrcDir, dryRun) {
|
|
111
119
|
let hypoSharedSha256;
|
|
@@ -115,12 +123,15 @@ export function writeProvenanceSidecar(hooksDir, pkgRoot, pkgVersion, hooksSrcDi
|
|
|
115
123
|
return null;
|
|
116
124
|
}
|
|
117
125
|
const hooksDigest = computeHooksDigest(pkgRoot, hooksSrcDir);
|
|
126
|
+
const cfgRes = readCoreHooksConfig(pkgRoot);
|
|
127
|
+
const managedFiles = cfgRes.ok ? [...deriveCoreHookBasenames(cfgRes.cfg)].sort() : null;
|
|
118
128
|
const dest = provenancePath(hooksDir);
|
|
119
129
|
const data = {
|
|
120
130
|
pkgRoot,
|
|
121
131
|
pkgVersion,
|
|
122
132
|
hypoSharedSha256,
|
|
123
133
|
...(hooksDigest ? { [HOOKS_DIGEST_FIELD]: hooksDigest } : {}),
|
|
134
|
+
...(managedFiles ? { managedFiles } : {}),
|
|
124
135
|
copiedAt: new Date().toISOString(),
|
|
125
136
|
};
|
|
126
137
|
if (!dryRun) atomicWriteJson(dest, data);
|
|
@@ -98,14 +98,20 @@ function readPkgVersionAt(root) {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// A pkgRoot is "usable"
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
101
|
+
// A pkgRoot is "usable" only if it is an ABSOLUTE path to a real package directory
|
|
102
|
+
// whose package.json carries a version. A relative path (e.g. installPath ".")
|
|
103
|
+
// would be resolved against the caller's cwd and break the vault git hook from any
|
|
104
|
+
// other directory; a version-less package.json cannot be attributed a version
|
|
105
|
+
// without lying. This is the WEAK predicate: it says nothing about WHOSE package
|
|
106
|
+
// sits at that path, only that a version can be read from it. That is enough for a
|
|
107
|
+
// diagnostic that reads and reports (doctor.mjs's per-row leaf-drift scan, which
|
|
108
|
+
// deliberately widens rather than narrows — see leafVersionDrift above), but it is
|
|
109
|
+
// NOT enough for a caller that adopts the path as Hypomnema's own durable identity
|
|
110
|
+
// and writes it to a sidecar (hypo-pkg.json) or a vault's pre-commit hook: nothing
|
|
111
|
+
// here stops a foreign package at an absolute, version-bearing path from being
|
|
112
|
+
// adopted as if it were this one. isHypomnemaInstallRoot below is the strong
|
|
113
|
+
// predicate for that case; keep the two in sync; a change to one's shape
|
|
114
|
+
// (absolute + version) almost certainly belongs in the other too.
|
|
109
115
|
export function usablePkgRoot(pkgRoot) {
|
|
110
116
|
return (
|
|
111
117
|
typeof pkgRoot === 'string' &&
|
|
@@ -116,6 +122,26 @@ export function usablePkgRoot(pkgRoot) {
|
|
|
116
122
|
);
|
|
117
123
|
}
|
|
118
124
|
|
|
125
|
+
// The STRONG predicate: usablePkgRoot, AND the package at that path is literally
|
|
126
|
+
// named "hypomnema". A registry or hypo-pkg.json entry can carry any absolute,
|
|
127
|
+
// version-bearing path — a corrupted sidecar, a hand-edited registry row, or (per
|
|
128
|
+
// the commit-time resolver's own `usable()` in git-hooks-dir.mjs) an attacker's
|
|
129
|
+
// project — so a caller that RECORDS a root as durable identity (init.mjs's
|
|
130
|
+
// resolveDurableRoot, selectEntry below) or WRITES it back to disk (upgrade.mjs's
|
|
131
|
+
// dualSkip provenance correction) must check producer identity, not just
|
|
132
|
+
// "a version is readable here". A caller that only READS for display (doctor.mjs's
|
|
133
|
+
// per-row leaf scan) stays on the weak usablePkgRoot: narrowing it would silence a
|
|
134
|
+
// foreign registry row doctor exists to surface, per the "does NOT exclude"
|
|
135
|
+
// rationale on leafVersionDrift above.
|
|
136
|
+
export function isHypomnemaInstallRoot(pkgRoot) {
|
|
137
|
+
if (!usablePkgRoot(pkgRoot)) return false;
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).name === 'hypomnema';
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
119
145
|
// The version-shaped leaf of a plugin cache path (its last path component), or
|
|
120
146
|
// null when that leaf does not look like a version. Claude Code names a plugin
|
|
121
147
|
// cache directory after the release it copied in (`cache/<marketplace>/<name>/
|
|
@@ -185,10 +211,18 @@ export function leafVersionDrift(pkgRoot) {
|
|
|
185
211
|
// #269 reimplemented this exact filter-then-prefer-scope rule at its call site,
|
|
186
212
|
// in the opposite order, which silently swaps rows whenever the user row has no
|
|
187
213
|
// gitCommitSha).
|
|
214
|
+
// isHypomnemaInstallRoot, not the weak usablePkgRoot: this is the row
|
|
215
|
+
// resolveEnabledPluginEntry hands to callers that RECORD it as durable identity
|
|
216
|
+
// (init.mjs's resolveDurableRoot, upgrade.mjs's dualSkip provenance write), so a
|
|
217
|
+
// registry row whose installPath merely resolves a readable version, without
|
|
218
|
+
// being a real Hypomnema package, must not be selected here. codex reproduction
|
|
219
|
+
// (2026-09-11): a registry row's package.json carried no `name` field at all and
|
|
220
|
+
// was still adopted as the durable root under the old, name-blind check.
|
|
188
221
|
function selectEntry(entries, scope) {
|
|
189
222
|
return (
|
|
190
223
|
entries.find(
|
|
191
|
-
(e) =>
|
|
224
|
+
(e) =>
|
|
225
|
+
e && (scope === undefined || e.scope === scope) && isHypomnemaInstallRoot(e.installPath),
|
|
192
226
|
) ?? null
|
|
193
227
|
);
|
|
194
228
|
}
|
|
@@ -19,3 +19,50 @@ export function templateSchemaVersion(pkgRoot) {
|
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
// One line per SCHEMA.md version bump, naming what that version added over the
|
|
24
|
+
// one before it. This is upgrade.mjs's only source for "what changed" text
|
|
25
|
+
// when it tells a user their installed SCHEMA.md is behind — without it, the
|
|
26
|
+
// notice can only name the two version numbers, and the two SCHEMA.md copies
|
|
27
|
+
// diverge (translation, local additions) far enough that a raw diff is
|
|
28
|
+
// dominated by noise unrelated to the actual upstream change. Add an entry
|
|
29
|
+
// here whenever templates/SCHEMA.md's `version:` frontmatter bumps; a version
|
|
30
|
+
// missing from this map falls back to the plain "review manually" notice
|
|
31
|
+
// (schemaVersionDeltas below returns nothing for it), so leaving one out is
|
|
32
|
+
// silent, not wrong.
|
|
33
|
+
// Quote any key with a trailing zero. An unquoted `2.10:` is a NUMBER literal
|
|
34
|
+
// and JS normalizes it to the string "2.1", so it would silently overwrite
|
|
35
|
+
// 2.1's line (or hand 2.10's text to somebody upgrading across 2.1). Verified:
|
|
36
|
+
// `Object.keys({2.10: 'x'})` is `['2.1']`. Prettier will not undo the quotes
|
|
37
|
+
// there — stripping them would change meaning, so it leaves "2.10" alone even
|
|
38
|
+
// though it rewrites '2.2' to 2.2. What prettier cannot save you from is
|
|
39
|
+
// writing 2.10 unquoted in the first place, which is why a test reads this
|
|
40
|
+
// file's text and rejects that shape rather than trusting the convention.
|
|
41
|
+
export const SCHEMA_VERSION_DELTAS = {
|
|
42
|
+
2.2: 'documents `sources_consulted` on `type: synthesis` pages (lint W15/W16 read it to flag a synthesis that has fallen behind the pages it condenses)',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function parseMinorVersion(v) {
|
|
46
|
+
const [major, minor] = String(v).split('.').map(Number);
|
|
47
|
+
return { major, minor: Number.isFinite(minor) ? minor : 0 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function compareMinorVersions(a, b) {
|
|
51
|
+
return a.major - b.major || a.minor - b.minor;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Returns one line per version strictly after `installed` and up to and
|
|
55
|
+
// including `current`, oldest first. `deltas` defaults to the real map above;
|
|
56
|
+
// tests pass their own to exercise the multi-version stepping logic without
|
|
57
|
+
// depending on how many real bumps have landed.
|
|
58
|
+
export function schemaVersionDeltas(installed, current, deltas = SCHEMA_VERSION_DELTAS) {
|
|
59
|
+
if (!installed || !current) return [];
|
|
60
|
+
const from = parseMinorVersion(installed);
|
|
61
|
+
const to = parseMinorVersion(current);
|
|
62
|
+
if (compareMinorVersions(to, from) <= 0) return [];
|
|
63
|
+
return Object.keys(deltas)
|
|
64
|
+
.map((v) => ({ v, mv: parseMinorVersion(v) }))
|
|
65
|
+
.filter(({ mv }) => compareMinorVersions(mv, from) > 0 && compareMinorVersions(mv, to) <= 0)
|
|
66
|
+
.sort((a, b) => compareMinorVersions(a.mv, b.mv))
|
|
67
|
+
.map(({ v }) => `${v}: ${deltas[v]}`);
|
|
68
|
+
}
|
package/scripts/uninstall.mjs
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
statSync,
|
|
52
52
|
realpathSync,
|
|
53
53
|
} from 'fs';
|
|
54
|
-
import { join } from 'path';
|
|
54
|
+
import { join, resolve, sep } from 'path';
|
|
55
55
|
import { homedir } from 'os';
|
|
56
56
|
import { fileURLToPath } from 'url';
|
|
57
57
|
import {
|
|
@@ -74,7 +74,12 @@ import {
|
|
|
74
74
|
hasSymlinkAncestor,
|
|
75
75
|
buildHookCommand,
|
|
76
76
|
} from './lib/extensions.mjs';
|
|
77
|
-
import {
|
|
77
|
+
import {
|
|
78
|
+
removeProvenanceSidecar,
|
|
79
|
+
readProvenanceSidecar,
|
|
80
|
+
provenancePath,
|
|
81
|
+
} from './lib/pkg-provenance.mjs';
|
|
82
|
+
import { loadHookInventory } from './lib/hook-inventory.mjs';
|
|
78
83
|
import {
|
|
79
84
|
hooksDirForInstall,
|
|
80
85
|
unsafeHookTargetReason,
|
|
@@ -94,12 +99,6 @@ const HOME = homedir();
|
|
|
94
99
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
95
100
|
const PKG_ROOT = join(SCRIPT_DIR, '..');
|
|
96
101
|
|
|
97
|
-
// Shown after every fatal package-integrity error. These conditions mean the
|
|
98
|
-
// shipped hooks/hooks.json is missing or malformed — never a user mistake —
|
|
99
|
-
// so the only useful next step is a re-install of the package.
|
|
100
|
-
const PKG_INTEGRITY_HINT =
|
|
101
|
-
'→ This indicates a corrupt or incomplete install. Re-install with `npm install -g hypomnema` (or re-install the Claude Code plugin).';
|
|
102
|
-
|
|
103
102
|
function removeCommands(apply, force) {
|
|
104
103
|
const targetDir = join(HOME, '.claude', 'commands', 'hypo');
|
|
105
104
|
const pkgPath = join(HOME, '.claude', 'hypo-pkg.json');
|
|
@@ -725,53 +724,60 @@ function parseArgs(argv) {
|
|
|
725
724
|
return args;
|
|
726
725
|
}
|
|
727
726
|
|
|
728
|
-
// ── hook
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
} else if (entry && Array.isArray(entry.hooks)) {
|
|
756
|
-
// current group format: extract filename from command string
|
|
757
|
-
for (const h of entry.hooks) {
|
|
758
|
-
if (h.type === 'command' && typeof h.command === 'string') {
|
|
759
|
-
const m = h.command.match(/\/hooks\/([^/\s]+\.mjs)$/);
|
|
760
|
-
if (m) {
|
|
761
|
-
hookFiles.add(m[1]);
|
|
762
|
-
filenames.push(m[1]);
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
normalizedHookMap[event] = filenames;
|
|
727
|
+
// ── hook file set (single source of truth, with a provenance fallback) ──────
|
|
728
|
+
//
|
|
729
|
+
// The normal path reads PKG_ROOT's own hooks.json/shared.json through
|
|
730
|
+
// loadHookInventory (scripts/lib/hook-inventory.mjs) — the same parser every
|
|
731
|
+
// install/uninstall consumer shares, so a command shape one of them would
|
|
732
|
+
// accept (or a trailing-argument command this file's own old `/hooks/(...)$`
|
|
733
|
+
// anchor silently ignored) cannot read differently here.
|
|
734
|
+
//
|
|
735
|
+
// But uninstall is the one command whose whole point is letting someone leave
|
|
736
|
+
// even when the package itself is broken. The other four consumers fail closed
|
|
737
|
+
// on a malformed hooks.json; refusing to run here instead would trade "a few
|
|
738
|
+
// files linger" for "you cannot remove this at all". So when the package list
|
|
739
|
+
// cannot be read, this falls back to the provenance sidecar THIS hooksDir's own
|
|
740
|
+
// last install/upgrade wrote: writeProvenanceSidecar (lib/pkg-provenance.mjs)
|
|
741
|
+
// records `managedFiles`, the exact basename set that run copied, so a hooks.json
|
|
742
|
+
// broken in the CURRENTLY-INSTALLED package does not strand the files a PAST,
|
|
743
|
+
// working package put there.
|
|
744
|
+
//
|
|
745
|
+
// Only when neither the package list nor a provenance record is available does
|
|
746
|
+
// this give up on identifying anything here as ours — `source: 'none'` below
|
|
747
|
+
// never guesses at deletion; the caller names what remains and reports failure
|
|
748
|
+
// via its exit code rather than silently succeeding at nothing.
|
|
749
|
+
function resolveHookFileSet(pkgRoot, hooksDir) {
|
|
750
|
+
const inv = loadHookInventory(pkgRoot);
|
|
751
|
+
if (inv.ok) {
|
|
752
|
+
const files = new Set([...Object.values(inv.hookMap).flat(), ...inv.shared]);
|
|
753
|
+
return { files, source: 'package', warning: null };
|
|
769
754
|
}
|
|
770
|
-
|
|
771
|
-
if (Array.isArray(
|
|
772
|
-
|
|
755
|
+
const sidecar = readProvenanceSidecar(hooksDir);
|
|
756
|
+
if (sidecar && Array.isArray(sidecar.managedFiles) && sidecar.managedFiles.length > 0) {
|
|
757
|
+
return {
|
|
758
|
+
files: new Set(sidecar.managedFiles),
|
|
759
|
+
source: 'provenance',
|
|
760
|
+
warning:
|
|
761
|
+
`hooks/hooks.json could not be read (${inv.error}). Recovered the managed file list for ` +
|
|
762
|
+
`${hooksDir} from ${provenancePath(hooksDir)} (recorded at the last install/upgrade) instead.`,
|
|
763
|
+
};
|
|
773
764
|
}
|
|
774
|
-
|
|
765
|
+
const present = existsSync(hooksDir)
|
|
766
|
+
? readdirSync(hooksDir).filter((f) => f.endsWith('.mjs'))
|
|
767
|
+
: [];
|
|
768
|
+
return {
|
|
769
|
+
files: new Set(),
|
|
770
|
+
source: 'none',
|
|
771
|
+
warning:
|
|
772
|
+
`hooks/hooks.json could not be read (${inv.error}) and no provenance record exists at ` +
|
|
773
|
+
`${provenancePath(hooksDir)} to recover a managed file list from (an install made before ` +
|
|
774
|
+
`this sidecar field existed never wrote one). Hook file removal for ${hooksDir} is skipped ` +
|
|
775
|
+
'entirely.' +
|
|
776
|
+
(present.length
|
|
777
|
+
? ` The following .mjs files are still there and were not evaluated — remove them by ` +
|
|
778
|
+
`hand after checking what they are: ${present.join(', ')}`
|
|
779
|
+
: ' No .mjs files were found there either.'),
|
|
780
|
+
};
|
|
775
781
|
}
|
|
776
782
|
|
|
777
783
|
// ── hook file removal ────────────────────────────────────────────────────────
|
|
@@ -779,8 +785,22 @@ function loadHookFiles() {
|
|
|
779
785
|
function removeHookFiles(hooksDir, hookFiles, apply) {
|
|
780
786
|
const removed = [],
|
|
781
787
|
missing = [];
|
|
788
|
+
const skipped = [];
|
|
789
|
+
// Every name here comes out of a file on disk: hooks.json's event map and
|
|
790
|
+
// hooks/shared.json. A name is supposed to be a bare basename, but nothing
|
|
791
|
+
// upstream forces that, and join() resolves `../x.mjs` straight out of the
|
|
792
|
+
// hooks directory. This function DELETES what it is handed, so it confirms
|
|
793
|
+
// the containment itself rather than trusting the list it was given. An
|
|
794
|
+
// out-of-tree name is skipped and named, never removed: a corrupt list is a
|
|
795
|
+
// reason to leave files alone, not to delete somewhere else.
|
|
796
|
+
const root = resolve(hooksDir);
|
|
797
|
+
const inside = (p) => p === root || p.startsWith(root + sep);
|
|
782
798
|
for (const file of hookFiles) {
|
|
783
799
|
const p = join(hooksDir, file);
|
|
800
|
+
if (!inside(resolve(p))) {
|
|
801
|
+
skipped.push(file);
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
784
804
|
if (existsSync(p)) {
|
|
785
805
|
if (apply) rmSync(p);
|
|
786
806
|
removed.push(p);
|
|
@@ -788,6 +808,12 @@ function removeHookFiles(hooksDir, hookFiles, apply) {
|
|
|
788
808
|
missing.push(p);
|
|
789
809
|
}
|
|
790
810
|
}
|
|
811
|
+
if (skipped.length > 0) {
|
|
812
|
+
console.warn(
|
|
813
|
+
`Warning: ${skipped.length} hook name(s) resolve outside ${hooksDir} and were left alone: ` +
|
|
814
|
+
`${skipped.join(', ')}. Remove them by hand after checking what they are.`,
|
|
815
|
+
);
|
|
816
|
+
}
|
|
791
817
|
// .hypo-provenance.json (scripts/lib/pkg-provenance.mjs) is written next to
|
|
792
818
|
// this exact hooksDir by installHooks/applyHookFiles — same lifecycle as the
|
|
793
819
|
// hook files themselves, so it is removed in the same pass rather than left
|
|
@@ -804,7 +830,13 @@ function removeHookFiles(hooksDir, hookFiles, apply) {
|
|
|
804
830
|
|
|
805
831
|
// ── settings.json cleanup ────────────────────────────────────────────────────
|
|
806
832
|
|
|
807
|
-
|
|
833
|
+
// `hookFiles` is a flat Set of basenames (not a per-event map): a command
|
|
834
|
+
// string already names the exact hook file (`node <hooksDir>/<file>`), so
|
|
835
|
+
// scoping "ours" by which event a settings.json group happens to sit under adds
|
|
836
|
+
// no protection a flat membership check does not already give — and a flat set
|
|
837
|
+
// is what both the normal (package) and provenance-fallback paths in
|
|
838
|
+
// resolveHookFileSet actually have on hand.
|
|
839
|
+
function stripSettingsJson(settingsPath, hooksDir, hookFiles, apply) {
|
|
808
840
|
if (!existsSync(settingsPath)) return { stripped: [], kept: 0 };
|
|
809
841
|
|
|
810
842
|
let settings;
|
|
@@ -816,18 +848,18 @@ function stripSettingsJson(settingsPath, hooksDir, hookMap, apply) {
|
|
|
816
848
|
|
|
817
849
|
if (!settings.hooks || typeof settings.hooks !== 'object') return { stripped: [], kept: 0 };
|
|
818
850
|
|
|
851
|
+
const expectedCmds = new Set(
|
|
852
|
+
[...hookFiles].map((file) => `node ${hooksDir.replace(HOME, '$HOME')}/${file}`),
|
|
853
|
+
);
|
|
854
|
+
const isHypoHook = (h) =>
|
|
855
|
+
h.type === 'command' && typeof h.command === 'string' && expectedCmds.has(h.command);
|
|
856
|
+
|
|
819
857
|
const stripped = [];
|
|
820
858
|
let changed = false;
|
|
821
859
|
|
|
822
860
|
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
823
861
|
if (!Array.isArray(groups)) continue;
|
|
824
862
|
|
|
825
|
-
const managed = hookMap[event] ?? [];
|
|
826
|
-
const isHypoHook = (h) =>
|
|
827
|
-
h.type === 'command' &&
|
|
828
|
-
typeof h.command === 'string' &&
|
|
829
|
-
managed.some((file) => h.command === `node ${hooksDir.replace(HOME, '$HOME')}/${file}`);
|
|
830
|
-
|
|
831
863
|
const filtered = groups.flatMap((group) => {
|
|
832
864
|
if (!Array.isArray(group.hooks)) return [group];
|
|
833
865
|
|
|
@@ -873,13 +905,31 @@ if (args.hooksDir && !args.keepShell && !args.keepWikiHook) {
|
|
|
873
905
|
);
|
|
874
906
|
}
|
|
875
907
|
|
|
876
|
-
const { hookMap, hookFiles } = loadHookFiles();
|
|
877
|
-
|
|
878
908
|
const claudeHooksDir = args.hooksDir ?? join(HOME, '.claude', 'hooks');
|
|
879
909
|
const claudeSettings = join(HOME, '.claude', 'settings.json');
|
|
880
910
|
|
|
881
|
-
|
|
882
|
-
|
|
911
|
+
// A `source: 'none'` resolution (package unreadable AND no provenance record)
|
|
912
|
+
// means hook-file removal for that target was skipped entirely rather than
|
|
913
|
+
// guessed at — this run must not report plain success in that case. Collected
|
|
914
|
+
// across both targets and applied to the exit code once the whole report is
|
|
915
|
+
// printed, so the caller still sees everything else this run did (or would do).
|
|
916
|
+
let hardFailure = false;
|
|
917
|
+
function reportHookSetWarning(set) {
|
|
918
|
+
if (!set.warning) return;
|
|
919
|
+
console.error(`Warning: ${set.warning}`);
|
|
920
|
+
if (set.source === 'none') hardFailure = true;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const claudeHookSet = resolveHookFileSet(PKG_ROOT, claudeHooksDir);
|
|
924
|
+
reportHookSetWarning(claudeHookSet);
|
|
925
|
+
|
|
926
|
+
const hookResult = removeHookFiles(claudeHooksDir, claudeHookSet.files, args.apply);
|
|
927
|
+
const settingsResult = stripSettingsJson(
|
|
928
|
+
claudeSettings,
|
|
929
|
+
claudeHooksDir,
|
|
930
|
+
claudeHookSet.files,
|
|
931
|
+
args.apply,
|
|
932
|
+
);
|
|
883
933
|
const commandResult = removeCommands(args.apply, args.forceCommands);
|
|
884
934
|
|
|
885
935
|
// Wiki-side cleanup: the git pre-commit hook and the shell rc block init.mjs
|
|
@@ -946,8 +996,15 @@ let codexExtSettings = { stripped: [] };
|
|
|
946
996
|
if (args.codex) {
|
|
947
997
|
const codexHooksDir = join(HOME, '.codex', 'hooks');
|
|
948
998
|
const codexSettings = join(HOME, '.codex', 'settings.json');
|
|
949
|
-
|
|
950
|
-
|
|
999
|
+
const codexHookSet = resolveHookFileSet(PKG_ROOT, codexHooksDir);
|
|
1000
|
+
reportHookSetWarning(codexHookSet);
|
|
1001
|
+
codexHookResult = removeHookFiles(codexHooksDir, codexHookSet.files, args.apply);
|
|
1002
|
+
codexSettingsResult = stripSettingsJson(
|
|
1003
|
+
codexSettings,
|
|
1004
|
+
codexHooksDir,
|
|
1005
|
+
codexHookSet.files,
|
|
1006
|
+
args.apply,
|
|
1007
|
+
);
|
|
951
1008
|
codexExtResult = removeExtensions('codex', args.apply, args.forceExtensions);
|
|
952
1009
|
codexExtSettings = stripExtensionSettings(
|
|
953
1010
|
codexSettings,
|
|
@@ -1105,3 +1162,10 @@ if (
|
|
|
1105
1162
|
}
|
|
1106
1163
|
|
|
1107
1164
|
console.log(lines.join('\n\n'));
|
|
1165
|
+
|
|
1166
|
+
// See resolveHookFileSet / reportHookSetWarning above (major D): neither the
|
|
1167
|
+
// package's own hooks.json nor a provenance record could identify what to
|
|
1168
|
+
// remove for at least one target. The report above already named what remains;
|
|
1169
|
+
// this is what tells an automated caller (or a script chaining onto this one)
|
|
1170
|
+
// that the run did not fully succeed, rather than exiting 0 on a silent skip.
|
|
1171
|
+
if (hardFailure) process.exit(1);
|