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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +36 -18
- package/README.md +26 -8
- package/commands/audit.md +1 -1
- package/commands/capture.md +40 -0
- package/commands/crystallize.md +15 -7
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/CONTRIBUTING.md +18 -0
- package/hooks/hypo-lookup.mjs +37 -2
- package/hooks/hypo-session-start.mjs +28 -2
- package/hooks/hypo-shared.mjs +159 -14
- package/package.json +1 -1
- package/scripts/capture.mjs +695 -0
- package/scripts/crystallize.mjs +292 -121
- package/scripts/doctor.mjs +71 -4
- package/scripts/init.mjs +16 -5
- package/scripts/lib/core-hooks.mjs +156 -0
- package/scripts/lib/extensions.mjs +317 -9
- package/scripts/lib/page-usage.mjs +141 -0
- package/scripts/uninstall.mjs +84 -42
- package/scripts/upgrade.mjs +68 -0
- package/skills/crystallize/SKILL.md +10 -4
- package/skills/graph/SKILL.md +1 -1
- package/skills/ingest/SKILL.md +1 -1
- package/skills/lint/SKILL.md +1 -1
- package/skills/query/SKILL.md +1 -1
- package/skills/verify/SKILL.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +1 -1
|
@@ -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)
|
|
188
|
-
* `
|
|
189
|
-
*
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|