hypomnema 1.3.2 → 1.3.4
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +2 -1
- package/README.md +2 -1
- package/commands/audit.md +1 -1
- package/commands/crystallize.md +3 -3
- package/commands/doctor.md +1 -1
- package/commands/feedback.md +1 -1
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +1 -1
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +79 -0
- package/commands/resume.md +1 -1
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +1 -1
- package/commands/upgrade.md +1 -1
- package/commands/verify.md +1 -1
- package/hooks/hypo-auto-commit.mjs +8 -24
- package/hooks/hypo-compact-guard.mjs +5 -2
- package/hooks/hypo-shared.mjs +318 -41
- package/package.json +1 -1
- package/scripts/crystallize.mjs +91 -14
- package/scripts/doctor.mjs +2 -2
- package/scripts/graph.mjs +2 -2
- package/scripts/init.mjs +31 -2
- package/scripts/lib/hypo-ignore.mjs +24 -0
- package/scripts/lint.mjs +7 -6
- package/scripts/query.mjs +2 -2
- package/scripts/rename.mjs +480 -17
- package/scripts/stats.mjs +2 -2
- package/scripts/verify.mjs +2 -2
- package/skills/crystallize/SKILL.md +2 -2
- package/skills/graph/SKILL.md +1 -1
- package/skills/ingest/SKILL.md +1 -1
- package/skills/lint/SKILL.md +1 -1
- package/skills/query/SKILL.md +1 -1
- package/skills/verify/SKILL.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +2 -0
- package/templates/hypo-help.md +1 -0
package/scripts/rename.mjs
CHANGED
|
@@ -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,11 +42,14 @@ 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
|
-
import { loadHypoIgnore,
|
|
52
|
+
import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
|
|
44
53
|
|
|
45
54
|
// ── arg parsing ───────────────────────────────────────────────────────────────
|
|
46
55
|
|
|
@@ -63,9 +72,13 @@ function collectPages(dir, root, pages = [], ignorePatterns = []) {
|
|
|
63
72
|
if (!existsSync(dir)) return pages;
|
|
64
73
|
for (const entry of readdirSync(dir)) {
|
|
65
74
|
const full = join(dir, entry);
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
if (isScanIgnored(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()) {
|
|
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
|
-
// ──
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
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
|
|
90
|
-
|
|
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) {
|
package/scripts/stats.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
17
17
|
import { join, extname } from 'path';
|
|
18
18
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
19
|
-
import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
|
|
19
|
+
import { loadHypoIgnore, isIgnored, isScanIgnored } from './lib/hypo-ignore.mjs';
|
|
20
20
|
|
|
21
21
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
22
22
|
|
|
@@ -37,7 +37,7 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
|
|
|
37
37
|
for (const entry of readdirSync(dir)) {
|
|
38
38
|
if (entry.startsWith('.')) continue;
|
|
39
39
|
const full = join(dir, entry);
|
|
40
|
-
if (hypoDir &&
|
|
40
|
+
if (hypoDir && isScanIgnored(full, hypoDir, ignorePatterns)) continue;
|
|
41
41
|
const st = statSync(full);
|
|
42
42
|
if (st.isDirectory()) collectMdFiles(full, acc, hypoDir, ignorePatterns);
|
|
43
43
|
else if (extname(entry) === '.md') acc.push(full);
|
package/scripts/verify.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
19
19
|
import { join, relative, extname } from 'path';
|
|
20
20
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
21
|
-
import { loadHypoIgnore,
|
|
21
|
+
import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
|
|
22
22
|
|
|
23
23
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
24
24
|
|
|
@@ -40,7 +40,7 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
|
|
|
40
40
|
for (const entry of readdirSync(dir)) {
|
|
41
41
|
if (entry.startsWith('.')) continue;
|
|
42
42
|
const full = join(dir, entry);
|
|
43
|
-
if (hypoDir &&
|
|
43
|
+
if (hypoDir && isScanIgnored(full, hypoDir, ignorePatterns)) continue;
|
|
44
44
|
const st = statSync(full);
|
|
45
45
|
if (st.isDirectory()) collectMdFiles(full, acc, hypoDir, ignorePatterns);
|
|
46
46
|
else if (extname(entry) === '.md') acc.push(full);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Close a session (steps 1
|
|
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
|
|
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
|
|
package/skills/graph/SKILL.md
CHANGED
|
@@ -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.
|
package/skills/ingest/SKILL.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Add
|
|
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/`.
|
package/skills/lint/SKILL.md
CHANGED
|
@@ -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.
|
package/skills/query/SKILL.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Search wiki
|
|
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.
|
package/skills/verify/SKILL.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
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.
|
package/templates/hypo-config.md
CHANGED
package/templates/hypo-guide.md
CHANGED
|
@@ -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)
|