hypomnema 1.5.0 → 1.6.0

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,156 @@
1
+ // core-hooks.mjs - read the packaged hooks/hooks.json without side effects.
2
+ //
3
+ // The init installer (init.mjs loadHookMap) reads hooks.json, validates it, and
4
+ // calls process.exit(1) on any malformation. That exit-on-error behavior is
5
+ // correct for init but fatal for other consumers: reverse-capture wants to
6
+ // reserve the core hook basenames so it never captures a core hook by mistake,
7
+ // and it must not die because someone else's hooks.json is malformed.
8
+ //
9
+ // So this module owns only the read+parse step, exit-free. It returns a result
10
+ // object; the caller decides what to do with a failure. init.mjs keeps its own
11
+ // detailed validation + exit wrapper on top; reverse-capture skips the whole
12
+ // hooks type when the result is not ok (better to capture nothing than to
13
+ // capture a core hook).
14
+ //
15
+ // fail-closed: a result is ok only when the file reads, parses, AND has the
16
+ // expected shape (a hooks registration map plus a shared array). A parsed but
17
+ // oddly shaped hooks.json would yield a thin basename set, which is exactly the
18
+ // gap through which a core hook could leak into capture. When the shape is off
19
+ // we still attach the parsed cfg so init can run its own validation and emit its
20
+ // own specific error, but ok is false so capture stays conservative.
21
+
22
+ import { readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+
25
+ /**
26
+ * Read and JSON-parse hooks/hooks.json from a package root. No process.exit, no
27
+ * top-level side effects.
28
+ *
29
+ * @param {string} pkgRoot absolute path to the package root (contains hooks/)
30
+ * @returns {{ ok: true, cfg: object }
31
+ * | { ok: false, error: string }
32
+ * | { ok: false, error: string, cfg: * }}
33
+ * On read or parse failure the `cfg` key is absent. On a successful parse the
34
+ * `cfg` key is always present (even for null/array/scalar/shape-off inputs),
35
+ * so a caller can discriminate read/parse failure from shape failure by the
36
+ * presence of the `cfg` key. `ok` is true only when the shape is as expected.
37
+ */
38
+ export function readCoreHooksConfig(pkgRoot) {
39
+ let raw;
40
+ try {
41
+ raw = readFileSync(join(pkgRoot, 'hooks', 'hooks.json'), 'utf-8');
42
+ } catch (err) {
43
+ return { ok: false, error: `cannot read hooks/hooks.json: ${err.message}` };
44
+ }
45
+ let cfg;
46
+ try {
47
+ cfg = JSON.parse(raw);
48
+ } catch (err) {
49
+ return { ok: false, error: `hooks/hooks.json is not valid JSON: ${err.message}` };
50
+ }
51
+ // Parse succeeded: attach cfg unconditionally so init can run its own checks.
52
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
53
+ return { ok: false, error: 'hooks/hooks.json must be a JSON object', cfg };
54
+ }
55
+ if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
56
+ return { ok: false, error: 'hooks/hooks.json must contain a "hooks" object', cfg };
57
+ }
58
+ if (!Array.isArray(cfg.shared)) {
59
+ return { ok: false, error: 'hooks/hooks.json must contain a "shared" array', cfg };
60
+ }
61
+ // Nested shape: a structurally odd rung (event not an array, a group without a
62
+ // hooks array, a hook entry with no string command, a non-string shared element)
63
+ // is silently skipped by deriveCoreHookBasenames, yielding a THIN reserved set.
64
+ // That is the gap through which a core hook could leak into reverse-capture, so
65
+ // validate every rung and fail closed. cfg is still attached so init can run its
66
+ // own detailed validation and emit its own specific error.
67
+ for (const groups of Object.values(cfg.hooks)) {
68
+ if (!Array.isArray(groups)) {
69
+ return { ok: false, error: 'each hooks event must map to an array of groups', cfg };
70
+ }
71
+ for (const group of groups) {
72
+ if (typeof group === 'string') continue; // legacy bare-filename group
73
+ if (!group || typeof group !== 'object' || Array.isArray(group)) {
74
+ return { ok: false, error: 'each hook group must be an object or a filename string', cfg };
75
+ }
76
+ if (!Array.isArray(group.hooks)) {
77
+ return { ok: false, error: 'each hook group must contain a "hooks" array', cfg };
78
+ }
79
+ for (const hook of group.hooks) {
80
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
81
+ return { ok: false, error: 'each hook entry must be an object', cfg };
82
+ }
83
+ if (typeof hook.command !== 'string') {
84
+ return { ok: false, error: 'each hook entry must have a string command', cfg };
85
+ }
86
+ }
87
+ }
88
+ }
89
+ for (const file of cfg.shared) {
90
+ if (typeof file !== 'string') {
91
+ return { ok: false, error: 'each "shared" entry must be a string', cfg };
92
+ }
93
+ }
94
+ return { ok: true, cfg };
95
+ }
96
+
97
+ // Match a bare .mjs basename with no path separators, whitespace, or quoting.
98
+ // Applied to the LAST path segment only, so a command like
99
+ // `node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-lookup.mjs` yields `hypo-lookup.mjs`.
100
+ const MJS_BASENAME = /^[^\s/\\'"`]+\.mjs$/i;
101
+
102
+ /**
103
+ * Extract the strict .mjs basename from a value that is either a bare filename
104
+ * or a full command string. Takes the last path segment (split on / and \) and
105
+ * accepts it only when it is a clean .mjs name. Returns null otherwise.
106
+ * @param {*} value
107
+ * @returns {string | null}
108
+ */
109
+ function strictMjsBasename(value) {
110
+ if (typeof value !== 'string') return null;
111
+ const segments = value.trim().split(/[/\\]/);
112
+ const last = segments[segments.length - 1];
113
+ return MJS_BASENAME.test(last) ? last : null;
114
+ }
115
+
116
+ /**
117
+ * Derive the set of core hook .mjs basenames reserved by hooks.json: the union
118
+ * of every registered event command's basename and every `shared` array
119
+ * basename, lowercased. Walks defensively so a partially odd cfg yields a
120
+ * partial (never throwing) set; the fatal shape gate lives in
121
+ * readCoreHooksConfig.
122
+ *
123
+ * @param {object} cfg parsed hooks.json (as returned in readCoreHooksConfig().cfg)
124
+ * @returns {Set<string>} lowercase .mjs basenames
125
+ */
126
+ export function deriveCoreHookBasenames(cfg) {
127
+ const names = new Set();
128
+ const add = (value) => {
129
+ const base = strictMjsBasename(value);
130
+ if (base) names.add(base.toLowerCase());
131
+ };
132
+
133
+ if (cfg && typeof cfg === 'object' && cfg.hooks && typeof cfg.hooks === 'object') {
134
+ for (const groups of Object.values(cfg.hooks)) {
135
+ if (!Array.isArray(groups)) continue;
136
+ for (const group of groups) {
137
+ if (typeof group === 'string') {
138
+ // Legacy format: the group is a bare .mjs filename.
139
+ add(group);
140
+ continue;
141
+ }
142
+ if (group && typeof group === 'object' && Array.isArray(group.hooks)) {
143
+ for (const hook of group.hooks) {
144
+ if (hook && typeof hook === 'object') add(hook.command);
145
+ }
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ if (cfg && Array.isArray(cfg.shared)) {
152
+ for (const file of cfg.shared) add(file);
153
+ }
154
+
155
+ return names;
156
+ }
@@ -49,6 +49,140 @@ export const CODEX_TYPES = ['hooks', 'commands'];
49
49
  // Per-type expected file extension. Hooks are executable .mjs; the rest are .md.
50
50
  const TYPE_FILE_EXT = { hooks: '.mjs', commands: '.md', skills: '.md', agents: '.md' };
51
51
 
52
+ // Singular manifest `type` value per directory (capture design §3). A captured
53
+ // extension's sidecar manifest must declare a `type` matching its parent dir
54
+ // before its `installName` is honored (guards against a mislabelled manifest
55
+ // retargeting an install path).
56
+ const TYPE_SINGULAR = { hooks: 'hook', commands: 'command', skills: 'skill', agents: 'agent' };
57
+
58
+ // installName carrier (capture design §3): reverse-captured commands/agents/hooks
59
+ // install under the user's ORIGINAL name, not the wiki `hypo-ext-*` storage name.
60
+ // For hooks the installName also drives the reconstructed settings.json command
61
+ // (buildHookCommand) and the sidecar manifest install name (P1), so all three
62
+ // ownership paths stay aligned. Skills are not captured in the MVP.
63
+ const INSTALLNAME_TYPES = new Set(['commands', 'agents', 'hooks']);
64
+
65
+ // A valid installName stem: same conservative charset as SAFE_EXT_STEM but
66
+ // WITHOUT the required hypo-ext- prefix (the whole point of C is to restore the
67
+ // user's own name). Rejects path separators, traversal (leading dot blocks `..`
68
+ // and `.`), and anything outside the charset.
69
+ const SAFE_INSTALL_STEM = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
70
+
71
+ // Windows reserved device names (capture design §5 — the extensions-companion design chose hard-copy partly
72
+ // for Windows compatibility, so an installName must never resolve to one).
73
+ const WINDOWS_RESERVED = new Set([
74
+ 'con',
75
+ 'prn',
76
+ 'aux',
77
+ 'nul',
78
+ 'com1',
79
+ 'com2',
80
+ 'com3',
81
+ 'com4',
82
+ 'com5',
83
+ 'com6',
84
+ 'com7',
85
+ 'com8',
86
+ 'com9',
87
+ 'lpt1',
88
+ 'lpt2',
89
+ 'lpt3',
90
+ 'lpt4',
91
+ 'lpt5',
92
+ 'lpt6',
93
+ 'lpt7',
94
+ 'lpt8',
95
+ 'lpt9',
96
+ ]);
97
+
98
+ /**
99
+ * True iff `stem` is a syntactically valid, non-reserved installName (the reverse-capture design
100
+ * §5). Case-insensitive on the reservations so `Hypo-x` cannot slip past on a
101
+ * case-insensitive filesystem. Does NOT append an extension — callers add
102
+ * TYPE_FILE_EXT in code so a manifest can never inject the extension.
103
+ */
104
+ export function isValidInstallStem(stem) {
105
+ if (typeof stem !== 'string' || !SAFE_INSTALL_STEM.test(stem)) return false;
106
+ // Reject `..` anywhere, not just a leading dot: an internal `a..b` passes the
107
+ // charset but parseExtKey (uninstall) rejects it, which would strand a captured
108
+ // file it can never remove. Keep the two validators in agreement.
109
+ if (stem.includes('..')) return false;
110
+ const lower = stem.toLowerCase();
111
+ if (lower === 'hypo' || lower.startsWith('hypo-')) return false; // reserved namespace
112
+ // Windows treats a device name reserved even WITH an extension (`con.v1`), so
113
+ // test the segment before the first dot, not just the whole stem.
114
+ const base = lower.split('.')[0];
115
+ if (WINDOWS_RESERVED.has(base)) return false;
116
+ return true;
117
+ }
118
+
119
+ /**
120
+ * Resolve the install filename for a discovered extension (capture design §3).
121
+ * Returns `{ installFile }` — the basename under `~/.claude/<type>/`. Defaults
122
+ * to `ext.file` (the wiki storage name) so existing hypo-ext-* extensions are
123
+ * untouched (backward compatible). Only commands/agents with a sidecar manifest
124
+ * whose `type` matches the directory AND whose `installName` is valid install
125
+ * under the user's original name. A present-but-invalid installName yields
126
+ * `{ skip, warn }` — the extension is dropped rather than silently installed
127
+ * under a surprising name.
128
+ */
129
+ export function resolveInstallFile(ext) {
130
+ if (!INSTALLNAME_TYPES.has(ext.type) || !ext.manifestPath) {
131
+ return { installFile: ext.file };
132
+ }
133
+ const parsed = parseManifest(ext.manifestPath);
134
+ // A malformed hook manifest is reachable here (parseManifest validates hooks);
135
+ // fall back to the wiki storage name so the later sync loop, which re-parses and
136
+ // skips the malformed hook, still finds a consistent install path.
137
+ if (!parsed.ok) return { installFile: ext.file };
138
+ const m = parsed.manifest;
139
+ if (!m || m.type !== TYPE_SINGULAR[ext.type] || m.installName === undefined) {
140
+ return { installFile: ext.file };
141
+ }
142
+ if (!isValidInstallStem(m.installName)) {
143
+ return {
144
+ skip: true,
145
+ warn: `${ext.type}/${ext.file}: invalid installName "${m.installName}" — extension skipped`,
146
+ };
147
+ }
148
+ return { installFile: m.installName + TYPE_FILE_EXT[ext.type] };
149
+ }
150
+
151
+ /**
152
+ * Parse + validate a recorded pkg-json extension key `${type}/${installFile}`
153
+ * for destructive use (capture design §8 — uninstall traverses recorded keys, which
154
+ * come from an on-disk JSON we do not fully control). Returns
155
+ * `{ type, installFile }` only when the key is exactly one covered type, a
156
+ * single path segment for the filename (no separators / traversal), and the
157
+ * expected extension. Returns null otherwise so the caller skips it — never
158
+ * `join`s an untrusted key that could escape the extension directory.
159
+ */
160
+ export function parseExtKey(key, coveredTypes) {
161
+ if (typeof key !== 'string') return null;
162
+ const slash = key.indexOf('/');
163
+ if (slash === -1) return null;
164
+ const type = key.slice(0, slash);
165
+ const installFile = key.slice(slash + 1);
166
+ if (!coveredTypes.includes(type)) return null;
167
+ // The filename portion must be a single safe segment: no further separators,
168
+ // no traversal, and it must end in the type's expected extension. Only HOOKS
169
+ // additionally track a `.manifest.json` sidecar copy in the SHA map, so the
170
+ // manifest suffix is accepted for hooks alone — accepting it for every type
171
+ // would let a forged `commands/x.manifest.json` key name a removable file that
172
+ // sync never owns (codex pre-commit CONCERN).
173
+ if (installFile.includes('/') || installFile.includes('\\')) return null;
174
+ if (installFile.includes('..') || installFile.startsWith('.')) return null;
175
+ const MANIFEST_SUFFIX = '.manifest.json';
176
+ const suffix =
177
+ type === 'hooks' && installFile.endsWith(MANIFEST_SUFFIX)
178
+ ? MANIFEST_SUFFIX
179
+ : TYPE_FILE_EXT[type];
180
+ if (!installFile.endsWith(suffix)) return null;
181
+ const stem = installFile.slice(0, -suffix.length);
182
+ if (stem.length === 0) return null;
183
+ return { type, installFile };
184
+ }
185
+
52
186
  // Claude Code hook events (D3 allowlist). A manifest with an event outside this
53
187
  // set is malformed → the whole extension is skipped (no copy, no registration).
54
188
  export const HOOK_EVENT_ALLOWLIST = new Set([
@@ -184,9 +318,11 @@ export function parseManifest(path) {
184
318
 
185
319
  /**
186
320
  * Build the settings.json entries expected for the discovered hook extensions.
187
- * The command string is constructed here (never from the manifest) as
188
- * `node $HOME/.claude/hooks/<basename>`. Extensions whose manifest is missing or
189
- * not registrable yield no entry.
321
+ * The command string is constructed here (never from the manifest) via the shared
322
+ * `buildHookCommand`, using the installName-resolved install filename so a captured
323
+ * hook registers under its original name and its command matches the installed
324
+ * `.mjs`. Extensions whose manifest is missing, not registrable, or whose
325
+ * installName is invalid (resolveInstallFile skip) yield no entry.
190
326
  */
191
327
  export function buildExpectedSettingsEntries(discoveredHooks, targetHooksDir) {
192
328
  const entries = [];
@@ -194,7 +330,9 @@ export function buildExpectedSettingsEntries(discoveredHooks, targetHooksDir) {
194
330
  if (!ext.manifestPath) continue;
195
331
  const parsed = parseManifest(ext.manifestPath);
196
332
  if (!parsed.ok || !parsed.registrable) continue;
197
- const command = `node ${targetHooksDir.replace(HOME, '$HOME')}/${ext.file}`;
333
+ const resolved = resolveInstallFile(ext);
334
+ if (resolved.skip) continue;
335
+ const command = buildHookCommand(targetHooksDir, resolved.installFile);
198
336
  entries.push({
199
337
  name: ext.name,
200
338
  file: ext.file,
@@ -207,6 +345,112 @@ export function buildExpectedSettingsEntries(discoveredHooks, targetHooksDir) {
207
345
  return entries;
208
346
  }
209
347
 
348
+ /**
349
+ * The single source of the settings.json hook command string (capture design
350
+ * F4). Both the forward path (`buildExpectedSettingsEntries`) and the reverse
351
+ * strict parser (`parseCapturableHookCommand`) mirror this one shape so the two
352
+ * directions can never drift. `hooksDir` is `<HOME>/.claude/hooks`, rewritten to
353
+ * a `$HOME` literal so a captured registration stays portable across machines.
354
+ */
355
+ export function buildHookCommand(hooksDir, installFile) {
356
+ return `node ${hooksDir.replace(HOME, '$HOME')}/${installFile}`;
357
+ }
358
+
359
+ // The exact canonical hook path prefix that `buildHookCommand` emits for a dir
360
+ // under HOME. The strict parser accepts only this literal — an absolute path, a
361
+ // `~`, or an env prefix all diverge and are rejected with a visible reason.
362
+ const CAPTURABLE_HOOK_PATH_PREFIX = '$HOME/.claude/hooks/';
363
+ const CAPTURABLE_NODE_PREFIX = 'node ';
364
+ const MJS_EXT = '.mjs';
365
+
366
+ /**
367
+ * Strict, fs-free parser for a capturable hook command (capture design F4). It
368
+ * accepts ONLY the byte-for-byte shape `buildHookCommand` produces for a dir
369
+ * under HOME: `node $HOME/.claude/hooks/<stem>.mjs`. Returns
370
+ * `{ ok:true, stem, basename }` on a match, else `{ ok:false, reason }` with a
371
+ * distinct reason per rejection axis so a caller can surface why a hook was not
372
+ * captured (never a silent drop). This is deliberately NOT the lenient
373
+ * `_extractCommandFileName` used by init: capture eligibility must be exact.
374
+ */
375
+ export function parseCapturableHookCommand(command) {
376
+ if (typeof command !== 'string') return { ok: false, reason: 'not-a-string' };
377
+ // A newline in a command would break the one-line canonical shape and could
378
+ // smuggle a second statement past a prefix check.
379
+ if (/[\r\n]/.test(command)) return { ok: false, reason: 'contains-newline' };
380
+ if (!command.startsWith(CAPTURABLE_NODE_PREFIX)) {
381
+ return { ok: false, reason: 'bad-node-prefix' };
382
+ }
383
+ const rest = command.slice(CAPTURABLE_NODE_PREFIX.length);
384
+ // Reject extra leading whitespace (a double space or a tab after `node`): the
385
+ // builder emits exactly one space, so anything more is a lossy divergence.
386
+ if (rest.length === 0 || rest[0] === ' ' || rest[0] === '\t') {
387
+ return { ok: false, reason: 'bad-node-prefix' };
388
+ }
389
+ if (!rest.startsWith(CAPTURABLE_HOOK_PATH_PREFIX)) {
390
+ // Covers `~`, a relative path, an env prefix, and an absolute path — none
391
+ // start with the `$HOME/.claude/hooks/` literal.
392
+ return { ok: false, reason: 'path-not-under-home-hooks' };
393
+ }
394
+ const tail = rest.slice(CAPTURABLE_HOOK_PATH_PREFIX.length);
395
+ // The tail must be a single filename segment: no further separators (no
396
+ // subdirectory) and no traversal.
397
+ if (tail.includes('/') || tail.includes('\\') || tail.includes('..')) {
398
+ return { ok: false, reason: 'nested-segment' };
399
+ }
400
+ if (!tail.endsWith(MJS_EXT)) {
401
+ return { ok: false, reason: 'not-mjs' };
402
+ }
403
+ const stem = tail.slice(0, -MJS_EXT.length);
404
+ // Same stem gate as install (rejects the reserved hypo-* namespace, Windows
405
+ // device names, and out-of-charset names) so parse and install agree.
406
+ if (!isValidInstallStem(stem)) {
407
+ return { ok: false, reason: 'invalid-stem' };
408
+ }
409
+ return { ok: true, stem, basename: tail };
410
+ }
411
+
412
+ /**
413
+ * Pure, defensive walk of `settings.hooks[event][group].hooks[]` (capture design
414
+ * F4). Yields one record per hook entry:
415
+ * `{ event, matcher, type, timeout, command, hookKeys, groupKeys }`. `type` is the
416
+ * raw `entry.type` value (reverse-capture requires `'command'`, distinct from the
417
+ * `type` KEY that hookKeys reports). `matcher` is taken
418
+ * verbatim from the parent group with `''` normalized to absent (undefined),
419
+ * matching `parseManifest`/`registerSettings`. Any malformed rung (non-array
420
+ * event list, non-object group, non-array hook list, non-object hook) is skipped
421
+ * rather than throwing, so a hand-edited settings.json cannot crash the caller.
422
+ */
423
+ export function scanSettingsHooks(settings) {
424
+ const records = [];
425
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return records;
426
+ const hooks = settings.hooks;
427
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return records;
428
+ for (const [event, groups] of Object.entries(hooks)) {
429
+ if (!Array.isArray(groups)) continue;
430
+ for (const group of groups) {
431
+ if (!group || typeof group !== 'object' || Array.isArray(group)) continue;
432
+ const groupKeys = Object.keys(group);
433
+ let matcher = group.matcher;
434
+ if (matcher === '') matcher = undefined; // empty matcher === absent matcher
435
+ const hookList = group.hooks;
436
+ if (!Array.isArray(hookList)) continue;
437
+ for (const entry of hookList) {
438
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
439
+ records.push({
440
+ event,
441
+ matcher,
442
+ type: entry.type,
443
+ timeout: entry.timeout,
444
+ command: entry.command,
445
+ hookKeys: Object.keys(entry),
446
+ groupKeys,
447
+ });
448
+ }
449
+ }
450
+ }
451
+ return records;
452
+ }
453
+
210
454
  /** Read the full hypo-pkg.json without any side effect (cf. pkg-json.readPkgJson,
211
455
  * which renames a corrupt file — unsafe for read-only callers, plan §5 #3). */
212
456
  function readFullPkgNoMutate(pkgPath) {
@@ -387,9 +631,67 @@ export function syncExtensions({
387
631
 
388
632
  const expectedHookExts = [];
389
633
 
634
+ // capture design §3/§3a: resolve each extension's install filename (installName
635
+ // decoupling) and detect duplicate install targets BEFORE any hard-copy. A
636
+ // case-folded collision on `${type}/${installFile}` (two wiki files mapping to
637
+ // the same install path, or a case-only clash on macOS/Windows) skips the
638
+ // WHOLE group — otherwise file traversal order would decide ownership and
639
+ // overwrite/skip unpredictably. Keyed by ext object identity.
640
+ const installFileByExt = new Map();
641
+ const dupSkip = new Set();
642
+ for (const type of types) {
643
+ const seen = new Map(); // case-folded `${type}/${installFile}` → first ext
644
+ for (const ext of discovered[type]) {
645
+ const res = resolveInstallFile(ext);
646
+ if (res.skip) {
647
+ dupSkip.add(ext);
648
+ result.warnings.push(res.warn);
649
+ continue;
650
+ }
651
+ installFileByExt.set(ext, res.installFile);
652
+ const norm = `${type}/${res.installFile.toLowerCase()}`;
653
+ const first = seen.get(norm);
654
+ if (first !== undefined) {
655
+ dupSkip.add(ext);
656
+ dupSkip.add(first);
657
+ result.warnings.push(
658
+ `${type}/${res.installFile} install target claimed by multiple extensions — all skipped (rename installName)`,
659
+ );
660
+ } else {
661
+ seen.set(norm, ext);
662
+ }
663
+ }
664
+ }
665
+
666
+ // Preserve the ownership record of an already-installed file whose extension we
667
+ // now skip on a duplicate-target collision. Without this, the newSHAs map (which
668
+ // replaces the target map wholesale) would drop the recorded SHA and orphan the
669
+ // previously-owned installed copy — it would linger on disk, untracked by doctor
670
+ // and unreachable by uninstall (codex pre-commit BLOCKER).
671
+ for (const ext of dupSkip) {
672
+ const inst = installFileByExt.get(ext);
673
+ if (!inst) continue; // invalid-installName skip: never owned
674
+ // Preserve BOTH ownership keys a hook records: the main `.mjs` AND its paired
675
+ // `.manifest.json` sidecar. Preserving only the main key (codex pre-commit
676
+ // CONCERN) would drop the sidecar SHA on a duplicate-target collision, leaving
677
+ // the installed manifest copy on disk untracked by doctor and unreachable by
678
+ // uninstall. commands/agents have no sidecar, so their single key is preserved.
679
+ const keys = [`${ext.type}/${inst}`];
680
+ if (ext.type === 'hooks') {
681
+ keys.push(`${ext.type}/${inst.replace(/\.mjs$/, '.manifest.json')}`);
682
+ }
683
+ for (const key of keys) {
684
+ if (recorded[key] !== undefined && newSHAs[key] === undefined) newSHAs[key] = recorded[key];
685
+ }
686
+ }
687
+
390
688
  for (const type of types) {
391
689
  const typeDir = join(targetRoot, type);
392
690
  for (const ext of discovered[type]) {
691
+ if (dupSkip.has(ext)) continue;
692
+ // Install under the manifest-declared installName (capture design §3) or, by
693
+ // default, the wiki storage name (backward compatible).
694
+ const installFile = installFileByExt.get(ext) ?? ext.file;
393
695
  // D3: validate the manifest before touching the filesystem.
394
696
  let manifestParsed = null;
395
697
  if (type === 'hooks') {
@@ -408,11 +710,12 @@ export function syncExtensions({
408
710
 
409
711
  if (apply) mkdirSync(typeDir, { recursive: true });
410
712
 
411
- // (2) hard-copy the main file.
412
- const fileKey = `${type}/${ext.file}`;
713
+ // (2) hard-copy the main file. Key + destPath use installFile so the
714
+ // recorded SHA map, doctor, and uninstall all agree on the install path.
715
+ const fileKey = `${type}/${installFile}`;
413
716
  const fileRes = copyOne({
414
717
  srcPath: ext.srcPath,
415
- destPath: join(typeDir, ext.file),
718
+ destPath: join(typeDir, installFile),
416
719
  key: fileKey,
417
720
  recordedSHA: recorded[fileKey],
418
721
  apply,
@@ -438,10 +741,15 @@ export function syncExtensions({
438
741
  manifestParsed &&
439
742
  manifestParsed.ok
440
743
  ) {
441
- const mKey = `${type}/${ext.manifestName}`;
744
+ // P1: derive the sidecar install name from the sibling `.mjs` installFile
745
+ // so the manifest copy + SHA key follow installName exactly as the hook
746
+ // file does. For a wiki-authored hook (no installName) installFile is
747
+ // `ext.file`, so this equals the old `ext.manifestName` (no regression).
748
+ const installManifestName = installFile.replace(/\.mjs$/, '.manifest.json');
749
+ const mKey = `${type}/${installManifestName}`;
442
750
  const mRes = copyOne({
443
751
  srcPath: ext.manifestPath,
444
- destPath: join(typeDir, ext.manifestName),
752
+ destPath: join(typeDir, installManifestName),
445
753
  key: mKey,
446
754
  recordedSHA: recorded[mKey],
447
755
  apply,
@@ -0,0 +1,141 @@
1
+ // page-usage.mjs: read-only aggregation over .cache/page-usage.jsonl (B3)
2
+ //
3
+ // The lookup hook appends one JSONL record per injected page slug (see
4
+ // hooks/hypo-shared.mjs recordLookupUsage). This lib reads that log and derives
5
+ // "lookup-cold candidates": pages that have inbound wikilinks (so the graph
6
+ // treats them as live) but have not been injected by lookup within a recency
7
+ // window. It writes nothing and is only invoked from scripts (crystallize), so
8
+ // it never touches the per-prompt hook hot path.
9
+
10
+ import { readFileSync, existsSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { collectPagesCrystallize, extractWikilinks, slugForms } from './wikilink.mjs';
13
+ import { parseFrontmatter } from './frontmatter.mjs';
14
+
15
+ const PAGE_USAGE_REL = '.cache/page-usage.jsonl';
16
+ const DAY_MS = 86400000;
17
+
18
+ // The distinct link forms a slug may appear as in a [[wikilink]] or in the log:
19
+ // its full path, its basename, and (when nested) the path minus its first
20
+ // segment. Matching by form-set intersection bridges the gap between the log's
21
+ // index-matched slug form and the reverse index's [[wikilink]] form.
22
+ function formSet(slug) {
23
+ const f = slugForms(slug);
24
+ const s = new Set([f.full, f.bare]);
25
+ if (f.dirRel) s.add(f.dirRel);
26
+ return s;
27
+ }
28
+
29
+ function intersects(a, b) {
30
+ for (const x of a) if (b.has(x)) return true;
31
+ return false;
32
+ }
33
+
34
+ // Read every record from the usage log, skipping malformed lines. Returns [] if
35
+ // the log is absent or unreadable (fail-soft: aggregation is advisory).
36
+ export function readPageUsage(hypoDir) {
37
+ const path = join(hypoDir, PAGE_USAGE_REL);
38
+ if (!existsSync(path)) return [];
39
+ let text;
40
+ try {
41
+ text = readFileSync(path, 'utf-8');
42
+ } catch {
43
+ return [];
44
+ }
45
+ const records = [];
46
+ for (const line of text.split('\n')) {
47
+ const trimmed = line.trim();
48
+ if (!trimmed) continue;
49
+ try {
50
+ const parsed = JSON.parse(trimmed);
51
+ // Only keep plain objects. A bare `null`, number, string, or array is
52
+ // valid JSON but not a record; keeping it would crash the field access in
53
+ // aggregateColdCandidates (and crystallize calls that outside a try).
54
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
55
+ records.push(parsed);
56
+ }
57
+ } catch {
58
+ // malformed line → skip (log is append-only and may be partially written)
59
+ }
60
+ }
61
+ return records;
62
+ }
63
+
64
+ // Derive lookup-cold candidates. Guards against cold-start: if there are no
65
+ // records, or the observed log span is shorter than minLogSpanDays, returns
66
+ // { status: 'insufficient-data' } so a fresh log doesn't flag the whole vault.
67
+ // Otherwise returns { status: 'ok', candidates: [{ slug, title }] } for pages
68
+ // that have >= 1 inbound wikilink from another page yet have not been logged
69
+ // within the last thresholdDays.
70
+ export function aggregateColdCandidates(
71
+ hypoDir,
72
+ { thresholdDays = 90, minLogSpanDays = 14, now = Date.now(), ignorePatterns = [] } = {},
73
+ ) {
74
+ const records = readPageUsage(hypoDir);
75
+ if (records.length === 0) return { status: 'insufficient-data', reason: 'no-records' };
76
+
77
+ const times = records.map((r) => Date.parse(r.ts)).filter((t) => Number.isFinite(t));
78
+ if (times.length === 0) return { status: 'insufficient-data', reason: 'no-timestamps' };
79
+ const span = Math.max(...times) - Math.min(...times);
80
+ if (span < minLogSpanDays * DAY_MS) {
81
+ return { status: 'insufficient-data', reason: 'span-too-short' };
82
+ }
83
+
84
+ // Forms of every slug logged within the recency window.
85
+ const recentCutoff = now - thresholdDays * DAY_MS;
86
+ const recentForms = new Set();
87
+ for (const r of records) {
88
+ const t = Date.parse(r.ts);
89
+ if (!Number.isFinite(t) || t < recentCutoff) continue;
90
+ if (typeof r.slug !== 'string' || !r.slug) continue;
91
+ for (const form of formSet(r.slug)) recentForms.add(form);
92
+ }
93
+
94
+ // Build the page list with slug, title, forms, and outbound links.
95
+ const pagesDir = join(hypoDir, 'pages');
96
+ const pageList = [];
97
+ for (const { path, rel } of collectPagesCrystallize(pagesDir, hypoDir, ignorePatterns)) {
98
+ let content;
99
+ try {
100
+ content = readFileSync(path, 'utf-8');
101
+ } catch {
102
+ continue;
103
+ }
104
+ const fm = parseFrontmatter(content);
105
+ if (!fm) continue;
106
+ const slug = rel.replace(/\.md$/, '');
107
+ const body = content.replace(/^---[\s\S]*?---/, '');
108
+ pageList.push({
109
+ slug,
110
+ title: fm.title || slug,
111
+ forms: formSet(slug),
112
+ links: extractWikilinks(body),
113
+ });
114
+ }
115
+
116
+ // Reverse index: which page slugs receive >= 1 inbound link from another page.
117
+ const formOwners = new Map(); // form → Set<page slug>
118
+ for (const p of pageList) {
119
+ for (const form of p.forms) {
120
+ if (!formOwners.has(form)) formOwners.set(form, new Set());
121
+ formOwners.get(form).add(p.slug);
122
+ }
123
+ }
124
+ const hasInbound = new Set();
125
+ for (const p of pageList) {
126
+ for (const link of p.links) {
127
+ const targets = new Set();
128
+ for (const form of formSet(link)) {
129
+ const owners = formOwners.get(form);
130
+ if (owners) for (const o of owners) targets.add(o);
131
+ }
132
+ for (const t of targets) if (t !== p.slug) hasInbound.add(t);
133
+ }
134
+ }
135
+
136
+ const candidates = pageList
137
+ .filter((p) => hasInbound.has(p.slug) && !intersects(p.forms, recentForms))
138
+ .map((p) => ({ slug: p.slug, title: p.title }));
139
+
140
+ return { status: 'ok', candidates };
141
+ }