hypomnema 1.5.1 → 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,695 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/capture.mjs — Reverse extension capture (MVP capture design).
4
+ *
5
+ * Pulls extensions authored the "normal way" under `~/.claude/{commands,agents}/`
6
+ * (readdir enumeration) and canonical hooks registered in `~/.claude/settings.json`
7
+ * (settings enumeration) into the wiki `~/hypomnema/extensions/{commands,agents,hooks}/`
8
+ * so they propagate to other machines via the existing forward-sync ("register on
9
+ * A → sync on B").
10
+ *
11
+ * Scope: commands + agents (pure file copy, no settings.json) and hooks (settings
12
+ * reverse-capture, lossless round-trip only). skills (directory support) are
13
+ * deferred to a separate PR.
14
+ *
15
+ * Naming (capture design §3): the wiki STORAGE name is `hypo-ext-<name>.<ext>` (keeps
16
+ * the discovery whitelist), and a sidecar `hypo-ext-<name>.manifest.json` records
17
+ * `{ type, installName, ... }` so forward-sync installs the file back under the
18
+ * user's ORIGINAL name (`~/.claude/<type>/<name>.<ext>`), not the wiki storage name.
19
+ * For hooks the manifest also carries `{ event, matcher?, timeout? }` so the original
20
+ * settings.json registration is restored on the far machine.
21
+ *
22
+ * Adopt (capture design §4): capture copies the source verbatim into the wiki, then
23
+ * runs forward-sync. The install target already holds byte-identical content, so
24
+ * `copyOne` hits its `up-to-date` branch and records the SHA — ownership is
25
+ * acquired through the normal sync path, never by injecting a SHA directly.
26
+ */
27
+
28
+ import {
29
+ existsSync,
30
+ readFileSync,
31
+ readdirSync,
32
+ writeFileSync,
33
+ mkdirSync,
34
+ unlinkSync,
35
+ renameSync,
36
+ realpathSync,
37
+ } from 'fs';
38
+ import { join, dirname } from 'path';
39
+ import { homedir } from 'os';
40
+ import { pathToFileURL, fileURLToPath } from 'url';
41
+ import {
42
+ sha256,
43
+ isRegularFile,
44
+ readFileIfRegular,
45
+ readPkgJson,
46
+ writePkgJsonAtomic,
47
+ } from './lib/pkg-json.mjs';
48
+ import { resolveHypoRoot } from './lib/hypo-root.mjs';
49
+ import {
50
+ syncExtensions,
51
+ readExtensionPkgStateNoMutate,
52
+ isValidInstallStem,
53
+ scanSettingsHooks,
54
+ parseCapturableHookCommand,
55
+ HOOK_EVENT_ALLOWLIST,
56
+ EXT_PREFIX,
57
+ } from './lib/extensions.mjs';
58
+ import { readCoreHooksConfig, deriveCoreHookBasenames } from './lib/core-hooks.mjs';
59
+
60
+ const HOME = homedir();
61
+
62
+ // The package root (contains hooks/hooks.json) so reverse-capture can reserve the
63
+ // core hook basenames without importing init.mjs (which has exit-on-error side
64
+ // effects). capture.mjs lives in scripts/, so the root is one level up.
65
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
66
+
67
+ // Captured types and their singular manifest `type` value. commands/agents install
68
+ // as top-level `.md` files; hooks install as `.mjs` and additionally register a
69
+ // settings.json entry (reverse of forward-sync).
70
+ const CAPTURE_TYPES = ['commands', 'agents', 'hooks'];
71
+ const TYPE_SINGULAR = { commands: 'command', agents: 'agent', hooks: 'hook' };
72
+ const TYPE_EXT = { commands: '.md', agents: '.md', hooks: '.mjs' };
73
+
74
+ function pkgJsonPath() {
75
+ return join(HOME, '.claude', 'hypo-pkg.json');
76
+ }
77
+
78
+ // ── pure helpers (exported for tests) ────────────────────────────────────────
79
+
80
+ /**
81
+ * Decide whether a top-level `~/.claude/<type>/<file>` is a capture candidate.
82
+ * Excludes (capture design §6): the reserved `hypo` namespace (case-insensitive — core
83
+ * hooks/commands + already-synced hypo-ext-* copies), anything already owned
84
+ * (a recorded SHA for its install path means the wiki already manages it), and
85
+ * non-.md files. Symlink/non-regular is checked by the caller (needs an fs stat).
86
+ */
87
+ export function isCaptureCandidate(type, file, recorded) {
88
+ if (!file.endsWith(TYPE_EXT[type])) return { ok: false, reason: `not a ${TYPE_EXT[type]} file` };
89
+ const lower = file.toLowerCase();
90
+ if (lower === 'hypo' || lower.startsWith('hypo-')) {
91
+ return { ok: false, reason: 'reserved hypo namespace' };
92
+ }
93
+ if (recorded[`${type}/${file}`]) {
94
+ return { ok: false, reason: 'already managed by the wiki' };
95
+ }
96
+ return { ok: true };
97
+ }
98
+
99
+ /** Order-independent deep equality for parsed JSON values (objects/arrays/scalars).
100
+ * Manifests are compared by content, not key order, so a hand-reordered but
101
+ * equivalent sidecar is still recognized as `already` rather than a false conflict. */
102
+ function deepEqual(a, b) {
103
+ if (a === b) return true;
104
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') return false;
105
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
106
+ const ka = Object.keys(a);
107
+ const kb = Object.keys(b);
108
+ if (ka.length !== kb.length) return false;
109
+ for (const k of ka) {
110
+ if (!Object.prototype.hasOwnProperty.call(b, k) || !deepEqual(a[k], b[k])) return false;
111
+ }
112
+ return true;
113
+ }
114
+
115
+ /**
116
+ * Plan the capture of one candidate against the current wiki state. Generalized
117
+ * over extension type: the caller assembles the desired `wantManifest` (commands
118
+ * and agents get `{ type, installName }`; hooks additionally carry
119
+ * `{ event, matcher?, timeout? }`) and this decides the outcome by comparing the
120
+ * source SHA and doing a deep-equality check against any existing sidecar. Pure
121
+ * w.r.t. decisions; the caller supplies the already-read source + wiki contents so
122
+ * this stays testable.
123
+ *
124
+ * status:
125
+ * 'invalid' — the installName stem is unsafe/reserved (skip).
126
+ * 'conflict' — a wiki storage file or sidecar manifest already exists and
127
+ * disagrees (refuse; never silently overwrite, capture design §7).
128
+ * 'already' — wiki file + manifest already match this capture (no-op).
129
+ * 'ready' — safe to write.
130
+ */
131
+ export function planCapture({ wantManifest, srcSha, existingFileSha, existingManifestRaw }) {
132
+ if (!isValidInstallStem(wantManifest.installName)) {
133
+ return { status: 'invalid', reason: `invalid installName "${wantManifest.installName}"` };
134
+ }
135
+
136
+ if (existingFileSha !== null && existingFileSha !== undefined) {
137
+ if (existingFileSha !== srcSha) {
138
+ return { status: 'conflict', reason: 'wiki storage file exists with different content' };
139
+ }
140
+ // The storage file matches — the manifest must also match, else install
141
+ // semantics (installName/event/matcher/timeout) would differ.
142
+ if (existingManifestRaw == null) {
143
+ return { status: 'conflict', reason: 'wiki file exists without its installName manifest' };
144
+ }
145
+ let parsed;
146
+ try {
147
+ parsed = JSON.parse(existingManifestRaw);
148
+ } catch {
149
+ return { status: 'conflict', reason: 'wiki manifest is not valid JSON' };
150
+ }
151
+ if (!deepEqual(parsed, wantManifest)) {
152
+ return { status: 'conflict', reason: 'wiki manifest declares a different mapping' };
153
+ }
154
+ return { status: 'already', manifest: wantManifest };
155
+ }
156
+ // No wiki storage file yet. A stray manifest with a different mapping is still a conflict.
157
+ if (existingManifestRaw != null) {
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(existingManifestRaw);
161
+ } catch {
162
+ return { status: 'conflict', reason: 'stray wiki manifest is not valid JSON' };
163
+ }
164
+ if (!deepEqual(parsed, wantManifest)) {
165
+ return { status: 'conflict', reason: 'stray wiki manifest declares a different mapping' };
166
+ }
167
+ }
168
+ return { status: 'ready', manifest: wantManifest };
169
+ }
170
+
171
+ /**
172
+ * Reverse-generate the sidecar manifest for a capture candidate. commands/agents
173
+ * carry only `{ type, installName }`; hooks additionally carry the settings
174
+ * registration fields `{ event, matcher?, timeout? }`. matcher/timeout are omitted
175
+ * when absent (an empty-string matcher is already normalized to undefined by the
176
+ * scanner) via conditional spread, so the in-memory object never carries an
177
+ * `undefined`-valued key that would break the planCapture deep-equality check.
178
+ */
179
+ function buildWantManifest(c) {
180
+ const manifest = { type: TYPE_SINGULAR[c.type], installName: c.stem };
181
+ if (c.type === 'hooks') {
182
+ manifest.event = c.event;
183
+ if (c.matcher !== undefined && c.matcher !== '') manifest.matcher = c.matcher;
184
+ if (c.timeout !== undefined) manifest.timeout = c.timeout;
185
+ }
186
+ return manifest;
187
+ }
188
+
189
+ // ── filesystem orchestration ─────────────────────────────────────────────────
190
+
191
+ function scanCandidates(claudeHome, recorded, types) {
192
+ const out = [];
193
+ for (const type of types) {
194
+ const dir = join(claudeHome, type);
195
+ if (!existsSync(dir)) continue;
196
+ let entries;
197
+ try {
198
+ entries = readdirSync(dir);
199
+ } catch {
200
+ continue;
201
+ }
202
+ for (const file of entries) {
203
+ const verdict = isCaptureCandidate(type, file, recorded);
204
+ if (!verdict.ok) continue;
205
+ const srcPath = join(dir, file);
206
+ if (!isRegularFile(srcPath)) continue; // never follow symlinks/sockets
207
+ out.push({ type, file, stem: file.slice(0, -TYPE_EXT[type].length), srcPath });
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ // The preservable shape a capturable hook must have (F3). Anything outside these
214
+ // key sets, or a non-positive-integer timeout, means a lossless round-trip cannot
215
+ // be guaranteed → skip rather than drop a field on the far machine.
216
+ const ALLOWED_HOOK_KEYS = new Set(['type', 'command', 'timeout']);
217
+ const ALLOWED_GROUP_KEYS = new Set(['matcher', 'hooks']);
218
+
219
+ function subsetOf(keys, allowed) {
220
+ for (const k of keys) if (!allowed.has(k)) return false;
221
+ return true;
222
+ }
223
+
224
+ /**
225
+ * Enumerate capturable hooks from `~/.claude/settings.json`. Unlike commands/agents
226
+ * (readdir), hooks are discovered by walking the settings registration and keeping
227
+ * only entries that round-trip losslessly to the canonical form forward-sync emits.
228
+ * Returns `{ candidates, skipped }`; every rejected entry gets a visible skip reason
229
+ * (never a silent drop, capture design F4) so a user can see why a hook was passed
230
+ * over — including absolute-path / tilde registrations that are legal but not
231
+ * canonical.
232
+ *
233
+ * A candidate must satisfy ALL of: strict command grammar
234
+ * (`parseCapturableHookCommand`) resolving under `~/.claude/hooks/`; the resolved
235
+ * `.mjs` is a real regular file (no symlink / non-regular); `type === 'command'`;
236
+ * the event is in `HOOK_EVENT_ALLOWLIST`; a preservable shape (F3); the case-folded
237
+ * basename appears exactly once across all settings hooks (F6); and the stem is not
238
+ * a core hook, not the reserved hypo namespace, and not already wiki-owned.
239
+ */
240
+ export function scanHookCandidates(claudeHome, settingsPath, recorded, coreBasenames) {
241
+ const candidates = [];
242
+ const skipped = [];
243
+ let settings;
244
+ try {
245
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
246
+ } catch {
247
+ // Absent or invalid settings.json → nothing to capture (not an error).
248
+ return { candidates, skipped };
249
+ }
250
+
251
+ const records = scanSettingsHooks(settings);
252
+
253
+ // First pass: count case-folded basenames across every record whose command
254
+ // parses to a canonical basename. A basename seen 2+ times (multiple events, or
255
+ // the same event with different matchers) cannot be reverse-generated to a single
256
+ // manifest, so ALL its registrations are skipped (F6).
257
+ const parsedByRecord = new Map();
258
+ const basenameCounts = new Map();
259
+ for (const rec of records) {
260
+ const parsed = parseCapturableHookCommand(rec.command);
261
+ parsedByRecord.set(rec, parsed);
262
+ if (parsed.ok) {
263
+ const key = parsed.basename.toLowerCase();
264
+ basenameCounts.set(key, (basenameCounts.get(key) || 0) + 1);
265
+ }
266
+ }
267
+
268
+ // Second pass: classify each record.
269
+ for (const rec of records) {
270
+ const parsed = parsedByRecord.get(rec);
271
+ if (!parsed.ok) {
272
+ skipped.push({ type: 'hooks', command: rec.command, reason: parsed.reason });
273
+ continue;
274
+ }
275
+ const { stem, basename } = parsed;
276
+ const label = `hooks/${basename}`;
277
+
278
+ // Duplicate first: a basename registered 2+ times is uniformly skipped so both
279
+ // sides report the same reason.
280
+ if (basenameCounts.get(basename.toLowerCase()) >= 2) {
281
+ skipped.push({
282
+ type: 'hooks',
283
+ command: rec.command,
284
+ basename,
285
+ reason: 'duplicate-registration',
286
+ });
287
+ continue;
288
+ }
289
+ if (rec.type !== 'command') {
290
+ skipped.push({ type: 'hooks', command: rec.command, basename, reason: 'non-command-type' });
291
+ continue;
292
+ }
293
+ if (!HOOK_EVENT_ALLOWLIST.has(rec.event)) {
294
+ skipped.push({
295
+ type: 'hooks',
296
+ command: rec.command,
297
+ basename,
298
+ reason: 'event-not-allowlisted',
299
+ });
300
+ continue;
301
+ }
302
+ if (
303
+ !subsetOf(rec.hookKeys, ALLOWED_HOOK_KEYS) ||
304
+ !subsetOf(rec.groupKeys, ALLOWED_GROUP_KEYS) ||
305
+ (rec.timeout !== undefined && !(Number.isInteger(rec.timeout) && rec.timeout > 0))
306
+ ) {
307
+ skipped.push({
308
+ type: 'hooks',
309
+ command: rec.command,
310
+ basename,
311
+ reason: 'unpreservable-shape',
312
+ });
313
+ continue;
314
+ }
315
+ if (coreBasenames.has(basename.toLowerCase())) {
316
+ skipped.push({ type: 'hooks', command: rec.command, basename, reason: 'core-hook' });
317
+ continue;
318
+ }
319
+ if (recorded[label]) {
320
+ skipped.push({
321
+ type: 'hooks',
322
+ command: rec.command,
323
+ basename,
324
+ reason: 'already-managed by the wiki',
325
+ });
326
+ continue;
327
+ }
328
+ // The command resolved under $HOME/.claude/hooks; the source must be a real
329
+ // regular file there (a symlink or non-regular is refused, capture design F4).
330
+ const srcPath = join(claudeHome, 'hooks', basename);
331
+ if (!existsSync(srcPath) || !isRegularFile(srcPath)) {
332
+ skipped.push({ type: 'hooks', command: rec.command, basename, reason: 'unresolved-source' });
333
+ continue;
334
+ }
335
+ candidates.push({
336
+ type: 'hooks',
337
+ file: basename,
338
+ stem,
339
+ srcPath,
340
+ event: rec.event,
341
+ matcher: rec.matcher,
342
+ timeout: rec.timeout,
343
+ label,
344
+ });
345
+ }
346
+ return { candidates, skipped };
347
+ }
348
+
349
+ function parseArgs(argv) {
350
+ const args = { names: [], all: false, types: CAPTURE_TYPES.slice(), dryRun: false, help: false };
351
+ let hypoDir = null;
352
+ for (const a of argv.slice(2)) {
353
+ if (a === '--all') args.all = true;
354
+ else if (a === '--dry-run') args.dryRun = true;
355
+ else if (a === '--help' || a === '-h') args.help = true;
356
+ else if (a.startsWith('--type=')) {
357
+ args.types = a
358
+ .slice(7)
359
+ .split(',')
360
+ .map((s) => s.trim())
361
+ .filter((t) => CAPTURE_TYPES.includes(t));
362
+ } else if (a.startsWith('--hypo-dir=')) hypoDir = a.slice(11);
363
+ else if (!a.startsWith('-')) args.names.push(a);
364
+ }
365
+ args.hypoDir = hypoDir || resolveHypoRoot();
366
+ if (args.types.length === 0) args.types = CAPTURE_TYPES.slice();
367
+ return args;
368
+ }
369
+
370
+ function log(msg) {
371
+ process.stdout.write(`${msg}\n`);
372
+ }
373
+
374
+ // Write via a sibling temp + rename so the final replace is atomic and, crucially,
375
+ // never follows a symlink already sitting at `dest` (rename swaps the link itself).
376
+ function writeAtomic(dest, buf) {
377
+ const tmp = `${dest}.tmp.${process.pid}`;
378
+ writeFileSync(tmp, buf);
379
+ try {
380
+ renameSync(tmp, dest);
381
+ } catch (err) {
382
+ try {
383
+ unlinkSync(tmp);
384
+ } catch {}
385
+ throw err;
386
+ }
387
+ }
388
+
389
+ // Undo one capture's wiki writes: drop the storage file (.md or .mjs), and either
390
+ // restore the manifest bytes we overwrote (a pre-existing same-mapping sidecar) or
391
+ // remove the manifest we created. Used on a failed/aborted adopt so a capture never
392
+ // half-lands.
393
+ function rollbackRec(rec) {
394
+ try {
395
+ unlinkSync(rec.filePath);
396
+ } catch {}
397
+ if (rec.manifestExisted && rec.manifestPrevBuf != null) {
398
+ try {
399
+ writeFileSync(rec.manifestPath, rec.manifestPrevBuf);
400
+ } catch {}
401
+ } else {
402
+ try {
403
+ unlinkSync(rec.manifestPath);
404
+ } catch {}
405
+ }
406
+ }
407
+
408
+ // Remove specific per-target ownership keys from hypo-pkg.json (codex pre-commit
409
+ // CONCERN). A partial adopt can leave forward-sync having recorded SOME of a
410
+ // capture's install keys (e.g. the byte-identical `.mjs` was owned but its sidecar
411
+ // manifest could not be), and the wiki-file rollback alone would leave that
412
+ // ownership stranded: a later capture would skip the hook as already-managed and
413
+ // uninstall would trust the recorded SHA and delete the user's ORIGINAL hook.
414
+ // Reads the freshly-written pkg, drops only the given keys, and rewrites atomically.
415
+ function purgeOwnedKeys(pkgPath, target, keys) {
416
+ if (keys.size === 0) return;
417
+ const pkg = readPkgJson(pkgPath);
418
+ const ext = pkg.extensions;
419
+ if (!ext || typeof ext !== 'object' || Array.isArray(ext)) return;
420
+ const perTarget = ext[target];
421
+ if (!perTarget || typeof perTarget !== 'object' || Array.isArray(perTarget)) return;
422
+ let changed = false;
423
+ for (const k of keys) {
424
+ if (perTarget[k] !== undefined) {
425
+ delete perTarget[k];
426
+ changed = true;
427
+ }
428
+ }
429
+ if (changed) writePkgJsonAtomic(pkgPath, pkg);
430
+ }
431
+
432
+ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
433
+ const extDir = join(args.hypoDir, 'extensions');
434
+ const settingsPath = join(claudeHome, 'settings.json');
435
+ const recorded = readExtensionPkgStateNoMutate(pkgJsonPath(), 'claude');
436
+ // commands/agents enumerate by readdir; hooks enumerate from settings.json.
437
+ const dirTypes = args.types.filter((t) => t !== 'hooks');
438
+ const candidates = scanCandidates(claudeHome, recorded, dirTypes);
439
+
440
+ // Hooks are captured from the settings registration. Reserve the core hook
441
+ // basenames deterministically from hooks.json; if that load is not ok (read,
442
+ // parse, or shape failure) skip the whole hooks type rather than risk capturing
443
+ // a core hook (T1 fail-closed contract: gate on ok, not on cfg presence).
444
+ const scanSkipped = [];
445
+ if (args.types.includes('hooks')) {
446
+ const core = readCoreHooksConfig(PKG_ROOT);
447
+ if (!core.ok) {
448
+ log(`⊘ hooks: core hook reservations unavailable (${core.error}) — hooks capture skipped`);
449
+ } else {
450
+ const coreBasenames = deriveCoreHookBasenames(core.cfg);
451
+ const hookScan = scanHookCandidates(claudeHome, settingsPath, recorded, coreBasenames);
452
+ candidates.push(...hookScan.candidates);
453
+ scanSkipped.push(...hookScan.skipped);
454
+ }
455
+ }
456
+
457
+ // Surface every scan-time skip with its reason (never a silent drop, F4). Printed
458
+ // before any early return so observability holds even when nothing is capturable.
459
+ for (const s of scanSkipped) {
460
+ const label = s.basename ? `hooks/${s.basename}` : `hooks (${s.command})`;
461
+ log(`⊘ ${label}: ${s.reason} — skipped`);
462
+ }
463
+
464
+ // No explicit selection → list candidates and stop (capture design §6).
465
+ if (!args.all && args.names.length === 0) {
466
+ if (candidates.length === 0) {
467
+ log('No capturable extensions found under ~/.claude/{' + args.types.join(',') + '}.');
468
+ return { selected: [], captured: [], skipped: scanSkipped, failed: [], listedOnly: true };
469
+ }
470
+ log('Capturable extensions (pass names to capture, or --all):');
471
+ for (const c of candidates) log(` ${c.type}/${c.file}`);
472
+ log('');
473
+ log('Note: --all captures every unowned candidate here — not a provenance check.');
474
+ log('Explicit selection is the trust boundary.');
475
+ return {
476
+ selected: candidates,
477
+ captured: [],
478
+ skipped: scanSkipped,
479
+ failed: [],
480
+ listedOnly: true,
481
+ };
482
+ }
483
+
484
+ // Resolve the selection.
485
+ let selected;
486
+ if (args.all) {
487
+ selected = candidates;
488
+ } else {
489
+ const byName = new Map();
490
+ for (const c of candidates) {
491
+ byName.set(c.file, c);
492
+ byName.set(c.stem, c);
493
+ byName.set(`${c.type}/${c.file}`, c);
494
+ }
495
+ selected = [];
496
+ for (const name of args.names) {
497
+ const c = byName.get(name);
498
+ if (c) selected.push(c);
499
+ else log(`⊘ ${name}: no capturable candidate by that name — skipped`);
500
+ }
501
+ }
502
+
503
+ const captured = [];
504
+ const skipped = [...scanSkipped];
505
+ const failed = [];
506
+ const created = []; // files written THIS run, for rollback on adopt failure
507
+
508
+ for (const c of selected) {
509
+ const fileExt = TYPE_EXT[c.type];
510
+ const wikiStem = `${EXT_PREFIX}${c.stem}`;
511
+ const typeDir = join(extDir, c.type);
512
+ const filePath = join(typeDir, `${wikiStem}${fileExt}`);
513
+ const manifestPath = join(typeDir, `${wikiStem}.manifest.json`);
514
+
515
+ // A pre-existing NON-regular wiki target (symlink / dir / socket) is a hard
516
+ // conflict: readFileIfRegular reports it as absent, so without this guard a
517
+ // naive write would follow a symlink out of the wiki (codex pre-commit
518
+ // BLOCKER). Refuse before reading or planning.
519
+ if (
520
+ (existsSync(filePath) && !isRegularFile(filePath)) ||
521
+ (existsSync(manifestPath) && !isRegularFile(manifestPath))
522
+ ) {
523
+ const reason = 'wiki target exists but is not a regular file';
524
+ log(`⊘ ${c.type}/${c.file}: ${reason} — skipped`);
525
+ skipped.push({ ...c, reason, status: 'conflict' });
526
+ continue;
527
+ }
528
+
529
+ const srcBuf = readFileSync(c.srcPath); // verbatim bytes (capture design §4)
530
+ const srcSha = sha256(srcBuf);
531
+ const existingFileBuf = readFileIfRegular(filePath);
532
+ const existingFileSha = existingFileBuf ? sha256(existingFileBuf) : null;
533
+ const existingManifestBuf = readFileIfRegular(manifestPath);
534
+ const existingManifestRaw = existingManifestBuf ? existingManifestBuf.toString('utf-8') : null;
535
+
536
+ const wantManifest = buildWantManifest(c);
537
+ const plan = planCapture({ wantManifest, srcSha, existingFileSha, existingManifestRaw });
538
+
539
+ // installFile is the ORIGINAL name the far machine installs under (installName
540
+ // adoption); for hooks that is `<stem>.mjs`, for commands/agents `<stem>.md`.
541
+ const installFile = `${c.stem}${fileExt}`;
542
+ // Ownership keys sync must record for the adoption to count. Hooks track BOTH
543
+ // the `.mjs` and its `.manifest.json` sidecar under the install path (success
544
+ // criterion d); commands/agents track only the single install file.
545
+ const requiredKeys =
546
+ c.type === 'hooks'
547
+ ? [`hooks/${installFile}`, `hooks/${c.stem}.manifest.json`]
548
+ : [`${c.type}/${installFile}`];
549
+
550
+ if (plan.status === 'invalid' || plan.status === 'conflict') {
551
+ log(`⊘ ${c.type}/${c.file}: ${plan.reason} — skipped`);
552
+ skipped.push({ ...c, reason: plan.reason, status: plan.status });
553
+ continue;
554
+ }
555
+ if (plan.status === 'already') {
556
+ log(`= ${c.type}/${c.file}: already captured`);
557
+ captured.push({ ...c, installFile, requiredKeys, status: 'already' });
558
+ continue;
559
+ }
560
+
561
+ // status === 'ready'. Write the manifest FIRST, then the storage file (capture
562
+ // design §4): a crash between the two leaves only a manifest (no sibling →
563
+ // discovery skips it), never a lone file that forward-sync would install under
564
+ // the wiki storage name. Both writes are atomic (temp + rename) so a symlink at
565
+ // the target is replaced, not followed.
566
+ if (!args.dryRun) {
567
+ mkdirSync(typeDir, { recursive: true });
568
+ const rec = {
569
+ type: c.type,
570
+ stem: c.stem,
571
+ filePath,
572
+ manifestPath,
573
+ manifestExisted: existingManifestBuf != null,
574
+ manifestPrevBuf: existingManifestBuf,
575
+ };
576
+ writeAtomic(manifestPath, JSON.stringify(plan.manifest, null, 2) + '\n');
577
+ writeAtomic(filePath, srcBuf);
578
+ created.push(rec);
579
+ }
580
+ captured.push({ ...c, installFile, requiredKeys, status: 'ready' });
581
+ }
582
+
583
+ const toAdopt = captured.filter((c) => c.status === 'ready');
584
+ if (toAdopt.length === 0 || args.dryRun) {
585
+ report(captured, skipped, failed, args.dryRun);
586
+ return { selected, captured, skipped, failed };
587
+ }
588
+
589
+ // Adopt: run forward-sync so the install targets (byte-identical) hit the
590
+ // up-to-date branch and record ownership. If sync THROWS, roll back every file
591
+ // written this run so a hard failure never leaves a half-captured extension
592
+ // behind (codex pre-commit CONCERN).
593
+ let sync;
594
+ try {
595
+ sync = syncExtensions({
596
+ extDir,
597
+ hypoDir: args.hypoDir,
598
+ target: 'claude',
599
+ settingsPath: join(claudeHome, 'settings.json'),
600
+ pkgPath: pkgJsonPath(),
601
+ apply: true,
602
+ force: false,
603
+ });
604
+ } catch (err) {
605
+ for (const rec of created) rollbackRec(rec);
606
+ log(`capture aborted: forward-sync failed (${err.message}); wiki files rolled back.`);
607
+ for (const c of toAdopt) {
608
+ c.status = 'failed';
609
+ failed.push(c);
610
+ }
611
+ report([], skipped, failed, false);
612
+ return { selected, captured, skipped, failed, sync: null };
613
+ }
614
+ const newSHAs = sync.newSHAs || {};
615
+
616
+ // VERIFY every expected key is owned; roll back any capture whose adoption did
617
+ // not take (e.g. the install target changed between copy and sync → skip-conflict
618
+ // with a null SHA) so the wiki does not keep an un-adopted file. Hooks require
619
+ // BOTH the `.mjs` and the sidecar `.manifest.json` keys (success criterion d).
620
+ // Ownership keys that this run's sync newly recorded for a capture whose adoption
621
+ // then failed. `recorded` is the pre-sync owned map, so a key absent there but
622
+ // present in newSHAs is new THIS run: safe to strip. A key already owned before
623
+ // this run is preserved (never touched here).
624
+ const strayOwnedKeys = new Set();
625
+ for (const c of toAdopt) {
626
+ if (c.requiredKeys.every((k) => newSHAs[k])) {
627
+ c.status = 'captured';
628
+ } else {
629
+ c.status = 'failed';
630
+ failed.push(c);
631
+ const rec = created.find((r) => r.type === c.type && r.stem === c.stem);
632
+ if (rec) rollbackRec(rec);
633
+ for (const k of c.requiredKeys) {
634
+ if (recorded[k] === undefined && newSHAs[k] !== undefined) strayOwnedKeys.add(k);
635
+ }
636
+ }
637
+ }
638
+ // Never strip a key a successful capture still depends on (distinct install
639
+ // stems make this impossible in practice, but the guard keeps the rollback
640
+ // strictly scoped to failed captures).
641
+ for (const c of toAdopt) {
642
+ if (c.status === 'captured') for (const k of c.requiredKeys) strayOwnedKeys.delete(k);
643
+ }
644
+ purgeOwnedKeys(pkgJsonPath(), 'claude', strayOwnedKeys);
645
+ const okCaptured = captured.filter((c) => c.status === 'captured' || c.status === 'already');
646
+ for (const w of sync.warnings || []) log(` sync: ${w}`);
647
+ report(okCaptured, skipped, failed, args.dryRun);
648
+ return { selected, captured, skipped, failed, sync };
649
+ }
650
+
651
+ function report(captured, skipped, failed, dryRun) {
652
+ log('');
653
+ if (captured.length > 0) {
654
+ log(`${dryRun ? 'Would capture' : 'Captured'} ${captured.length} extension(s):`);
655
+ for (const c of captured)
656
+ log(` ${c.type}/${c.file} → extensions/${c.type}/${EXT_PREFIX}${c.stem}${TYPE_EXT[c.type]}`);
657
+ }
658
+ if (skipped.length > 0) log(`Skipped ${skipped.length} (conflict/invalid — see above).`);
659
+ if (failed.length > 0)
660
+ log(`Failed to adopt ${failed.length} (see sync warnings; wiki files rolled back).`);
661
+ if (!dryRun && captured.some((c) => c.status === 'captured')) {
662
+ log('');
663
+ log('Next: commit + push the wiki, then run `hypomnema upgrade --apply` on another machine.');
664
+ }
665
+ }
666
+
667
+ function main() {
668
+ const args = parseArgs(process.argv);
669
+ if (args.help) {
670
+ log('Usage: hypomnema capture [names…] [--all] [--type=commands,agents,hooks] [--dry-run]');
671
+ log(' Pull ~/.claude/{commands,agents} extensions and canonical settings.json');
672
+ log(' hooks into the wiki for cross-machine sync. Hooks are captured only when');
673
+ log(' they round-trip losslessly to the canonical form; others are skipped with');
674
+ log(' a reason.');
675
+ return 0;
676
+ }
677
+ const res = run(args);
678
+ return res.failed.length > 0 ? 1 : 0;
679
+ }
680
+
681
+ function isMain() {
682
+ try {
683
+ if (!process.argv[1]) return false;
684
+ return pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
685
+ } catch {
686
+ return false;
687
+ }
688
+ }
689
+
690
+ if (isMain()) {
691
+ process.exit(main());
692
+ }
693
+
694
+ export { parseArgs, scanCandidates, run };
695
+ // scanHookCandidates is exported at its definition above.