hypomnema 1.3.2 → 1.3.3

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.
@@ -11,6 +11,12 @@
11
11
  *
12
12
  * Default is a dry-run (report only); --apply performs the move + rewrites.
13
13
  *
14
+ * Two modes, auto-detected from --from:
15
+ * • page mode — --from resolves to a .md page (the original behavior below).
16
+ * • directory mode — --from is an existing directory: the whole subtree is
17
+ * relocated (renameSync, carrying non-.md assets) and inbound full-slug /
18
+ * dir-relative links are rewritten across the vault. See runDirectory.
19
+ *
14
20
  * Two design invariants:
15
21
  *
16
22
  * 1. Resolution, not string-match. A link `[[foo]]` (bare basename) can be
@@ -36,9 +42,12 @@ import {
36
42
  rmSync,
37
43
  mkdirSync,
38
44
  readdirSync,
45
+ renameSync,
39
46
  statSync,
47
+ lstatSync,
48
+ realpathSync,
40
49
  } from 'fs';
41
- import { join, relative, extname, basename, dirname, normalize, isAbsolute } from 'path';
50
+ import { join, relative, extname, basename, dirname, normalize, isAbsolute, sep } from 'path';
42
51
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
43
52
  import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
44
53
 
@@ -64,8 +73,12 @@ function collectPages(dir, root, pages = [], ignorePatterns = []) {
64
73
  for (const entry of readdirSync(dir)) {
65
74
  const full = join(dir, entry);
66
75
  if (isIgnored(full, root, ignorePatterns)) continue;
67
- const st = statSync(full);
68
- if (st.isDirectory()) {
76
+ // lstat (never follow): a symlinked dir or file is skipped entirely so the
77
+ // whole-vault scan can never traverse out of the vault via a symlink.
78
+ const st = lstatSync(full);
79
+ if (st.isSymbolicLink()) {
80
+ continue;
81
+ } else if (st.isDirectory()) {
69
82
  collectPages(full, root, pages, ignorePatterns);
70
83
  } else if (extname(entry) === '.md' && !entry.startsWith('.')) {
71
84
  const rel = relative(root, full).replace(/\\/g, '/');
@@ -75,19 +88,34 @@ function collectPages(dir, root, pages = [], ignorePatterns = []) {
75
88
  return pages;
76
89
  }
77
90
 
78
- // ── preserved (append-only / immutable) link-source paths ──────────────────────
79
- // These are skipped as link SOURCES: their content is a frozen record. Rewriting
80
- // a [[old]] reference inside a past journal/session-log/weekly/archive/postmortem
81
- // snapshot (or root log.md) would falsify that moment. sources/* is immutable
82
- // captured material. decisions/ and other-project handoffs are NOT preserved here
83
- // (they were kept in the read-side triage for renumber/ownership reasons, not
84
- // because they are time records a forward rename should update their live
85
- // cross-references). Matches a path SEGMENT so `pages/journal/x.md` and
86
- // `projects/p/session-log/2026-06.md` both qualify.
87
- function isPreservedSource(rel) {
91
+ // ── preservation class of a link-source path ───────────────────────────────────
92
+ // Two distinct reasons a file is normally skipped as a link SOURCE, kept separate
93
+ // because directory mode treats them differently (codex design BLOCKER):
94
+ //
95
+ // 'timerecord' append-only snapshots (journal / session-log / weekly / archive
96
+ // / postmortems + root log.md). Rewriting a [[old]] inside a past entry would
97
+ // falsify that moment. BUT a directory move relocates the whole subtree, so a
98
+ // time-record INSIDE the moved subtree that links a moving sibling must update
99
+ // that intra-subtree path label (the page genuinely moved); see runDirectory.
100
+ //
101
+ // 'sources' — sources/* immutable CAPTURED material. Never rewritten, not even
102
+ // inside a moved subtree: a directory move must not claim ownership over the
103
+ // bytes of an external source we transcribed verbatim.
104
+ //
105
+ // Matches a path SEGMENT so `pages/journal/x.md` and `projects/p/session-log/y.md`
106
+ // both qualify. Returns null for ordinary live pages.
107
+ function preservationClass(rel) {
88
108
  const p = rel.replace(/\\/g, '/');
89
- if (p === 'log.md') return true; // root append-only log
90
- return /(^|\/)(journal|session-log|weekly|archive|postmortems|sources)(\/|$)/.test(p);
109
+ if (/(^|\/)sources(\/|$)/.test(p)) return 'sources';
110
+ if (p === 'log.md') return 'timerecord';
111
+ if (/(^|\/)(journal|session-log|weekly|archive|postmortems)(\/|$)/.test(p)) return 'timerecord';
112
+ return null;
113
+ }
114
+
115
+ // Single-page mode preserves BOTH classes as link sources (unchanged behavior): a
116
+ // rename elsewhere in the vault must never churn a frozen snapshot or a source.
117
+ function isPreservedSource(rel) {
118
+ return preservationClass(rel) !== null;
91
119
  }
92
120
 
93
121
  // ── slug-form index (resolution with collision detection) ──────────────────────
@@ -223,6 +251,408 @@ function fail(args, msg) {
223
251
  process.exit(1);
224
252
  }
225
253
 
254
+ // ── directory mode ─────────────────────────────────────────────────────────────
255
+ // A directory rename relocates a whole subtree (`projects/old/**` → `projects/new/**`)
256
+ // and rewrites inbound links across the vault. Key facts that shape the algorithm:
257
+ //
258
+ // • A directory move does NOT change basenames, only the path prefix. So bare
259
+ // `[[0052]]` links keep resolving to the relocated page automatically and need
260
+ // NO rewrite — only the FULL-slug and 1-seg DIR-RELATIVE forms encode the moved
261
+ // prefix and break. (Forms that drop ≥2 segments, e.g. `[[decisions/0052]]`, do
262
+ // not encode the renamed prefix either: lint cannot resolve them today and they
263
+ // survive a move unchanged — out of scope, matching lint's buildSlugMap.)
264
+ // • Every moved page is BOTH a target (it relocates) and a source (its links to
265
+ // moving siblings must update). The move is done with one renameSync of the
266
+ // whole directory — which also carries non-.md assets (logo svg/png, etc.) — and
267
+ // rewritten page bodies are written at their NEW paths afterward.
268
+
269
+ // Recursively detect any symlink under a directory (lstat, never follows). A moved
270
+ // subtree containing a symlink is refused: renameSync would move the link verbatim
271
+ // and statSync-based page collection could otherwise pull external pages in.
272
+ function subtreeHasSymlink(dir) {
273
+ for (const entry of readdirSync(dir)) {
274
+ const full = join(dir, entry);
275
+ let st;
276
+ try {
277
+ st = lstatSync(full);
278
+ } catch {
279
+ continue;
280
+ }
281
+ if (st.isSymbolicLink()) return true;
282
+ if (st.isDirectory() && subtreeHasSymlink(full)) return true;
283
+ }
284
+ return false;
285
+ }
286
+
287
+ // Classify a link target against the SET of moving pages. Only FULL / DIR-RELATIVE
288
+ // kinds are rewritten (bare survives a dir move untouched). Returns:
289
+ // { kind: null } → not (uniquely) a moving target
290
+ // { kind: null, ambiguous: true, target } → form shared by >1 page, a mover
291
+ // among them → report, never rewrite
292
+ // { kind, fromPage, toPage } → unambiguous moving target
293
+ function classifyMovedTarget(target, movedByRel, formIndex) {
294
+ const owners = formIndex.get(target);
295
+ if (!owners) return { kind: null };
296
+ const movingOwners = [...owners].filter((rel) => movedByRel.has(rel));
297
+ if (movingOwners.length === 0) return { kind: null }; // link unrelated to this move
298
+ if (owners.size > 1) {
299
+ // Shared form. Bare collisions are irrelevant (bare is never rewritten in dir
300
+ // mode); only a shared full/dirrel form is worth reporting. full slugs are
301
+ // unique, so this realistically only guards an exotic dir-relative clash.
302
+ const rel = movingOwners[0];
303
+ const { fromPage } = movedByRel.get(rel);
304
+ if (target === fromPage.slug || target === dirRelForm(fromPage.slug)) {
305
+ return { kind: null, ambiguous: true, target };
306
+ }
307
+ return { kind: null };
308
+ }
309
+ const rel = movingOwners[0];
310
+ const { fromPage, toPage } = movedByRel.get(rel);
311
+ let kind = null;
312
+ if (target === fromPage.slug) kind = 'full';
313
+ else if (target === dirRelForm(fromPage.slug)) kind = 'dirrel';
314
+ // bare (target === fromPage.bare) → intentionally null: unchanged by a dir move.
315
+ if (!kind) return { kind: null };
316
+ return { kind, fromPage, toPage };
317
+ }
318
+
319
+ // Rewrite inbound references to any moving page in `content`. aliasPreserve keeps
320
+ // the original rendered label via `[[new|old]]` for unaliased links — used for
321
+ // time-record files inside the moved subtree so a relocated snapshot still reads
322
+ // with its original path label while linking to the live page.
323
+ function rewriteContentDir(content, movedByRel, formIndex, aliasPreserve) {
324
+ const mask = maskNonWikilinkRegions(content);
325
+ const re = /\[\[([^\]]+?)\]\]/g;
326
+ const edits = [];
327
+ const rewrites = [];
328
+ const ambiguous = [];
329
+ let m;
330
+ while ((m = re.exec(mask)) !== null) {
331
+ const start = m.index;
332
+ const end = start + m[0].length;
333
+ const body = content.slice(start + 2, end - 2);
334
+ const parsed = splitLinkBody(body);
335
+ if (!parsed) continue;
336
+ const cls = classifyMovedTarget(parsed.target, movedByRel, formIndex);
337
+ const line = content.slice(0, start).split('\n').length;
338
+ if (cls.ambiguous) {
339
+ ambiguous.push({ link: m[0], line, target: parsed.target });
340
+ continue;
341
+ }
342
+ if (!cls.kind) continue;
343
+ const newTarget = newTargetFor(cls.kind, cls.toPage);
344
+ const replacement =
345
+ aliasPreserve && parsed.suffix === ''
346
+ ? `[[${newTarget}|${parsed.target}]]`
347
+ : `[[${newTarget}${parsed.suffix}]]`;
348
+ edits.push({ start, end, replacement });
349
+ rewrites.push({ from: m[0], to: replacement, line });
350
+ }
351
+ let out = content;
352
+ for (const e of edits.sort((a, b) => b.start - a.start)) {
353
+ out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
354
+ }
355
+ return { content: out, rewrites, ambiguous };
356
+ }
357
+
358
+ // Verify a path resolves — following any symlink ANCESTOR — to a location inside
359
+ // the real vault root. A lexical ../-check cannot catch this: `projects/link/new`
360
+ // where `projects/link` → /tmp/outside is lexically in-vault, yet renameSync would
361
+ // follow the symlink and write across the vault boundary. The destination may not
362
+ // exist, so the deepest existing prefix is resolved (its realpath is where a write
363
+ // would actually land). Fail-closed: returns false on any resolution error.
364
+ //
365
+ // The walk uses lstat (NOT existsSync) so a DANGLING symlink prefix is detected as
366
+ // present-but-unresolvable rather than skipped as absent — otherwise the walk would
367
+ // step past it to an in-vault parent and wrongly report containment, letting an
368
+ // external rewrite land before renameSync crashes on the dangling target.
369
+ function realContainedInVault(absPath, realRoot) {
370
+ let probe = absPath;
371
+ // Walk up to the deepest path component that exists as a link-or-real entry.
372
+ for (;;) {
373
+ let exists = true;
374
+ try {
375
+ lstatSync(probe);
376
+ } catch {
377
+ exists = false;
378
+ }
379
+ if (exists) break;
380
+ const parent = dirname(probe);
381
+ if (parent === probe) return false;
382
+ probe = parent;
383
+ }
384
+ let real;
385
+ try {
386
+ real = realpathSync(probe); // follows links; throws on a dangling symlink
387
+ } catch {
388
+ return false;
389
+ }
390
+ return real === realRoot || real.startsWith(realRoot + sep);
391
+ }
392
+
393
+ // The destination's deepest existing ancestor must be a directory. Otherwise
394
+ // mkdirSync(dirname, {recursive}) fails with ENOTDIR — but only AFTER the inbound
395
+ // rewrites were already written, leaving a partial mutation on a move that can
396
+ // never complete (e.g. `--to=projects/file/sub` where `projects/file` is a regular
397
+ // file). Refuse up front so a failed move never churns the vault. Runs after the
398
+ // realpath-containment check, so a symlink-to-dir ancestor is already in-vault.
399
+ function destinationHostable(absPath) {
400
+ let probe = dirname(absPath);
401
+ for (;;) {
402
+ let st;
403
+ try {
404
+ st = statSync(probe);
405
+ } catch {
406
+ const parent = dirname(probe);
407
+ if (parent === probe) return false;
408
+ probe = parent;
409
+ continue;
410
+ }
411
+ return st.isDirectory();
412
+ }
413
+ }
414
+
415
+ function runDirectory(args, fromDirRel, ignorePatterns) {
416
+ const fromAbs = join(args.hypoDir, fromDirRel);
417
+ if (lstatSync(fromAbs).isSymbolicLink()) {
418
+ fail(args, `--from '${args.from}' is a symlink — refusing to rename a linked directory`);
419
+ }
420
+ // Real-root containment: reject a --from whose realpath (after following any
421
+ // symlink ancestor) lands outside the vault — else the move would drag an
422
+ // outside-the-vault directory in.
423
+ let realRoot;
424
+ try {
425
+ realRoot = realpathSync(args.hypoDir);
426
+ } catch {
427
+ fail(args, `wiki root cannot be resolved: ${args.hypoDir}`);
428
+ }
429
+ if (!realContainedInVault(fromAbs, realRoot)) {
430
+ fail(args, `--from '${args.from}' resolves outside the wiki root (symlink escape)`);
431
+ }
432
+
433
+ // Destination: normalize + keep inside the vault (mirrors page mode's --to guard).
434
+ const toNorm = args.to.replace(/\\/g, '/').replace(/\.md$/, '').replace(/\/+$/, '');
435
+ const toDirRel = normalize(toNorm).replace(/\\/g, '/');
436
+ if (toDirRel === '..' || toDirRel.startsWith('../') || isAbsolute(toDirRel) || toDirRel === '.') {
437
+ fail(args, `--to escapes the wiki root: ${args.to}`);
438
+ }
439
+ if (toDirRel === fromDirRel) {
440
+ fail(args, `--from and --to are the same directory (${toDirRel}) — nothing to rename`);
441
+ }
442
+ // Same top-level segment only. A move that changes the leading scan-dir segment
443
+ // (e.g. journal/x → pages/x) would change the targetability class and the
444
+ // dir-relative form semantics; that is out of scope for this increment.
445
+ const fromTop = fromDirRel.split('/')[0];
446
+ const toTop = toDirRel.split('/')[0];
447
+ if (fromTop !== toTop) {
448
+ fail(
449
+ args,
450
+ `--to '${toDirRel}' changes the top-level area ('${fromTop}' → '${toTop}'). ` +
451
+ `Cross-area directory moves are not supported (they change link resolution).`,
452
+ );
453
+ }
454
+ // No nesting either way: renaming a dir into its own subtree (or vice versa) is
455
+ // undefined.
456
+ if (toDirRel === fromDirRel || toDirRel.startsWith(`${fromDirRel}/`)) {
457
+ fail(args, `--to '${toDirRel}' is nested inside --from '${fromDirRel}'`);
458
+ }
459
+ if (fromDirRel.startsWith(`${toDirRel}/`)) {
460
+ fail(args, `--from '${fromDirRel}' is nested inside --to '${toDirRel}'`);
461
+ }
462
+ // Real-root containment for the destination: a symlinked --to ancestor (e.g.
463
+ // `projects/link` → /tmp/outside) is lexically in-vault but renameSync would
464
+ // follow it and write outside the vault. Resolve the deepest existing prefix.
465
+ const toAbs = join(args.hypoDir, toDirRel);
466
+ if (!realContainedInVault(toAbs, realRoot)) {
467
+ fail(args, `--to '${args.to}' resolves outside the wiki root (symlink escape)`);
468
+ }
469
+ if (!destinationHostable(toAbs)) {
470
+ fail(args, `--to '${args.to}' has a non-directory ancestor — destination cannot be created`);
471
+ }
472
+ if (subtreeHasSymlink(fromAbs)) {
473
+ fail(args, `--from subtree contains a symlink — refusing (move could escape the vault)`);
474
+ }
475
+
476
+ const pages = collectPages(args.hypoDir, args.hypoDir, [], ignorePatterns);
477
+ const formIndex = buildFormIndex(pages);
478
+
479
+ // The moving pages: every collected page whose rel is under the from-directory.
480
+ const prefix = `${fromDirRel}/`;
481
+ const movedByRel = new Map(); // rel → { fromPage, toPage }
482
+ for (const p of pages) {
483
+ if (!p.rel.startsWith(prefix)) continue;
484
+ const toRel = `${toDirRel}/${p.rel.slice(prefix.length)}`;
485
+ const toPage = {
486
+ rel: toRel,
487
+ slug: toRel.replace(/\.md$/, ''),
488
+ bare: basename(toRel, '.md'),
489
+ path: join(args.hypoDir, toRel),
490
+ };
491
+ movedByRel.set(p.rel, { fromPage: p, toPage });
492
+ }
493
+ if (movedByRel.size === 0) {
494
+ fail(args, `--from '${fromDirRel}' contains no wiki pages to rename`);
495
+ }
496
+
497
+ // Renumber / merge report: a destination that already exists means this is not a
498
+ // clean 1:1 move. Rather than a terse hard-fail, report exactly which destination
499
+ // paths collide and refuse --apply, leaving the merge/renumber for manual handling.
500
+ if (existsSync(toAbs)) {
501
+ const collisions = [];
502
+ for (const { toPage } of movedByRel.values()) {
503
+ if (existsSync(toPage.path)) collisions.push(toPage.rel);
504
+ }
505
+ const report = {
506
+ ok: false,
507
+ error: `--to '${toDirRel}' already exists — directory rename requires a fresh destination`,
508
+ reason: 'renumber-or-merge',
509
+ from: fromDirRel,
510
+ to: toDirRel,
511
+ destination_collisions: collisions,
512
+ };
513
+ if (args.json) console.log(JSON.stringify(report, null, 2));
514
+ else {
515
+ console.error(`✗ ${report.error}`);
516
+ console.error(
517
+ ` This is a renumber/merge: ${collisions.length} destination page(s) already exist.`,
518
+ );
519
+ for (const c of collisions) console.error(` · ${c}`);
520
+ console.error(` Resolve manually (merge or renumber), then retry into a fresh directory.`);
521
+ }
522
+ process.exit(1);
523
+ }
524
+
525
+ // Post-move form index: validate that no moved page's GENERATED full/dir-relative
526
+ // form collides with a different page in the post-move world. (Bare forms and
527
+ // full slugs cannot collide here — the destination dir is fresh — so this guards
528
+ // the exotic dir-relative clash, per the codex design BLOCKER.)
529
+ const postIndex = new Map(); // form → Set<post-rel>
530
+ const addPost = (form, rel) => {
531
+ if (!form) return;
532
+ if (!postIndex.has(form)) postIndex.set(form, new Set());
533
+ postIndex.get(form).add(rel);
534
+ };
535
+ for (const p of pages) {
536
+ const mv = movedByRel.get(p.rel);
537
+ const target = mv ? mv.toPage : p;
538
+ addPost(target.slug, target.rel);
539
+ if (/(^|\/)sources(\/|$)/.test(target.rel)) continue;
540
+ addPost(target.bare, target.rel);
541
+ addPost(dirRelForm(target.slug), target.rel);
542
+ }
543
+ const formCollisions = [];
544
+ for (const { toPage } of movedByRel.values()) {
545
+ for (const form of [toPage.slug, dirRelForm(toPage.slug)]) {
546
+ if (!form) continue;
547
+ const owners = postIndex.get(form);
548
+ if (owners && [...owners].some((rel) => rel !== toPage.rel)) {
549
+ formCollisions.push({ form, page: toPage.rel });
550
+ }
551
+ }
552
+ }
553
+ if (formCollisions.length > 0) {
554
+ const report = {
555
+ ok: false,
556
+ error: 'directory rename would create ambiguous link forms',
557
+ reason: 'form-collision',
558
+ from: fromDirRel,
559
+ to: toDirRel,
560
+ form_collisions: formCollisions,
561
+ };
562
+ if (args.json) console.log(JSON.stringify(report, null, 2));
563
+ else {
564
+ console.error(`✗ ${report.error}`);
565
+ for (const fc of formCollisions) console.error(` · '${fc.form}' (${fc.page})`);
566
+ }
567
+ process.exit(1);
568
+ }
569
+
570
+ // Rewrite inbound references across the vault.
571
+ const externalWrites = new Map(); // abs path → content (non-moved source files)
572
+ const movedBodies = new Map(); // new rel → content (moved page bodies, written post-move)
573
+ const fileResults = [];
574
+ const ambiguities = [];
575
+ let totalRewrites = 0;
576
+ for (const p of pages) {
577
+ const cls = preservationClass(p.rel);
578
+ const inSubtree = movedByRel.has(p.rel);
579
+ // Eligibility:
580
+ // sources/* → never rewritten (immutable), even inside the subtree.
581
+ // time-record outside → frozen (a snapshot elsewhere must not change).
582
+ // time-record inside → rewrite intra-subtree links, preserving the rendered
583
+ // label via [[new|old]] (the page genuinely relocated).
584
+ // ordinary page → rewrite normally.
585
+ if (cls === 'sources') continue;
586
+ if (cls === 'timerecord' && !inSubtree) continue;
587
+ const aliasPreserve = cls === 'timerecord' && inSubtree;
588
+ let raw;
589
+ try {
590
+ raw = readFileSync(p.path, 'utf-8');
591
+ } catch {
592
+ continue;
593
+ }
594
+ const { content, rewrites, ambiguous } = rewriteContentDir(
595
+ raw,
596
+ movedByRel,
597
+ formIndex,
598
+ aliasPreserve,
599
+ );
600
+ if (rewrites.length > 0) {
601
+ const landRel = inSubtree ? movedByRel.get(p.rel).toPage.rel : p.rel;
602
+ fileResults.push({ file: landRel, rewrites });
603
+ totalRewrites += rewrites.length;
604
+ if (inSubtree) movedBodies.set(movedByRel.get(p.rel).toPage.rel, content);
605
+ else externalWrites.set(p.path, content);
606
+ }
607
+ if (ambiguous.length > 0) ambiguities.push({ file: p.rel, ambiguous });
608
+ }
609
+
610
+ // Apply: external rewrites in place → renameSync the whole subtree (carries
611
+ // non-.md assets) → write rewritten moved bodies at their new paths.
612
+ let moved = false;
613
+ if (args.apply) {
614
+ for (const [path, content] of externalWrites) writeFileSync(path, content);
615
+ mkdirSync(dirname(toAbs), { recursive: true });
616
+ renameSync(fromAbs, toAbs);
617
+ for (const [newRel, content] of movedBodies) {
618
+ writeFileSync(join(args.hypoDir, newRel), content);
619
+ }
620
+ moved = true;
621
+ }
622
+
623
+ const result = {
624
+ ok: true,
625
+ applied: args.apply,
626
+ mode: 'directory',
627
+ from: fromDirRel,
628
+ to: toDirRel,
629
+ moved,
630
+ pages_moved: movedByRel.size,
631
+ files_rewritten: fileResults.length,
632
+ links_rewritten: totalRewrites,
633
+ rewrites: fileResults,
634
+ ambiguous: ambiguities,
635
+ };
636
+ if (args.json) {
637
+ console.log(JSON.stringify(result, null, 2));
638
+ } else {
639
+ const mode = args.apply ? 'Renamed directory' : 'Dry-run (no changes written)';
640
+ console.log(`${mode}: ${fromDirRel}/ → ${toDirRel}/ (${movedByRel.size} page(s))`);
641
+ console.log(` ${totalRewrites} inbound link(s) across ${fileResults.length} file(s).`);
642
+ for (const f of fileResults) {
643
+ console.log(` · ${f.file}: ${f.rewrites.map((r) => `${r.from}→${r.to}`).join(', ')}`);
644
+ }
645
+ if (ambiguities.length > 0) {
646
+ console.log('\n ⚠ ambiguous links NOT rewritten (form shared by >1 page):');
647
+ for (const a of ambiguities) {
648
+ for (const x of a.ambiguous) console.log(` · ${a.file}:${x.line} ${x.link}`);
649
+ }
650
+ }
651
+ if (!args.apply) console.log('\n Re-run with --apply to write the move + rewrites.');
652
+ }
653
+ process.exit(0);
654
+ }
655
+
226
656
  // ── main ────────────────────────────────────────────────────────────────────
227
657
 
228
658
  function run(args) {
@@ -230,6 +660,22 @@ function run(args) {
230
660
  fail(args, '--from=<slug|rel> and --to=<slug|rel> are required');
231
661
  }
232
662
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
663
+
664
+ // Directory mode: --from points at an existing directory → relocate the subtree.
665
+ const fromNorm = args.from.replace(/\\/g, '/').replace(/\.md$/, '').replace(/\/+$/, '');
666
+ const fromAbs = join(args.hypoDir, fromNorm);
667
+ const fromIsDir = existsSync(fromAbs) && statSync(fromAbs).isDirectory();
668
+ if (fromIsDir) {
669
+ // A literal `foo/` directory AND a `foo.md` page both present → ambiguous intent.
670
+ if (existsSync(`${fromAbs}.md`)) {
671
+ fail(
672
+ args,
673
+ `--from '${args.from}' is ambiguous: both a directory and a page exist. Pass a more specific path.`,
674
+ );
675
+ }
676
+ return runDirectory(args, fromNorm, ignorePatterns);
677
+ }
678
+
233
679
  const pages = collectPages(args.hypoDir, args.hypoDir, [], ignorePatterns);
234
680
  const formIndex = buildFormIndex(pages);
235
681
 
@@ -255,6 +701,23 @@ function run(args) {
255
701
  const toSlug = toRel.replace(/\.md$/, '');
256
702
  const toPath = join(args.hypoDir, toRel);
257
703
 
704
+ // Real-root containment: a symlinked --to ancestor (e.g. `projects/link` →
705
+ // /tmp/outside) is lexically in-vault, yet writeFileSync(toPath) would follow it
706
+ // and write the moved page outside the wiki. Resolve the deepest existing prefix
707
+ // (lstat-based, fail-closed on a dangling link) and require it to stay in-vault.
708
+ let realRoot;
709
+ try {
710
+ realRoot = realpathSync(args.hypoDir);
711
+ } catch {
712
+ fail(args, `wiki root cannot be resolved: ${args.hypoDir}`);
713
+ }
714
+ if (!realContainedInVault(toPath, realRoot)) {
715
+ fail(args, `--to '${args.to}' resolves outside the wiki root (symlink escape)`);
716
+ }
717
+ if (!destinationHostable(toPath)) {
718
+ fail(args, `--to '${args.to}' has a non-directory ancestor — destination cannot be created`);
719
+ }
720
+
258
721
  // Guard: never overwrite an existing destination (an ADR-renumber / merge would
259
722
  // land here — that is a report-only case, not a blind move).
260
723
  if (existsSync(toPath) && toRel !== fromPage.rel) {
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Close a session (steps 1~6) and, on request, consolidate scattered wiki knowledge into stable pages (steps 7~11)
2
+ description: Close a session by capturing what happened into the wiki (steps 1-6), and on request consolidate scattered notes into stable pages (steps 7-11). Use when the user explicitly signals session end, asks to save or crystallize the session, or before a /compact. Task completion alone is not a close signal.
3
3
  ---
4
4
 
5
5
  You are running `/hypo:crystallize`. The command serves two modes (spec §5.2.7 / §8.3):
@@ -7,7 +7,7 @@ You are running `/hypo:crystallize`. The command serves two modes (spec §5.2.7
7
7
  1. **Session close (steps 1~6)** — gate the 5 mandatory memory files plus open-questions (conditional) so `/compact` can pass.
8
8
  2. **Synthesis (steps 7~11)** — surface tag clusters, orphan pages, and drafts that are ready to consolidate.
9
9
 
10
- When invoked at the end of a session (or with phrases like "세션 종료", "wrap up"), run the session-close checklist first. The synthesis scan only runs after close is confirmed and the user agrees.
10
+ When invoked to close a session via an explicit close signal ("세션 종료", "wrap up"), an accepted proactive-offer [세션 마무리], or `/compact` — run the session-close checklist first. Task completion alone does not put you in close mode. The synthesis scan only runs after close is confirmed and the user agrees.
11
11
 
12
12
  ## What this does
13
13
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Generate a wikilink dependency graph from wiki pages
2
+ description: Generate a wikilink dependency graph from wiki pages (json, mermaid, or dot). Use when the user asks to visualize wiki structure, find orphan or hub pages, or map how notes link together.
3
3
  ---
4
4
 
5
5
  You are running `/hypo:graph`. Build a wikilink dependency graph from all pages under `pages/` and `projects/` and output it in the requested format.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Add a source document to the wiki and synthesize a source-summary page
2
+ description: Add an external source (URL, doc, article, command output) to the wiki and synthesize a citable summary page. Use when the user shares a source to capture, asks to ingest something, or wants reliable external knowledge saved for reuse.
3
3
  ---
4
4
 
5
5
  You are running `/hypo:ingest`. Add a new source document to `sources/` and create (or update) its corresponding `source-summary` page under `pages/`.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Lint wiki pages for frontmatter and broken wikilinks
2
+ description: Lint wiki pages for frontmatter errors and broken wikilinks. Use when the user asks to check or validate the wiki health, before a commit, or after bulk edits or renames.
3
3
  ---
4
4
 
5
5
  You are running `/hypo:lint`. Validate all wiki pages for frontmatter correctness and broken `[[wikilink]]` references.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Search wiki pages by keyword and retrieve relevant knowledge
2
+ description: Search the wiki by keyword and synthesize an answer from the relevant pages. Use when the user asks what the wiki knows about a topic, wants to recall a past decision, or needs prior context before starting work.
3
3
  ---
4
4
 
5
5
  You are running `/hypo:query`. Full-text search across all wiki pages and projects, then synthesize an answer from the matching pages.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Check wiki pages for stale or unverified knowledge and prompt review
2
+ description: Surface wiki knowledge that is stale or past its verify-by date and prompt review. Use when the user asks what needs re-checking, wants to audit knowledge freshness, or is reviewing the wiki for rot.
3
3
  ---
4
4
 
5
5
  You are running `/hypo:verify`. Check all wiki pages for `verify_by` and `verify_by_date` fields, surface overdue pages, and guide a review pass.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.3.2"
4
+ version: "1.3.3"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7
 
@@ -74,6 +74,8 @@ Trigger: explicit close mention, `/compact` request, or context limit approachin
74
74
 
75
75
  Do **not** treat these as completion: plan approvals, clarification answers, permission grants, progress updates, partial findings, or mid-task checkpoints. A "좋아요" / "감사합니다" / "ok" following any of those is *not* a close signal — only offer after a genuine final completion/verification report.
76
76
 
77
+ **Proactive offer means offer, not close.** In this path (task done, no close signal) do not run the close checklist, write the `session-closed` marker, or declare the session ended on your own. Fire `AskUserQuestion` and proceed only after the user picks [세션 마무리]. Task completion is not a close trigger; closing without asking violates this procedure. This scopes the proactive path only: a real close signal, `/compact`, or context limit still closes via the triggers above.
78
+
77
79
  Ask: *"이 작업이 마무리되었나요? 세션을 정리(crystallize)할까요?"* with options **[세션 마무리 / 계속 작업]**. On **세션 마무리** → run the session close checklist (or invoke `/hypo:crystallize`). On **계속 작업** → continue and do not re-ask until the next task completes. Ask at most once per completed task; never loop. (Decline simply ends the turn — Layer 3's Stop-chain only blocks on an explicit close signal, so no repeated prompts occur.)
78
80
 
79
81
  1. Update `projects/<name>/session-state.md` (next tasks, overwrite)
@@ -42,6 +42,7 @@ Quick reference for all `/hypo:*` commands.
42
42
  | `/hypo:verify` | Review overdue verify_by deadlines |
43
43
  | `/hypo:lint` | Validate frontmatter and `[[wikilinks]]` |
44
44
  | `/hypo:graph` | Generate link graph (json / mermaid / dot) |
45
+ | `/hypo:rename` | Rename a page or directory, rewriting inbound `[[wikilinks]]` |
45
46
 
46
47
  ---
47
48