hypomnema 1.3.1 → 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.
Files changed (65) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.ko.md +11 -10
  4. package/README.md +11 -10
  5. package/commands/audit.md +1 -1
  6. package/commands/crystallize.md +11 -11
  7. package/commands/doctor.md +1 -1
  8. package/commands/feedback.md +2 -2
  9. package/commands/graph.md +1 -1
  10. package/commands/ingest.md +1 -1
  11. package/commands/init.md +1 -1
  12. package/commands/lint.md +1 -1
  13. package/commands/query.md +1 -1
  14. package/commands/rename.md +79 -0
  15. package/commands/resume.md +2 -2
  16. package/commands/stats.md +1 -1
  17. package/commands/uninstall.md +1 -1
  18. package/commands/upgrade.md +1 -1
  19. package/commands/verify.md +1 -1
  20. package/docs/ARCHITECTURE.md +3 -3
  21. package/docs/CONTRIBUTING.md +2 -2
  22. package/hooks/hypo-auto-commit.mjs +8 -24
  23. package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
  24. package/hooks/hypo-compact-guard.mjs +6 -3
  25. package/hooks/hypo-cwd-change.mjs +1 -1
  26. package/hooks/hypo-first-prompt.mjs +2 -2
  27. package/hooks/hypo-hot-rebuild.mjs +14 -1
  28. package/hooks/hypo-personal-check.mjs +91 -179
  29. package/hooks/hypo-pre-commit.mjs +1 -1
  30. package/hooks/hypo-session-end.mjs +1 -1
  31. package/hooks/hypo-session-start.mjs +7 -7
  32. package/hooks/hypo-shared.mjs +1082 -89
  33. package/hooks/hypo-web-fetch-ingest.mjs +2 -2
  34. package/hooks/version-check-fetch.mjs +2 -8
  35. package/hooks/version-check.mjs +18 -0
  36. package/package.json +3 -2
  37. package/scripts/check-tracker-ids.mjs +329 -0
  38. package/scripts/crystallize.mjs +322 -110
  39. package/scripts/doctor.mjs +6 -9
  40. package/scripts/feedback-sync.mjs +1 -1
  41. package/scripts/init.mjs +1 -1
  42. package/scripts/install-git-hooks.mjs +75 -40
  43. package/scripts/lib/check-tracker-ids.mjs +140 -0
  44. package/scripts/lib/design-history-stale.mjs +59 -14
  45. package/scripts/lib/extensions.mjs +4 -4
  46. package/scripts/lib/fix-manifest.mjs +2 -2
  47. package/scripts/lib/fix-status-verify.mjs +5 -4
  48. package/scripts/lib/plugin-detect.mjs +15 -6
  49. package/scripts/lib/project-create.mjs +1 -1
  50. package/scripts/lint.mjs +63 -8
  51. package/scripts/rename.mjs +836 -0
  52. package/scripts/resume.mjs +75 -19
  53. package/scripts/uninstall.mjs +1 -1
  54. package/scripts/upgrade.mjs +26 -25
  55. package/skills/crystallize/SKILL.md +12 -9
  56. package/skills/graph/SKILL.md +1 -1
  57. package/skills/ingest/SKILL.md +1 -1
  58. package/skills/lint/SKILL.md +1 -1
  59. package/skills/query/SKILL.md +1 -1
  60. package/skills/verify/SKILL.md +1 -1
  61. package/templates/SCHEMA.md +2 -2
  62. package/templates/hypo-config.md +2 -2
  63. package/templates/hypo-guide.md +22 -1
  64. package/templates/hypo-help.md +1 -0
  65. package/templates/projects/_template/index.md +1 -1
@@ -0,0 +1,836 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * rename.mjs — rename a wiki page and rewrite inbound wikilinks.
4
+ *
5
+ * A bare `git mv` leaves every `[[old]]` / `[[old|alias]]` / `[[old#anchor]]` /
6
+ * `[[dir/old]]` pointing at a now-missing target — broken links accumulate on
7
+ * every rename. This helper moves the page AND content-aware rewrites every
8
+ * inbound reference across the vault so a rename never breaks a link.
9
+ *
10
+ * node rename.mjs --hypo-dir=<dir> --from=<slug|rel> --to=<slug|rel> [--apply] [--json]
11
+ *
12
+ * Default is a dry-run (report only); --apply performs the move + rewrites.
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
+ *
20
+ * Two design invariants:
21
+ *
22
+ * 1. Resolution, not string-match. A link `[[foo]]` (bare basename) can be
23
+ * shared by two pages; a blind text replace would break the wrong one. Each
24
+ * link target is resolved with the SAME precedence lint uses (full noExt →
25
+ * bare basename → dir-relative drop). A reference is rewritten only when it
26
+ * resolves UNAMBIGUOUSLY to the from-page. An ambiguous bare form (the slug
27
+ * maps to >1 file) is reported, never auto-rewritten — which also makes the
28
+ * ADR-renumber guard free: a `--to` that is not a unique destination is
29
+ * rejected.
30
+ *
31
+ * 2. Preserve append-only time records. journal / session-log / weekly / archive
32
+ * / postmortems (and root log.md) are frozen snapshots — rewriting a `[[old]]`
33
+ * inside a past entry would falsify that moment's record. They are skipped as
34
+ * link SOURCES. sources/* is likewise immutable. This tool's value is "update
35
+ * live links at rename time", not "retroactively churn old snapshots".
36
+ */
37
+
38
+ import {
39
+ existsSync,
40
+ readFileSync,
41
+ writeFileSync,
42
+ rmSync,
43
+ mkdirSync,
44
+ readdirSync,
45
+ renameSync,
46
+ statSync,
47
+ lstatSync,
48
+ realpathSync,
49
+ } from 'fs';
50
+ import { join, relative, extname, basename, dirname, normalize, isAbsolute, sep } from 'path';
51
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
52
+ import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
53
+
54
+ // ── arg parsing ───────────────────────────────────────────────────────────────
55
+
56
+ function parseArgs(argv) {
57
+ const args = { hypoDir: null, from: null, to: null, apply: false, json: false };
58
+ for (const arg of argv.slice(2)) {
59
+ if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
60
+ else if (arg.startsWith('--from=')) args.from = arg.slice(7);
61
+ else if (arg.startsWith('--to=')) args.to = arg.slice(5);
62
+ else if (arg === '--apply') args.apply = true;
63
+ else if (arg === '--json') args.json = true;
64
+ }
65
+ if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
66
+ return args;
67
+ }
68
+
69
+ // ── page collection (mirrors graph.mjs / crystallize.mjs collectPages) ──────────
70
+
71
+ function collectPages(dir, root, pages = [], ignorePatterns = []) {
72
+ if (!existsSync(dir)) return pages;
73
+ for (const entry of readdirSync(dir)) {
74
+ const full = join(dir, entry);
75
+ if (isIgnored(full, root, ignorePatterns)) continue;
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()) {
82
+ collectPages(full, root, pages, ignorePatterns);
83
+ } else if (extname(entry) === '.md' && !entry.startsWith('.')) {
84
+ const rel = relative(root, full).replace(/\\/g, '/');
85
+ pages.push({ path: full, rel, slug: rel.replace(/\.md$/, ''), bare: basename(full, '.md') });
86
+ }
87
+ }
88
+ return pages;
89
+ }
90
+
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) {
108
+ const p = rel.replace(/\\/g, '/');
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;
119
+ }
120
+
121
+ // ── slug-form index (resolution with collision detection) ──────────────────────
122
+ // Unlike lint's buildSlugMap (a Set that silently dedups collisions), rename
123
+ // needs to KNOW when a form is shared, so it maps each form → the set of page
124
+ // rels that expose it. precedence forms per page: full noExt slug, bare
125
+ // basename, dir-relative (drop the leading scan-dir segment).
126
+ function dirRelForm(slug) {
127
+ const slash = slug.indexOf('/');
128
+ return slash === -1 ? null : slug.slice(slash + 1);
129
+ }
130
+
131
+ function buildFormIndex(pages) {
132
+ const index = new Map(); // form → Set<rel>
133
+ const add = (form, rel) => {
134
+ if (!form) return;
135
+ if (!index.has(form)) index.set(form, new Set());
136
+ index.get(form).add(rel);
137
+ };
138
+ for (const p of pages) {
139
+ add(p.slug, p.rel);
140
+ // sources/* are full-slug-only link targets, exactly as lint's
141
+ // collectLinkTargets treats them: a bare `[[name]]` must NOT resolve to a
142
+ // source file. Adding their bare/dir-relative aliases here would make a real
143
+ // page's bare link look ambiguous and skip a legitimate rewrite.
144
+ if (/(^|\/)sources(\/|$)/.test(p.rel)) continue;
145
+ add(p.bare, p.rel);
146
+ add(dirRelForm(p.slug), p.rel);
147
+ }
148
+ return index;
149
+ }
150
+
151
+ // Classify a link target against the from-page. Returns the form KIND when the
152
+ // target points at from-page, plus whether that form is ambiguous (shared with
153
+ // another page → unsafe to auto-rewrite).
154
+ function classifyTarget(target, fromPage, formIndex) {
155
+ const owners = formIndex.get(target);
156
+ if (!owners || !owners.has(fromPage.rel)) return { kind: null, ambiguous: false };
157
+ const ambiguous = owners.size > 1;
158
+ let kind = null;
159
+ if (target === fromPage.slug) kind = 'full';
160
+ else if (target === dirRelForm(fromPage.slug)) kind = 'dirrel';
161
+ else if (target === fromPage.bare) kind = 'bare';
162
+ return { kind, ambiguous };
163
+ }
164
+
165
+ // The new target string for a matched form kind — same kind, new page.
166
+ function newTargetFor(kind, toPage) {
167
+ if (kind === 'full') return toPage.slug;
168
+ if (kind === 'dirrel') return dirRelForm(toPage.slug) ?? toPage.bare;
169
+ return toPage.bare; // bare
170
+ }
171
+
172
+ // ── wikilink masking (mirror lint.mjs stripNonWikilinkRegions) ──────────────────
173
+ // Blank out fenced code, inline code, and HTML comments WITHOUT changing length,
174
+ // so a [[ref]] match index in the mask aligns with the same index in the source.
175
+ // Rewriting then edits the source at those exact spans, never touching a link
176
+ // that only appears inside a code sample.
177
+ function maskNonWikilinkRegions(content) {
178
+ let out = content;
179
+ out = out.replace(/^[ \t]{0,3}```[\s\S]*?^[ \t]{0,3}```/gm, (m) => m.replace(/[^\n]/g, ' '));
180
+ out = out.replace(/^[ \t]{0,3}~~~[\s\S]*?^[ \t]{0,3}~~~/gm, (m) => m.replace(/[^\n]/g, ' '));
181
+ out = out.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
182
+ out = out.replace(/``[^`\n]*``/g, (m) => ' '.repeat(m.length));
183
+ out = out.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
184
+ return out;
185
+ }
186
+
187
+ // Parse the inside of a `[[ ... ]]` into { target, suffix } where suffix is the
188
+ // alias/anchor tail to preserve verbatim (including a table-escaped `\|`). The
189
+ // target capture stops before an optional `\` preceding the `|`/`#` delimiter,
190
+ // matching lint's extractor exactly.
191
+ function splitLinkBody(body) {
192
+ const m = body.match(/^([^|#\\]+?)(\\?[|#][\s\S]*)?$/);
193
+ if (!m) return null;
194
+ return { target: m[1].trim(), suffix: m[2] || '' };
195
+ }
196
+
197
+ // Rewrite every inbound reference to fromPage in `content`. Returns
198
+ // { content, rewrites, ambiguous } where rewrites/ambiguous list the links
199
+ // changed / skipped-as-ambiguous (with their 1-based line numbers).
200
+ function rewriteContent(content, fromPage, toPage, formIndex) {
201
+ const mask = maskNonWikilinkRegions(content);
202
+ const re = /\[\[([^\]]+?)\]\]/g;
203
+ const edits = []; // { start, end, replacement }
204
+ const rewrites = [];
205
+ const ambiguous = [];
206
+ let m;
207
+ while ((m = re.exec(mask)) !== null) {
208
+ const start = m.index;
209
+ const end = start + m[0].length;
210
+ const body = content.slice(start + 2, end - 2); // real body from source
211
+ const parsed = splitLinkBody(body);
212
+ if (!parsed) continue;
213
+ const { kind, ambiguous: amb } = classifyTarget(parsed.target, fromPage, formIndex);
214
+ if (!kind) continue; // does not resolve to from-page
215
+ const line = content.slice(0, start).split('\n').length;
216
+ if (amb) {
217
+ ambiguous.push({ link: m[0], line, target: parsed.target });
218
+ continue; // shared form — report, never auto-rewrite
219
+ }
220
+ const replacement = `[[${newTargetFor(kind, toPage)}${parsed.suffix}]]`;
221
+ edits.push({ start, end, replacement });
222
+ rewrites.push({ from: m[0], to: replacement, line });
223
+ }
224
+ // Apply edits back-to-front so earlier indices stay valid.
225
+ let out = content;
226
+ for (const e of edits.sort((a, b) => b.start - a.start)) {
227
+ out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
228
+ }
229
+ return { content: out, rewrites, ambiguous };
230
+ }
231
+
232
+ // ── resolve a --from / --to argument to a page rel + forms ──────────────────────
233
+ // Accepts a full rel ("pages/foo.md" / "pages/foo"), a noExt slug, or a bare
234
+ // basename when unambiguous. Returns { rel, slug, bare } or null.
235
+ function resolveArgToPage(arg, pages, formIndex) {
236
+ const norm = arg.replace(/\\/g, '/').replace(/\.md$/, '');
237
+ // exact full-slug match first
238
+ const direct = pages.find((p) => p.slug === norm);
239
+ if (direct) return direct;
240
+ const owners = formIndex.get(norm);
241
+ if (owners && owners.size === 1) {
242
+ const rel = [...owners][0];
243
+ return pages.find((p) => p.rel === rel) || null;
244
+ }
245
+ return null; // missing or ambiguous
246
+ }
247
+
248
+ function fail(args, msg) {
249
+ if (args.json) console.log(JSON.stringify({ ok: false, error: msg }, null, 2));
250
+ else console.error(`✗ ${msg}`);
251
+ process.exit(1);
252
+ }
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
+
656
+ // ── main ────────────────────────────────────────────────────────────────────
657
+
658
+ function run(args) {
659
+ if (!args.from || !args.to) {
660
+ fail(args, '--from=<slug|rel> and --to=<slug|rel> are required');
661
+ }
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
+
679
+ const pages = collectPages(args.hypoDir, args.hypoDir, [], ignorePatterns);
680
+ const formIndex = buildFormIndex(pages);
681
+
682
+ const fromPage = resolveArgToPage(args.from, pages, formIndex);
683
+ if (!fromPage) {
684
+ fail(args, `--from did not resolve to a unique existing page: ${args.from}`);
685
+ }
686
+
687
+ // Compute the destination rel. --to may be a full rel (move across dirs) or a
688
+ // bare new name (rename in place within from-page's directory).
689
+ const toNorm = args.to.replace(/\\/g, '/').replace(/\.md$/, '');
690
+ const toRelRaw = toNorm.includes('/')
691
+ ? `${toNorm}.md`
692
+ : `${dirname(fromPage.rel)}/${toNorm}.md`.replace(/^\.\//, '');
693
+ // Normalize away ./ and ../ segments, then require the result stays inside the
694
+ // vault: a `--to` like `../moved` must not move a page out of the wiki, and
695
+ // `pages/../pages/bar` must not become a non-canonical `[[pages/../pages/bar]]`
696
+ // link target.
697
+ const toRel = normalize(toRelRaw).replace(/\\/g, '/');
698
+ if (toRel === '..' || toRel.startsWith('../') || isAbsolute(toRel)) {
699
+ fail(args, `--to escapes the wiki root: ${args.to}`);
700
+ }
701
+ const toSlug = toRel.replace(/\.md$/, '');
702
+ const toPath = join(args.hypoDir, toRel);
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
+
721
+ // Guard: never overwrite an existing destination (an ADR-renumber / merge would
722
+ // land here — that is a report-only case, not a blind move).
723
+ if (existsSync(toPath) && toRel !== fromPage.rel) {
724
+ fail(
725
+ args,
726
+ `--to already exists: ${toRel}. Rename cannot overwrite a live page (renumber/merge is manual).`,
727
+ );
728
+ }
729
+ if (toRel === fromPage.rel) {
730
+ fail(args, `--from and --to resolve to the same page (${toRel}) — nothing to rename`);
731
+ }
732
+
733
+ const toPage = {
734
+ rel: toRel,
735
+ slug: toSlug,
736
+ bare: basename(toRel, '.md'),
737
+ path: toPath,
738
+ };
739
+
740
+ // Destination must be a UNIQUE link destination: existsSync(toPath) above only
741
+ // catches a same-path clobber, not a cross-directory basename collision
742
+ // (pages/bar.md vs projects/bar.md). If the new bare or dir-relative form
743
+ // already resolves to a DIFFERENT existing page, the rewritten `[[new]]` links
744
+ // would be ambiguous — the same contract that bars rewriting an ambiguous
745
+ // source link. Refuse rather than emit ambiguous links.
746
+ for (const form of [toPage.bare, dirRelForm(toPage.slug)]) {
747
+ if (!form) continue;
748
+ const owners = formIndex.get(form);
749
+ if (owners && [...owners].some((rel) => rel !== fromPage.rel)) {
750
+ fail(
751
+ args,
752
+ `--to '${args.to}' collides with an existing page on form '${form}' — rewritten links would be ambiguous. Pick a unique name.`,
753
+ );
754
+ }
755
+ }
756
+
757
+ // Rewrite inbound references across every NON-preserved page (skip the moved
758
+ // page itself — self-references are rewritten on its own content separately).
759
+ const fileResults = [];
760
+ let totalRewrites = 0;
761
+ const ambiguities = [];
762
+ for (const p of pages) {
763
+ if (isPreservedSource(p.rel)) continue;
764
+ let raw;
765
+ try {
766
+ raw = readFileSync(p.path, 'utf-8');
767
+ } catch {
768
+ continue;
769
+ }
770
+ const { content, rewrites, ambiguous } = rewriteContent(raw, fromPage, toPage, formIndex);
771
+ if (rewrites.length > 0) {
772
+ fileResults.push({ file: p.rel, rewrites });
773
+ totalRewrites += rewrites.length;
774
+ if (args.apply && content !== raw) {
775
+ // The from-page is about to move; write its rewritten body to the NEW
776
+ // path below, not the old one.
777
+ if (p.rel === fromPage.rel) {
778
+ fromPage._rewritten = content;
779
+ } else {
780
+ writeFileSync(p.path, content);
781
+ }
782
+ } else if (p.rel === fromPage.rel) {
783
+ fromPage._rewritten = content;
784
+ }
785
+ }
786
+ if (ambiguous.length > 0) {
787
+ ambiguities.push({ file: p.rel, ambiguous });
788
+ }
789
+ }
790
+
791
+ // Move the file (--apply only): write the (possibly self-rewritten) body at the
792
+ // new path, then drop the old one. Done as write-then-remove rather than a raw
793
+ // rename so the carried-over self-reference rewrites are preserved.
794
+ let moved = false;
795
+ if (args.apply) {
796
+ mkdirSync(dirname(toPath), { recursive: true });
797
+ const body = fromPage._rewritten ?? readFileSync(fromPage.path, 'utf-8');
798
+ writeFileSync(toPath, body);
799
+ if (toPath !== fromPage.path) rmSync(fromPage.path, { force: true });
800
+ moved = true;
801
+ }
802
+
803
+ const result = {
804
+ ok: true,
805
+ applied: args.apply,
806
+ from: fromPage.rel,
807
+ to: toRel,
808
+ moved,
809
+ files_rewritten: fileResults.length,
810
+ links_rewritten: totalRewrites,
811
+ rewrites: fileResults,
812
+ ambiguous: ambiguities,
813
+ };
814
+
815
+ if (args.json) {
816
+ console.log(JSON.stringify(result, null, 2));
817
+ } else {
818
+ const mode = args.apply ? 'Renamed' : 'Dry-run (no changes written)';
819
+ console.log(`${mode}: ${fromPage.rel} → ${toRel}`);
820
+ console.log(` ${totalRewrites} inbound link(s) across ${fileResults.length} file(s).`);
821
+ for (const f of fileResults) {
822
+ console.log(` · ${f.file}: ${f.rewrites.map((r) => `${r.from}→${r.to}`).join(', ')}`);
823
+ }
824
+ if (ambiguities.length > 0) {
825
+ console.log('\n ⚠ ambiguous links NOT rewritten (bare slug shared by >1 page):');
826
+ for (const a of ambiguities) {
827
+ for (const x of a.ambiguous) console.log(` · ${a.file}:${x.line} ${x.link}`);
828
+ }
829
+ console.log(' Resolve these manually (use a dir-relative or full-slug form).');
830
+ }
831
+ if (!args.apply) console.log('\n Re-run with --apply to write the move + rewrites.');
832
+ }
833
+ process.exit(0);
834
+ }
835
+
836
+ run(parseArgs(process.argv));