hypomnema 1.6.2 → 1.7.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.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +904 -128
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -8,9 +8,16 @@
8
8
  * so they propagate to other machines via the existing forward-sync ("register on
9
9
  * A → sync on B").
10
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.
11
+ * Scope: commands + agents (pure file copy, no settings.json), hooks (settings
12
+ * reverse-capture, lossless round-trip only), and skills (a DIRECTORY: SKILL.md
13
+ * plus an arbitrary subtree, stored as `extensions/skills/hypo-ext-<name>/`).
14
+ *
15
+ * Skills (skills-capture design): the same subtree walker forward-sync uses is run
16
+ * in `strict` mode here, so anything that cannot round-trip through the wiki
17
+ * (symlink, hardlink, empty directory, VCS control dir, reserved manifest name)
18
+ * refuses the WHOLE skill rather than capturing a lossy subset — what lands in the
19
+ * wiki is exactly what the far machine installs. Cumulative ceilings abort the walk
20
+ * early so a vendored skill (14k files, 1.1GB) never gets enumerated in full.
14
21
  *
15
22
  * Naming (capture design §3): the wiki STORAGE name is `hypo-ext-<name>.<ext>` (keeps
16
23
  * the discovery whitelist), and a sidecar `hypo-ext-<name>.manifest.json` records
@@ -31,11 +38,16 @@ import {
31
38
  readdirSync,
32
39
  writeFileSync,
33
40
  mkdirSync,
41
+ rmdirSync,
34
42
  unlinkSync,
35
43
  renameSync,
36
44
  realpathSync,
45
+ lstatSync,
46
+ openSync,
47
+ closeSync,
37
48
  } from 'fs';
38
- import { join, dirname } from 'path';
49
+ import { randomBytes } from 'crypto';
50
+ import { join, dirname, relative, sep } from 'path';
39
51
  import { homedir } from 'os';
40
52
  import { pathToFileURL, fileURLToPath } from 'url';
41
53
  import {
@@ -46,13 +58,23 @@ import {
46
58
  writePkgJsonAtomic,
47
59
  } from './lib/pkg-json.mjs';
48
60
  import { resolveHypoRoot } from './lib/hypo-root.mjs';
61
+ import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
49
62
  import {
50
63
  syncExtensions,
51
64
  readExtensionPkgStateNoMutate,
52
65
  isValidInstallStem,
66
+ isValidSkillDirSegment,
67
+ parseSkillKey,
68
+ parseSkillShaValue,
69
+ walkSkillSubtree,
70
+ emptyShaMap,
71
+ isRealDir,
72
+ isContainedUnder,
73
+ hasSymlinkAncestor,
53
74
  scanSettingsHooks,
54
75
  parseCapturableHookCommand,
55
76
  HOOK_EVENT_ALLOWLIST,
77
+ SKILL_ROOT_FILE,
56
78
  EXT_PREFIX,
57
79
  } from './lib/extensions.mjs';
58
80
  import { readCoreHooksConfig, deriveCoreHookBasenames } from './lib/core-hooks.mjs';
@@ -66,11 +88,27 @@ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
66
88
 
67
89
  // Captured types and their singular manifest `type` value. commands/agents install
68
90
  // 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' };
91
+ // settings.json entry (reverse of forward-sync); skills install as a DIRECTORY.
92
+ const CAPTURE_TYPES = ['commands', 'agents', 'hooks', 'skills'];
93
+ // The flat, single-file types. `TYPE_EXT` has no `skills` entry on purpose: a skill
94
+ // has no install extension, and letting it through a flat helper would silently
95
+ // build paths with an `undefined` suffix. Every flat helper keys off this list.
96
+ const FLAT_TYPES = ['commands', 'agents', 'hooks'];
97
+ // The flat types enumerated by readdir. hooks are enumerated from the settings.json
98
+ // registration instead, and skills have their own directory scanner.
99
+ const READDIR_TYPES = ['commands', 'agents'];
100
+ const TYPE_SINGULAR = { commands: 'command', agents: 'agent', hooks: 'hook', skills: 'skill' };
72
101
  const TYPE_EXT = { commands: '.md', agents: '.md', hooks: '.mjs' };
73
102
 
103
+ // Ceilings for a captured skill subtree (skills-capture design §3). A hand-authored
104
+ // skill is a handful of files; a vendored one (gstack: 14,311 files / 1.1GB, mostly
105
+ // node_modules) must be refused, not copied into a git-tracked vault. Enforced as
106
+ // cumulative counters DURING the walk, from lstat sizes, so the verdict lands before
107
+ // anything is read and the candidate listing never enumerates a vendored tree.
108
+ const SKILL_MAX_FILES = 500;
109
+ const SKILL_MAX_BYTES = 5 * 1024 * 1024;
110
+ const SKILL_LIMITS = { strict: true, maxFiles: SKILL_MAX_FILES, maxBytes: SKILL_MAX_BYTES };
111
+
74
112
  function pkgJsonPath() {
75
113
  return join(HOME, '.claude', 'hypo-pkg.json');
76
114
  }
@@ -168,6 +206,90 @@ export function planCapture({ wantManifest, srcSha, existingFileSha, existingMan
168
206
  return { status: 'ready', manifest: wantManifest };
169
207
  }
170
208
 
209
+ /**
210
+ * The set of install directories the wiki VALIDLY owns as directory skills.
211
+ *
212
+ * Ownership must be read through the same schema the rest of the module trusts, not
213
+ * raw property truthiness (skills-capture design §8). A corrupt hypo-pkg.json that
214
+ * parks a string under `skills/mine` would otherwise read as "already managed" and
215
+ * lock that skill out of capture forever, and it would also poison the "keys this
216
+ * run newly recorded" bookkeeping the failed-adopt purge depends on.
217
+ */
218
+ export function ownedSkillDirs(recorded) {
219
+ const out = new Set();
220
+ for (const [key, value] of Object.entries(recorded)) {
221
+ const parsed = parseSkillKey(key);
222
+ if (!parsed) continue;
223
+ const nested = parseSkillShaValue(value);
224
+ if (nested === null || Object.keys(nested).length === 0) continue;
225
+ out.add(parsed.installDir);
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * Plan the capture of one directory skill. Same three outcomes as the flat planner,
232
+ * but `ready` is deliberately narrow: it means the wiki skill directory is WHOLLY
233
+ * ABSENT (skills-capture design §4). That narrowness is what makes the rollback safe
234
+ * — every path the rollback removes is a path this run created.
235
+ *
236
+ * `wikiShas` is null when the wiki directory does not exist, and a rel→sha map when
237
+ * it does. A wiki path that exists but cannot be read as a clean skill (a file, a
238
+ * symlink, a crash-truncated directory with no SKILL.md) arrives here as
239
+ * `wikiPresent: true, wikiShas: null` and is a conflict — never a crash, never a
240
+ * silent overwrite.
241
+ */
242
+ export function planSkillCapture({
243
+ wantManifest,
244
+ srcShas,
245
+ wikiPresent,
246
+ wikiShas,
247
+ existingManifestRaw,
248
+ }) {
249
+ if (!isValidSkillDirSegment(wantManifest.installName)) {
250
+ return { status: 'invalid', reason: `invalid installName "${wantManifest.installName}"` };
251
+ }
252
+
253
+ if (wikiPresent) {
254
+ if (wikiShas === null) {
255
+ return {
256
+ status: 'conflict',
257
+ reason: 'wiki target exists but is not a readable skill directory',
258
+ };
259
+ }
260
+ if (!deepEqual(wikiShas, srcShas)) {
261
+ return { status: 'conflict', reason: 'wiki skill directory exists with different content' };
262
+ }
263
+ if (existingManifestRaw == null) {
264
+ return { status: 'conflict', reason: 'wiki skill exists without its installName manifest' };
265
+ }
266
+ let parsed;
267
+ try {
268
+ parsed = JSON.parse(existingManifestRaw);
269
+ } catch {
270
+ return { status: 'conflict', reason: 'wiki manifest is not valid JSON' };
271
+ }
272
+ if (!deepEqual(parsed, wantManifest)) {
273
+ return { status: 'conflict', reason: 'wiki manifest declares a different mapping' };
274
+ }
275
+ return { status: 'already', manifest: wantManifest };
276
+ }
277
+
278
+ // No wiki directory yet. A stray sidecar with a different mapping is still a conflict.
279
+ if (existingManifestRaw != null) {
280
+ let parsed;
281
+ try {
282
+ parsed = JSON.parse(existingManifestRaw);
283
+ } catch {
284
+ return { status: 'conflict', reason: 'stray wiki manifest is not valid JSON' };
285
+ }
286
+ if (!deepEqual(parsed, wantManifest)) {
287
+ return { status: 'conflict', reason: 'stray wiki manifest declares a different mapping' };
288
+ }
289
+ }
290
+ return { status: 'ready', manifest: wantManifest };
291
+ }
292
+
171
293
  /**
172
294
  * Reverse-generate the sidecar manifest for a capture candidate. commands/agents
173
295
  * carry only `{ type, installName }`; hooks additionally carry the settings
@@ -188,9 +310,91 @@ function buildWantManifest(c) {
188
310
 
189
311
  // ── filesystem orchestration ─────────────────────────────────────────────────
190
312
 
313
+ /**
314
+ * Enumerate capturable DIRECTORY skills under `~/.claude/skills/`.
315
+ *
316
+ * Every rejected entry gets a visible reason (never a silent drop): the strict walker
317
+ * refuses the whole skill for anything that cannot round-trip, and the ceilings abort
318
+ * the walk as soon as they are crossed. Returns `{ candidates, skipped }`.
319
+ *
320
+ * A flat `.md` under `skills/` is not a candidate: the directory is the real Claude
321
+ * Code layout, and the flat form only exists as a backward-compatible wiki storage
322
+ * shape. A symlinked directory is not a candidate either (never followed).
323
+ *
324
+ * `vault` is `{ hypoDir, patterns }` — the vault's `.hypoignore`. Capture must judge a
325
+ * source file by the rule that will apply to it AFTER it lands in the wiki: forward-sync
326
+ * discovers the wiki subtree through that filter, so a file the vault ignores (a `.pdf`
327
+ * reference, a `.cache/` directory) would be written here and then dropped from
328
+ * discovery, leaving the adopt check short one relpath and failing with no reason a user
329
+ * could act on. Refuse it up front instead, with the pattern named.
330
+ */
331
+ export function scanSkillCandidates(claudeHome, ownedDirs, vault = null) {
332
+ const candidates = [];
333
+ const skipped = [];
334
+ const dir = join(claudeHome, 'skills');
335
+ if (!existsSync(dir)) return { candidates, skipped };
336
+ let entries;
337
+ try {
338
+ entries = readdirSync(dir);
339
+ } catch {
340
+ return { candidates, skipped };
341
+ }
342
+ for (const name of entries) {
343
+ const srcPath = join(dir, name);
344
+ if (!isRealDir(srcPath)) continue; // flat .md, symlinked dir, or junk: not a skill dir
345
+ const lower = name.toLowerCase();
346
+ if (lower === 'hypo' || lower.startsWith('hypo-')) continue; // reserved namespace
347
+ if (ownedDirs.has(name)) continue; // already managed by the wiki
348
+ const label = `skills/${name}`;
349
+ if (!isValidSkillDirSegment(name)) {
350
+ skipped.push({ type: 'skills', file: name, reason: `unsafe skill directory name "${name}"` });
351
+ continue;
352
+ }
353
+ // The SOURCE walk applies no ignore filter (the patterns are the vault's, and the
354
+ // source lives under ~/.claude). The dir argument still has to be a string: isIgnored()
355
+ // calls relative() on it before it ever looks at the (empty) patterns.
356
+ const walked = walkSkillSubtree(srcPath, label, dir, [], SKILL_LIMITS);
357
+ if (walked.skip) {
358
+ skipped.push({
359
+ type: 'skills',
360
+ file: name,
361
+ reason: walked.fatal || `no regular ${SKILL_ROOT_FILE} at the skill root`,
362
+ });
363
+ continue;
364
+ }
365
+ // Would any of these files be invisible to forward-sync once they are in the wiki?
366
+ if (vault && vault.patterns.length > 0) {
367
+ const wikiRootForSkill = join(vault.hypoDir, 'extensions', 'skills', `${EXT_PREFIX}${name}`);
368
+ const ignored = walked.files.find((f) =>
369
+ isIgnored(join(wikiRootForSkill, ...f.rel.split('/')), vault.hypoDir, vault.patterns),
370
+ );
371
+ if (ignored) {
372
+ skipped.push({
373
+ type: 'skills',
374
+ file: name,
375
+ reason: `${ignored.rel} matches the vault .hypoignore and would not sync back`,
376
+ });
377
+ continue;
378
+ }
379
+ }
380
+ candidates.push({
381
+ type: 'skills',
382
+ file: name,
383
+ stem: name,
384
+ srcPath,
385
+ isDir: true,
386
+ files: walked.files,
387
+ });
388
+ }
389
+ return { candidates, skipped };
390
+ }
391
+
191
392
  function scanCandidates(claudeHome, recorded, types) {
192
393
  const out = [];
193
394
  for (const type of types) {
395
+ // hooks come from the settings registration, skills from their own directory
396
+ // scanner. Letting either through here would double-enumerate them.
397
+ if (!READDIR_TYPES.includes(type)) continue;
194
398
  const dir = join(claudeHome, type);
195
399
  if (!existsSync(dir)) continue;
196
400
  let entries;
@@ -373,9 +577,19 @@ function log(msg) {
373
577
 
374
578
  // Write via a sibling temp + rename so the final replace is atomic and, crucially,
375
579
  // never follows a symlink already sitting at `dest` (rename swaps the link itself).
580
+ //
581
+ // The temp path is randomized and opened with `wx` (O_CREAT|O_EXCL). The old
582
+ // `${dest}.tmp.${pid}` name was predictable, and writeFileSync on a path someone had
583
+ // already planted a symlink at would follow it straight out of the wiki. O_EXCL fails
584
+ // on an existing path of any kind, symlink included.
376
585
  function writeAtomic(dest, buf) {
377
- const tmp = `${dest}.tmp.${process.pid}`;
378
- writeFileSync(tmp, buf);
586
+ const tmp = `${dest}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
587
+ const fd = openSync(tmp, 'wx');
588
+ try {
589
+ writeFileSync(fd, buf);
590
+ } finally {
591
+ closeSync(fd);
592
+ }
379
593
  try {
380
594
  renameSync(tmp, dest);
381
595
  } catch (err) {
@@ -386,11 +600,41 @@ function writeAtomic(dest, buf) {
386
600
  }
387
601
  }
388
602
 
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.
603
+ // Undo one capture's wiki writes: drop the storage file(s), and either restore the
604
+ // manifest bytes we overwrote (a pre-existing same-mapping sidecar) or remove the
605
+ // manifest we created. Used on a failed/aborted adopt so a capture never half-lands.
606
+ //
607
+ // A skill's rollback walks the ledger of paths this run actually created, newest
608
+ // first: files, then the directories that held them. It never removes a tree
609
+ // wholesale. The plan only says `ready` when the wiki directory was absent, and the
610
+ // root is claimed with an exclusive mkdir, so everything in the ledger is ours — but
611
+ // deleting only what we recorded is what makes that true by construction rather than
612
+ // by trusting a check made earlier in time.
393
613
  function rollbackRec(rec) {
614
+ // A skill ledger is AUTHORITATIVE: only paths this run actually created are removed,
615
+ // and the manifest is touched only if we actually wrote it. The flat branch below
616
+ // unlinks `manifestPath` unconditionally, which is wrong for a skill whose write
617
+ // failed BEFORE the manifest was written — a dangling sidecar symlink is invisible to
618
+ // existsSync, so the guard refuses the write and the rollback would then delete a
619
+ // path the run never touched (codex pre-commit BLOCKER, with a repro).
620
+ if (rec.createdFiles) {
621
+ for (const p of [...rec.createdFiles].reverse()) {
622
+ try {
623
+ unlinkSync(p);
624
+ } catch {}
625
+ }
626
+ for (const d of [...(rec.createdDirs || [])].reverse()) {
627
+ try {
628
+ rmdirSync(d); // fails on a non-empty dir, which is the point: never a tree wipe
629
+ } catch {}
630
+ }
631
+ if (rec.manifestOverwritten && rec.manifestPrevBuf != null) {
632
+ try {
633
+ writeFileSync(rec.manifestPath, rec.manifestPrevBuf);
634
+ } catch {}
635
+ }
636
+ return;
637
+ }
394
638
  try {
395
639
  unlinkSync(rec.filePath);
396
640
  } catch {}
@@ -405,6 +649,81 @@ function rollbackRec(rec) {
405
649
  }
406
650
  }
407
651
 
652
+ /**
653
+ * Write one directory skill into the wiki, recording every path created so a failed
654
+ * adopt can be undone exactly (skills-capture design §5/§7).
655
+ *
656
+ * Order: the sidecar manifest first, then the subtree with SKILL.md LAST. A crash at
657
+ * any point therefore leaves a directory with no SKILL.md, which discovery skips —
658
+ * never a half-skill that the far machine would partially install.
659
+ *
660
+ * `guard` re-checks the destination against the wiki boundary immediately before each
661
+ * mutation. Lexical containment cannot see a symlinked ancestor, and the boundary has
662
+ * to be the wiki root itself: rooting the walk at `extensions/skills` would lstat
663
+ * straight through a symlinked `extensions/`.
664
+ */
665
+ function writeSkill({ rec, skillRoot, manifestPath, manifest, files, guard, wikiRoot }) {
666
+ const prev = readFileIfRegular(manifestPath);
667
+ rec.manifestExisted = prev != null;
668
+ rec.manifestPrevBuf = prev;
669
+
670
+ if (!guard(manifestPath) || !guard(skillRoot)) {
671
+ throw new Error('wiki path is not inside the vault or crosses a symlinked directory');
672
+ }
673
+ // Build the chain down to the type directory segment by segment, so a directory this
674
+ // run creates (a fresh `extensions/skills/`) lands in the ledger and can be undone.
675
+ // A recursive mkdir would create them silently and leak them on rollback.
676
+ const typeDir = dirname(skillRoot);
677
+ const chain = relative(wikiRoot, typeDir).split(sep).filter(Boolean);
678
+ let cur = wikiRoot;
679
+ for (const seg of chain) {
680
+ cur = join(cur, seg);
681
+ if (existsSync(cur)) continue;
682
+ if (!guard(cur)) throw new Error('wiki path crosses a symlinked directory');
683
+ mkdirSync(cur);
684
+ rec.createdDirs.push(cur);
685
+ }
686
+ // Exclusive: EEXIST here means the "absent" verdict the plan made is already stale,
687
+ // and the rollback must not be handed a directory it did not create.
688
+ mkdirSync(skillRoot);
689
+ rec.createdDirs.push(skillRoot);
690
+
691
+ writeAtomic(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
692
+ // Record what the write actually did, not what we predicted it would do: the rollback
693
+ // must never remove or restore a manifest it did not touch.
694
+ if (rec.manifestExisted) rec.manifestOverwritten = true;
695
+ else rec.createdFiles.push(manifestPath);
696
+
697
+ // SKILL.md last: it is the file that makes the directory discoverable.
698
+ const ordered = [
699
+ ...files.filter((f) => f.rel !== SKILL_ROOT_FILE),
700
+ ...files.filter((f) => f.rel === SKILL_ROOT_FILE),
701
+ ];
702
+ const madeDirs = new Set([skillRoot]);
703
+ for (const f of ordered) {
704
+ const destPath = join(skillRoot, ...f.rel.split('/'));
705
+ if (!guard(destPath)) {
706
+ throw new Error(`unsafe wiki destination for ${f.rel}`);
707
+ }
708
+ const parent = dirname(destPath);
709
+ if (!madeDirs.has(parent)) {
710
+ // Build the chain segment by segment so every new directory lands in the ledger.
711
+ const segs = f.rel.split('/').slice(0, -1);
712
+ let cur = skillRoot;
713
+ for (const s of segs) {
714
+ cur = join(cur, s);
715
+ if (madeDirs.has(cur)) continue;
716
+ if (!guard(cur)) throw new Error(`unsafe wiki destination for ${f.rel}`);
717
+ mkdirSync(cur);
718
+ rec.createdDirs.push(cur);
719
+ madeDirs.add(cur);
720
+ }
721
+ }
722
+ writeAtomic(destPath, readFileSync(f.srcPath));
723
+ rec.createdFiles.push(destPath);
724
+ }
725
+ }
726
+
408
727
  // Remove specific per-target ownership keys from hypo-pkg.json (codex pre-commit
409
728
  // CONCERN). A partial adopt can leave forward-sync having recorded SOME of a
410
729
  // capture's install keys (e.g. the byte-identical `.mjs` was owned but its sidecar
@@ -429,19 +748,155 @@ function purgeOwnedKeys(pkgPath, target, keys) {
429
748
  if (changed) writePkgJsonAtomic(pkgPath, pkg);
430
749
  }
431
750
 
751
+ /**
752
+ * Plan + write one directory skill. Pushes into the caller's captured/skipped/created
753
+ * lists so the shared adopt + rollback machinery downstream treats it like any other
754
+ * capture.
755
+ *
756
+ * The wiki side is read with the SAME strict walker as the source, so a wiki directory
757
+ * that is unreadable, symlink-poisoned, or crash-truncated (no SKILL.md) surfaces as
758
+ * `wikiShas: null` and lands on the conflict branch instead of being silently treated
759
+ * as absent and then blown away by a rollback.
760
+ */
761
+ function captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped, created }) {
762
+ const wikiStem = `${EXT_PREFIX}${c.stem}`;
763
+ const typeDir = join(extDir, 'skills');
764
+ const skillRoot = join(typeDir, wikiStem);
765
+ const manifestPath = join(typeDir, `${wikiStem}.manifest.json`);
766
+ const label = `skills/${c.file}`;
767
+
768
+ // Null-prototype: a skill file legitimately named `__proto__` would otherwise assign
769
+ // through the prototype setter instead of creating an own key, so it would vanish from
770
+ // the comparison and from the adopt check (forward-sync keeps its SHA maps this way for
771
+ // the same reason). codex pre-commit CONCERN.
772
+ const srcShas = emptyShaMap();
773
+ for (const f of c.files) srcShas[f.rel] = sha256(readFileSync(f.srcPath));
774
+
775
+ const wikiPresent = existsSync(skillRoot);
776
+ let wikiShas = null;
777
+ if (wikiPresent && isRealDir(skillRoot)) {
778
+ const walked = walkSkillSubtree(skillRoot, label, args.hypoDir, [], SKILL_LIMITS);
779
+ if (!walked.skip) {
780
+ wikiShas = emptyShaMap();
781
+ for (const f of walked.files) wikiShas[f.rel] = sha256(readFileSync(f.srcPath));
782
+ }
783
+ }
784
+ if (existsSync(manifestPath) && !isRegularFile(manifestPath)) {
785
+ const reason = 'wiki manifest exists but is not a regular file';
786
+ log(`⊘ ${label}: ${reason} — skipped`);
787
+ skipped.push({ ...c, reason, status: 'conflict' });
788
+ return;
789
+ }
790
+ const existingManifestBuf = readFileIfRegular(manifestPath);
791
+ const existingManifestRaw = existingManifestBuf ? existingManifestBuf.toString('utf-8') : null;
792
+
793
+ const wantManifest = { type: TYPE_SINGULAR.skills, installName: c.stem };
794
+ const plan = planSkillCapture({
795
+ wantManifest,
796
+ srcShas,
797
+ wikiPresent,
798
+ wikiShas,
799
+ existingManifestRaw,
800
+ });
801
+
802
+ // The install key is the whole directory; forward-sync records one nested map under
803
+ // it, one entry per relpath (skills-dir design).
804
+ const rec = { ...c, installKey: `skills/${c.stem}`, srcShas };
805
+
806
+ if (plan.status === 'invalid' || plan.status === 'conflict') {
807
+ log(`⊘ ${label}: ${plan.reason} — skipped`);
808
+ skipped.push({ ...rec, reason: plan.reason, status: plan.status });
809
+ return;
810
+ }
811
+ if (plan.status === 'already') {
812
+ log(`= ${label}: already captured`);
813
+ captured.push({ ...rec, status: 'already' });
814
+ return;
815
+ }
816
+
817
+ // Content round-trips; the executable bit does not (forward-sync writes with the
818
+ // default mode). Say so rather than let a captured `scripts/run.sh` arrive
819
+ // non-executable on the far machine without a word.
820
+ const execFiles = c.files.filter((f) => {
821
+ try {
822
+ return (lstatSync(f.srcPath).mode & 0o111) !== 0;
823
+ } catch {
824
+ return false;
825
+ }
826
+ });
827
+ if (execFiles.length > 0) {
828
+ log(
829
+ `! ${label}: ${execFiles.length} executable file(s) — content is captured, but the executable bit is not carried by sync`,
830
+ );
831
+ }
832
+
833
+ if (!args.dryRun) {
834
+ // The ledger is owned by the caller so a throw MID-write is still recoverable: the
835
+ // paths created before the failure are already recorded in it.
836
+ const ledger = {
837
+ type: 'skills',
838
+ stem: c.stem,
839
+ manifestPath,
840
+ manifestExisted: false,
841
+ manifestOverwritten: false,
842
+ manifestPrevBuf: null,
843
+ createdFiles: [],
844
+ createdDirs: [],
845
+ };
846
+ try {
847
+ writeSkill({
848
+ rec: ledger,
849
+ skillRoot,
850
+ manifestPath,
851
+ manifest: plan.manifest,
852
+ files: c.files,
853
+ guard,
854
+ wikiRoot,
855
+ });
856
+ } catch (err) {
857
+ rollbackRec(ledger);
858
+ const reason = `wiki write failed (${err.message})`;
859
+ log(`⊘ ${label}: ${reason} — skipped`);
860
+ skipped.push({ ...rec, reason, status: 'conflict' });
861
+ return;
862
+ }
863
+ created.push(ledger);
864
+ }
865
+ captured.push({ ...rec, status: 'ready' });
866
+ }
867
+
432
868
  function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
433
- const extDir = join(args.hypoDir, 'extensions');
869
+ // Resolve the vault root through realpath. Every wiki mutation below is boundary-
870
+ // checked against it, and a symlinked ancestor INSIDE the vault must be caught while
871
+ // a vault root that is itself a symlink (a perfectly normal setup) still works. Doing
872
+ // it here means extDir and the sync call see the same real root.
873
+ let wikiRoot = args.hypoDir;
874
+ try {
875
+ wikiRoot = realpathSync(args.hypoDir);
876
+ } catch {
877
+ // Not created yet: fall back to the lexical path; the guard below still applies.
878
+ }
879
+ const extDir = join(wikiRoot, 'extensions');
434
880
  const settingsPath = join(claudeHome, 'settings.json');
435
881
  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);
882
+ // commands/agents enumerate by readdir; hooks enumerate from settings.json; skills
883
+ // enumerate as directories under ~/.claude/skills.
884
+ const candidates = scanCandidates(claudeHome, recorded, args.types);
885
+
886
+ const scanSkipped = [];
887
+ if (args.types.includes('skills')) {
888
+ const skillScan = scanSkillCandidates(claudeHome, ownedSkillDirs(recorded), {
889
+ hypoDir: wikiRoot,
890
+ patterns: loadHypoIgnore(wikiRoot),
891
+ });
892
+ candidates.push(...skillScan.candidates);
893
+ scanSkipped.push(...skillScan.skipped);
894
+ }
439
895
 
440
896
  // Hooks are captured from the settings registration. Reserve the core hook
441
897
  // basenames deterministically from hooks.json; if that load is not ok (read,
442
898
  // parse, or shape failure) skip the whole hooks type rather than risk capturing
443
899
  // a core hook (T1 fail-closed contract: gate on ok, not on cfg presence).
444
- const scanSkipped = [];
445
900
  if (args.types.includes('hooks')) {
446
901
  const core = readCoreHooksConfig(PKG_ROOT);
447
902
  if (!core.ok) {
@@ -457,7 +912,10 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
457
912
  // Surface every scan-time skip with its reason (never a silent drop, F4). Printed
458
913
  // before any early return so observability holds even when nothing is capturable.
459
914
  for (const s of scanSkipped) {
460
- const label = s.basename ? `hooks/${s.basename}` : `hooks (${s.command})`;
915
+ let label;
916
+ if (s.type === 'skills') label = `skills/${s.file}`;
917
+ else if (s.basename) label = `hooks/${s.basename}`;
918
+ else label = `hooks (${s.command})`;
461
919
  log(`⊘ ${label}: ${s.reason} — skipped`);
462
920
  }
463
921
 
@@ -481,22 +939,37 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
481
939
  };
482
940
  }
483
941
 
484
- // Resolve the selection.
942
+ // Resolve the selection. A bare name can now be ambiguous (`commands/mine.md` and
943
+ // `skills/mine/` can both exist), and the old map silently let the last candidate
944
+ // win. Collect every match per name and refuse when there is more than one: the
945
+ // caller must say which by qualifying with the type.
485
946
  let selected;
486
947
  if (args.all) {
487
948
  selected = candidates;
488
949
  } else {
489
950
  const byName = new Map();
951
+ const add = (key, c) => {
952
+ if (!byName.has(key)) byName.set(key, []);
953
+ byName.get(key).push(c);
954
+ };
490
955
  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);
956
+ add(c.file, c);
957
+ if (c.stem !== c.file) add(c.stem, c);
958
+ add(`${c.type}/${c.file}`, c);
494
959
  }
495
960
  selected = [];
496
961
  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`);
962
+ const matches = byName.get(name) || [];
963
+ if (matches.length === 0) {
964
+ log(`⊘ ${name}: no capturable candidate by that name — skipped`);
965
+ continue;
966
+ }
967
+ if (matches.length > 1) {
968
+ const qualified = matches.map((c) => `${c.type}/${c.file}`).join(', ');
969
+ log(`⊘ ${name}: ambiguous (matches ${qualified}) — qualify with the type — skipped`);
970
+ continue;
971
+ }
972
+ selected.push(matches[0]);
500
973
  }
501
974
  }
502
975
 
@@ -505,7 +978,15 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
505
978
  const failed = [];
506
979
  const created = []; // files written THIS run, for rollback on adopt failure
507
980
 
981
+ // Every wiki mutation is checked against the vault root, not the type directory: a
982
+ // symlinked `extensions/` would otherwise be walked straight through.
983
+ const guard = (dest) => isContainedUnder(wikiRoot, dest) && !hasSymlinkAncestor(wikiRoot, dest);
984
+
508
985
  for (const c of selected) {
986
+ if (c.type === 'skills') {
987
+ captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped, created });
988
+ continue;
989
+ }
509
990
  const fileExt = TYPE_EXT[c.type];
510
991
  const wikiStem = `${EXT_PREFIX}${c.stem}`;
511
992
  const typeDir = join(extDir, c.type);
@@ -594,7 +1075,7 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
594
1075
  try {
595
1076
  sync = syncExtensions({
596
1077
  extDir,
597
- hypoDir: args.hypoDir,
1078
+ hypoDir: wikiRoot,
598
1079
  target: 'claude',
599
1080
  settingsPath: join(claudeHome, 'settings.json'),
600
1081
  pkgPath: pkgJsonPath(),
@@ -623,14 +1104,17 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
623
1104
  // this run is preserved (never touched here).
624
1105
  const strayOwnedKeys = new Set();
625
1106
  for (const c of toAdopt) {
626
- if (c.requiredKeys.every((k) => newSHAs[k])) {
1107
+ const ok =
1108
+ c.type === 'skills' ? skillAdopted(c, sync) : c.requiredKeys.every((k) => newSHAs[k]);
1109
+ if (ok) {
627
1110
  c.status = 'captured';
628
1111
  } else {
629
1112
  c.status = 'failed';
630
1113
  failed.push(c);
631
1114
  const rec = created.find((r) => r.type === c.type && r.stem === c.stem);
632
1115
  if (rec) rollbackRec(rec);
633
- for (const k of c.requiredKeys) {
1116
+ const keys = c.type === 'skills' ? [c.installKey] : c.requiredKeys;
1117
+ for (const k of keys) {
634
1118
  if (recorded[k] === undefined && newSHAs[k] !== undefined) strayOwnedKeys.add(k);
635
1119
  }
636
1120
  }
@@ -639,7 +1123,8 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
639
1123
  // stems make this impossible in practice, but the guard keeps the rollback
640
1124
  // strictly scoped to failed captures).
641
1125
  for (const c of toAdopt) {
642
- if (c.status === 'captured') for (const k of c.requiredKeys) strayOwnedKeys.delete(k);
1126
+ if (c.status !== 'captured') continue;
1127
+ for (const k of c.type === 'skills' ? [c.installKey] : c.requiredKeys) strayOwnedKeys.delete(k);
643
1128
  }
644
1129
  purgeOwnedKeys(pkgJsonPath(), 'claude', strayOwnedKeys);
645
1130
  const okCaptured = captured.filter((c) => c.status === 'captured' || c.status === 'already');
@@ -648,12 +1133,43 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
648
1133
  return { selected, captured, skipped, failed, sync };
649
1134
  }
650
1135
 
1136
+ /**
1137
+ * Did forward-sync actually adopt this skill, file for file?
1138
+ *
1139
+ * Presence of the key is not enough. A skill's recorded value is a NESTED map, so the
1140
+ * `newSHAs[key]` truthiness test the flat types use passes as soon as a single relpath
1141
+ * lands — a skill missing half its files would count as adopted and the wiki would own
1142
+ * a lossy install. Worse, copyOne hands back the previously recorded SHA on its drift
1143
+ * and skip paths, so even a per-relpath presence check can be satisfied by a file sync
1144
+ * refused to touch.
1145
+ *
1146
+ * So: every captured relpath must be recorded with EXACTLY the SHA we captured, the
1147
+ * recorded map must hold nothing else, and sync must not have reported drift or a
1148
+ * conflict against this skill. A first capture is source == install target, so every
1149
+ * file is expected to land on the up-to-date branch.
1150
+ */
1151
+ function skillAdopted(c, sync) {
1152
+ const nested = (sync.newSHAs || {})[c.installKey];
1153
+ if (!nested || typeof nested !== 'object' || Array.isArray(nested)) return false;
1154
+ const wikiName = `${EXT_PREFIX}${c.stem}`;
1155
+ const touched = (list) => (list || []).some((e) => e.name === wikiName);
1156
+ if (touched(sync.drifts) || touched(sync.conflicts)) return false;
1157
+ const relpaths = Object.keys(c.srcShas);
1158
+ if (Object.keys(nested).length !== relpaths.length) return false;
1159
+ return relpaths.every((rel) => nested[rel] === c.srcShas[rel]);
1160
+ }
1161
+
651
1162
  function report(captured, skipped, failed, dryRun) {
652
1163
  log('');
653
1164
  if (captured.length > 0) {
654
1165
  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]}`);
1166
+ for (const c of captured) {
1167
+ const dest =
1168
+ c.type === 'skills'
1169
+ ? `extensions/skills/${EXT_PREFIX}${c.stem}/`
1170
+ : `extensions/${c.type}/${EXT_PREFIX}${c.stem}${TYPE_EXT[c.type]}`;
1171
+ log(` ${c.type}/${c.file} → ${dest}`);
1172
+ }
657
1173
  }
658
1174
  if (skipped.length > 0) log(`Skipped ${skipped.length} (conflict/invalid — see above).`);
659
1175
  if (failed.length > 0)
@@ -667,11 +1183,14 @@ function report(captured, skipped, failed, dryRun) {
667
1183
  function main() {
668
1184
  const args = parseArgs(process.argv);
669
1185
  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.');
1186
+ log(
1187
+ 'Usage: hypomnema capture [names…] [--all] [--type=commands,agents,hooks,skills] [--dry-run]',
1188
+ );
1189
+ log(' Pull ~/.claude/{commands,agents,skills} extensions and canonical');
1190
+ log(' settings.json hooks into the wiki for cross-machine sync. Hooks and skills');
1191
+ log(' are captured only when they round-trip losslessly; others are skipped with');
1192
+ log(' a reason. A skill is a directory (SKILL.md + subtree); one holding symlinks,');
1193
+ log(` hardlinks, empty dirs, or more than ${SKILL_MAX_FILES} files is refused.`);
675
1194
  return 0;
676
1195
  }
677
1196
  const res = run(args);