hypomnema 1.3.0 → 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 +12 -10
- package/README.md +12 -10
- package/commands/crystallize.md +8 -8
- package/commands/feedback.md +1 -1
- package/commands/resume.md +1 -1
- package/commands/upgrade.md +2 -0
- 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 +26 -19
- package/hooks/hypo-shared.mjs +839 -58
- 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 +249 -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 +60 -0
- package/scripts/lib/project-create.mjs +1 -1
- package/scripts/lint.mjs +63 -8
- package/scripts/rename.mjs +373 -0
- package/scripts/resume.mjs +127 -13
- package/scripts/smoke-pack.mjs +23 -2
- package/scripts/uninstall.mjs +1 -1
- package/scripts/upgrade.mjs +266 -47
- 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
|
@@ -9,13 +9,26 @@
|
|
|
9
9
|
* node scripts/resume.mjs [options]
|
|
10
10
|
*
|
|
11
11
|
* Options:
|
|
12
|
-
* --hypo-dir=<path> Hypomnema root
|
|
13
|
-
*
|
|
12
|
+
* --hypo-dir=<path> Hypomnema root. When omitted, resolveHypoRoot()
|
|
13
|
+
* (see lib/hypo-root.mjs) resolves it in priority order:
|
|
14
|
+
* 1. $HYPO_DIR if set — returned immediately; the
|
|
15
|
+
* hypo-config.md scan below is then skipped.
|
|
16
|
+
* 2. else the first of 7 fixed candidates
|
|
17
|
+
* (~/{hypomnema,wiki,notes,knowledge},
|
|
18
|
+
* ~/Documents/{hypomnema,wiki,notes}) that contains
|
|
19
|
+
* a hypo-config.md marker.
|
|
20
|
+
* 3. else the default ~/hypomnema.
|
|
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.
|
|
14
26
|
* --json Output as JSON
|
|
15
27
|
*/
|
|
16
28
|
|
|
17
29
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
18
30
|
import { join } from 'path';
|
|
31
|
+
import { homedir } from 'os';
|
|
19
32
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
20
33
|
|
|
21
34
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
@@ -33,7 +46,71 @@ function parseArgs(argv) {
|
|
|
33
46
|
|
|
34
47
|
// ── active project from hot.md ───────────────────────────────────────────────
|
|
35
48
|
|
|
36
|
-
|
|
49
|
+
// Parse a single frontmatter scalar (mirrors the hook helpers in
|
|
50
|
+
// hypo-session-start.mjs / hypo-cwd-change.mjs — kept local per the hook
|
|
51
|
+
// self-contained convention rather than shared, to avoid script↔hook coupling).
|
|
52
|
+
function parseFrontmatterField(content, key) {
|
|
53
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
54
|
+
if (!m) return null;
|
|
55
|
+
const line = m[1].split('\n').find((l) => l.startsWith(`${key}:`));
|
|
56
|
+
if (!line) return null;
|
|
57
|
+
return line
|
|
58
|
+
.slice(key.length + 1)
|
|
59
|
+
.trim()
|
|
60
|
+
.replace(/^['"]|['"]$/g, '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
|
|
64
|
+
// is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
|
|
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.
|
|
67
|
+
function pickByCwd(hypoDir, slugs, cwd) {
|
|
68
|
+
if (!cwd) return null;
|
|
69
|
+
let best = null;
|
|
70
|
+
let bestLen = -1;
|
|
71
|
+
for (const slug of slugs) {
|
|
72
|
+
const indexPath = join(hypoDir, 'projects', slug, 'index.md');
|
|
73
|
+
if (!existsSync(indexPath)) continue;
|
|
74
|
+
const wd = parseFrontmatterField(readFileSync(indexPath, 'utf-8'), 'working_dir');
|
|
75
|
+
if (!wd) continue;
|
|
76
|
+
let resolved = wd.startsWith('~/') ? join(homedir(), wd.slice(2)) : wd;
|
|
77
|
+
resolved = resolved.replace(/\/+$/, ''); // trailing-slash normalize
|
|
78
|
+
if ((cwd === resolved || cwd.startsWith(resolved + '/')) && resolved.length > bestLen) {
|
|
79
|
+
bestLen = resolved.length;
|
|
80
|
+
best = slug;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return best;
|
|
84
|
+
}
|
|
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
|
+
|
|
113
|
+
function resolveActiveProject(hypoDir, cwd = null) {
|
|
37
114
|
const hotPath = join(hypoDir, 'hot.md');
|
|
38
115
|
if (!existsSync(hotPath)) return null;
|
|
39
116
|
|
|
@@ -48,25 +125,62 @@ function resolveActiveProject(hypoDir) {
|
|
|
48
125
|
),
|
|
49
126
|
].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
|
|
50
127
|
if (wikiRows.length > 0) {
|
|
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) {
|
|
136
|
+
const picked = pickByCwd(
|
|
137
|
+
hypoDir,
|
|
138
|
+
wikiRows.map((r) => r.slug),
|
|
139
|
+
cwd,
|
|
140
|
+
);
|
|
141
|
+
if (picked) return picked;
|
|
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
|
+
);
|
|
51
151
|
wikiRows.sort((a, b) => b.date.localeCompare(a.date));
|
|
52
152
|
return wikiRows[0].slug;
|
|
53
153
|
}
|
|
54
154
|
// Legacy markdown-link rows: | [name](projects/name/...) | ...
|
|
55
|
-
const
|
|
56
|
-
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
|
+
}
|
|
57
164
|
|
|
58
|
-
// 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.)
|
|
59
168
|
const projectsDir = join(hypoDir, 'projects');
|
|
60
169
|
if (!existsSync(projectsDir)) return null;
|
|
61
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
|
+
}
|
|
62
180
|
let latest = null;
|
|
63
181
|
let latestMtime = 0;
|
|
64
|
-
for (const p of
|
|
65
|
-
|
|
66
|
-
if (p === '_template') continue;
|
|
67
|
-
const ssPath = join(projectsDir, p, 'session-state.md');
|
|
68
|
-
if (!existsSync(ssPath)) continue;
|
|
69
|
-
const mtime = statSync(ssPath).mtimeMs;
|
|
182
|
+
for (const p of candidates) {
|
|
183
|
+
const mtime = statSync(join(projectsDir, p, 'session-state.md')).mtimeMs;
|
|
70
184
|
if (mtime > latestMtime) {
|
|
71
185
|
latestMtime = mtime;
|
|
72
186
|
latest = p;
|
|
@@ -93,7 +207,7 @@ function readHot(hypoDir, project) {
|
|
|
93
207
|
|
|
94
208
|
const args = parseArgs(process.argv);
|
|
95
209
|
|
|
96
|
-
const project = args.project || resolveActiveProject(args.hypoDir);
|
|
210
|
+
const project = args.project || resolveActiveProject(args.hypoDir, process.cwd());
|
|
97
211
|
|
|
98
212
|
if (!project) {
|
|
99
213
|
console.error('Error: no active project found. Use --project=<name> or create a hot.md entry.');
|
package/scripts/smoke-pack.mjs
CHANGED
|
@@ -32,6 +32,24 @@ import { fileURLToPath } from 'node:url';
|
|
|
32
32
|
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..');
|
|
33
33
|
const KEEP = process.argv.includes('--keep');
|
|
34
34
|
|
|
35
|
+
// When this script runs inside `npm publish --dry-run` (the release workflow's
|
|
36
|
+
// publish-credential pre-check), npm exports `npm_config_dry_run=true` into the
|
|
37
|
+
// lifecycle environment. spawnSync inherits process.env, so the nested
|
|
38
|
+
// `npm pack` below would ALSO run in dry-run mode — reporting a tarball
|
|
39
|
+
// filename/size it never actually writes to disk — and the subsequent
|
|
40
|
+
// `npm install <tarball>` then fails with ENOENT (npm maps errno -2 → exit 254).
|
|
41
|
+
// Strip the flag so the nested npm commands always perform real writes,
|
|
42
|
+
// matching the real tag-push publish job (which has no outer `--dry-run`). The
|
|
43
|
+
// outer workflow stays dry-run and still skips the registry PUT.
|
|
44
|
+
// (This was the precheck-254 root cause; the earlier "missing NODE_AUTH_TOKEN"
|
|
45
|
+
// theory was wrong — it is file absence, not auth.)
|
|
46
|
+
const npmEnv = { ...process.env };
|
|
47
|
+
for (const key of Object.keys(npmEnv)) {
|
|
48
|
+
if (key.toLowerCase().replaceAll('-', '_') === 'npm_config_dry_run') {
|
|
49
|
+
delete npmEnv[key];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
const PKG = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf-8'));
|
|
36
54
|
const PKG_NAME = PKG.name;
|
|
37
55
|
|
|
@@ -64,7 +82,7 @@ try {
|
|
|
64
82
|
const preCommitBefore = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : null;
|
|
65
83
|
|
|
66
84
|
step('npm pack');
|
|
67
|
-
const pack = run('npm', ['pack', '--json'], { cwd: REPO });
|
|
85
|
+
const pack = run('npm', ['pack', '--json'], { cwd: REPO, env: npmEnv });
|
|
68
86
|
const meta = JSON.parse(pack.stdout)[0];
|
|
69
87
|
const tarball = join(REPO, meta.filename);
|
|
70
88
|
console.log(` → ${meta.filename} (${meta.size} bytes, ${meta.entryCount} entries)`);
|
|
@@ -79,7 +97,10 @@ try {
|
|
|
79
97
|
private: true,
|
|
80
98
|
}) + '\n',
|
|
81
99
|
);
|
|
82
|
-
run(
|
|
100
|
+
// No `--silent`: run() only prints npm's stdout/stderr on a non-zero exit, so
|
|
101
|
+
// a real nested-install failure must not be muted (the precheck-254 error was
|
|
102
|
+
// masked by `--silent` for two release cycles).
|
|
103
|
+
run('npm', ['install', '--no-audit', '--no-fund', tarball], { cwd: installRoot, env: npmEnv });
|
|
83
104
|
|
|
84
105
|
// Move tarball into the work dir so it's not left in the repo.
|
|
85
106
|
renameSync(tarball, join(work, meta.filename));
|
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
|