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
@@ -27,8 +27,10 @@ import {
27
27
  renameSync,
28
28
  unlinkSync,
29
29
  mkdirSync,
30
+ lstatSync,
31
+ rmdirSync,
30
32
  } from 'fs';
31
- import { join } from 'path';
33
+ import { join, dirname, relative, resolve, posix, sep } from 'path';
32
34
  import { homedir } from 'os';
33
35
  import { sha256, isRegularFile, readFileIfRegular, writePkgJsonAtomic } from './pkg-json.mjs';
34
36
  import { loadHypoIgnore, isIgnored } from './hypo-ignore.mjs';
@@ -148,6 +150,46 @@ export function resolveInstallFile(ext) {
148
150
  return { installFile: m.installName + TYPE_FILE_EXT[ext.type] };
149
151
  }
150
152
 
153
+ /**
154
+ * Resolve the install DIRECTORY segment for a directory skill. Deliberately NOT
155
+ * resolveInstallFile: that one appends TYPE_FILE_EXT (`foo.md`), which would both
156
+ * miss the directory key and regress the flat hypo-ext-*.md skills.
157
+ *
158
+ * Default: strip the `hypo-ext-` wiki storage prefix, so a skill keeps the name it
159
+ * is actually invoked by (`skills/gstack/`, not `skills/hypo-ext-gstack/`). Unlike
160
+ * commands/agents there is no shipped directory-skill behavior to stay compatible
161
+ * with — none were ever discovered — so the clean name is free to be the default.
162
+ * A sidecar manifest `installName` (with a matching `type`) overrides it.
163
+ *
164
+ * The resolved segment becomes a directory we create and delete under, so it gets
165
+ * the same injection defenses as a flat installName plus a trailing-dot rejection.
166
+ */
167
+ export function resolveSkillInstallDir(ext) {
168
+ if (ext.manifestPath) {
169
+ const parsed = parseManifest(ext.manifestPath);
170
+ if (parsed.ok) {
171
+ const m = parsed.manifest;
172
+ if (m && m.type === TYPE_SINGULAR.skills && m.installName !== undefined) {
173
+ if (!isValidSkillDirSegment(m.installName)) {
174
+ return {
175
+ skip: true,
176
+ warn: `skills/${ext.file}: invalid installName "${m.installName}" — skill skipped`,
177
+ };
178
+ }
179
+ return { installDir: m.installName };
180
+ }
181
+ }
182
+ }
183
+ const stem = ext.file.startsWith(EXT_PREFIX) ? ext.file.slice(EXT_PREFIX.length) : ext.file;
184
+ if (!isValidSkillDirSegment(stem)) {
185
+ return {
186
+ skip: true,
187
+ warn: `skills/${ext.file} skipped (unsafe install directory name "${stem}")`,
188
+ };
189
+ }
190
+ return { installDir: stem };
191
+ }
192
+
151
193
  /**
152
194
  * Parse + validate a recorded pkg-json extension key `${type}/${installFile}`
153
195
  * for destructive use (capture design §8 — uninstall traverses recorded keys, which
@@ -183,6 +225,179 @@ export function parseExtKey(key, coveredTypes) {
183
225
  return { type, installFile };
184
226
  }
185
227
 
228
+ // ── directory skills (skills-dir design) ─────────────────────────────────────
229
+ //
230
+ // A real Claude Code skill is a DIRECTORY (`skills/<name>/SKILL.md` + an
231
+ // arbitrary subtree), not the flat single `.md` the rest of this module assumes.
232
+ // The SHA map keeps its single-segment top-level key (`skills/<name>`) so
233
+ // parseExtKey's no-slash rule — the capture design §8 traversal fix — stays shut;
234
+ // only the VALUE widens to a per-relpath map. Everything below validates that
235
+ // widened shape: a corrupt hypo-pkg.json can now express many paths under one key.
236
+
237
+ // The file that makes a directory a skill. Absent (or not a regular file) → not a skill.
238
+ export const SKILL_ROOT_FILE = 'SKILL.md';
239
+
240
+ // Depth/length ceilings. A pathological subtree is skipped with a warning rather
241
+ // than crashing the sync.
242
+ const SKILL_MAX_DEPTH = 16;
243
+ const SKILL_MAX_RELPATH_LEN = 400;
244
+
245
+ const HEX_SHA = /^[0-9a-f]{64}$/;
246
+
247
+ /**
248
+ * True iff `seg` is safe to use as an install DIRECTORY segment for a skill.
249
+ * Reuses the flat installName defenses (charset, `..`, reserved `hypo`
250
+ * namespace, Windows device names) and adds a trailing-dot rejection: Windows
251
+ * strips trailing dots from directory names, so `foo.` and `foo` alias.
252
+ */
253
+ export function isValidSkillDirSegment(seg) {
254
+ if (!isValidInstallStem(seg)) return false;
255
+ if (seg.endsWith('.')) return false;
256
+ return true;
257
+ }
258
+
259
+ /**
260
+ * Parse a recorded pkg-json key for a DIRECTORY skill. parseExtKey rejects
261
+ * `skills/foo` outright (it demands the type's file extension), so a skill key
262
+ * must never be routed through it — uninstall validates every recorded key and
263
+ * would silently drop the record. Returns `{ type, installDir }` or null.
264
+ */
265
+ export function parseSkillKey(key) {
266
+ if (typeof key !== 'string') return null;
267
+ const slash = key.indexOf('/');
268
+ if (slash === -1) return null;
269
+ if (key.slice(0, slash) !== 'skills') return null;
270
+ const installDir = key.slice(slash + 1);
271
+ if (installDir.includes('/') || installDir.includes('\\')) return null;
272
+ if (!isValidSkillDirSegment(installDir)) return null;
273
+ return { type: 'skills', installDir };
274
+ }
275
+
276
+ /**
277
+ * Validate one subtree-relative path (`SKILL.md`, `references/x.md`). Returns the
278
+ * canonical posix relpath, or null when unsafe. Rejects traversal, absolute and
279
+ * Windows-absolute forms, backslash separators, `.` segments, NUL, and anything
280
+ * past the depth/length ceiling. The canonical return value is what callers must
281
+ * key on: `references/./x.md` and `references/x.md` are the same destination, and
282
+ * treating them as distinct would manufacture a phantom orphan.
283
+ */
284
+ export function normalizeSkillRelPath(rel) {
285
+ if (typeof rel !== 'string' || rel.length === 0) return null;
286
+ if (rel.length > SKILL_MAX_RELPATH_LEN) return null;
287
+ if (rel.includes('\0')) return null;
288
+ // Backslash is a separator on Windows; treat it as unsafe everywhere rather
289
+ // than letting `a\..\b` mean different things per platform.
290
+ if (rel.includes('\\')) return null;
291
+ if (rel.startsWith('/')) return null;
292
+ // Windows drive (`C:x`, `C:/x`) and UNC (`//host/share`) forms.
293
+ if (/^[A-Za-z]:/.test(rel)) return null;
294
+ if (rel.startsWith('//')) return null;
295
+ const segs = rel.split('/');
296
+ if (segs.length > SKILL_MAX_DEPTH) return null;
297
+ for (const s of segs) {
298
+ if (s === '' || s === '.' || s === '..') return null;
299
+ if (s.endsWith('.')) return null; // Windows directory-name aliasing
300
+ }
301
+ const norm = posix.normalize(rel);
302
+ // normalize() cannot escape given the segment checks above, but re-assert:
303
+ // a caller-supplied relpath must never resolve outside its own subtree.
304
+ if (norm.startsWith('..') || norm.startsWith('/')) return null;
305
+ return norm;
306
+ }
307
+
308
+ /**
309
+ * True iff `target` resolves strictly inside `root`. Boundary-aware: a raw
310
+ * `startsWith` would accept `/a/bc` as living under `/a/b`.
311
+ */
312
+ export function isContainedUnder(root, target) {
313
+ const rel = relative(resolve(root), resolve(target));
314
+ if (rel === '' || rel.startsWith('..')) return false;
315
+ return !rel.split(sep).includes('..');
316
+ }
317
+
318
+ /**
319
+ * True iff `root` itself, or any path segment from `root` down to `target`
320
+ * (inclusive), is a symlink. Lexical containment alone cannot see this: a hostile
321
+ * `references -> /outside` directory symlink lets `references/x.md` pass every
322
+ * string check while the write or unlink lands outside the skill entirely.
323
+ * copyOne's leaf check (`isRegularFile`) only guards the FINAL component.
324
+ *
325
+ * `root` is checked too, not just what is below it. Skipping it left the whole
326
+ * guard bypassable by symlinking the boundary directory itself (`~/.claude/skills`),
327
+ * which is the one ancestor every install path shares (codex pre-commit BLOCKER).
328
+ *
329
+ * Callers must re-run this immediately before EVERY mutation (mkdir, copy,
330
+ * unlink, rmdir). The residual TOCTOU window between this check and the syscall
331
+ * is accepted (skills-dir design §7); re-checking per mutation keeps it from widening.
332
+ */
333
+ export function hasSymlinkAncestor(root, target) {
334
+ const rootAbs = resolve(root);
335
+ const targetAbs = resolve(target);
336
+ if (!isContainedUnder(rootAbs, targetAbs)) return true; // treat as unsafe
337
+ try {
338
+ if (lstatSync(rootAbs).isSymbolicLink()) return true;
339
+ } catch {
340
+ // The boundary dir does not exist yet: nothing to escape through.
341
+ }
342
+ const rel = relative(rootAbs, targetAbs);
343
+ const segs = rel.split(sep).filter(Boolean);
344
+ let cur = rootAbs;
345
+ // Walk every intermediate segment AND the leaf: a symlinked leaf must not be
346
+ // written through or unlinked either.
347
+ for (const s of segs) {
348
+ cur = join(cur, s);
349
+ let st;
350
+ try {
351
+ st = lstatSync(cur);
352
+ } catch {
353
+ continue; // does not exist yet (fresh install) — nothing to escape through
354
+ }
355
+ if (st.isSymbolicLink()) return true;
356
+ }
357
+ return false;
358
+ }
359
+
360
+ /**
361
+ * True iff `value` is a plain hex SHA — the only shape a FLAT extension key may
362
+ * carry. A corrupt pkg-json that parks an object (or any other type) under a flat
363
+ * key must not be treated as ownership: under --force-extensions that would remove
364
+ * a file on the strength of a value we never wrote (codex pre-commit CONCERN).
365
+ */
366
+ export function isFlatShaValue(value) {
367
+ return typeof value === 'string' && HEX_SHA.test(value);
368
+ }
369
+
370
+ /**
371
+ * A nested SHA map keyed by relpath. Null-prototype on purpose: relpaths come from
372
+ * the filesystem, so a file named `__proto__` would otherwise assign through the
373
+ * prototype setter instead of creating an own key (installed but never recorded,
374
+ * hence unowned and unreachable by uninstall), and a file named `constructor` would
375
+ * read back a truthy inherited value as if it were a recorded SHA. Codex found the
376
+ * `__proto__` case with a working repro.
377
+ */
378
+ export function emptyShaMap() {
379
+ return Object.create(null);
380
+ }
381
+
382
+ /**
383
+ * Validate a recorded skill SHA value: a plain object mapping canonical relpaths
384
+ * to hex SHAs. Returns a clean map containing only the entries that pass; a
385
+ * corrupt or malicious entry is dropped rather than trusted. Returns null when
386
+ * the value is not a plain object at all (shape mismatch — e.g. a flat string
387
+ * SHA parked under a skill key).
388
+ */
389
+ export function parseSkillShaValue(value) {
390
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
391
+ const out = emptyShaMap();
392
+ for (const [rel, sha] of Object.entries(value)) {
393
+ if (typeof sha !== 'string' || !HEX_SHA.test(sha)) continue;
394
+ const norm = normalizeSkillRelPath(rel);
395
+ if (norm === null || norm !== rel) continue; // only canonical keys are trusted
396
+ out[norm] = sha;
397
+ }
398
+ return out;
399
+ }
400
+
186
401
  // Claude Code hook events (D3 allowlist). A manifest with an event outside this
187
402
  // set is malformed → the whole extension is skipped (no copy, no registration).
188
403
  export const HOOK_EVENT_ALLOWLIST = new Set([
@@ -212,11 +427,183 @@ function pkgRootDir(target) {
212
427
 
213
428
  // ── read-only helpers ─────────────────────────────────────────────────────────
214
429
 
430
+ /**
431
+ * True iff `p` is a directory and NOT a symlink to one. A symlinked directory in
432
+ * the wiki subtree (`references -> /etc`) would otherwise let the walker copy
433
+ * files from outside the vault into the install target.
434
+ */
435
+ export function isRealDir(p) {
436
+ try {
437
+ return lstatSync(p).isDirectory();
438
+ } catch {
439
+ return false;
440
+ }
441
+ }
442
+
443
+ // Directory names that must never be captured into the wiki: the wiki is itself a
444
+ // git repo, so a `.git` under a skill would nest one repo inside another. Strict
445
+ // (capture) mode only.
446
+ const VCS_DIRS = new Set(['.git', '.hg', '.svn']);
447
+
448
+ /**
449
+ * Walk a skill directory and collect its owned files. lstat-based: symlinked
450
+ * files and symlinked directories are skipped with a warning, never followed.
451
+ *
452
+ * Returns `{ files: [{ rel, srcPath, size }], warnings, skip }`. `skip` is set when
453
+ * the whole skill must be dropped: no regular SKILL.md at the root, or two source
454
+ * paths that canonicalize to the same destination (case-fold aliasing), which
455
+ * would make ownership order-dependent.
456
+ *
457
+ * `opts` is empty for forward-sync, which keeps its tolerant behavior verbatim.
458
+ * Reverse capture passes:
459
+ * - `strict`: any per-file skip reason poisons the WHOLE skill instead of
460
+ * dropping that one file. Capture must never write a lossy copy into the
461
+ * wiki, because what lands there is what the far machine installs. It also
462
+ * turns on the checks that only matter for content coming INTO the wiki:
463
+ * empty directories (git cannot carry them), VCS control dirs, and hardlinks
464
+ * (link identity does not round-trip).
465
+ * - `maxFiles` / `maxBytes`: cumulative ceilings, checked during the walk so a
466
+ * vendored skill (gstack: 14k files / 1.1GB) aborts early instead of being
467
+ * enumerated in full on every candidate listing. Sizes come from lstat, so
468
+ * the verdict lands before anything is read.
469
+ */
470
+ export function walkSkillSubtree(skillDir, label, hypoDir, hypoignorePatterns, opts = {}) {
471
+ const { strict = false, maxFiles = Infinity, maxBytes = Infinity } = opts;
472
+ const files = [];
473
+ const warnings = [];
474
+ const seen = new Map(); // case-folded canonical rel -> first rel that claimed it
475
+ let bytes = 0;
476
+ // Set once the whole skill is doomed; unwinds the recursion immediately.
477
+ let fatal = null;
478
+ const poison = (reason) => {
479
+ fatal = reason;
480
+ warnings.push(`${label} skipped (${reason})`);
481
+ files.length = 0;
482
+ return { fatal: true };
483
+ };
484
+ // A per-file problem: tolerated (skip the file) by forward-sync, fatal in strict.
485
+ const reject = (rel, reason) => {
486
+ if (strict) return poison(`${rel}: ${reason}`);
487
+ warnings.push(`${label}/${rel} skipped (${reason})`);
488
+ return null;
489
+ };
490
+
491
+ const rootFile = join(skillDir, SKILL_ROOT_FILE);
492
+ if (!isRegularFile(rootFile)) {
493
+ return {
494
+ files: [],
495
+ warnings: [`${label} skipped (no regular ${SKILL_ROOT_FILE} at the skill root)`],
496
+ skip: true,
497
+ };
498
+ }
499
+
500
+ const walk = (dir, prefix, depth) => {
501
+ if (depth > SKILL_MAX_DEPTH) {
502
+ return reject(prefix, `subtree deeper than ${SKILL_MAX_DEPTH}`);
503
+ }
504
+ let entries;
505
+ try {
506
+ entries = readdirSync(dir);
507
+ } catch {
508
+ return reject(prefix, 'unreadable directory');
509
+ }
510
+ // git cannot carry an empty directory, so a skill that needs one cannot be
511
+ // reproduced through the wiki. Refuse rather than capture a subset.
512
+ if (strict && entries.length === 0 && depth > 1) {
513
+ return poison(`${prefix}: empty directory (add a .gitkeep to carry it)`);
514
+ }
515
+ for (const name of entries) {
516
+ const full = join(dir, name);
517
+ const rel = prefix ? `${prefix}/${name}` : name;
518
+ if (isIgnored(full, hypoDir, hypoignorePatterns)) continue;
519
+ if (isRealDir(full)) {
520
+ if (strict && VCS_DIRS.has(name)) {
521
+ return poison(`${rel}: VCS control directory cannot be nested in the wiki`);
522
+ }
523
+ // Propagate a collision found further down: ignoring this return value let a
524
+ // deep `references/A.md` vs `references/a.md` clash through while only the
525
+ // root level was poisoned (codex pre-commit NIT).
526
+ const sub = walk(full, rel, depth + 1);
527
+ if (sub && (sub.collision || sub.fatal)) return sub;
528
+ continue;
529
+ }
530
+ if (!isRegularFile(full)) {
531
+ // Symlink, socket, or a symlinked dir: never followed, never owned.
532
+ const r = reject(rel, 'not a regular file');
533
+ if (r) return r;
534
+ continue;
535
+ }
536
+ // A sidecar manifest lives NEXT TO the skill dir, not inside it. Reserve the
537
+ // name inside the subtree so skill content can never be confused for install
538
+ // metadata later.
539
+ if (name.endsWith('.manifest.json')) {
540
+ const r = reject(rel, 'reserved manifest name inside a skill');
541
+ if (r) return r;
542
+ continue;
543
+ }
544
+ const norm = normalizeSkillRelPath(rel);
545
+ if (norm === null) {
546
+ const r = reject(rel, 'unsafe path');
547
+ if (r) return r;
548
+ continue;
549
+ }
550
+ // NFD and NFC spellings of the same name are one file on macOS, so collapse
551
+ // the unicode form before case-folding or the alias check misses them.
552
+ const fold = norm.normalize('NFC').toLowerCase();
553
+ if (seen.has(fold)) {
554
+ // Poison the whole skill: which file wins would depend on readdir order. `fatal`
555
+ // carries the reason out so a caller (capture) reports THIS, not the fallback
556
+ // "no SKILL.md" (codex pre-commit CONCERN).
557
+ fatal = `${norm} and ${seen.get(fold)} collide on a case-insensitive filesystem`;
558
+ warnings.push(`${label} skipped (${fatal})`);
559
+ files.length = 0;
560
+ return { collision: true };
561
+ }
562
+ let st = null;
563
+ if (strict || maxBytes !== Infinity) {
564
+ try {
565
+ st = lstatSync(full);
566
+ } catch {
567
+ const r = reject(rel, 'unreadable file');
568
+ if (r) return r;
569
+ continue;
570
+ }
571
+ // A hardlink is a regular file, so nothing above catches it, and copying it
572
+ // silently flattens a link the far machine cannot reconstruct.
573
+ if (strict && st.nlink !== 1) {
574
+ return poison(`${rel}: hardlink (nlink=${st.nlink}) does not round-trip`);
575
+ }
576
+ }
577
+ seen.set(fold, norm);
578
+ files.push({ rel: norm, srcPath: full, size: st ? st.size : undefined });
579
+ if (files.length > maxFiles) {
580
+ return poison(`over ${maxFiles} files (aborted while walking)`);
581
+ }
582
+ if (st) {
583
+ bytes += st.size;
584
+ if (bytes > maxBytes) {
585
+ const mib = (n) => `${Math.round(n / (1024 * 1024))} MiB`;
586
+ return poison(`over ${mib(maxBytes)} (${mib(bytes)}+ so far, aborted while walking)`);
587
+ }
588
+ }
589
+ }
590
+ return null;
591
+ };
592
+
593
+ const res = walk(skillDir, '', 1);
594
+ if (res && (res.collision || res.fatal)) return { files: [], warnings, skip: true, fatal };
595
+ return { files, warnings, skip: false, bytes };
596
+ }
597
+
215
598
  /**
216
599
  * Discover sync-eligible extensions under `extDir`. Returns a per-type map plus a
217
600
  * `warnings` array. Applies the `.hypoignore` filter, the basename
218
601
  * whitelist (plan §5 #9), and pairs each file with its optional `<name>.manifest.json`.
219
602
  * No-ops gracefully when extDir is absent (e.g. --from-remote clones, plan §5 #8).
603
+ *
604
+ * Skills come in two shapes. A directory (`skills/hypo-ext-<name>/SKILL.md` + a
605
+ * subtree) is the real Claude Code layout and carries a `files[]` list; a flat
606
+ * `hypo-ext-<name>.md` keeps working exactly as before (backward compatible).
220
607
  */
221
608
  export function discoverExtensions(extDir, hypoignorePatterns, hypoDir) {
222
609
  const result = { hooks: [], commands: [], skills: [], agents: [], warnings: [] };
@@ -238,6 +625,39 @@ export function discoverExtensions(extDir, hypoignorePatterns, hypoDir) {
238
625
  if (fname.endsWith('.manifest.json')) continue;
239
626
  const srcPath = join(typeDir, fname);
240
627
  if (isIgnored(srcPath, hypoDir, hypoignorePatterns)) continue;
628
+
629
+ // A directory under skills/ is the real skill layout. Everything else falls
630
+ // through to the flat single-file path below.
631
+ if (type === 'skills' && isRealDir(srcPath)) {
632
+ if (!SAFE_EXT_STEM.test(fname)) {
633
+ result.warnings.push(
634
+ `skills/${fname} skipped (extensions must use a 'hypo-ext-<name>' directory name)`,
635
+ );
636
+ continue;
637
+ }
638
+ const label = `skills/${fname}`;
639
+ const walked = walkSkillSubtree(srcPath, label, hypoDir, hypoignorePatterns);
640
+ result.warnings.push(...walked.warnings);
641
+ if (walked.skip) continue;
642
+ const manifestName = `${fname}.manifest.json`;
643
+ const manifestPath = join(typeDir, manifestName);
644
+ const hasSkillManifest =
645
+ existsSync(manifestPath) &&
646
+ isRegularFile(manifestPath) &&
647
+ !isIgnored(manifestPath, hypoDir, hypoignorePatterns);
648
+ result.skills.push({
649
+ type,
650
+ name: fname,
651
+ file: fname,
652
+ srcPath,
653
+ isDir: true,
654
+ files: walked.files,
655
+ manifestName,
656
+ manifestPath: hasSkillManifest ? manifestPath : null,
657
+ });
658
+ continue;
659
+ }
660
+
241
661
  if (!isRegularFile(srcPath)) continue;
242
662
  if (!fname.endsWith(fileExt)) continue;
243
663
  const stem = fname.slice(0, -fileExt.length);
@@ -563,6 +983,190 @@ function recordCopyOutcome(result, name, key, action, apply) {
563
983
  }
564
984
  }
565
985
 
986
+ /** Join a canonical posix relpath onto a native base path. */
987
+ function joinRel(base, rel) {
988
+ return join(base, ...rel.split('/'));
989
+ }
990
+
991
+ /**
992
+ * Sync one DIRECTORY skill: mirror its wiki subtree into the install directory and
993
+ * remove the files we own that the wiki no longer has.
994
+ *
995
+ * This is the only place forward-sync deletes anything. The gate is the same
996
+ * ownership rule the rest of the module uses for overwrite: a file is removed only
997
+ * when its on-disk SHA still matches the SHA we recorded when we installed it. A
998
+ * user-modified copy, an unowned file the user dropped in, and anything that is not
999
+ * a regular file are all preserved. The recorded SHA of a PRESERVED orphan is kept,
1000
+ * not dropped — without it the file becomes unowned and --force-extensions could
1001
+ * never clean it up.
1002
+ *
1003
+ * Every mutation (mkdir, copy, unlink, rmdir) re-runs the symlink-ancestor walk
1004
+ * first: lexical containment alone cannot see a `references -> /outside` directory
1005
+ * symlink, and both the write and the unlink would land outside the skill.
1006
+ */
1007
+ function syncOneSkill({
1008
+ ext,
1009
+ installDir,
1010
+ typeDir,
1011
+ recorded,
1012
+ newSHAs,
1013
+ result,
1014
+ target,
1015
+ apply,
1016
+ force,
1017
+ }) {
1018
+ const key = `skills/${installDir}`;
1019
+ const skillRoot = join(typeDir, installDir);
1020
+ // A recorded value of the wrong shape (e.g. a flat string SHA parked under a
1021
+ // skill key by a corrupt pkg-json) is not trusted as ownership of anything.
1022
+ const recordedNested = parseSkillShaValue(recorded[key]) ?? emptyShaMap();
1023
+ const newNested = emptyShaMap();
1024
+
1025
+ const unsafe = (destPath) =>
1026
+ !isContainedUnder(skillRoot, destPath) || hasSymlinkAncestor(typeDir, destPath);
1027
+
1028
+ // (1) install / update every file the wiki subtree currently has.
1029
+ const desired = new Set();
1030
+ // A path we refuse to touch must KEEP whatever ownership it already had. Dropping
1031
+ // the record here would silently disown a file we installed on an earlier run,
1032
+ // putting it beyond doctor and uninstall (codex pre-commit CONCERN).
1033
+ const refuse = (fileKey, rel) => {
1034
+ result.conflicts.push({ name: ext.name, file: fileKey, action: 'skip-unsafe-path' });
1035
+ result.warnings.push(`${fileKey} (skip-unsafe-path) — left untouched`);
1036
+ if (recordedNested[rel] !== undefined) newNested[rel] = recordedNested[rel];
1037
+ };
1038
+ for (const f of ext.files) {
1039
+ desired.add(f.rel);
1040
+ const destPath = joinRel(skillRoot, f.rel);
1041
+ const fileKey = `${key}/${f.rel}`; // display only — never a pkg-json key
1042
+ if (unsafe(destPath)) {
1043
+ refuse(fileKey, f.rel);
1044
+ continue;
1045
+ }
1046
+ if (apply) {
1047
+ const parent = dirname(destPath);
1048
+ if (hasSymlinkAncestor(typeDir, parent)) {
1049
+ refuse(fileKey, f.rel);
1050
+ continue;
1051
+ }
1052
+ mkdirSync(parent, { recursive: true });
1053
+ }
1054
+ const res = copyOne({
1055
+ srcPath: f.srcPath,
1056
+ destPath,
1057
+ key: fileKey,
1058
+ recordedSHA: recordedNested[f.rel],
1059
+ apply,
1060
+ force,
1061
+ });
1062
+ if (res.sha != null) newNested[f.rel] = res.sha;
1063
+ result.actions.push({ target, file: fileKey, action: res.action });
1064
+ recordCopyOutcome(result, ext.name, fileKey, res.action, apply);
1065
+ }
1066
+
1067
+ // (2) orphans: recorded, but the wiki subtree no longer carries them.
1068
+ for (const [rel, sha] of Object.entries(recordedNested)) {
1069
+ if (desired.has(rel)) continue;
1070
+ const destPath = joinRel(skillRoot, rel);
1071
+ const fileKey = `${key}/${rel}`;
1072
+ const keep = (reason) => {
1073
+ // Preserving the record is the point: drop it and the file goes unowned,
1074
+ // which puts it beyond --force-extensions forever.
1075
+ newNested[rel] = sha;
1076
+ result.warnings.push(`${fileKey} (${reason}) — left untouched`);
1077
+ };
1078
+ if (unsafe(destPath)) {
1079
+ keep('skip-unsafe-path');
1080
+ continue;
1081
+ }
1082
+ if (!existsSync(destPath)) continue; // already gone: the record can go too
1083
+ if (!isRegularFile(destPath)) {
1084
+ keep('skip-non-regular');
1085
+ continue;
1086
+ }
1087
+ const onDisk = readFileIfRegular(destPath);
1088
+ if (onDisk === null) {
1089
+ keep('skip-unreadable');
1090
+ continue;
1091
+ }
1092
+ const onDiskSHA = sha256(onDisk);
1093
+ if (onDiskSHA !== sha && !force) {
1094
+ // Owned once, edited since. Same call as an overwrite drift: warn, preserve.
1095
+ result.drifts.push({ name: ext.name, file: fileKey });
1096
+ result.needsWork = true;
1097
+ newNested[rel] = sha;
1098
+ result.warnings.push(`${fileKey} (drift — user-modified orphan) — left untouched`);
1099
+ continue;
1100
+ }
1101
+ if (!apply) {
1102
+ // Check mode: the deletion is pending work, and nothing is written yet.
1103
+ newNested[rel] = sha;
1104
+ result.needsWork = true;
1105
+ result.actions.push({ target, file: fileKey, action: 'delete' });
1106
+ continue;
1107
+ }
1108
+ // CAS: re-read immediately before unlink. A save between the hash above and
1109
+ // this syscall must not lose the user's bytes.
1110
+ const verify = readFileIfRegular(destPath);
1111
+ if (verify === null || (sha256(verify) !== sha && !force)) {
1112
+ keep('skip-changed');
1113
+ continue;
1114
+ }
1115
+ if (hasSymlinkAncestor(typeDir, destPath)) {
1116
+ keep('skip-unsafe-path');
1117
+ continue;
1118
+ }
1119
+ if (force && sha256(verify) !== sha) writeFreshAtomic(`${destPath}.bak`, verify);
1120
+ try {
1121
+ unlinkSync(destPath);
1122
+ } catch {
1123
+ keep('skip-unlink-failed');
1124
+ continue;
1125
+ }
1126
+ result.actions.push({ target, file: fileKey, action: 'delete' });
1127
+ }
1128
+
1129
+ // (3) prune directories the orphan removal emptied. The skill root itself stays:
1130
+ // removing it is uninstall's job, not sync's.
1131
+ if (apply) pruneEmptyDirs(skillRoot, typeDir, result);
1132
+
1133
+ if (Object.keys(newNested).length > 0) newSHAs[key] = newNested;
1134
+ }
1135
+
1136
+ /**
1137
+ * Remove now-empty directories under `skillRoot` (exclusive of the root itself),
1138
+ * deepest first. Skips anything reachable through a symlinked ancestor.
1139
+ */
1140
+ function pruneEmptyDirs(skillRoot, typeDir, result) {
1141
+ const dirs = [];
1142
+ const collect = (dir, depth) => {
1143
+ if (depth > SKILL_MAX_DEPTH) return;
1144
+ let entries;
1145
+ try {
1146
+ entries = readdirSync(dir);
1147
+ } catch {
1148
+ return;
1149
+ }
1150
+ for (const name of entries) {
1151
+ const full = join(dir, name);
1152
+ if (!isRealDir(full)) continue;
1153
+ collect(full, depth + 1);
1154
+ dirs.push(full);
1155
+ }
1156
+ };
1157
+ if (!isRealDir(skillRoot)) return;
1158
+ collect(skillRoot, 1);
1159
+ // Deepest first, so a parent emptied by its children is caught in the same pass.
1160
+ for (const dir of dirs.reverse()) {
1161
+ if (!isContainedUnder(skillRoot, dir) || hasSymlinkAncestor(typeDir, dir)) continue;
1162
+ try {
1163
+ if (readdirSync(dir).length === 0) rmdirSync(dir);
1164
+ } catch {
1165
+ result.warnings.push(`skills: could not prune empty directory ${dir}`);
1166
+ }
1167
+ }
1168
+ }
1169
+
566
1170
  /**
567
1171
  * Sync all discovered extensions for one target ('claude' | 'codex').
568
1172
  *
@@ -638,10 +1242,33 @@ export function syncExtensions({
638
1242
  // WHOLE group — otherwise file traversal order would decide ownership and
639
1243
  // overwrite/skip unpredictably. Keyed by ext object identity.
640
1244
  const installFileByExt = new Map();
1245
+ // Directory skills resolve to an install DIRECTORY segment, not a filename.
1246
+ const installDirByExt = new Map();
641
1247
  const dupSkip = new Set();
642
1248
  for (const type of types) {
643
1249
  const seen = new Map(); // case-folded `${type}/${installFile}` → first ext
644
1250
  for (const ext of discovered[type]) {
1251
+ if (ext.isDir) {
1252
+ const dirRes = resolveSkillInstallDir(ext);
1253
+ if (dirRes.skip) {
1254
+ dupSkip.add(ext);
1255
+ result.warnings.push(dirRes.warn);
1256
+ continue;
1257
+ }
1258
+ installDirByExt.set(ext, dirRes.installDir);
1259
+ const dirNorm = `skills/${dirRes.installDir.toLowerCase()}`;
1260
+ const firstDir = seen.get(dirNorm);
1261
+ if (firstDir !== undefined) {
1262
+ dupSkip.add(ext);
1263
+ dupSkip.add(firstDir);
1264
+ result.warnings.push(
1265
+ `skills/${dirRes.installDir} install target claimed by multiple extensions — all skipped (rename installName)`,
1266
+ );
1267
+ } else {
1268
+ seen.set(dirNorm, ext);
1269
+ }
1270
+ continue;
1271
+ }
645
1272
  const res = resolveInstallFile(ext);
646
1273
  if (res.skip) {
647
1274
  dupSkip.add(ext);
@@ -669,6 +1296,15 @@ export function syncExtensions({
669
1296
  // previously-owned installed copy — it would linger on disk, untracked by doctor
670
1297
  // and unreachable by uninstall (codex pre-commit BLOCKER).
671
1298
  for (const ext of dupSkip) {
1299
+ if (ext.isDir) {
1300
+ // Same reasoning for a directory skill: keep its whole nested record so the
1301
+ // installed subtree stays owned (doctor sees it, uninstall can remove it).
1302
+ const dir = installDirByExt.get(ext);
1303
+ if (!dir) continue; // invalid installName: never owned
1304
+ const key = `skills/${dir}`;
1305
+ if (recorded[key] !== undefined && newSHAs[key] === undefined) newSHAs[key] = recorded[key];
1306
+ continue;
1307
+ }
672
1308
  const inst = installFileByExt.get(ext);
673
1309
  if (!inst) continue; // invalid-installName skip: never owned
674
1310
  // Preserve BOTH ownership keys a hook records: the main `.mjs` AND its paired
@@ -689,6 +1325,25 @@ export function syncExtensions({
689
1325
  const typeDir = join(targetRoot, type);
690
1326
  for (const ext of discovered[type]) {
691
1327
  if (dupSkip.has(ext)) continue;
1328
+
1329
+ // A directory skill mirrors a subtree (and prunes what the wiki dropped)
1330
+ // rather than copying one file.
1331
+ if (ext.isDir) {
1332
+ if (apply) mkdirSync(typeDir, { recursive: true });
1333
+ syncOneSkill({
1334
+ ext,
1335
+ installDir: installDirByExt.get(ext),
1336
+ typeDir,
1337
+ recorded,
1338
+ newSHAs,
1339
+ result,
1340
+ target,
1341
+ apply,
1342
+ force,
1343
+ });
1344
+ continue;
1345
+ }
1346
+
692
1347
  // Install under the manifest-declared installName (capture design §3) or, by
693
1348
  // default, the wiki storage name (backward compatible).
694
1349
  const installFile = installFileByExt.get(ext) ?? ext.file;