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.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.ko.md +10 -10
  4. package/README.md +10 -10
  5. package/commands/crystallize.md +8 -8
  6. package/commands/feedback.md +1 -1
  7. package/commands/resume.md +1 -1
  8. package/docs/ARCHITECTURE.md +3 -3
  9. package/docs/CONTRIBUTING.md +2 -2
  10. package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
  11. package/hooks/hypo-compact-guard.mjs +1 -1
  12. package/hooks/hypo-cwd-change.mjs +1 -1
  13. package/hooks/hypo-first-prompt.mjs +2 -2
  14. package/hooks/hypo-hot-rebuild.mjs +14 -1
  15. package/hooks/hypo-personal-check.mjs +91 -179
  16. package/hooks/hypo-pre-commit.mjs +1 -1
  17. package/hooks/hypo-session-end.mjs +1 -1
  18. package/hooks/hypo-session-start.mjs +7 -7
  19. package/hooks/hypo-shared.mjs +788 -72
  20. package/hooks/hypo-web-fetch-ingest.mjs +2 -2
  21. package/hooks/version-check-fetch.mjs +2 -8
  22. package/hooks/version-check.mjs +18 -0
  23. package/package.json +3 -2
  24. package/scripts/check-tracker-ids.mjs +329 -0
  25. package/scripts/crystallize.mjs +244 -109
  26. package/scripts/doctor.mjs +6 -9
  27. package/scripts/feedback-sync.mjs +1 -1
  28. package/scripts/init.mjs +1 -1
  29. package/scripts/install-git-hooks.mjs +75 -40
  30. package/scripts/lib/check-tracker-ids.mjs +140 -0
  31. package/scripts/lib/design-history-stale.mjs +59 -14
  32. package/scripts/lib/extensions.mjs +4 -4
  33. package/scripts/lib/fix-manifest.mjs +2 -2
  34. package/scripts/lib/fix-status-verify.mjs +5 -4
  35. package/scripts/lib/plugin-detect.mjs +15 -6
  36. package/scripts/lib/project-create.mjs +1 -1
  37. package/scripts/lint.mjs +63 -8
  38. package/scripts/rename.mjs +373 -0
  39. package/scripts/resume.mjs +75 -19
  40. package/scripts/uninstall.mjs +1 -1
  41. package/scripts/upgrade.mjs +26 -25
  42. package/skills/crystallize/SKILL.md +10 -7
  43. package/templates/SCHEMA.md +2 -2
  44. package/templates/hypo-config.md +2 -2
  45. package/templates/hypo-guide.md +20 -1
  46. 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));
@@ -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 (default: most recently active from hot.md)
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. Used only as a same-date tie-breaker.
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
- wikiRows.sort((a, b) => b.date.localeCompare(a.date));
97
- // Same-date tie-break (ISSUE-1): when the top date is shared by >1 row,
98
- // prefer the project whose working_dir contains cwd. No cwd / no match
99
- // keep the stable-sort winner (the legacy "first table row" behavior).
100
- const topDate = wikiRows[0].date;
101
- const tied = wikiRows.filter((r) => r.date === topDate);
102
- if (cwd && tied.length > 1) {
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
- tied.map((r) => r.slug),
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 mdRow = content.match(/\|\s*\[([^\]]+)\]\(projects\/([^/)]+)/);
114
- 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
+ }
115
164
 
116
- // 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.)
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 readdirSync(projectsDir)) {
123
- // Skip the scaffold project init.mjs writes — it isn't a real active project.
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;
@@ -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