hypomnema 1.8.2 → 1.9.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 (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +141 -51
  4. package/README.ko.md +18 -17
  5. package/README.md +16 -15
  6. package/commands/crystallize.md +89 -24
  7. package/commands/doctor.md +3 -1
  8. package/docs/ARCHITECTURE.md +45 -14
  9. package/docs/CONTRIBUTING.md +26 -8
  10. package/hooks/atomic-write.mjs +44 -0
  11. package/hooks/base-store.mjs +409 -90
  12. package/hooks/close-gate-store.mjs +131 -16
  13. package/hooks/close-journal.mjs +250 -0
  14. package/hooks/close-receipt.mjs +559 -0
  15. package/hooks/hooks.json +3 -38
  16. package/hooks/hypo-auto-commit.mjs +80 -10
  17. package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
  18. package/hooks/hypo-auto-stage.mjs +13 -1
  19. package/hooks/hypo-close-guard.mjs +2 -1
  20. package/hooks/hypo-cwd-change.mjs +45 -20
  21. package/hooks/hypo-file-watch.mjs +44 -6
  22. package/hooks/hypo-first-prompt.mjs +15 -9
  23. package/hooks/hypo-hot-rebuild.mjs +169 -112
  24. package/hooks/hypo-personal-check.mjs +9 -1
  25. package/hooks/hypo-session-record.mjs +1 -1
  26. package/hooks/hypo-session-start.mjs +566 -20
  27. package/hooks/hypo-shared.mjs +2250 -206
  28. package/hooks/hypo-stop.mjs +207 -0
  29. package/hooks/proposal-store.mjs +201 -35
  30. package/hooks/shared.json +15 -0
  31. package/hooks/version-check.mjs +9 -7
  32. package/package.json +3 -1
  33. package/scripts/crystallize.mjs +3 -4
  34. package/scripts/doctor.mjs +549 -123
  35. package/scripts/init.mjs +86 -109
  36. package/scripts/lib/core-hooks.mjs +48 -22
  37. package/scripts/lib/crystallize-close-apply.mjs +2397 -188
  38. package/scripts/lib/crystallize-close-check.mjs +43 -23
  39. package/scripts/lib/design-history-stale.mjs +36 -9
  40. package/scripts/lib/frontmatter.mjs +39 -0
  41. package/scripts/lib/git-hooks-dir.mjs +427 -52
  42. package/scripts/lib/hook-inventory.mjs +180 -0
  43. package/scripts/lib/page-usage.mjs +1 -1
  44. package/scripts/lib/pkg-provenance.mjs +18 -1
  45. package/scripts/lib/plugin-detect.mjs +43 -9
  46. package/scripts/lib/project-create.mjs +40 -44
  47. package/scripts/lib/schema-vocab.mjs +22 -11
  48. package/scripts/lib/template-schema-version.mjs +48 -0
  49. package/scripts/lib/time.mjs +46 -0
  50. package/scripts/lint.mjs +66 -18
  51. package/scripts/proposal.mjs +423 -18
  52. package/scripts/resume.mjs +3 -2
  53. package/scripts/session-audit.mjs +2 -1
  54. package/scripts/uninstall.mjs +144 -66
  55. package/scripts/upgrade.mjs +523 -234
  56. package/scripts/verify.mjs +2 -1
  57. package/scripts/weekly-report.mjs +2 -1
  58. package/templates/SCHEMA.md +27 -7
  59. package/templates/gitignore +11 -0
  60. package/templates/hypo-automation.md +10 -13
  61. package/templates/hypo-config.md +1 -1
  62. package/templates/hypo-guide.md +53 -25
  63. package/templates/hot.md +0 -24
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { readFileSync, writeFileSync, existsSync, realpathSync } from 'fs';
11
+ import { atomicWrite } from './atomic-write.mjs';
11
12
  import { homedir } from 'os';
12
13
  import { join, dirname } from 'path';
13
14
  import { fileURLToPath } from 'url';
@@ -39,6 +40,12 @@ import {
39
40
  readVisibilityScope,
40
41
  pkgRootDriftStatus,
41
42
  PKG_ROOT,
43
+ withFileLock,
44
+ vaultCommitLockTarget,
45
+ claimAndWriteRootHotProjection,
46
+ recordTouchedPaths,
47
+ consumeRootHotHealthNotice,
48
+ rootHotBackupRecoveryNotice,
42
49
  } from './hypo-shared.mjs';
43
50
  import {
44
51
  defaultCachePath,
@@ -52,6 +59,8 @@ import {
52
59
  computeSiblingNotice,
53
60
  siblingAlreadyNotified,
54
61
  markSiblingNotified,
62
+ classifyInstall,
63
+ parseSemver,
55
64
  pkgRootDriftAlreadyNotified,
56
65
  markPkgRootDriftNotified,
57
66
  clearPkgRootDriftNotified,
@@ -67,7 +76,7 @@ import {
67
76
  recordObserved,
68
77
  hashContent,
69
78
  } from './base-store.mjs';
70
- import { listProposals } from './proposal-store.mjs';
79
+ import { listProposalsChecked } from './proposal-store.mjs';
71
80
 
72
81
  // Privacy guard: refuse to read+inject .hypoignore-matched
73
82
  // wiki files into additionalContext. Without this, a user who lists
@@ -237,15 +246,176 @@ function buildSiblingNotice() {
237
246
  }
238
247
  }
239
248
 
249
+ // Full path to the file init/upgrade own writing (hypo-shared.mjs's
250
+ // readCachedPkgRoot reads it but does not export the path). Self-heal below
251
+ // reads and rewrites it directly rather than adding a write path into
252
+ // hypo-shared.mjs, which currently owns only the READ side of this file.
253
+ const HYPO_PKG_JSON_PATH = join(homedir(), '.claude', 'hypo-pkg.json');
254
+
255
+ // ── self-heal lock ───────────────────────────────────────────────────────────
256
+ // Two sessions can self-locate to two DIFFERENT install roots at once (a dual
257
+ // install, or one upgrade mid-flight while another session already started)
258
+ // and both hit this same homedir()-keyed hypo-pkg.json. Reading it once,
259
+ // comparing, then writing let whichever session finished SECOND clobber
260
+ // whatever the first had just written, even when its own incoming version
261
+ // was itself newer than the file's ORIGINAL value (Codex 3rd-round review,
262
+ // 2026-09-11: reproduced by running a 1.8.2 and a 1.8.1 install against the
263
+ // same HOME; both read the pre-existing 1.0.0, both judged themselves an
264
+ // improvement over it, and whichever wrote last won regardless of which
265
+ // incoming version was actually higher).
266
+ //
267
+ // This used to hand-roll its own `wx` lockfile (write-then-fill), which a
268
+ // Codex 3rd-round review caught publishing an empty lock before the pid was
269
+ // written: a holder preempted inside that window is indistinguishable from a
270
+ // pid-less legacy lock, so a second writer steals it and both run the
271
+ // critical section, which is the exact bug a liveness check is supposed to
272
+ // close. `withFileLock` (below, from hypo-shared.mjs) already exists in this
273
+ // repo, and its own docstring names that same failure mode as the reason it
274
+ // stages the pid into a private sibling first and `linkSync`s it into place
275
+ // atomically. Reuse it instead of repeating the mistake it was written to
276
+ // avoid. `timeoutMs: 150` keeps the bounded, best-effort wait this hook
277
+ // always had (long enough for a sibling session's heal, a handful of sync fs
278
+ // calls, to finish and release; short enough that SessionStart itself never
279
+ // stalls noticeably even under contention); `staleMs: 30000` keeps the same
280
+ // crashed-holder cutoff the old lock used.
281
+ //
282
+ // withFileLock throws ELOCKTIMEOUT when it cannot acquire in time, never a
283
+ // bare Error, so selfHealPkgRoot's caller can tell "lock contention" apart
284
+ // from "fn() itself failed". Either way this hook must fail OPEN: a
285
+ // SessionStart hook must never block or crash a session over a lock it
286
+ // couldn't get, so both outcomes collapse to the caller's `{ healed: false }`
287
+ // via the try/catch already wrapping the read-compare-write below.
288
+ //
289
+ // Known residue in withFileLock itself, inherited here rather than
290
+ // re-solved: a microscopic stat-then-unlink TOCTOU on the stale-steal path
291
+ // (a fresh holder can grab the path in that gap and have its lock removed by
292
+ // the stealer, bounded by staleMs being far above a normal close), a
293
+ // local-filesystem-only guarantee (no network FS atomicity), and a PID-reuse
294
+ // window where a live process happens to reuse a crashed holder's pid and
295
+ // gets treated as the still-alive original (an availability loss, the lock
296
+ // is never stolen from it, not a correctness bug, since two writers still
297
+ // never enter the critical section together).
298
+
299
+ /**
300
+ * Self-heal `pkgRoot`/`pkgVersion` in `hypo-pkg.json` in place, once a session
301
+ * has already detected they disagree with the code's own self-location
302
+ * (`status`, from pkgRootDriftStatus()). Until now the user's only remaining
303
+ * job was copying those two values into the file by hand; this does that copy
304
+ * for them.
305
+ *
306
+ * Preserves every other key untouched (`schemaVersion`, `extensions`,
307
+ * `commands`, ...): this file is shared with init/upgrade's own writes, so a
308
+ * narrower op than "read the whole object, patch two keys, write the whole
309
+ * object back" would silently drop state another lane depends on. Mirrors
310
+ * scripts/lib/pkg-json.mjs's writeDualSkipProvenance in spirit, but cannot
311
+ * import it — hooks never reach into scripts/.
312
+ *
313
+ * Fails closed to `{ healed: false }` on anything short of a verified,
314
+ * complete write: a lock that could not be acquired (timeout or a genuine
315
+ * fs error from `withFileLock`), an unreadable/corrupt existing file, a
316
+ * non-object parse result, no resolvable-and-parseable version at the new
317
+ * root, a DOWNGRADE (below), or the write itself throwing. The caller must
318
+ * fall back to the manual `/hypo:upgrade` guidance rather than claim a fix
319
+ * that never landed.
320
+ *
321
+ * Concurrency: the whole read-compare-write runs inside `withFileLock`
322
+ * (hypo-shared.mjs), which re-reads `hypo-pkg.json` AFTER acquiring the
323
+ * lock, not before, since a pre-lock snapshot is exactly what let two
324
+ * concurrent installs race. Losing the lock (ELOCKTIMEOUT, or any other
325
+ * throw from `withFileLock` or from `fn` itself) is caught by the outer
326
+ * try/catch and treated as fail-open: this session simply skips the heal,
327
+ * the same outcome as any other precondition miss below.
328
+ *
329
+ * The lock's reach is narrower than the sentence above about sharing this
330
+ * file with init/upgrade might suggest: it serializes SessionStart heals
331
+ * against each other, and nothing else. `writePkgJsonAtomic` (what init,
332
+ * upgrade and writeDualSkipProvenance go through) does not take it, so an
333
+ * `upgrade --apply` running while another session starts is still a lost
334
+ * update. The damage is bounded rather than absent: heal refuses a
335
+ * downgrade, so whichever write lands last leaves `pkgVersion` at the same
336
+ * value or higher. That bound is the whole argument for leaving it, and it
337
+ * dies the moment a heal path is allowed to lower the version.
338
+ *
339
+ * Downgrade guard: a dual install (e.g. a plugin-scope cache at 1.8.2 and a
340
+ * project-scope cache at 1.8.1, both reading the SAME `hypo-pkg.json` because
341
+ * that path is keyed on `homedir()`, not on scope) means a session that
342
+ * happens to self-locate to the OLDER sibling would otherwise overwrite this
343
+ * file's `pkgVersion` with a lower number — the one baseline
344
+ * `scripts/upgrade.mjs`'s downgrade guard and `hooks/version-check.mjs`'s
345
+ * `computeSiblingNotice` both compare against. `classifyInstall` is the same
346
+ * judgment those two already trust, but this only blocks the `'downgrade'`
347
+ * verdict: a comparison that resolves and comes out lower. On `'downgrade'`,
348
+ * this returns `{ healed: false }` before touching the file, so the normal
349
+ * failure fallback below fires the original manual-fix guidance instead of
350
+ * quietly recording the sibling's older version as truth.
351
+ *
352
+ * Direction matters for the `'unknown'` verdict (one side's semver failed to
353
+ * parse), which is why the INCOMING version is checked with `parseSemver`
354
+ * up front rather than folded into the same `'unknown'` escape hatch as an
355
+ * unparseable EXISTING value. An unparseable incoming version must never
356
+ * heal: that would overwrite a good `pkgVersion` with garbage read from a
357
+ * corrupt `package.json` (Codex 3rd-round review, 2026-09-11: reproduced with
358
+ * `package.json.version: "not-semver"` against an existing `1.8.2`, which
359
+ * used to heal to `"not-semver"`). An unparseable EXISTING value with a
360
+ * valid incoming one is the opposite case, a good value replacing a
361
+ * corrupted one, and is still allowed through by `classifyInstall`'s
362
+ * `'unknown'` verdict: there is no valid comparison to protect against
363
+ * there, and refusing it would leave a broken value in place forever with no
364
+ * path back to a good one.
365
+ */
366
+ function selfHealPkgRoot(status) {
367
+ try {
368
+ return withFileLock(
369
+ HYPO_PKG_JSON_PATH,
370
+ () => {
371
+ const meta = JSON.parse(readFileSync(HYPO_PKG_JSON_PATH, 'utf-8'));
372
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return { healed: false };
373
+ const newVersion = readInstalledVersion(status.self);
374
+ if (!newVersion || !parseSemver(newVersion)) return { healed: false };
375
+ const oldVersion = typeof meta.pkgVersion === 'string' ? meta.pkgVersion : null;
376
+ const incoming = { pkgRoot: status.self, version: newVersion };
377
+ const active = { pkgRoot: meta.pkgRoot, version: oldVersion };
378
+
379
+ if (classifyInstall(incoming, active) === 'downgrade') return { healed: false };
380
+ const updated = { ...meta, pkgRoot: status.self, pkgVersion: newVersion };
381
+ // temp + rename in the SAME directory: a crash mid-write leaves only a
382
+ // throwaway temp file behind, never a torn hypo-pkg.json that the next
383
+ // session's every hook read of this file would then choke on.
384
+ // Shared writer rather than a local temp+rename: this one had no
385
+ // cleanup, so a failed rename left its temp behind for good. The temp
386
+ // name loses its leading dot in the trade, so a leaked one would now be
387
+ // visible in a directory the user reads; that is the point, since the
388
+ // shared writer is the one that does not leak.
389
+ atomicWrite(HYPO_PKG_JSON_PATH, `${JSON.stringify(updated, null, 2)}\n`);
390
+ return { healed: true, oldVersion, newVersion };
391
+ },
392
+ { timeoutMs: 150, staleMs: 30_000, pollMs: 20 },
393
+ );
394
+ } catch {
395
+ // Fail open: a lock timeout (ELOCKTIMEOUT) or any other throw from
396
+ // withFileLock/fn must not block or crash SessionStart. The caller
397
+ // treats this exactly like any other heal precondition miss.
398
+ return { healed: false };
399
+ }
400
+ }
401
+
240
402
  /**
241
403
  * pkgRoot drift notice. hypo-shared.mjs's resolvePkgRoot() already
242
404
  * self-corrects PKG_ROOT in memory whenever the code's own resolved location
243
- * disagrees with the cached hypo-pkg.json — but silent self-correction is the
244
- * exact failure this closes: the user's own `upgrade` habit stops mattering
245
- * and nothing ever tells them hypo-pkg.json fell behind. Surfaced once per
246
- * (cached → self-location) pair via the same notify-once cache the sibling
247
- * notice above uses — a fresh drift (new self-location) re-notifies, but
248
- * staying on the same drifted state doesn't nag every session.
405
+ * disagrees with the cached hypo-pkg.json — but silent self-correction used
406
+ * to leave the FILE itself stale forever, with nothing but a "please copy
407
+ * these two values in yourself" notice to show for it. This now does that
408
+ * copy itself (selfHealPkgRoot above), on the spot, in the same session that
409
+ * detected the drift.
410
+ *
411
+ * Notify-once cache: a successful heal marks the (cached → self-location)
412
+ * pair notified, but next session's pkgRootDriftStatus() reads back 'match'
413
+ * from the now-fixed file regardless and clears it on its own — the mark here
414
+ * only prevents a duplicate "fixed" line within a session that re-checks
415
+ * drift more than once. A FAILED heal must NOT mark the pair: marking would
416
+ * suppress the only guidance the user has (the manual `/hypo:upgrade` line)
417
+ * for a drift that never actually got fixed, on every later session that
418
+ * hits the same stale pair.
249
419
  *
250
420
  * Tri-state (pkgRootDriftStatus): 'match' CLEARS any earlier mark (checked
251
421
  * FIRST, unconditionally — even under opt-out, so a drift that resolves while
@@ -253,8 +423,9 @@ function buildSiblingNotice() {
253
423
  * recurrence once opt-out is lifted); 'unknown' touches nothing (self-location
254
424
  * could not be resolved this session — the permanent steady state for the
255
425
  * npm/manual channel, not evidence either way); only 'drift' can produce a
256
- * banner, and opt-out is checked there so an opted-out session never marks a
257
- * pair as notified it never actually showed.
426
+ * banner (or attempt a heal), and opt-out is checked there so an opted-out
427
+ * session never writes a fix — or marks a pair notified — it never actually
428
+ * showed.
258
429
  */
259
430
  function buildPkgRootDriftNotice() {
260
431
  try {
@@ -269,7 +440,24 @@ function buildPkgRootDriftNotice() {
269
440
  const key = `${status.cached || '(none)'}->${status.self}`;
270
441
  const cache = readCache(cachePath);
271
442
  if (pkgRootDriftAlreadyNotified(cache, key)) return '';
272
- markPkgRootDriftNotified(cachePath, key);
443
+
444
+ const heal = selfHealPkgRoot(status);
445
+ if (heal.healed) {
446
+ markPkgRootDriftNotified(cachePath, key);
447
+ return (
448
+ `[Hypomnema] Package metadata drift fixed: hypo-pkg.json pointed at ` +
449
+ `\`${status.cached || '(none)'}\`` +
450
+ `${heal.oldVersion ? ` (version ${heal.oldVersion})` : ''}, now synced to ` +
451
+ `\`${status.self}\` (version ${heal.newVersion}).\n` +
452
+ ` This was corrected automatically for this session — no action needed.`
453
+ );
454
+ }
455
+
456
+ // Heal did not land (unreadable/corrupt hypo-pkg.json, no resolvable
457
+ // version at the new root, or the write itself failed). Do NOT mark this
458
+ // pair notified here — see the docstring above — and fall back to the
459
+ // original manual-fix guidance.
460
+ //
273
461
  // status.cached is null both for a genuinely fresh install (never ran
274
462
  // /hypo:init) AND for the channel-judgment-failure guard (init/upgrade
275
463
  // positively decided to leave pkgRoot unset; see scripts/init.mjs's
@@ -287,7 +475,7 @@ function buildPkgRootDriftNotice() {
287
475
  `\`${status.cached || '(none)'}\`, but the code actually running resolves to ` +
288
476
  `\`${status.self}\`.\n` +
289
477
  ` Hooks already resolved the correct root for this session — this is a ` +
290
- `heads-up, not a blocker.\n${recoveryLine}`
478
+ `heads-up, not a blocker (the automatic fix did not take — see below).\n${recoveryLine}`
291
479
  );
292
480
  } catch {
293
481
  return '';
@@ -471,15 +659,30 @@ function syncStateNotice(pullOk) {
471
659
  * exactly like syncStateNotice: the line joins the `notices` array (→
472
660
  * additionalContext) and is also written to stderr, so both the model and the
473
661
  * user's transcript see it. NOT a systemMessage banner (that channel is
474
- * reserved for the update/sibling notices). Pure read (listProposals never
475
- * mutates); best-effort so a store read failure never breaks SessionStart. '' when
476
- * there are no pending proposals, so nothing surfaces on the empty path.
662
+ * reserved for the update/sibling notices). Pure read (listProposalsChecked
663
+ * never mutates); best-effort so a store read failure never breaks
664
+ * SessionStart. '' when there are no pending proposals AND nothing is wrong
665
+ * with the store, so nothing surfaces on the ordinary empty path.
666
+ *
667
+ * Uses listProposalsChecked rather than the plain listProposals this used to
668
+ * call: a store that could not be listed at all (`ok: false`, permissions or
669
+ * a file sitting where the directory should be) and a store with an
670
+ * unparseable `.json` candidate both used to look identical to zero here,
671
+ * silently, at the start of every session. Both get a short clause appended
672
+ * to the SAME line rather than a line of their own, so a clean vault's
673
+ * prompt budget does not grow by a byte.
477
674
  */
478
675
  function pendingProposalNotice() {
479
676
  try {
480
- const n = listProposals(HYPO_DIR).length;
481
- if (n === 0) return '';
482
- return `[WIKI: 대기 proposal ${n}건 (검토: hypomnema proposal list)]`;
677
+ const inventory = listProposalsChecked(HYPO_DIR);
678
+ if (!inventory.ok) return '[WIKI: 파킹 proposal 측정 불가 (hypomnema doctor 확인)]';
679
+ const n = inventory.proposals.length;
680
+ const u = inventory.unreadable.length;
681
+ if (n === 0 && u === 0) return '';
682
+ const parts = [];
683
+ if (n > 0) parts.push(`대기 proposal ${n}건 (검토: hypomnema proposal list)`);
684
+ if (u > 0) parts.push(`안 읽히는 후보 ${u}건 (hypomnema doctor 확인)`);
685
+ return `[WIKI: ${parts.join(', ')}]`;
483
686
  } catch {
484
687
  return '';
485
688
  }
@@ -721,7 +924,32 @@ process.stdin.on('end', () => {
721
924
  data = JSON.parse(raw);
722
925
  } catch {}
723
926
 
724
- const pullOk = gitPull(HYPO_DIR);
927
+ // BLOCKER fix: serialized against every other writer of this vault
928
+ // (hypo-auto-commit.mjs's stage+commit+sync, crystallize.mjs's apply, and
929
+ // writeRootHotProjection below) on the SAME lock. A fast-forward pull
930
+ // rewrites hot.md as surely as the projection writer does, so locking the
931
+ // writer alone would just move the unguarded overwrite onto this line.
932
+ // Not reentrant: this hold is released before the projection call below
933
+ // takes the same lock for itself, and nothing between them holds it.
934
+ //
935
+ // A lock timeout leaves pullOk false, which is the same state a failed
936
+ // pull produces: the sync-state notice stays unresolved for one more
937
+ // session rather than being cleared on a pull that never ran.
938
+ //
939
+ // The `.git` check is gitPull's own precondition, hoisted out so the lock
940
+ // is never taken for a call that cannot pull: withFileLock creates the
941
+ // lock's parent directory, which on an install with no vault would mint
942
+ // `.cache/` under a wiki root that does not exist.
943
+ let pullOk = false;
944
+ if (existsSync(join(HYPO_DIR, '.git'))) {
945
+ try {
946
+ pullOk = withFileLock(vaultCommitLockTarget(HYPO_DIR), () => gitPull(HYPO_DIR), {
947
+ timeoutMs: Number(process.env.HYPO_VAULT_LOCK_TIMEOUT_MS) || 5000,
948
+ });
949
+ } catch {
950
+ pullOk = false;
951
+ }
952
+ }
725
953
  const syncLine = syncStateNotice(pullOk);
726
954
  const proposalLine = pendingProposalNotice();
727
955
  const growthLine = readLastGrowthLine();
@@ -788,6 +1016,197 @@ process.stdin.on('end', () => {
788
1016
  process.stderr.write(`\n\x1b[33m${foreignNotice}\x1b[0m\n`);
789
1017
  }
790
1018
 
1019
+ // Root hot.md projection. Must run AFTER gitPull (so it
1020
+ // reflects the merged tree) and BEFORE snapshotBase below (so this
1021
+ // session's base for 'hot.md' is the freshly generated bytes, not
1022
+ // whatever was on disk before this write). Also deliberately AFTER
1023
+ // foreignUncommittedNotice above: that check reads `git status` on the
1024
+ // wiki tree, and this session's OWN regeneration of hot.md is not a
1025
+ // foreign or unattributed change: running the write first would make the
1026
+ // projection's own necessary write show up in that scan and misreport as
1027
+ // one. resolveActiveProject, closeCandidateSlugs, and the HIT/MISS
1028
+ // injection later in this hook all read hot.md, so the write still has to
1029
+ // land before any of those.
1030
+ // recordTouchedPaths here is the ONLY thing that gets this write into a
1031
+ // scoped hypo-auto-commit at Stop. Without it: SessionStart writes fresh
1032
+ // bytes, Stop's own rebuild sees disk already equals the projection
1033
+ // (writeRootHotProjection's no-op branch) and records nothing touched, and
1034
+ // hypo-auto-commit only ever commits its touched-paths set, so the write
1035
+ // this hook just made would never be staged or committed by ANY path, and
1036
+ // would sit as an unattributable dirty file (closeFileTargetsGlobal scopes
1037
+ // hot.md as close-owned; classifyForeignOnlyDirty cannot demote it to
1038
+ // foreign) blocking both the close gate and PreCompact. TODO(wave 3): this
1039
+ // whole call disappears the moment hot.md comes out of overwriteTargets /
1040
+ // the commit path, since a projection target no longer needs a commit.
1041
+ //
1042
+ // The touched-paths record is claimed BEFORE the write, not after: it is
1043
+ // itself best-effort (a lock timeout can fail it), and recording it after
1044
+ // the fact means a failure there leaves a real disk write with nothing in
1045
+ // any session's touched-paths set, the exact orphan this comment already
1046
+ // described, just moved from "Stop's rebuild is a no-op" to "the record
1047
+ // call itself failed." Claiming it first and skipping the write on a
1048
+ // failed claim means hot.md is never rewritten without something able to
1049
+ // account for that rewrite at close time.
1050
+ //
1051
+ // The claim and the write share one hold of the vault lock
1052
+ // (claimAndWriteRootHotProjection). Claimed first and locked second, a
1053
+ // Stop for the same session_id could take the lock in between, retire
1054
+ // the still-unwritten claim, and leave the write that followed with no
1055
+ // claim at all.
1056
+ if (existsSync(HYPO_DIR)) {
1057
+ // A missing session_id reads as "not claimed" here (claimProjectionWrite
1058
+ // underneath), unlike every other best-effort accumulate call, or this
1059
+ // write proceeds with nothing able to ever bring it into a commit.
1060
+ let outcome;
1061
+ let writeError = null;
1062
+ try {
1063
+ // n1 fix: session_id goes along so this session's own digest lands in
1064
+ // ITS OWN receipt, not just the shared global ownership record (see
1065
+ // writeRootHotProjection's doc comment).
1066
+ outcome = claimAndWriteRootHotProjection(HYPO_DIR, data.session_id);
1067
+ } catch (err) {
1068
+ writeError = err;
1069
+ }
1070
+ if (writeError) {
1071
+ // BLOCKER fix: writeRootHotProjection now throws instead of
1072
+ // silently treating an unreadable existing hot.md as absent. The
1073
+ // claim this call added was already dropped inside the lock (see
1074
+ // claimAndWriteRootHotProjection), so no stale 'hot.md' claim is
1075
+ // left to sweep in whatever another session or a person writes next.
1076
+ // catch-message-accuracy fix (r5-w1.md): the write can throw from
1077
+ // several different steps (reading the existing file, writing the
1078
+ // migration backup, updating .gitignore, or the final atomic write
1079
+ // itself), not only a read failure: wording this as "읽을 수 없어"
1080
+ // (could not read) misnamed a disk-full or backup-write failure as
1081
+ // something it was not. What stays true regardless of which step
1082
+ // failed is that the atomic write never ran, so the old file is
1083
+ // untouched.
1084
+ const err = writeError;
1085
+ const msg = `루트 hot.md 갱신에 실패해 이전 파일을 그대로 두었습니다: ${err?.message ?? String(err)}`;
1086
+ notices.push(msg);
1087
+ noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
1088
+ outExtra = {
1089
+ ...outExtra,
1090
+ systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
1091
+ };
1092
+ process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
1093
+ } else if (outcome.claimFailed) {
1094
+ const msg =
1095
+ '루트 hot.md 갱신을 건너뛰었습니다: 이번 세션의 커밋 범위에 기록하지 못해 갱신 후 미커밋 파일로 남을 수 있었습니다.';
1096
+ notices.push(msg);
1097
+ noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
1098
+ outExtra = {
1099
+ ...outExtra,
1100
+ systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
1101
+ };
1102
+ process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
1103
+ } else {
1104
+ const { result } = outcome;
1105
+ if (result.lockTimeout) {
1106
+ // BLOCKER fix, the other half: the projection writer holds the
1107
+ // vault lock, so a vault busy with another session's commit can
1108
+ // leave this session on the previous pointer table. Say so instead
1109
+ // of starting the session on a stale table with no explanation.
1110
+ // No claim to drop: it is taken inside the same lock, so a lock
1111
+ // timeout means this run never claimed anything, and a resumed
1112
+ // session_id's earlier, still-unresolved 'hot.md' claim is left
1113
+ // exactly as it was.
1114
+ const msg =
1115
+ '루트 hot.md 를 갱신하지 못했습니다: 다른 세션이 이 저장소를 쓰는 중이라 잠금을 얻지 못했습니다. 이번 세션은 이전 포인터 표를 그대로 읽습니다 (다음 세션 시작이나 종료 때 다시 시도합니다).';
1116
+ notices.push(msg);
1117
+ noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
1118
+ outExtra = {
1119
+ ...outExtra,
1120
+ systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
1121
+ };
1122
+ process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
1123
+ } else if (result.scanError) {
1124
+ const msg =
1125
+ '루트 hot.md 갱신 실패: projects/ 디렉터리를 읽을 수 없어 이전 파일을 그대로 두었습니다.' +
1126
+ ` 권한과 경로를 확인하고(\`ls -ld ${join(HYPO_DIR, 'projects')}\`), 고치면 다음 세션 시작이나 종료 때 자동으로 다시 시도합니다.`;
1127
+ notices.push(msg);
1128
+ noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
1129
+ outExtra = {
1130
+ ...outExtra,
1131
+ systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
1132
+ };
1133
+ process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
1134
+ } else if (result.warnings.length > 0) {
1135
+ // major-1/3: a per-project read failure no longer disappears
1136
+ // silently: it kept its row (blank date) in the table already
1137
+ // written above, but the reason must still reach a human.
1138
+ const msg =
1139
+ `루트 hot.md 갱신 중 일부 프로젝트를 읽지 못했습니다:\n${result.warnings.join('\n')}\n` +
1140
+ '해당 파일의 권한과 경로를 확인하면 다음 세션 시작이나 종료 때 자동으로 다시 읽습니다.';
1141
+ notices.push(msg);
1142
+ noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
1143
+ outExtra = {
1144
+ ...outExtra,
1145
+ systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
1146
+ };
1147
+ process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
1148
+ }
1149
+ if (result.gitignoreUpdated) {
1150
+ // Best-effort: the gitignore change itself already landed on
1151
+ // disk (writeRootHotProjection would have thrown otherwise); a
1152
+ // failure to also claim it only risks THIS one infra file
1153
+ // showing up as an unattributed dirty file at close, not the
1154
+ // backup it protects (which is gitignored either way).
1155
+ const gClaimed = recordTouchedPaths(HYPO_DIR, data.session_id, ['.gitignore']);
1156
+ if (!gClaimed) {
1157
+ process.stderr.write(
1158
+ '[hypo-session-start] .gitignore 갱신(백업 유출 방지 패턴)을 커밋 범위에 기록하지 못했습니다\n',
1159
+ );
1160
+ }
1161
+ }
1162
+ if (result.backedUp && result.backupPath) {
1163
+ // MAJOR fix: the backup itself was never in question (it lands
1164
+ // on disk and is gitignored either way), only whether a human
1165
+ // ever learns it exists. Before this, `backedUp`/`backupPath`
1166
+ // reached neither `notices` nor `systemMessage`: the backup file
1167
+ // is gitignored, so it does not even show up in `git status`, and
1168
+ // a person who had just hand-edited root hot.md would see their
1169
+ // edit silently replaced with no trace of where it went. Naming
1170
+ // only the file (basename), not its full path: the vault root is
1171
+ // already implied by the session, and nothing else here leaks a
1172
+ // full filesystem path into a notice. This branch should be rare
1173
+ // once rootHot leaves the close payload/overwrite targets: with
1174
+ // no scripted writer left that targets this file, a backup only
1175
+ // happens when a person edits it by hand, so this notice does
1176
+ // not become noise on every session.
1177
+ // Naming the file is not a recovery path on its own: copying the
1178
+ // backup back onto root hot.md just gets it replaced again on the
1179
+ // next rebuild. rootHotBackupRecoveryNotice (hooks/hypo-shared.mjs)
1180
+ // is the one sentence that says what actually recovers the
1181
+ // content, shared with the Stop-hook rebuild and project-create so
1182
+ // the three writers cannot drift into different wordings.
1183
+ const msg = rootHotBackupRecoveryNotice(result.backupPath);
1184
+ notices.push(msg);
1185
+ noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
1186
+ outExtra = {
1187
+ ...outExtra,
1188
+ systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
1189
+ };
1190
+ process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
1191
+ }
1192
+ }
1193
+ }
1194
+
1195
+ // A prior Stop's rebuild may have failed invisibly (its own stdout is
1196
+ // suppressed) and left a note for the next SessionStart to show. One-shot:
1197
+ // consumed (and deleted) the moment it is read, so it surfaces exactly
1198
+ // once instead of repeating on every future session.
1199
+ const staleProjectionNotice = consumeRootHotHealthNotice(HYPO_DIR);
1200
+ if (staleProjectionNotice) {
1201
+ notices.push(staleProjectionNotice);
1202
+ noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
1203
+ outExtra = {
1204
+ ...outExtra,
1205
+ systemMessage: [outExtra.systemMessage, staleProjectionNotice].filter(Boolean).join('\n\n'),
1206
+ };
1207
+ process.stderr.write(`\n\x1b[33m${staleProjectionNotice}\x1b[0m\n`);
1208
+ }
1209
+
791
1210
  // Observed-base snapshot for the write=proposal gate. Deliberately AFTER gitPull: the base must
792
1211
  // describe the tree this session actually starts from, remote merges
793
1212
  // included, or the first close would raise a proposal against content the
@@ -796,8 +1215,24 @@ process.stdin.on('end', () => {
796
1215
  // alone. `data.session_id` is used raw rather than the 'default' fallback
797
1216
  // above. A session with no id has no base and closes down the legacy
798
1217
  // direct-write path.
1218
+ //
1219
+ // Only the root-only targets (hot.md, open-questions.md) are snapshotted
1220
+ // HERE, unconditionally. They carry no .hypoignore/visibility gate of
1221
+ // their own, so hashing them before any injection decision costs
1222
+ // nothing a later read would have refused anyway. The current project's
1223
+ // OWN two targets (hit.proj's hot.md / session-state.md) used to be
1224
+ // snapshotted right here too, blind to whether the HIT branch below was
1225
+ // about to actually show them (review r4-w4 major 3): that let a
1226
+ // .hypoignore'd or machine-scoped-out project target mint a "clean" base
1227
+ // from bytes this session was never shown a single line of, and for a
1228
+ // LATE HIT (this session's cwd only resolves to a project on a later
1229
+ // resume/compact, not its first SessionStart), that base came from
1230
+ // whatever was on disk at that arbitrary later moment, with no relation
1231
+ // to what injection actually revealed. Enrollment for those two moved
1232
+ // down to right after the HIT branch's own reads decide that question
1233
+ // (see the call there).
799
1234
  if (data.session_id) {
800
- snapshotBase(HYPO_DIR, data.session_id, overwriteTargets(hit ? hit.proj : null));
1235
+ snapshotBase(HYPO_DIR, data.session_id, overwriteTargets(null));
801
1236
  // Bumps the observed generation on EVERY SessionStart (first run and
802
1237
  // resume/compact alike), before the injections below record into it. A
803
1238
  // resume that ends up injecting nothing (ignored, scoped out, absent)
@@ -823,6 +1258,109 @@ process.stdin.on('end', () => {
823
1258
  const TODAY = new Date().toISOString().slice(0, 10);
824
1259
  const hotRead = readIfNotIgnored(hit.hotPath, HOT_CHARS, ignorePatterns);
825
1260
  const stateRead = readIfNotIgnored(hit.statePath, STATE_CHARS, ignorePatterns);
1261
+ // Enroll THIS project's two overwrite targets now, right next to the
1262
+ // reads that just decided whether this session is actually about to be
1263
+ // shown them (review r4-w4 major 3: see the top-of-hook snapshotBase
1264
+ // call's comment for why this moved here instead of running blind
1265
+ // before this decision existed). A target `readIfNotIgnored` refused
1266
+ // (.hypoignore, machine-scoped-out) but that still exists on disk is
1267
+ // left OUT of `relPaths` on purpose: `snapshotBase` would otherwise
1268
+ // hash it anyway (it re-reads disk itself, not these `Read`s) and mint
1269
+ // a base from bytes this session was never shown, so the key stays
1270
+ // 'unknown' and this session's closes keep failing safe on it, same as
1271
+ // before late enrollment existed. A target genuinely absent on disk
1272
+ // (no file yet) is still safe to enroll as `null` (nothing was hidden
1273
+ // from anyone, there is simply nothing there yet), so `existsSync`
1274
+ // lets that case through even though `readIfNotIgnored` also returns
1275
+ // null for it.
1276
+ //
1277
+ // `knownHashes` (review r5-w3 blocker 1): the root-only snapshotBase
1278
+ // call above (line ~999) always creates base.json before this HIT
1279
+ // branch runs, so this call would otherwise ALWAYS take snapshotBase's
1280
+ // "enroll" branch and hash `hit.hotPath`/`hit.statePath` off disk again,
1281
+ // a SEPARATE read from `hotRead`/`stateRead` above. A writer landing in
1282
+ // that window mints a base from bytes this session was never shown, and
1283
+ // a later close built from `hotRead.raw`/`stateRead.raw` reads
1284
+ // disk-equals-base and overwrites that writer's change with no park.
1285
+ // Passing the hash of the bytes `hotRead`/`stateRead` ALREADY read pins
1286
+ // the base to what this session was actually shown, not to whatever a
1287
+ // second, independent read happens to find.
1288
+ if (data.session_id) {
1289
+ // Test-only barrier, the other half of the enroll probe below. The
1290
+ // dump alone catches a call that drops `knownHashes`, but not one
1291
+ // that re-reads disk here and hashes that: with nothing changing the
1292
+ // file, the re-read and the shown bytes agree. When
1293
+ // HYPO_TEST_ENROLL_BARRIER is a non-empty string, the hook says so on
1294
+ // stderr and waits until that path exists (10s at most), which gives
1295
+ // a test the gap between the reads above and the hashing below to
1296
+ // change the file in. It only waits and reads: it writes no file,
1297
+ // inside the vault or out, for the reason the dump comment gives.
1298
+ const barrier = process.env.HYPO_TEST_ENROLL_BARRIER;
1299
+ if (typeof barrier === 'string' && barrier.length > 0) {
1300
+ process.stderr.write('[hypo-test] enroll-barrier-ready\n');
1301
+ const deadline = Date.now() + 10000;
1302
+ while (!existsSync(barrier) && Date.now() < deadline) {
1303
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
1304
+ }
1305
+ }
1306
+ const relPaths = [];
1307
+ const knownHashes = {};
1308
+ const hotRelPath = join('projects', hit.proj, 'hot.md');
1309
+ const stateRelPath = join('projects', hit.proj, 'session-state.md');
1310
+ if (hotRead) {
1311
+ relPaths.push(hotRelPath);
1312
+ knownHashes[hotRelPath] = hashContent(hotRead.raw);
1313
+ } else if (!existsSync(hit.hotPath)) {
1314
+ relPaths.push(hotRelPath);
1315
+ knownHashes[hotRelPath] = null; // observed-absent, not unread
1316
+ }
1317
+ if (stateRead) {
1318
+ relPaths.push(stateRelPath);
1319
+ knownHashes[stateRelPath] = hashContent(stateRead.raw);
1320
+ } else if (!existsSync(hit.statePath)) {
1321
+ relPaths.push(stateRelPath);
1322
+ knownHashes[stateRelPath] = null;
1323
+ }
1324
+ // Test-only wiring probe (review r5-w4 blocker 1,
1325
+ // tests/proposal-base.test.mjs). It replaced a seam that wrote
1326
+ // `process.env.HYPO_TEST_RACE_HOT_WRITE` straight into `hit.hotPath`
1327
+ // to fake a concurrent writer: every file under hooks/ is copied into
1328
+ // every install, that path is a real person's project hot.md, and the
1329
+ // guard was `!== undefined`, so an empty value truncated the file to
1330
+ // zero bytes. Nothing here writes inside the vault any more.
1331
+ //
1332
+ // What is left is the half the seam was actually needed for. Whether
1333
+ // `snapshotBase` honors a hash it is told is a property of
1334
+ // `snapshotBase`, and a direct unit test covers it; whether THIS call
1335
+ // site hands it the hashes of the bytes it just showed is only
1336
+ // observable here, and dropping the argument leaves every other
1337
+ // real-hook test green (with no writer in the gap, a fresh disk read
1338
+ // and the shown bytes agree). So when HYPO_TEST_ENROLL_DUMP names a
1339
+ // path, the arguments this call actually passes are written there as
1340
+ // JSON, and a test reads them back. `args`, not the local
1341
+ // `knownHashes`: reading the local would report the map as passed even
1342
+ // when the call below stopped passing it, which is precisely the
1343
+ // regression this exists to catch. An argument list of three shows up
1344
+ // as `argc: 3` with no `knownHashes` key at all.
1345
+ //
1346
+ // Best-effort, like every other cache write in these hooks: a bad
1347
+ // dump path must not take down a SessionStart.
1348
+ const enroll = (...args) => {
1349
+ const dumpPath = process.env.HYPO_TEST_ENROLL_DUMP;
1350
+ if (dumpPath) {
1351
+ try {
1352
+ writeFileSync(
1353
+ dumpPath,
1354
+ JSON.stringify({ argc: args.length, relPaths: args[2], knownHashes: args[3] }),
1355
+ );
1356
+ } catch {
1357
+ // ignore: the probe is diagnostics, never a precondition
1358
+ }
1359
+ }
1360
+ return snapshotBase(...args);
1361
+ };
1362
+ if (relPaths.length) enroll(HYPO_DIR, data.session_id, relPaths, knownHashes);
1363
+ }
826
1364
  let hotContent = hotRead ? hotRead.shown : null;
827
1365
  let stateContent = stateRead ? stateRead.shown : null;
828
1366
  const hotMarker = staleMarkerForPath(hit.hotPath, ignorePatterns, TODAY);
@@ -969,9 +1507,17 @@ process.stdin.on('end', () => {
969
1507
  );
970
1508
  // Observed-set record: the MISS branch's root hot.md injection is the one
971
1509
  // place a project-less session observes anything at all. Recorded AFTER
972
- // the console.log above — see the HIT branch's comment for why (fail-open
1510
+ // the console.log above. See the HIT branch's comment for why (fail-open
973
1511
  // ordering). Hashes `.raw`, not a fresh disk read, for the same reason
974
1512
  // given there.
1513
+ //
1514
+ // This call records nothing any more. overwriteTargets() stopped returning
1515
+ // 'hot.md' when the root pointer table became a hook-generated projection,
1516
+ // and recordObserved only writes for keys the base snapshot is tracking, so
1517
+ // the guard rejects this one every time. It is kept here, rather than
1518
+ // deleted, because this file is being rewritten in another lane at the same
1519
+ // time and a deletion would widen that merge. Delete it when the two lanes
1520
+ // land together.
975
1521
  if (data.session_id) {
976
1522
  recordObserved(
977
1523
  HYPO_DIR,