hypomnema 1.3.1 → 1.3.2
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 +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.ko.md +10 -10
- package/README.md +10 -10
- package/commands/crystallize.md +8 -8
- package/commands/feedback.md +1 -1
- package/commands/resume.md +1 -1
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
- package/hooks/hypo-compact-guard.mjs +1 -1
- package/hooks/hypo-cwd-change.mjs +1 -1
- package/hooks/hypo-first-prompt.mjs +2 -2
- package/hooks/hypo-hot-rebuild.mjs +14 -1
- package/hooks/hypo-personal-check.mjs +91 -179
- package/hooks/hypo-pre-commit.mjs +1 -1
- package/hooks/hypo-session-end.mjs +1 -1
- package/hooks/hypo-session-start.mjs +7 -7
- package/hooks/hypo-shared.mjs +788 -72
- package/hooks/hypo-web-fetch-ingest.mjs +2 -2
- package/hooks/version-check-fetch.mjs +2 -8
- package/hooks/version-check.mjs +18 -0
- package/package.json +3 -2
- package/scripts/check-tracker-ids.mjs +329 -0
- package/scripts/crystallize.mjs +244 -109
- package/scripts/doctor.mjs +6 -9
- package/scripts/feedback-sync.mjs +1 -1
- package/scripts/init.mjs +1 -1
- package/scripts/install-git-hooks.mjs +75 -40
- package/scripts/lib/check-tracker-ids.mjs +140 -0
- package/scripts/lib/design-history-stale.mjs +59 -14
- package/scripts/lib/extensions.mjs +4 -4
- package/scripts/lib/fix-manifest.mjs +2 -2
- package/scripts/lib/fix-status-verify.mjs +5 -4
- package/scripts/lib/plugin-detect.mjs +15 -6
- package/scripts/lib/project-create.mjs +1 -1
- package/scripts/lint.mjs +63 -8
- package/scripts/rename.mjs +373 -0
- package/scripts/resume.mjs +75 -19
- package/scripts/uninstall.mjs +1 -1
- package/scripts/upgrade.mjs +26 -25
- package/skills/crystallize/SKILL.md +10 -7
- package/templates/SCHEMA.md +2 -2
- package/templates/hypo-config.md +2 -2
- package/templates/hypo-guide.md +20 -1
- package/templates/projects/_template/index.md +1 -1
|
@@ -0,0 +1,373 @@
|
|
|
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 design invariants:
|
|
15
|
+
*
|
|
16
|
+
* 1. Resolution, not string-match. A link `[[foo]]` (bare basename) can be
|
|
17
|
+
* shared by two pages; a blind text replace would break the wrong one. Each
|
|
18
|
+
* link target is resolved with the SAME precedence lint uses (full noExt →
|
|
19
|
+
* bare basename → dir-relative drop). A reference is rewritten only when it
|
|
20
|
+
* resolves UNAMBIGUOUSLY to the from-page. An ambiguous bare form (the slug
|
|
21
|
+
* maps to >1 file) is reported, never auto-rewritten — which also makes the
|
|
22
|
+
* ADR-renumber guard free: a `--to` that is not a unique destination is
|
|
23
|
+
* rejected.
|
|
24
|
+
*
|
|
25
|
+
* 2. Preserve append-only time records. journal / session-log / weekly / archive
|
|
26
|
+
* / postmortems (and root log.md) are frozen snapshots — rewriting a `[[old]]`
|
|
27
|
+
* inside a past entry would falsify that moment's record. They are skipped as
|
|
28
|
+
* link SOURCES. sources/* is likewise immutable. This tool's value is "update
|
|
29
|
+
* live links at rename time", not "retroactively churn old snapshots".
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
existsSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
rmSync,
|
|
37
|
+
mkdirSync,
|
|
38
|
+
readdirSync,
|
|
39
|
+
statSync,
|
|
40
|
+
} from 'fs';
|
|
41
|
+
import { join, relative, extname, basename, dirname, normalize, isAbsolute } from 'path';
|
|
42
|
+
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
43
|
+
import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
|
|
44
|
+
|
|
45
|
+
// ── arg parsing ───────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function parseArgs(argv) {
|
|
48
|
+
const args = { hypoDir: null, from: null, to: null, apply: false, json: false };
|
|
49
|
+
for (const arg of argv.slice(2)) {
|
|
50
|
+
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
51
|
+
else if (arg.startsWith('--from=')) args.from = arg.slice(7);
|
|
52
|
+
else if (arg.startsWith('--to=')) args.to = arg.slice(5);
|
|
53
|
+
else if (arg === '--apply') args.apply = true;
|
|
54
|
+
else if (arg === '--json') args.json = true;
|
|
55
|
+
}
|
|
56
|
+
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
57
|
+
return args;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── page collection (mirrors graph.mjs / crystallize.mjs collectPages) ──────────
|
|
61
|
+
|
|
62
|
+
function collectPages(dir, root, pages = [], ignorePatterns = []) {
|
|
63
|
+
if (!existsSync(dir)) return pages;
|
|
64
|
+
for (const entry of readdirSync(dir)) {
|
|
65
|
+
const full = join(dir, entry);
|
|
66
|
+
if (isIgnored(full, root, ignorePatterns)) continue;
|
|
67
|
+
const st = statSync(full);
|
|
68
|
+
if (st.isDirectory()) {
|
|
69
|
+
collectPages(full, root, pages, ignorePatterns);
|
|
70
|
+
} else if (extname(entry) === '.md' && !entry.startsWith('.')) {
|
|
71
|
+
const rel = relative(root, full).replace(/\\/g, '/');
|
|
72
|
+
pages.push({ path: full, rel, slug: rel.replace(/\.md$/, ''), bare: basename(full, '.md') });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return pages;
|
|
76
|
+
}
|
|
77
|
+
|
|
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) {
|
|
88
|
+
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);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── slug-form index (resolution with collision detection) ──────────────────────
|
|
94
|
+
// Unlike lint's buildSlugMap (a Set that silently dedups collisions), rename
|
|
95
|
+
// needs to KNOW when a form is shared, so it maps each form → the set of page
|
|
96
|
+
// rels that expose it. precedence forms per page: full noExt slug, bare
|
|
97
|
+
// basename, dir-relative (drop the leading scan-dir segment).
|
|
98
|
+
function dirRelForm(slug) {
|
|
99
|
+
const slash = slug.indexOf('/');
|
|
100
|
+
return slash === -1 ? null : slug.slice(slash + 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildFormIndex(pages) {
|
|
104
|
+
const index = new Map(); // form → Set<rel>
|
|
105
|
+
const add = (form, rel) => {
|
|
106
|
+
if (!form) return;
|
|
107
|
+
if (!index.has(form)) index.set(form, new Set());
|
|
108
|
+
index.get(form).add(rel);
|
|
109
|
+
};
|
|
110
|
+
for (const p of pages) {
|
|
111
|
+
add(p.slug, p.rel);
|
|
112
|
+
// sources/* are full-slug-only link targets, exactly as lint's
|
|
113
|
+
// collectLinkTargets treats them: a bare `[[name]]` must NOT resolve to a
|
|
114
|
+
// source file. Adding their bare/dir-relative aliases here would make a real
|
|
115
|
+
// page's bare link look ambiguous and skip a legitimate rewrite.
|
|
116
|
+
if (/(^|\/)sources(\/|$)/.test(p.rel)) continue;
|
|
117
|
+
add(p.bare, p.rel);
|
|
118
|
+
add(dirRelForm(p.slug), p.rel);
|
|
119
|
+
}
|
|
120
|
+
return index;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Classify a link target against the from-page. Returns the form KIND when the
|
|
124
|
+
// target points at from-page, plus whether that form is ambiguous (shared with
|
|
125
|
+
// another page → unsafe to auto-rewrite).
|
|
126
|
+
function classifyTarget(target, fromPage, formIndex) {
|
|
127
|
+
const owners = formIndex.get(target);
|
|
128
|
+
if (!owners || !owners.has(fromPage.rel)) return { kind: null, ambiguous: false };
|
|
129
|
+
const ambiguous = owners.size > 1;
|
|
130
|
+
let kind = null;
|
|
131
|
+
if (target === fromPage.slug) kind = 'full';
|
|
132
|
+
else if (target === dirRelForm(fromPage.slug)) kind = 'dirrel';
|
|
133
|
+
else if (target === fromPage.bare) kind = 'bare';
|
|
134
|
+
return { kind, ambiguous };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The new target string for a matched form kind — same kind, new page.
|
|
138
|
+
function newTargetFor(kind, toPage) {
|
|
139
|
+
if (kind === 'full') return toPage.slug;
|
|
140
|
+
if (kind === 'dirrel') return dirRelForm(toPage.slug) ?? toPage.bare;
|
|
141
|
+
return toPage.bare; // bare
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── wikilink masking (mirror lint.mjs stripNonWikilinkRegions) ──────────────────
|
|
145
|
+
// Blank out fenced code, inline code, and HTML comments WITHOUT changing length,
|
|
146
|
+
// so a [[ref]] match index in the mask aligns with the same index in the source.
|
|
147
|
+
// Rewriting then edits the source at those exact spans, never touching a link
|
|
148
|
+
// that only appears inside a code sample.
|
|
149
|
+
function maskNonWikilinkRegions(content) {
|
|
150
|
+
let out = content;
|
|
151
|
+
out = out.replace(/^[ \t]{0,3}```[\s\S]*?^[ \t]{0,3}```/gm, (m) => m.replace(/[^\n]/g, ' '));
|
|
152
|
+
out = out.replace(/^[ \t]{0,3}~~~[\s\S]*?^[ \t]{0,3}~~~/gm, (m) => m.replace(/[^\n]/g, ' '));
|
|
153
|
+
out = out.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
|
|
154
|
+
out = out.replace(/``[^`\n]*``/g, (m) => ' '.repeat(m.length));
|
|
155
|
+
out = out.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Parse the inside of a `[[ ... ]]` into { target, suffix } where suffix is the
|
|
160
|
+
// alias/anchor tail to preserve verbatim (including a table-escaped `\|`). The
|
|
161
|
+
// target capture stops before an optional `\` preceding the `|`/`#` delimiter,
|
|
162
|
+
// matching lint's extractor exactly.
|
|
163
|
+
function splitLinkBody(body) {
|
|
164
|
+
const m = body.match(/^([^|#\\]+?)(\\?[|#][\s\S]*)?$/);
|
|
165
|
+
if (!m) return null;
|
|
166
|
+
return { target: m[1].trim(), suffix: m[2] || '' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Rewrite every inbound reference to fromPage in `content`. Returns
|
|
170
|
+
// { content, rewrites, ambiguous } where rewrites/ambiguous list the links
|
|
171
|
+
// changed / skipped-as-ambiguous (with their 1-based line numbers).
|
|
172
|
+
function rewriteContent(content, fromPage, toPage, formIndex) {
|
|
173
|
+
const mask = maskNonWikilinkRegions(content);
|
|
174
|
+
const re = /\[\[([^\]]+?)\]\]/g;
|
|
175
|
+
const edits = []; // { start, end, replacement }
|
|
176
|
+
const rewrites = [];
|
|
177
|
+
const ambiguous = [];
|
|
178
|
+
let m;
|
|
179
|
+
while ((m = re.exec(mask)) !== null) {
|
|
180
|
+
const start = m.index;
|
|
181
|
+
const end = start + m[0].length;
|
|
182
|
+
const body = content.slice(start + 2, end - 2); // real body from source
|
|
183
|
+
const parsed = splitLinkBody(body);
|
|
184
|
+
if (!parsed) continue;
|
|
185
|
+
const { kind, ambiguous: amb } = classifyTarget(parsed.target, fromPage, formIndex);
|
|
186
|
+
if (!kind) continue; // does not resolve to from-page
|
|
187
|
+
const line = content.slice(0, start).split('\n').length;
|
|
188
|
+
if (amb) {
|
|
189
|
+
ambiguous.push({ link: m[0], line, target: parsed.target });
|
|
190
|
+
continue; // shared form — report, never auto-rewrite
|
|
191
|
+
}
|
|
192
|
+
const replacement = `[[${newTargetFor(kind, toPage)}${parsed.suffix}]]`;
|
|
193
|
+
edits.push({ start, end, replacement });
|
|
194
|
+
rewrites.push({ from: m[0], to: replacement, line });
|
|
195
|
+
}
|
|
196
|
+
// Apply edits back-to-front so earlier indices stay valid.
|
|
197
|
+
let out = content;
|
|
198
|
+
for (const e of edits.sort((a, b) => b.start - a.start)) {
|
|
199
|
+
out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
|
|
200
|
+
}
|
|
201
|
+
return { content: out, rewrites, ambiguous };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── resolve a --from / --to argument to a page rel + forms ──────────────────────
|
|
205
|
+
// Accepts a full rel ("pages/foo.md" / "pages/foo"), a noExt slug, or a bare
|
|
206
|
+
// basename when unambiguous. Returns { rel, slug, bare } or null.
|
|
207
|
+
function resolveArgToPage(arg, pages, formIndex) {
|
|
208
|
+
const norm = arg.replace(/\\/g, '/').replace(/\.md$/, '');
|
|
209
|
+
// exact full-slug match first
|
|
210
|
+
const direct = pages.find((p) => p.slug === norm);
|
|
211
|
+
if (direct) return direct;
|
|
212
|
+
const owners = formIndex.get(norm);
|
|
213
|
+
if (owners && owners.size === 1) {
|
|
214
|
+
const rel = [...owners][0];
|
|
215
|
+
return pages.find((p) => p.rel === rel) || null;
|
|
216
|
+
}
|
|
217
|
+
return null; // missing or ambiguous
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function fail(args, msg) {
|
|
221
|
+
if (args.json) console.log(JSON.stringify({ ok: false, error: msg }, null, 2));
|
|
222
|
+
else console.error(`✗ ${msg}`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── main ────────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
function run(args) {
|
|
229
|
+
if (!args.from || !args.to) {
|
|
230
|
+
fail(args, '--from=<slug|rel> and --to=<slug|rel> are required');
|
|
231
|
+
}
|
|
232
|
+
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
233
|
+
const pages = collectPages(args.hypoDir, args.hypoDir, [], ignorePatterns);
|
|
234
|
+
const formIndex = buildFormIndex(pages);
|
|
235
|
+
|
|
236
|
+
const fromPage = resolveArgToPage(args.from, pages, formIndex);
|
|
237
|
+
if (!fromPage) {
|
|
238
|
+
fail(args, `--from did not resolve to a unique existing page: ${args.from}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Compute the destination rel. --to may be a full rel (move across dirs) or a
|
|
242
|
+
// bare new name (rename in place within from-page's directory).
|
|
243
|
+
const toNorm = args.to.replace(/\\/g, '/').replace(/\.md$/, '');
|
|
244
|
+
const toRelRaw = toNorm.includes('/')
|
|
245
|
+
? `${toNorm}.md`
|
|
246
|
+
: `${dirname(fromPage.rel)}/${toNorm}.md`.replace(/^\.\//, '');
|
|
247
|
+
// Normalize away ./ and ../ segments, then require the result stays inside the
|
|
248
|
+
// vault: a `--to` like `../moved` must not move a page out of the wiki, and
|
|
249
|
+
// `pages/../pages/bar` must not become a non-canonical `[[pages/../pages/bar]]`
|
|
250
|
+
// link target.
|
|
251
|
+
const toRel = normalize(toRelRaw).replace(/\\/g, '/');
|
|
252
|
+
if (toRel === '..' || toRel.startsWith('../') || isAbsolute(toRel)) {
|
|
253
|
+
fail(args, `--to escapes the wiki root: ${args.to}`);
|
|
254
|
+
}
|
|
255
|
+
const toSlug = toRel.replace(/\.md$/, '');
|
|
256
|
+
const toPath = join(args.hypoDir, toRel);
|
|
257
|
+
|
|
258
|
+
// Guard: never overwrite an existing destination (an ADR-renumber / merge would
|
|
259
|
+
// land here — that is a report-only case, not a blind move).
|
|
260
|
+
if (existsSync(toPath) && toRel !== fromPage.rel) {
|
|
261
|
+
fail(
|
|
262
|
+
args,
|
|
263
|
+
`--to already exists: ${toRel}. Rename cannot overwrite a live page (renumber/merge is manual).`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (toRel === fromPage.rel) {
|
|
267
|
+
fail(args, `--from and --to resolve to the same page (${toRel}) — nothing to rename`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const toPage = {
|
|
271
|
+
rel: toRel,
|
|
272
|
+
slug: toSlug,
|
|
273
|
+
bare: basename(toRel, '.md'),
|
|
274
|
+
path: toPath,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Destination must be a UNIQUE link destination: existsSync(toPath) above only
|
|
278
|
+
// catches a same-path clobber, not a cross-directory basename collision
|
|
279
|
+
// (pages/bar.md vs projects/bar.md). If the new bare or dir-relative form
|
|
280
|
+
// already resolves to a DIFFERENT existing page, the rewritten `[[new]]` links
|
|
281
|
+
// would be ambiguous — the same contract that bars rewriting an ambiguous
|
|
282
|
+
// source link. Refuse rather than emit ambiguous links.
|
|
283
|
+
for (const form of [toPage.bare, dirRelForm(toPage.slug)]) {
|
|
284
|
+
if (!form) continue;
|
|
285
|
+
const owners = formIndex.get(form);
|
|
286
|
+
if (owners && [...owners].some((rel) => rel !== fromPage.rel)) {
|
|
287
|
+
fail(
|
|
288
|
+
args,
|
|
289
|
+
`--to '${args.to}' collides with an existing page on form '${form}' — rewritten links would be ambiguous. Pick a unique name.`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Rewrite inbound references across every NON-preserved page (skip the moved
|
|
295
|
+
// page itself — self-references are rewritten on its own content separately).
|
|
296
|
+
const fileResults = [];
|
|
297
|
+
let totalRewrites = 0;
|
|
298
|
+
const ambiguities = [];
|
|
299
|
+
for (const p of pages) {
|
|
300
|
+
if (isPreservedSource(p.rel)) continue;
|
|
301
|
+
let raw;
|
|
302
|
+
try {
|
|
303
|
+
raw = readFileSync(p.path, 'utf-8');
|
|
304
|
+
} catch {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const { content, rewrites, ambiguous } = rewriteContent(raw, fromPage, toPage, formIndex);
|
|
308
|
+
if (rewrites.length > 0) {
|
|
309
|
+
fileResults.push({ file: p.rel, rewrites });
|
|
310
|
+
totalRewrites += rewrites.length;
|
|
311
|
+
if (args.apply && content !== raw) {
|
|
312
|
+
// The from-page is about to move; write its rewritten body to the NEW
|
|
313
|
+
// path below, not the old one.
|
|
314
|
+
if (p.rel === fromPage.rel) {
|
|
315
|
+
fromPage._rewritten = content;
|
|
316
|
+
} else {
|
|
317
|
+
writeFileSync(p.path, content);
|
|
318
|
+
}
|
|
319
|
+
} else if (p.rel === fromPage.rel) {
|
|
320
|
+
fromPage._rewritten = content;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (ambiguous.length > 0) {
|
|
324
|
+
ambiguities.push({ file: p.rel, ambiguous });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Move the file (--apply only): write the (possibly self-rewritten) body at the
|
|
329
|
+
// new path, then drop the old one. Done as write-then-remove rather than a raw
|
|
330
|
+
// rename so the carried-over self-reference rewrites are preserved.
|
|
331
|
+
let moved = false;
|
|
332
|
+
if (args.apply) {
|
|
333
|
+
mkdirSync(dirname(toPath), { recursive: true });
|
|
334
|
+
const body = fromPage._rewritten ?? readFileSync(fromPage.path, 'utf-8');
|
|
335
|
+
writeFileSync(toPath, body);
|
|
336
|
+
if (toPath !== fromPage.path) rmSync(fromPage.path, { force: true });
|
|
337
|
+
moved = true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const result = {
|
|
341
|
+
ok: true,
|
|
342
|
+
applied: args.apply,
|
|
343
|
+
from: fromPage.rel,
|
|
344
|
+
to: toRel,
|
|
345
|
+
moved,
|
|
346
|
+
files_rewritten: fileResults.length,
|
|
347
|
+
links_rewritten: totalRewrites,
|
|
348
|
+
rewrites: fileResults,
|
|
349
|
+
ambiguous: ambiguities,
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
if (args.json) {
|
|
353
|
+
console.log(JSON.stringify(result, null, 2));
|
|
354
|
+
} else {
|
|
355
|
+
const mode = args.apply ? 'Renamed' : 'Dry-run (no changes written)';
|
|
356
|
+
console.log(`${mode}: ${fromPage.rel} → ${toRel}`);
|
|
357
|
+
console.log(` ${totalRewrites} inbound link(s) across ${fileResults.length} file(s).`);
|
|
358
|
+
for (const f of fileResults) {
|
|
359
|
+
console.log(` · ${f.file}: ${f.rewrites.map((r) => `${r.from}→${r.to}`).join(', ')}`);
|
|
360
|
+
}
|
|
361
|
+
if (ambiguities.length > 0) {
|
|
362
|
+
console.log('\n ⚠ ambiguous links NOT rewritten (bare slug shared by >1 page):');
|
|
363
|
+
for (const a of ambiguities) {
|
|
364
|
+
for (const x of a.ambiguous) console.log(` · ${a.file}:${x.line} ${x.link}`);
|
|
365
|
+
}
|
|
366
|
+
console.log(' Resolve these manually (use a dir-relative or full-slug form).');
|
|
367
|
+
}
|
|
368
|
+
if (!args.apply) console.log('\n Re-run with --apply to write the move + rewrites.');
|
|
369
|
+
}
|
|
370
|
+
process.exit(0);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
run(parseArgs(process.argv));
|
package/scripts/resume.mjs
CHANGED
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
* ~/Documents/{hypomnema,wiki,notes}) that contains
|
|
19
19
|
* a hypo-config.md marker.
|
|
20
20
|
* 3. else the default ~/hypomnema.
|
|
21
|
-
* --project=<name> Project name
|
|
21
|
+
* --project=<name> Project name. When omitted, resolveActiveProject()
|
|
22
|
+
* prefers the project whose working_dir contains the
|
|
23
|
+
* current directory (cwd-first), and only falls back to
|
|
24
|
+
* the most recently active hot.md row when nothing under
|
|
25
|
+
* cwd matches.
|
|
22
26
|
* --json Output as JSON
|
|
23
27
|
*/
|
|
24
28
|
|
|
@@ -58,7 +62,8 @@ function parseFrontmatterField(content, key) {
|
|
|
58
62
|
|
|
59
63
|
// Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
|
|
60
64
|
// is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
|
|
61
|
-
// when cwd is falsy or matches none.
|
|
65
|
+
// when cwd is falsy or matches none. resume gives this authority OVER recency
|
|
66
|
+
// (ADR 0044): a cwd↔working_dir match wins regardless of which row is newest.
|
|
62
67
|
function pickByCwd(hypoDir, slugs, cwd) {
|
|
63
68
|
if (!cwd) return null;
|
|
64
69
|
let best = null;
|
|
@@ -78,6 +83,33 @@ function pickByCwd(hypoDir, slugs, cwd) {
|
|
|
78
83
|
return best;
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
// When cwd is set but no project's working_dir matches it, resume falls back to
|
|
87
|
+
// recency silently — the user lands in an unrelated project with no clue why.
|
|
88
|
+
// Emit a one-line stderr diagnostic (stdout `Project:`/`--json` contract is untouched)
|
|
89
|
+
// naming why each candidate failed: missing index.md, missing working_dir, or a
|
|
90
|
+
// working_dir that simply doesn't contain cwd. Logic mirrors pickByCwd's lookup.
|
|
91
|
+
function warnCwdFallback(hypoDir, slugs, cwd) {
|
|
92
|
+
// No cwd, or no candidate rows at all (fresh-init / no real project): there is
|
|
93
|
+
// nothing to "fall back to most-recent" toward, so stay silent — the caller
|
|
94
|
+
// surfaces the real "no active project found" error instead.
|
|
95
|
+
if (!cwd || slugs.length === 0) return;
|
|
96
|
+
const reasons = [];
|
|
97
|
+
for (const slug of slugs) {
|
|
98
|
+
const indexPath = join(hypoDir, 'projects', slug, 'index.md');
|
|
99
|
+
if (!existsSync(indexPath)) {
|
|
100
|
+
reasons.push(`${slug} (no index.md)`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const wd = parseFrontmatterField(readFileSync(indexPath, 'utf-8'), 'working_dir');
|
|
104
|
+
if (!wd) reasons.push(`${slug} (no working_dir)`);
|
|
105
|
+
// else: has working_dir but didn't contain cwd — expected, not flagged.
|
|
106
|
+
}
|
|
107
|
+
const detail = reasons.length ? ` Candidates missing cwd metadata: ${reasons.join(', ')}.` : '';
|
|
108
|
+
process.stderr.write(
|
|
109
|
+
`note: cwd "${cwd}" matched no project working_dir; falling back to most-recent.${detail}\n`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
81
113
|
function resolveActiveProject(hypoDir, cwd = null) {
|
|
82
114
|
const hotPath = join(hypoDir, 'hot.md');
|
|
83
115
|
if (!existsSync(hotPath)) return null;
|
|
@@ -93,38 +125,62 @@ function resolveActiveProject(hypoDir, cwd = null) {
|
|
|
93
125
|
),
|
|
94
126
|
].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
|
|
95
127
|
if (wikiRows.length > 0) {
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
128
|
+
// cwd-first (ADR 0044): a cwd↔working_dir match wins over recency, across
|
|
129
|
+
// ALL rows (not just a same-date tie). The user is physically in that
|
|
130
|
+
// project, so cwd is a stronger intent signal than "some other project was
|
|
131
|
+
// touched more recently". This reverses the earlier tie-breaker-only
|
|
132
|
+
// semantics now that resume=cwd-positive (ADR 0043). Tradeoff: a stale cwd
|
|
133
|
+
// match can mask a genuinely newer project; `--project` overrides. close
|
|
134
|
+
// callers pass null → recency path below (resume=cwd-positive / close=no-pick).
|
|
135
|
+
if (cwd) {
|
|
103
136
|
const picked = pickByCwd(
|
|
104
137
|
hypoDir,
|
|
105
|
-
|
|
138
|
+
wikiRows.map((r) => r.slug),
|
|
106
139
|
cwd,
|
|
107
140
|
);
|
|
108
141
|
if (picked) return picked;
|
|
109
142
|
}
|
|
143
|
+
// No cwd match → most recent by date (stable-sort keeps the first table row
|
|
144
|
+
// on a tie, the legacy behavior).
|
|
145
|
+
if (cwd)
|
|
146
|
+
warnCwdFallback(
|
|
147
|
+
hypoDir,
|
|
148
|
+
wikiRows.map((r) => r.slug),
|
|
149
|
+
cwd,
|
|
150
|
+
);
|
|
151
|
+
wikiRows.sort((a, b) => b.date.localeCompare(a.date));
|
|
110
152
|
return wikiRows[0].slug;
|
|
111
153
|
}
|
|
112
154
|
// Legacy markdown-link rows: | [name](projects/name/...) | ...
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
155
|
+
const mdSlugs = [...content.matchAll(/\|\s*\[([^\]]+)\]\(projects\/([^/)]+)/g)].map((m) => m[2]);
|
|
156
|
+
if (mdSlugs.length > 0) {
|
|
157
|
+
if (cwd) {
|
|
158
|
+
const picked = pickByCwd(hypoDir, mdSlugs, cwd);
|
|
159
|
+
if (picked) return picked;
|
|
160
|
+
warnCwdFallback(hypoDir, mdSlugs, cwd);
|
|
161
|
+
}
|
|
162
|
+
return mdSlugs[0]; // legacy: first table row
|
|
163
|
+
}
|
|
115
164
|
|
|
116
|
-
// fallback: most recently modified
|
|
165
|
+
// fallback: a cwd-matched project, else the most recently modified one with a
|
|
166
|
+
// session-state.md. (mtime is only a heuristic once hot.md can't name a
|
|
167
|
+
// project — an explicit working_dir match is safer, so cwd-first here too.)
|
|
117
168
|
const projectsDir = join(hypoDir, 'projects');
|
|
118
169
|
if (!existsSync(projectsDir)) return null;
|
|
119
170
|
|
|
171
|
+
// Skip the scaffold project init.mjs writes — it isn't a real active project.
|
|
172
|
+
const candidates = readdirSync(projectsDir).filter(
|
|
173
|
+
(p) => p !== '_template' && existsSync(join(projectsDir, p, 'session-state.md')),
|
|
174
|
+
);
|
|
175
|
+
if (cwd) {
|
|
176
|
+
const picked = pickByCwd(hypoDir, candidates, cwd);
|
|
177
|
+
if (picked) return picked;
|
|
178
|
+
warnCwdFallback(hypoDir, candidates, cwd);
|
|
179
|
+
}
|
|
120
180
|
let latest = null;
|
|
121
181
|
let latestMtime = 0;
|
|
122
|
-
for (const p of
|
|
123
|
-
|
|
124
|
-
if (p === '_template') continue;
|
|
125
|
-
const ssPath = join(projectsDir, p, 'session-state.md');
|
|
126
|
-
if (!existsSync(ssPath)) continue;
|
|
127
|
-
const mtime = statSync(ssPath).mtimeMs;
|
|
182
|
+
for (const p of candidates) {
|
|
183
|
+
const mtime = statSync(join(projectsDir, p, 'session-state.md')).mtimeMs;
|
|
128
184
|
if (mtime > latestMtime) {
|
|
129
185
|
latestMtime = mtime;
|
|
130
186
|
latest = p;
|
package/scripts/uninstall.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* --force-extensions Remove user-modified extension files (hypo-ext-*) instead of preserving them
|
|
16
16
|
* --hooks-dir=<path> Override Claude hooks directory (default: ~/.claude/hooks)
|
|
17
17
|
*
|
|
18
|
-
* Extensions (ADR 0024
|
|
18
|
+
* Extensions (ADR 0024): hypo-ext-* hard-copies under
|
|
19
19
|
* ~/.claude/{hooks,commands,skills,agents}/ and ~/.codex/{hooks,commands}/ (with
|
|
20
20
|
* --codex) are removed when their on-disk SHA matches the recorded one in
|
|
21
21
|
* ~/.claude/hypo-pkg.json#extensions.<target>. User-modified copies are preserved
|