hypomnema 1.4.2 → 1.5.0

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 (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/commands/audit.md +1 -1
  6. package/commands/crystallize.md +8 -8
  7. package/commands/doctor.md +1 -1
  8. package/commands/feedback.md +2 -2
  9. package/commands/graph.md +1 -1
  10. package/commands/ingest.md +1 -1
  11. package/commands/init.md +2 -2
  12. package/commands/lint.md +1 -1
  13. package/commands/query.md +1 -1
  14. package/commands/rename.md +1 -1
  15. package/commands/resume.md +1 -1
  16. package/commands/stats.md +1 -1
  17. package/commands/uninstall.md +4 -2
  18. package/commands/upgrade.md +1 -1
  19. package/commands/verify.md +1 -1
  20. package/docs/ARCHITECTURE.md +2 -2
  21. package/docs/CONTRIBUTING.md +8 -7
  22. package/hooks/hypo-auto-commit.mjs +2 -2
  23. package/hooks/hypo-auto-minimal-crystallize.mjs +7 -7
  24. package/hooks/hypo-compact-guard.mjs +2 -2
  25. package/hooks/hypo-cwd-change.mjs +27 -37
  26. package/hooks/hypo-personal-check.mjs +37 -22
  27. package/hooks/hypo-session-end.mjs +1 -1
  28. package/hooks/hypo-session-record.mjs +2 -2
  29. package/hooks/hypo-session-start.mjs +34 -40
  30. package/hooks/hypo-shared.mjs +271 -71
  31. package/hooks/version-check.mjs +1 -1
  32. package/package.json +5 -1
  33. package/scripts/check-tracker-ids.mjs +69 -31
  34. package/scripts/crystallize.mjs +82 -38
  35. package/scripts/doctor.mjs +7 -7
  36. package/scripts/feedback-sync.mjs +5 -5
  37. package/scripts/feedback.mjs +9 -10
  38. package/scripts/init.mjs +7 -7
  39. package/scripts/lib/check-tracker-ids.mjs +90 -32
  40. package/scripts/lib/design-history-stale.mjs +1 -1
  41. package/scripts/lib/extensions.mjs +7 -7
  42. package/scripts/lib/failure-type.mjs +1 -1
  43. package/scripts/lib/feedback-scope.mjs +1 -1
  44. package/scripts/lib/project-create.mjs +2 -2
  45. package/scripts/lib/template-schema-version.mjs +1 -1
  46. package/scripts/lib/wd-match.mjs +181 -0
  47. package/scripts/lib/wikilink.mjs +1 -1
  48. package/scripts/lint.mjs +5 -5
  49. package/scripts/rename.mjs +1 -1
  50. package/scripts/resume.mjs +20 -22
  51. package/scripts/session-audit.mjs +1 -1
  52. package/scripts/stats.mjs +3 -3
  53. package/scripts/uninstall.mjs +3 -3
  54. package/scripts/upgrade.mjs +17 -18
  55. package/skills/crystallize/SKILL.md +7 -7
  56. package/skills/graph/SKILL.md +2 -2
  57. package/skills/ingest/SKILL.md +4 -4
  58. package/skills/lint/SKILL.md +2 -2
  59. package/skills/query/SKILL.md +2 -2
  60. package/skills/verify/SKILL.md +2 -2
  61. package/templates/SCHEMA.md +1 -1
  62. package/templates/hypo-config.md +1 -1
  63. package/templates/hypo-guide.md +20 -14
  64. package/templates/projects/_template/hot.md +1 -1
  65. package/scripts/fix-status-verify.mjs +0 -256
  66. package/scripts/lib/adr-corpus.mjs +0 -79
  67. package/scripts/lib/fix-manifest.mjs +0 -109
  68. package/scripts/lib/fix-status-verify.mjs +0 -439
@@ -129,7 +129,7 @@ export function lastSubstantialOpIsSession() {
129
129
  return /^## \[\d{4}-\d{2}-\d{2}\] session/.test(substantial[substantial.length - 1]);
130
130
  }
131
131
 
132
- // Returns the wiki's git state split into its two independent axes (ADR 0056):
132
+ // Returns the wiki's git state split into its two independent axes:
133
133
  // uncommitted — working-tree changes (real unsaved work; a human-fixable blocker)
134
134
  // ahead — committed-but-unpushed commits (a soft, auto-synced state: the
135
135
  // auto-commit Stop hook pushes, and push failures are non-fatal —
@@ -232,7 +232,7 @@ export function hasSessionLogHeading(content, date) {
232
232
 
233
233
  /**
234
234
  * Canonical session-log shard path (repo-relative POSIX) for a single day.
235
- * ADR 0050 / option D: the date IS the filename (`YYYY-MM-DD.md`), so no
235
+ * Option D: the date IS the filename (`YYYY-MM-DD.md`), so no
236
236
  * "current part" resolver is needed and the per-session write touches a small
237
237
  * file instead of a multi-thousand-line monthly log. This is the WRITE target.
238
238
  */
@@ -352,41 +352,221 @@ function parseFrontmatterField(content, key) {
352
352
  .replace(/^['"]|['"]$/g, '');
353
353
  }
354
354
 
355
- // Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
356
- // is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
357
- // when cwd is falsy or matches none. resume gives this authority OVER recency
358
- // (ADR 0044); close callers never pass cwd, so it stays inert for them.
359
- function pickByCwd(hypoDir, slugs, cwd) {
360
- if (!cwd) return null;
361
- let best = null;
362
- let bestLen = -1;
363
- for (const slug of slugs) {
364
- const indexPath = join(hypoDir, 'projects', slug, 'index.md');
355
+ // ── cwd project matcher ────────────────────────────────────────────────────
356
+ // Hand-synced with scripts/lib/wd-match.mjs: hooks deploy to ~/.claude/hooks/
357
+ // without scripts/, so this cannot import the lib and must mirror it. Keep the
358
+ // two in step; the lib carries the unit tests.
359
+
360
+ // Expand a leading ~/ (or bare ~), strip trailing slashes. null for empty.
361
+ export function normalizeWorkingDir(p) {
362
+ if (!p) return null;
363
+ let s = String(p).trim();
364
+ if (s === '~') s = homedir();
365
+ else if (s.startsWith('~/')) s = `${homedir()}/${s.slice(2)}`;
366
+ s = s.replace(/\/+$/, '');
367
+ return s || null;
368
+ }
369
+
370
+ // macOS/Windows match paths case-insensitively; Linux does not. Fold only there.
371
+ export function isCaseInsensitiveFs(platform = process.platform) {
372
+ return platform === 'darwin' || platform === 'win32';
373
+ }
374
+
375
+ function _fold(s, ci) {
376
+ return ci ? s.toLowerCase() : s;
377
+ }
378
+ function _lastSeg(p) {
379
+ const i = p.lastIndexOf('/');
380
+ return i < 0 ? p : p.slice(i + 1);
381
+ }
382
+
383
+ // Resolve which project owns cwd. Tier 1: longest absolute working_dir prefix
384
+ // (the original behavior). Tier 2 (cross-machine): when the synced vault holds
385
+ // another machine's absolute path, a cwd ancestor whose directory name is a
386
+ // GLOBALLY unique project basename identifies the project; a shared dirname
387
+ // declines (null) so the caller falls back to recency. `projects` is the whole
388
+ // universe (for the uniqueness gate); `eligible` restricts the answer.
389
+ export function pickProjectByCwd(projects, cwd, opts = {}) {
390
+ const { eligible = null, realpathCwd = null, caseInsensitive = isCaseInsensitiveFs() } = opts;
391
+ if (!cwd && !realpathCwd) return null;
392
+ const eligibleSet = eligible ? new Set(eligible) : null;
393
+ const isEligible = (slug) => !eligibleSet || eligibleSet.has(slug);
394
+
395
+ const entries = [];
396
+ for (const p of projects) {
397
+ const path = normalizeWorkingDir(p.workingDir);
398
+ if (path) entries.push({ slug: p.slug, path });
399
+ }
400
+ if (entries.length === 0) return null;
401
+
402
+ // raw cwd first, realpath only as a fallback (a raw match must not be
403
+ // overridden by a longer realpath match).
404
+ const cwds = [];
405
+ for (const c of [cwd, realpathCwd]) {
406
+ const n = normalizeWorkingDir(c);
407
+ if (n && !cwds.includes(n)) cwds.push(n);
408
+ }
409
+
410
+ // Tier 1: first cwd variant with any longest-prefix match wins.
411
+ for (const c of cwds) {
412
+ const cf = _fold(c, caseInsensitive);
413
+ let bestSlug = null;
414
+ let bestLen = -1;
415
+ for (const e of entries) {
416
+ if (!isEligible(e.slug)) continue;
417
+ const pf = _fold(e.path, caseInsensitive);
418
+ if ((cf === pf || cf.startsWith(`${pf}/`)) && e.path.length > bestLen) {
419
+ bestLen = e.path.length;
420
+ bestSlug = e.slug;
421
+ }
422
+ }
423
+ if (bestSlug) return bestSlug;
424
+ }
425
+
426
+ // Tier 2: unique-basename ancestor, but only when the chain points at exactly
427
+ // ONE project (two distinct matches along the path → decline, fail closed).
428
+ const byBasename = new Map();
429
+ for (const e of entries) {
430
+ const b = _fold(_lastSeg(e.path), caseInsensitive);
431
+ if (!b) continue;
432
+ const hit = byBasename.get(b);
433
+ if (hit) hit.count += 1;
434
+ else byBasename.set(b, { slug: e.slug, count: 1 });
435
+ }
436
+
437
+ for (const c of cwds) {
438
+ const matched = new Set();
439
+ let cur = c;
440
+ while (cur && cur.includes('/')) {
441
+ const b = _fold(_lastSeg(cur), caseInsensitive);
442
+ const hit = b && byBasename.get(b);
443
+ if (hit && hit.count === 1 && isEligible(hit.slug)) matched.add(hit.slug);
444
+ cur = cur.slice(0, cur.lastIndexOf('/'));
445
+ }
446
+ if (matched.size === 1) return [...matched][0];
447
+ }
448
+ return null;
449
+ }
450
+
451
+ // Disk companion: [{slug, workingDir}] for every real project (skips _template
452
+ // and dirs without index.md). The full set is the tier-2 uniqueness universe.
453
+ // working_dir is cleaned exactly like scripts/lib/frontmatter.mjs parseFrontmatter
454
+ // (strip a trailing ` # comment`, then surrounding quotes) so this stays in step
455
+ // with the script-side collector — parseFrontmatterField alone skips the comment.
456
+ export function collectProjectWorkingDirs(hypoDir) {
457
+ const projectsDir = join(hypoDir, 'projects');
458
+ if (!existsSync(projectsDir)) return [];
459
+ const out = [];
460
+ for (const slug of readdirSync(projectsDir)) {
461
+ if (slug === '_template') continue;
462
+ const dir = join(projectsDir, slug);
463
+ try {
464
+ if (!statSync(dir).isDirectory()) continue;
465
+ } catch {
466
+ continue;
467
+ }
468
+ const indexPath = join(dir, 'index.md');
365
469
  if (!existsSync(indexPath)) continue;
366
- const wd = parseFrontmatterField(readFileSync(indexPath, 'utf-8'), 'working_dir');
367
- if (!wd) continue;
368
- let resolved = wd.startsWith('~/') ? join(homedir(), wd.slice(2)) : wd;
369
- resolved = resolved.replace(/\/+$/, ''); // trailing-slash normalize
370
- if ((cwd === resolved || cwd.startsWith(resolved + '/')) && resolved.length > bestLen) {
371
- bestLen = resolved.length;
372
- best = slug;
470
+ let workingDir = null;
471
+ try {
472
+ const fm = readFileSync(indexPath, 'utf-8').match(/^---\r?\n([\s\S]*?)\r?\n---/);
473
+ const line = fm && fm[1].split(/\r?\n/).find((l) => /^working_dir:/.test(l));
474
+ if (line) {
475
+ workingDir = line
476
+ .slice('working_dir:'.length)
477
+ .trim()
478
+ .replace(/\s+#.*$/, '')
479
+ .replace(/^['"]|['"]$/g, '');
480
+ }
481
+ } catch {
482
+ workingDir = null;
373
483
  }
484
+ out.push({ slug, workingDir: workingDir || null });
485
+ }
486
+ return out;
487
+ }
488
+
489
+ /**
490
+ * When the session cwd is a project working_dir distinct from the vault root,
491
+ * the wiki/knowledge files live in the VAULT, not in this cwd. SessionStart and
492
+ * CwdChanged inject hot.md/session-state content but never the vault's absolute
493
+ * path, so the AI re-discovers it each session and can wrongly conclude a wiki
494
+ * file is missing after checking only the code repo (a real misjudgment seen in
495
+ * a dev-repo session, 2026-06-23).
496
+ *
497
+ * Returns a one-line "look in the vault, not here" orientation carrying the
498
+ * absolute vault path, or '' when cwd is anywhere inside the vault tree (the
499
+ * "wiki files live in the vault, not here" framing would be false there) or
500
+ * hypoDir is unset. The HIT matcher is prefix-based, so a project whose
501
+ * working_dir is the vault root can match a vault SUBDIRECTORY; checking only
502
+ * exact root-equality would wrongly fire for those. Compared via realpath so a
503
+ * symlinked cwd or vault still matches.
504
+ *
505
+ * Containment uses the SAME normalize + case-fold + prefix policy as
506
+ * pickProjectByCwd so the two never disagree: on a case-insensitive FS the
507
+ * matcher case-folds, so a cwd differing only in case is still a HIT and must be
508
+ * suppressed here too (otherwise a false orientation leaks).
509
+ *
510
+ * @param {string} cwd the session cwd (already known to be a project HIT)
511
+ * @param {string} [hypoDir=HYPO_DIR]
512
+ * @param {{caseInsensitive?: boolean}} [opts] override FS case policy (tests)
513
+ * @returns {string}
514
+ */
515
+ export function buildVaultOrientation(cwd, hypoDir = HYPO_DIR, opts = {}) {
516
+ if (!cwd || !hypoDir) return '';
517
+ const { caseInsensitive = isCaseInsensitiveFs() } = opts;
518
+ let realCwd = cwd;
519
+ let realVault = hypoDir;
520
+ try {
521
+ realCwd = realpathSync(cwd);
522
+ } catch {
523
+ /* keep raw path when cwd is unreadable */
374
524
  }
375
- return best;
525
+ try {
526
+ realVault = realpathSync(hypoDir);
527
+ } catch {
528
+ /* keep raw path when vault is unreadable */
529
+ }
530
+ // Suppress when cwd is the vault root OR a descendant of it.
531
+ const c = _fold(normalizeWorkingDir(realCwd) || '', caseInsensitive);
532
+ const v = _fold(normalizeWorkingDir(realVault) || '', caseInsensitive);
533
+ if (!c || !v) return '';
534
+ if (c === v || c.startsWith(`${v}/`)) return '';
535
+ return (
536
+ `[WIKI VAULT: ${hypoDir}] 이 cwd는 작업/코드 레포이고 vault가 아니다. ` +
537
+ `wiki·knowledge·세션로그 파일은 여기가 아니라 vault(${hypoDir})에서 조회한다.`
538
+ );
539
+ }
540
+
541
+ // resume/close entry: match `slugs` against cwd via the two-tier matcher.
542
+ // Uniqueness is judged over EVERY project on disk, not just `slugs`. close
543
+ // callers pass no cwd, so it stays inert for them.
544
+ function pickByCwd(hypoDir, slugs, cwd) {
545
+ if (!cwd) return null;
546
+ let realpathCwd = null;
547
+ try {
548
+ realpathCwd = realpathSync(cwd);
549
+ } catch {
550
+ realpathCwd = null;
551
+ }
552
+ return pickProjectByCwd(collectProjectWorkingDirs(hypoDir), cwd, {
553
+ eligible: slugs,
554
+ realpathCwd,
555
+ });
376
556
  }
377
557
 
378
558
  /**
379
559
  * Resolve the active project slug from root hot.md. With a cwd, a project whose
380
- * working_dir contains it wins (cwd-first, ADR 0044); otherwise the
560
+ * working_dir contains it wins (cwd-first); otherwise the
381
561
  * most-recently-active row is returned.
382
562
  * The cwd helpers (parseFrontmatterField / pickByCwd) and the cwd-first body
383
563
  * are kept in sync with scripts/resume.mjs by hand; the surrounding wrapper
384
564
  * intentionally differs (resume.mjs adds an mtime fallback, this does not).
385
- * `cwd` is an optional cwd-first selector (ADR 0044): a cwd↔working_dir match
565
+ * `cwd` is an optional cwd-first selector: a cwd↔working_dir match
386
566
  * wins over recency. resume passes process.cwd(); session-close callers
387
567
  * (sessionCloseFileStatus / closeFileTargets) intentionally pass null — close
388
- * has a different authority (payload.project / freshness, the global invariant
389
- * of ADR 0043), so it never picks by cwd. When cwd is omitted, behavior is
568
+ * has a different authority (payload.project / freshness, the global invariant),
569
+ * so it never picks by cwd. When cwd is omitted, behavior is
390
570
  * identical to the legacy recency version.
391
571
  * @param {string} hypoDir
392
572
  * @param {string|null} [cwd]
@@ -410,7 +590,7 @@ export function resolveActiveProject(hypoDir, cwd = null) {
410
590
  ),
411
591
  ].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
412
592
  if (wikiRows.length > 0) {
413
- // cwd-first (ADR 0044): a cwd↔working_dir match wins over recency, across
593
+ // cwd-first: a cwd↔working_dir match wins over recency, across
414
594
  // ALL rows. Kept in sync with scripts/resume.mjs. close callers pass null →
415
595
  // recency path below (resume=cwd-positive / close=no-pick).
416
596
  if (cwd) {
@@ -445,7 +625,7 @@ export function resolveActiveProject(hypoDir, cwd = null) {
445
625
  * - projects/<project>/hot.md — frontmatter `updated:` is today
446
626
  * - hot.md (root) — frontmatter `updated:` is today
447
627
  * - projects/<project>/session-log/YYYY-MM-DD.md — has a `## [today]` heading
448
- * (daily shard, ADR 0050; legacy YYYY-MM.md is still accepted as fallback)
628
+ * (daily shard; legacy YYYY-MM.md is still accepted as fallback)
449
629
  * - log.md — has a `## [today] session | <project>` entry
450
630
  * The log.md check is project-scoped so a session close left incomplete for
451
631
  * project A can't be masked by a fresh close of project B (and vice versa).
@@ -499,7 +679,7 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
499
679
  checkUpdated(join('projects', project, 'hot.md'));
500
680
  checkUpdated('hot.md');
501
681
 
502
- // session-log: daily shard (ADR 0050), with legacy monthly fallback must
682
+ // session-log: daily shard, with legacy monthly fallback: must
503
683
  // carry a today-dated heading in whichever file holds it. Daily-first read
504
684
  // order short-circuits on the small shard. When no match is found, the gap is
505
685
  // reported under the canonical daily shard for the local date (dates[0]).
@@ -544,19 +724,19 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
544
724
  return { ok: stale.length === 0 && missing.length === 0, project, dates, stale, missing };
545
725
  }
546
726
 
547
- // ── global session-close gate (ADR 0043) ────
727
+ // ── global session-close gate ────────────────
548
728
  // The no-payload close paths must NOT pick one project (recency / cwd) and check
549
729
  // it — that re-derivation is the prior session-close false-block, and a cwd
550
730
  // tie-break here would let a fresh cwd mask a DIFFERENT project's dangling
551
731
  // close. Instead the gate enforces a global invariant: no project may end a
552
- // session with a partial close. resume stays cwd-positive (ADR 0044); close
732
+ // session with a partial close. resume stays cwd-positive; close
553
733
  // never picks. The two copies of resolveActiveProject share the cwd-first body
554
734
  // but the resume.mjs copy adds an mtime fallback this one omits — see resume.mjs.
555
735
 
556
736
  // Root hot.md Active-Projects rows as {slug, date}. The per-row date column is
557
737
  // project-scoped (unlike the shared frontmatter `updated:`). Used for candidate
558
- // DISCOVERY in closeCandidateSlugs NOT as close-activity evidence: ADR 0057
559
- // dropped the row date as an activity signal because project-create and
738
+ // DISCOVERY in closeCandidateSlugs, not as close-activity evidence: the row date
739
+ // was dropped as an activity signal because project-create and
560
740
  // hypo-hot-rebuild both stamp rows today without a real session. Mirrors
561
741
  // resolveActiveProject's regex.
562
742
  function rootHotRows(hypoDir) {
@@ -625,9 +805,9 @@ function closeCandidateSlugs(hypoDir, dates) {
625
805
  // True when project P shows an AUTHORITATIVE today close-activity signal: a
626
806
  // today-dated session-log heading, or a today-dated `## [today] session | P`
627
807
  // log.md entry. These are written ONLY by a real session close (apply, or its
628
- // auto-derived root log — ADR 0049).
808
+ // auto-derived root log).
629
809
  //
630
- // ADR 0057: soft state files are EXCLUDED, because each is bumped to today by
810
+ // Soft state files are EXCLUDED, because each is bumped to today by
631
811
  // non-session tooling and is therefore indistinguishable from a real close:
632
812
  // - session-state.md `updated:` — tracker bookkeeping mirrors a new item into
633
813
  // the "next tasks" section (a cross-block incident: editing one project's
@@ -641,7 +821,7 @@ function closeCandidateSlugs(hypoDir, dates) {
641
821
  // Tradeoff (documented, accepted): apply writes session-state.md FIRST, then the
642
822
  // project files, then the session-log + log entry. A process crash before the
643
823
  // session-log write leaves a torn close that this gate no longer flags. Accepted:
644
- // a torn close never reached apply's ok=true so it wrote no marker (ADR 0055/0056);
824
+ // a torn close never reached apply's ok=true so it wrote no marker;
645
825
  // the surviving session-state is the resume pointer the next session overwrites;
646
826
  // what is lost is a narrative log entry, not continuity.
647
827
  function hasTodayCloseActivity(hypoDir, project, dates) {
@@ -669,7 +849,7 @@ function hasTodayCloseActivity(hypoDir, project, dates) {
669
849
  }
670
850
 
671
851
  /**
672
- * Global session-close status for the no-payload close paths (ADR 0043).
852
+ * Global session-close status for the no-payload close paths.
673
853
  * Checks EVERY project with today close-activity; ok only when all are complete.
674
854
  * When no project has today activity, falls back to the legacy single recency
675
855
  * project (preserves "force the initial close" behavior, byte-identical gate).
@@ -690,7 +870,7 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
690
870
  // bypass discovery and report the single project, preserving the global return
691
871
  // shape. NEVER threaded from a marker-writing path (--mark / apply auto-marker /
692
872
  // PreCompact): those stay global so the marker == compact-ready invariant holds
693
- // (ADR 0047, codex design review). A green status here is a project-scoped
873
+ // (codex design review). A green status here is a project-scoped
694
874
  // diagnostic, not the global compact-readiness verdict.
695
875
  if (opts.projectOverride) {
696
876
  const s = sessionCloseFileStatus(hypoDir, { projectOverride: opts.projectOverride });
@@ -754,7 +934,7 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
754
934
  // for EVERY today-active project — cross-session, looking like a fresh bug each
755
935
  // time. This derives the missing entry from the session-log heading so the gate
756
936
  // never blocks on a purely-derivable gap. The session-closed MARKER is NOT derived
757
- // here: it is a proof artifact the close gate actually ran (ADR 0022 invariant).
937
+ // here: it is a proof artifact the close gate actually ran.
758
938
  //
759
939
  // Safety guard (codex design review): derive ONLY for a project whose close is
760
940
  // otherwise complete — i.e. its sole gate problem is log.md. If session-state /
@@ -923,7 +1103,7 @@ export function appendSyncFailure(hypoDir, op, error) {
923
1103
  * never left half-merged. Called by the auto-commit Stop hook after a local
924
1104
  * commit succeeds.
925
1105
  *
926
- * Failure policy (v1.4 "sync hardening", tracker FEAT-17):
1106
+ * Failure policy (v1.4 "sync hardening"):
927
1107
  * - clean fast-forward / conflict-free merge → push.
928
1108
  * - MERGE CONFLICT (`git pull --no-rebase` leaves unmerged paths): abort the
929
1109
  * merge so the tree returns to the just-committed local state ("ours"),
@@ -932,7 +1112,7 @@ export function appendSyncFailure(hypoDir, op, error) {
932
1112
  * is lost: ours stays committed locally, "theirs" stays on the remote, and
933
1113
  * the divergence is surfaced by session-start + doctor until the user merges
934
1114
  * manually. Inline auto-resolution (preserving the losing version as a
935
- * `.conflict-*` sibling) is deferred — see tracker PRAC-18.
1115
+ * `.conflict-*` sibling) is deferred.
936
1116
  * - non-conflict pull failure (network/auth: no unmerged paths) → record
937
1117
  * op='pull', then still attempt push (a transient pull blip should not block
938
1118
  * an otherwise-pushable commit); record op='push' if that also fails.
@@ -987,7 +1167,7 @@ export function syncRemote(hypoDir) {
987
1167
  * remote sync stays in the auto-commit Stop hook (commit is local + cheap; sync is
988
1168
  * network + soft-fail). Shared by hypo-auto-commit.mjs and crystallize.mjs's
989
1169
  * --apply-session-close path so the .hypoignore staging filter cannot diverge
990
- * between the two commit loci (ADR 0056).
1170
+ * between the two commit loci.
991
1171
  *
992
1172
  * "Nothing to commit" (clean tree, or only .hypoignore'd changes) is SUCCESS, not
993
1173
  * failure — the caller's tree is already in the committed state it wanted.
@@ -1065,7 +1245,7 @@ export function clearSyncState(hypoDir) {
1065
1245
  }
1066
1246
  }
1067
1247
 
1068
- // ── auto-project suggestion (ADR 0023) ────────────────────────────
1248
+ // ── auto-project suggestion ────────────────────────────────────────
1069
1249
  // `.cache/project-suggestions.json` is a single JSON object:
1070
1250
  // { "skips": [{cwd, declined_at, reason}], "cooldowns": {"<cwd>": "<iso>"} }
1071
1251
  // `skips` is written by the LLM (Layer-1 behavioral rule) when the user answers
@@ -1139,7 +1319,7 @@ export function cwdHasProjectMarker(cwd) {
1139
1319
  /**
1140
1320
  * Decide whether SessionStart/CwdChanged should offer to create a project for
1141
1321
  * `cwd`. The caller MUST have already confirmed `cwd` matches no project's
1142
- * `working_dir` (the hook's MISS branch); this evaluates the remaining ADR 0023
1322
+ * `working_dir` (the hook's MISS branch); this evaluates the remaining
1143
1323
  * trigger conditions: (a) cwd is a git repo, (b) carries a project marker
1144
1324
  * (`.git` alone is a weak signal — §8.11), (c) not in cooldown, (d) not a cwd
1145
1325
  * the user previously declined. A corrupt store stays silent (doctor surfaces).
@@ -1184,7 +1364,7 @@ export function buildProjectSuggestionLine(cwd) {
1184
1364
  return `[WIKI: cwd '${safe}'에 매칭되는 프로젝트가 없습니다. 자동 생성할까요? (Y/n)]`;
1185
1365
  }
1186
1366
 
1187
- // ── clear-marker (ADR 0022 amendment 2026-05-14) ────────────
1367
+ // ── clear-marker (amendment 2026-05-14) ──────────────────────
1188
1368
  // `/clear` cannot be blocked (no UserPromptSubmit fire). The only intervention
1189
1369
  // point is the SessionEnd(reason='clear') → SessionStart(source='clear') pair:
1190
1370
  // SessionEnd writes `.cache/clear-marker.json` with the dying session's id +
@@ -1202,7 +1382,7 @@ function clearMarkerPath(hypoDir) {
1202
1382
 
1203
1383
  /**
1204
1384
  * Persist the dying session's identity so the next SessionStart(source=clear)
1205
- * can issue a recovery nudge. Single-file by design (see ADR 0022 amendment):
1385
+ * can issue a recovery nudge. Single-file by design (see the amendment):
1206
1386
  * /clear is a single-client UX action, multi-marker disambiguation buys no
1207
1387
  * safety and breaks the 1-of-1 read-and-unlink contract.
1208
1388
  *
@@ -1269,7 +1449,7 @@ export function clearClearMarker(hypoDir) {
1269
1449
  }
1270
1450
  }
1271
1451
 
1272
- // ── session-closed marker (ADR 0022 amendment 2026-05-19) ────
1452
+ // ── session-closed marker (amendment 2026-05-19) ─────────────
1273
1453
  // Per-session marker proving session-close completed. Stop hook
1274
1454
  // (`hypo-auto-minimal-crystallize`) reads it; `scripts/crystallize.mjs` writes
1275
1455
  // it after a verified close. Per-session (not per-day) precision resolves the
@@ -1277,7 +1457,7 @@ export function clearClearMarker(hypoDir) {
1277
1457
  // later session reuses an earlier session's entry on the same day.
1278
1458
  //
1279
1459
  // Writer authority lives in crystallize, NOT this hook: the hook only checks
1280
- // presence. See ADR 0022 amendment 2026-05-19 Q2 for the split rationale.
1460
+ // presence. See amendment 2026-05-19 Q2 for the split rationale.
1281
1461
 
1282
1462
  const SESSION_CLOSED_MARKER_STALE_MS = 7 * 24 * 60 * 60 * 1000;
1283
1463
 
@@ -1372,7 +1552,7 @@ export function clearSessionClosedMarker(hypoDir, sessionId) {
1372
1552
  }
1373
1553
  }
1374
1554
 
1375
- // ── transcript activity heuristic (ADR 0022 amendment 2026-05-19; 6a 2026-06-14) ──
1555
+ // ── transcript activity heuristic (amendment 2026-05-19; 6a 2026-06-14) ─────────
1376
1556
  // Substantial-session gate for the Stop hook: a session "worth" blocking on for
1377
1557
  // session-close is either (a) any mutation (Edit/Write/MultiEdit/NotebookEdit)
1378
1558
  // or (b) a high-volume read-only investigation (≥ READONLY_SUBSTANTIAL_THRESHOLD
@@ -1600,7 +1780,7 @@ export function closeFileTargets(hypoDir) {
1600
1780
 
1601
1781
  /**
1602
1782
  * Global variant of closeFileTargets for the no-payload lint-scope callers
1603
- * (ADR 0043). Union of the close files over every today-active project
1783
+ * Union of the close files over every today-active project
1604
1784
  * (fallback: the recency project when none is active). Includes the session-log
1605
1785
  * evidence file for EVERY freshDate (not just dates[0]) so the lint scope matches
1606
1786
  * what sessionCloseFileStatus actually checks across a local/UTC date boundary.
@@ -1671,6 +1851,21 @@ export function partitionLintScope(findings, scope) {
1671
1851
  return { blocking, notice };
1672
1852
  }
1673
1853
 
1854
+ /**
1855
+ * True if a repo-relative file lives under any of the given project dirs
1856
+ * (`projects/<slug>/...`, or the dir itself). Both surfaces that surface
1857
+ * pre-existing lint-debt NOTICES use this to decide what to LIST vs fold: debt
1858
+ * under a close-target project is the close's own neighborhood and stays listed;
1859
+ * debt elsewhere (other projects, shared `pages/`, root `hot.md`/`log.md`) folds
1860
+ * to a count so the same untouched-file debt does not re-list its filenames on
1861
+ * every close. Same path policy as the lint-scope matcher above: separators
1862
+ * normalized to POSIX, exact prefix at a segment boundary.
1863
+ */
1864
+ export function isUnderProjectDirs(file, slugs) {
1865
+ const f = posixPath(file);
1866
+ return (slugs || []).some((s) => s && (f === `projects/${s}` || f.startsWith(`projects/${s}/`)));
1867
+ }
1868
+
1674
1869
  // ── PreCompact gate — single source of truth ────────────────────────────────
1675
1870
  /**
1676
1871
  * The full PreCompact gate decision as a READ-ONLY status. This is the single
@@ -1680,9 +1875,9 @@ export function partitionLintScope(findings, scope) {
1680
1875
  *
1681
1876
  * Read-only: feedback projection PURE drift is reported as a non-blocking notice
1682
1877
  * with its targets in `driftTargets` (an "effect requirement"), NOT a blocker —
1683
- * the hook self-heals it with `feedback-sync --write` before continuing (ADR
1684
- * 0045) and a verify caller needs no human action for it. over-cap and conflict
1685
- * DO block (ADR 0031 rules 3 & 6 — human demote/import required).
1878
+ * the hook self-heals it with `feedback-sync --write` before continuing,
1879
+ * and a verify caller needs no human action for it. over-cap and conflict
1880
+ * DO block (human demote/import required).
1686
1881
  *
1687
1882
  * Faithfulness caveats (why "compact-ready", not "guaranteed pass"): the hook
1688
1883
  * has paths outside this status — a context-≥70% early block, HYPO_SKIP_GATE
@@ -1694,16 +1889,16 @@ export function partitionLintScope(findings, scope) {
1694
1889
  * opts.projectOverride (CHECK-ONLY) narrows BOTH the close status and the lint
1695
1890
  * scope to a single project, for `--check-session-close --project=<slug>`. A
1696
1891
  * green result is then a project-scoped diagnostic, NOT the global compact-ready
1697
- * verdict (ADR 0046) — the caller must surface the scope. It is NEVER passed from
1892
+ * verdict: the caller must surface the scope. It is NEVER passed from
1698
1893
  * a marker-writing path (--mark / apply auto-marker / PreCompact); those stay
1699
1894
  * global so a marker can't attest compact-ready while PreCompact re-checks red
1700
- * (the marker == compact-ready invariant, ADR 0047 / codex design review). When a
1895
+ * (the marker == compact-ready invariant, codex design review). When a
1701
1896
  * log-only marker governs the session, log-only mode wins and projectOverride is
1702
1897
  * ignored.
1703
1898
  *
1704
1899
  * @param {string} hypoDir
1705
1900
  * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null}} [opts]
1706
- * @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
1901
+ * @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string,file?:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
1707
1902
  */
1708
1903
  export function precompactGateStatus(hypoDir, opts = {}) {
1709
1904
  const blockers = [];
@@ -1724,11 +1919,11 @@ export function precompactGateStatus(hypoDir, opts = {}) {
1724
1919
  const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
1725
1920
  const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
1726
1921
 
1727
- // 1. wiki git state (ADR 0056). Uncommitted changes (real unsaved work) BLOCK
1922
+ // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK:
1728
1923
  // they are human-fixable. Unpushed commits (ahead) DEMOTE to a notice: push is
1729
1924
  // automatic (auto-commit Stop hook) and its failures are already non-fatal, so
1730
1925
  // "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
1731
- // here (the shared gate) keeps the marker == compact-ready invariant (ADR 0047):
1926
+ // here (the shared gate) keeps the marker == compact-ready invariant:
1732
1927
  // a committed-but-unpushed close marks AND compacts, instead of the close writer
1733
1928
  // committing its own payload and then being blocked by its own (unpushed) commit.
1734
1929
  const git = hypoIsClean(hypoDir);
@@ -1743,7 +1938,7 @@ export function precompactGateStatus(hypoDir, opts = {}) {
1743
1938
  const hot = hotMdIsClean(hypoDir);
1744
1939
  if (!hot.clean) blockers.push({ type: 'hot', reason: hot.reason });
1745
1940
 
1746
- // 3. session-close files (global invariant, ADR 0043) — or, in log-only mode,
1941
+ // 3. session-close files (global invariant); in log-only mode,
1747
1942
  // the minimum proof (a today log.md entry) with NO project attribution.
1748
1943
  let close;
1749
1944
  if (logOnly) {
@@ -1840,9 +2035,13 @@ export function precompactGateStatus(hypoDir, opts = {}) {
1840
2035
  });
1841
2036
  }
1842
2037
  for (const n of part.notice)
1843
- notices.push({ type: 'lint', reason: `${n.file}${n.id ? ` (${n.id})` : ''}` });
2038
+ notices.push({
2039
+ type: 'lint',
2040
+ file: n.file,
2041
+ reason: `${n.file}${n.id ? ` (${n.id})` : ''}`,
2042
+ });
1844
2043
  // W8 (design-history stale) is each today-active project's own close
1845
- // responsibility; others' are non-blocking notices (ADR 0043). In log-only
2044
+ // responsibility; others' are non-blocking notices. In log-only
1846
2045
  // mode there is NO project this session is accountable for, so every W8 is a
1847
2046
  // notice — a non-project session must never be blocked by some project's
1848
2047
  // stale design-history (codex design BLOCKER: the attribution leak this fix
@@ -1867,7 +2066,8 @@ export function precompactGateStatus(hypoDir, opts = {}) {
1867
2066
  reason: `design-history stale: ${w8Blocking.map((w) => w.file.split('/')[1]).join(', ')}`,
1868
2067
  });
1869
2068
  }
1870
- for (const w of w8Notice) notices.push({ type: 'design-history', reason: w.file });
2069
+ for (const w of w8Notice)
2070
+ notices.push({ type: 'design-history', file: w.file, reason: w.file });
1871
2071
  } catch (e) {
1872
2072
  skipped.lint = true; // fail-open on tooling error
1873
2073
  // Surface WHY the gate skipped lint (truncated stdout, timeout, spawn error)
@@ -1879,7 +2079,7 @@ export function precompactGateStatus(hypoDir, opts = {}) {
1879
2079
  }
1880
2080
  }
1881
2081
 
1882
- // 5. feedback projection (ADR 0031 / 0045). over-cap/conflict block; pure
2082
+ // 5. feedback projection. over-cap/conflict block; pure
1883
2083
  // drift is a self-healable notice (driftTargets = effect requirement the
1884
2084
  // hook runs as --write). Classification mirrors hypo-personal-check exactly.
1885
2085
  const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
@@ -1992,7 +2192,7 @@ export function isClearCommand(prompt) {
1992
2192
  return prompt === '/clear' || /^\/clear(\s|$)/.test(prompt);
1993
2193
  }
1994
2194
 
1995
- /** Returns true if the prompt is either /compact or /clear (ADR 0022 Layer 2). */
2195
+ /** Returns true if the prompt is either /compact or /clear (Layer 2). */
1996
2196
  export function isCompactOrClearCommand(prompt) {
1997
2197
  return isCompactCommand(prompt) || isClearCommand(prompt);
1998
2198
  }
@@ -2023,7 +2223,7 @@ export function extractUserMessages(transcriptPath, tailN = 30) {
2023
2223
  .map((line) => {
2024
2224
  try {
2025
2225
  const obj = JSON.parse(line);
2026
- // Skill-injection vector (ADR 0055): drop system-injected role:user
2226
+ // Skill-injection vector: drop system-injected role:user
2027
2227
  // messages before they pollute the close-intent signal.
2028
2228
  // • isMeta:true — slash-command bodies, skill bodies, local-command
2029
2229
  // caveats. Their text is docs/specs, often full of close vocabulary
@@ -2081,7 +2281,7 @@ export function isClosePattern(text) {
2081
2281
  // 끝: bare terminal noun, boundary-guarded so "세션 끝내는 방법" /
2082
2282
  // "세션 끝나면" don't trip.
2083
2283
  /세션\s*끝(?![가-힣])/,
2084
- // 세션 마무리/종료 (ADR 0055): the OLD pattern required a fixed verb suffix
2284
+ // 세션 마무리/종료: the OLD pattern required a fixed verb suffix
2085
2285
  // (하자/할게/했어) and missed the most common real phrasings — "세션 마무리
2086
2286
  // 해줘" (imperative), bare "세션 마무리", "세션마무리" (no space). A
2087
2287
  // blacklist lookahead (excluding 조건/로직/여부/…) is whack-a-mole because
@@ -2097,8 +2297,8 @@ export function isClosePattern(text) {
2097
2297
  // the rare FN over the FP. The residual: connective forms that happen to put a
2098
2298
  // space after a complete terminal can't be separated by regex without a
2099
2299
  // morphological parser — that's bounded by the compound gate (precompact-green
2100
- // + signal) and the unambiguous /compact and AskUserQuestion channels (ADR 0055
2101
- // threat boundary), not chased further.
2300
+ // + signal) and the unambiguous /compact and AskUserQuestion channels (threat
2301
+ // boundary), not chased further.
2102
2302
  /세션\s*(?:마무리|종료)(?:\s*(?:해줘|해주세요|해요|해|하자|하죠|했어|했다|했음|했지|합시다|합니다|할게|할께|할래|할까|할까요|한\s?거(?:지|야|니)?|함)(?![가-힣])|\s*[)\].,!?~。…]|\s*$)/m,
2103
2303
  /오늘\s*은?\s*(여기|작업|세션).*(끝|마치|마무리|종료)/,
2104
2304
  // 여기까지: requires no continuation action word (e.g. 여기까지 구현해줘 is not a close signal)
@@ -2116,7 +2316,7 @@ export function isClosePattern(text) {
2116
2316
  // objects. The review/analysis/debug/audit/investigation nouns were added
2117
2317
  // for 6a — read-only review sessions are now "substantial", so "wrap up the
2118
2318
  // review" must read as a task-level signal, not a session-close one.
2119
- // Leading \b on each pattern (ADR 0055) so an EN close phrase embedded as a
2319
+ // Leading \b on each pattern so an EN close phrase embedded as a
2120
2320
  // substring of a longer token can't trip the gate (e.g. "designing off…").
2121
2321
  /\bwrap(?:ping)?\s+up(?!\s+(?:this|the)\s+(?:pr|issue|bug|task|function|component|module|feature|code|test|review|analysis|investigation|debugging|debug|audit|refactor)\b)/i,
2122
2322
  /\bdone\s+for\s+(?:today|now|the\s+day)\b/i,
@@ -2138,7 +2338,7 @@ export function isClosePattern(text) {
2138
2338
  * slug would miss the file. The session id is a UUID, so the glob disambiguates
2139
2339
  * without needing the slug — verified globally unique across all project dirs.
2140
2340
  *
2141
- * Fail-closed on ambiguity (ADR 0055, codex review): returns the single resolved
2341
+ * Fail-closed on ambiguity (codex review): returns the single resolved
2142
2342
  * path, or null when ZERO or MORE-THAN-ONE distinct files match (realpath-deduped
2143
2343
  * so a symlink to the same file is not "multiple"). The caller treats null as
2144
2344
  * "refuse the marker".
@@ -2175,13 +2375,13 @@ export function resolveTranscriptBySessionId(
2175
2375
 
2176
2376
  /**
2177
2377
  * Returns true iff the transcript carries a genuine USER session-close signal —
2178
- * the hard gate for the session-closed marker writers (ADR 0055). Scans the FULL
2378
+ * the hard gate for the session-closed marker writers. Scans the FULL
2179
2379
  * transcript: a close request can precede the marker write by the entire close
2180
2380
  * checklist, so the Stop hook's 30-line tail would miss it.
2181
2381
  *
2182
2382
  * Evidence (any one is sufficient):
2183
2383
  * 1. a de-polluted NL close phrase — isClosePattern over extractUserMessages,
2184
- * which already drops injected / tool / hook-feedback text (ADR 0055);
2384
+ * which already drops injected / tool / hook-feedback text;
2185
2385
  * 2. a `/compact` invocation (queue-operation). `/clear` is deliberately NOT
2186
2386
  * counted: it abandons context, whereas a session-close PRESERVES the work
2187
2387
  * to the wiki — a different intent;
@@ -2264,7 +2464,7 @@ export function buildOutput(context, extra = {}) {
2264
2464
  // ── growth metrics (F2 + E4) ───────────────────────────────────────────────
2265
2465
  // Single formatter used by Stop (hot-rebuild) and SessionStart hooks so the
2266
2466
  // "[hypo] +N pages, ~M updated, K wikilinks" line stays consistent at both
2267
- // ends of a session. See ADR-0018 / Lane B.
2467
+ // ends of a session. See Lane B.
2268
2468
 
2269
2469
  /**
2270
2470
  * Format a growth-metrics one-liner. Returns '' when all counts are 0 so
@@ -249,7 +249,7 @@ export function isOptedOut(env = process.env) {
249
249
  return Boolean(env.HYPO_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
250
250
  }
251
251
 
252
- // ── stale-sibling detection (ADR 0038) ───────────────────────────────────────
252
+ // ── stale-sibling detection ───────────────────────────────────────────────────
253
253
  //
254
254
  // A second, OLDER Hypomnema can sit on $PATH (e.g. a stale `npm i -g hypomnema`)
255
255
  // while a newer copy owns the active hooks. The CLI bin (`hypomnema`) then routes