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.
@@ -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" as a DURABLE install root only if it is an ABSOLUTE path to
102
- // a real package directory whose package.json carries a version. A relative path
103
- // (e.g. installPath ".") would be resolved against the caller's cwd and break the
104
- // vault git hook from any other directory; a version-less package.json cannot be
105
- // attributed a version without lying. A bare path that merely exists is a pointer
106
- // the runtime cannot resolve scripts through. Shared by init's registry resolution,
107
- // its durable-root fallback, and upgrade's dualSkip provenance correction so they
108
- // all agree on what is real.
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) => e && (scope === undefined || e.scope === scope) && usablePkgRoot(e.installPath),
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
+ }
@@ -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 { removeProvenanceSidecar } from './lib/pkg-provenance.mjs';
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 map (single source of truth) ───────────────────────────────────────
729
-
730
- function loadHookFiles() {
731
- let cfg;
732
- try {
733
- cfg = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
734
- } catch {
735
- console.error('Error: cannot read hooks/hooks.json');
736
- console.error(PKG_INTEGRITY_HINT);
737
- process.exit(1);
738
- }
739
- if (!cfg?.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
740
- console.error('Error: hooks/hooks.json must contain a "hooks" object');
741
- console.error(PKG_INTEGRITY_HINT);
742
- process.exit(1);
743
- }
744
-
745
- const hookFiles = new Set();
746
- const normalizedHookMap = {};
747
-
748
- for (const [event, groups] of Object.entries(cfg.hooks)) {
749
- const filenames = [];
750
- for (const entry of groups) {
751
- if (typeof entry === 'string') {
752
- // legacy flat format: entry is a filename
753
- hookFiles.add(entry);
754
- filenames.push(entry);
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(cfg.shared)) {
772
- for (const f of cfg.shared) hookFiles.add(f);
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
- return { hookMap: normalizedHookMap, hookFiles };
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
- function stripSettingsJson(settingsPath, hooksDir, hookMap, apply) {
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
- const hookResult = removeHookFiles(claudeHooksDir, hookFiles, args.apply);
882
- const settingsResult = stripSettingsJson(claudeSettings, claudeHooksDir, hookMap, args.apply);
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
- codexHookResult = removeHookFiles(codexHooksDir, hookFiles, args.apply);
950
- codexSettingsResult = stripSettingsJson(codexSettings, codexHooksDir, hookMap, args.apply);
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);