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.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.ko.md +12 -10
  4. package/README.md +12 -10
  5. package/commands/crystallize.md +8 -8
  6. package/commands/feedback.md +1 -1
  7. package/commands/resume.md +1 -1
  8. package/commands/upgrade.md +2 -0
  9. package/docs/ARCHITECTURE.md +3 -3
  10. package/docs/CONTRIBUTING.md +2 -2
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
  12. package/hooks/hypo-compact-guard.mjs +1 -1
  13. package/hooks/hypo-cwd-change.mjs +1 -1
  14. package/hooks/hypo-first-prompt.mjs +2 -2
  15. package/hooks/hypo-hot-rebuild.mjs +14 -1
  16. package/hooks/hypo-personal-check.mjs +91 -179
  17. package/hooks/hypo-pre-commit.mjs +1 -1
  18. package/hooks/hypo-session-end.mjs +1 -1
  19. package/hooks/hypo-session-start.mjs +26 -19
  20. package/hooks/hypo-shared.mjs +839 -58
  21. package/hooks/hypo-web-fetch-ingest.mjs +2 -2
  22. package/hooks/version-check-fetch.mjs +2 -8
  23. package/hooks/version-check.mjs +18 -0
  24. package/package.json +3 -2
  25. package/scripts/check-tracker-ids.mjs +329 -0
  26. package/scripts/crystallize.mjs +249 -109
  27. package/scripts/doctor.mjs +6 -9
  28. package/scripts/feedback-sync.mjs +1 -1
  29. package/scripts/init.mjs +1 -1
  30. package/scripts/install-git-hooks.mjs +75 -40
  31. package/scripts/lib/check-tracker-ids.mjs +140 -0
  32. package/scripts/lib/design-history-stale.mjs +59 -14
  33. package/scripts/lib/extensions.mjs +4 -4
  34. package/scripts/lib/fix-manifest.mjs +2 -2
  35. package/scripts/lib/fix-status-verify.mjs +5 -4
  36. package/scripts/lib/plugin-detect.mjs +60 -0
  37. package/scripts/lib/project-create.mjs +1 -1
  38. package/scripts/lint.mjs +63 -8
  39. package/scripts/rename.mjs +373 -0
  40. package/scripts/resume.mjs +127 -13
  41. package/scripts/smoke-pack.mjs +23 -2
  42. package/scripts/uninstall.mjs +1 -1
  43. package/scripts/upgrade.mjs +266 -47
  44. package/skills/crystallize/SKILL.md +10 -7
  45. package/templates/SCHEMA.md +2 -2
  46. package/templates/hypo-config.md +2 -2
  47. package/templates/hypo-guide.md +20 -1
  48. 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));
@@ -9,13 +9,26 @@
9
9
  * node scripts/resume.mjs [options]
10
10
  *
11
11
  * Options:
12
- * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
13
- * --project=<name> Project name (default: most recently active from hot.md)
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
- function resolveActiveProject(hypoDir) {
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 mdRow = content.match(/\|\s*\[([^\]]+)\]\(projects\/([^/)]+)/);
56
- if (mdRow) return mdRow[2];
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 project with a session-state.md
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 readdirSync(projectsDir)) {
65
- // Skip the scaffold project init.mjs writes — it isn't a real active project.
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.');
@@ -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('npm', ['install', '--no-audit', '--no-fund', '--silent', tarball], { cwd: installRoot });
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));
@@ -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 fix #34): hypo-ext-* hard-copies under
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