hypomnema 1.8.4 → 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 (60) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +36 -0
  4. package/README.ko.md +16 -15
  5. package/README.md +14 -13
  6. package/commands/crystallize.md +84 -30
  7. package/commands/doctor.md +3 -1
  8. package/docs/ARCHITECTURE.md +32 -9
  9. package/docs/CONTRIBUTING.md +5 -1
  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 +141 -19
  14. package/hooks/close-receipt.mjs +559 -0
  15. package/hooks/hooks.json +2 -29
  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 +381 -18
  27. package/hooks/hypo-shared.mjs +2151 -185
  28. package/hooks/hypo-stop.mjs +207 -0
  29. package/hooks/proposal-store.mjs +166 -34
  30. package/hooks/shared.json +7 -1
  31. package/hooks/version-check.mjs +9 -7
  32. package/package.json +2 -1
  33. package/scripts/crystallize.mjs +3 -4
  34. package/scripts/doctor.mjs +506 -32
  35. package/scripts/init.mjs +32 -3
  36. package/scripts/lib/crystallize-close-apply.mjs +1949 -230
  37. package/scripts/lib/crystallize-close-check.mjs +43 -23
  38. package/scripts/lib/design-history-stale.mjs +36 -9
  39. package/scripts/lib/frontmatter.mjs +39 -0
  40. package/scripts/lib/hook-inventory.mjs +31 -1
  41. package/scripts/lib/page-usage.mjs +1 -1
  42. package/scripts/lib/pkg-provenance.mjs +7 -1
  43. package/scripts/lib/project-create.mjs +40 -44
  44. package/scripts/lib/schema-vocab.mjs +22 -11
  45. package/scripts/lib/template-schema-version.mjs +1 -0
  46. package/scripts/lib/time.mjs +46 -0
  47. package/scripts/lint.mjs +66 -18
  48. package/scripts/proposal.mjs +423 -18
  49. package/scripts/resume.mjs +3 -2
  50. package/scripts/session-audit.mjs +2 -1
  51. package/scripts/uninstall.mjs +15 -1
  52. package/scripts/upgrade.mjs +281 -90
  53. package/scripts/verify.mjs +2 -1
  54. package/scripts/weekly-report.mjs +2 -1
  55. package/templates/SCHEMA.md +27 -7
  56. package/templates/gitignore +11 -0
  57. package/templates/hypo-automation.md +10 -13
  58. package/templates/hypo-config.md +1 -1
  59. package/templates/hypo-guide.md +53 -25
  60. package/templates/hot.md +0 -24
@@ -29,19 +29,26 @@
29
29
  // Everything here is best-effort: a hook must never fail a session start because
30
30
  // a cache write did not land. Read failures degrade to "base unknown", which the
31
31
  // caller treats as fail-safe (proposal), never as "no conflict".
32
+ //
33
+ // Every mutator below (snapshotBase, advanceBase, advanceBaseForWrite,
34
+ // beginObservedGeneration, recordObserved, recordAppliedHash, clearAppliedHash,
35
+ // advanceBaseAndRecordApplied) wraps its read-modify-write in `withFileLock`
36
+ // (review r5-w3 major 1): SessionStart, a PostToolUse hand-edit hook, and
37
+ // crystallize's own apply can all touch the SAME session's base.json around the
38
+ // same time, and a plain `readBaseFile` → mutate → `atomicWrite` sequence with
39
+ // no lock lets a later writer's read start before an earlier writer's write
40
+ // lands, so the later one then republishes the whole file from its stale copy and
41
+ // silently reverts the earlier writer's field. `atomicWrite`'s rename already
42
+ // guarantees a reader never sees a torn/partial file, so this lock exists for
43
+ // lost-update races between writers, not for read tearing. A lock timeout
44
+ // degrades to the same fail-safe as any other write failure here: the field
45
+ // that could not be recorded is simply left as it was.
32
46
 
33
47
  import { createHash } from 'node:crypto';
34
- import {
35
- existsSync,
36
- mkdirSync,
37
- readFileSync,
38
- writeFileSync,
39
- renameSync,
40
- closeSync,
41
- openSync,
42
- writeSync,
43
- } from 'node:fs';
48
+ import { existsSync, mkdirSync, readFileSync, closeSync, openSync, writeSync } from 'node:fs';
44
49
  import { join, dirname } from 'node:path';
50
+ import { atomicWrite } from './atomic-write.mjs';
51
+ import { withFileLock } from './hypo-shared.mjs';
45
52
 
46
53
  /** sha256 of a UTF-8 string, hex. */
47
54
  export function hashContent(content) {
@@ -68,14 +75,6 @@ export function basePath(hypoDir, sessionId) {
68
75
  return join(hypoDir, '.cache', 'sessions', String(sessionId), 'base.json');
69
76
  }
70
77
 
71
- /** Atomic overwrite via tmp+rename, mirroring crystallize's atomicWrite. */
72
- function atomicWrite(path, content) {
73
- mkdirSync(dirname(path), { recursive: true });
74
- const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
75
- writeFileSync(tmp, content);
76
- renameSync(tmp, path);
77
- }
78
-
79
78
  /** Read and parse base.json. Returns null when absent, unreadable, or malformed. */
80
79
  function readBaseFile(hypoDir, sessionId) {
81
80
  const path = basePath(hypoDir, sessionId);
@@ -91,55 +90,133 @@ function readBaseFile(hypoDir, sessionId) {
91
90
  }
92
91
 
93
92
  /**
94
- * Snapshot the observed base hashes for `relPaths`, ONCE per session.
93
+ * Snapshot the observed base hashes for `relPaths`, ONCE PER KEY per session.
95
94
  *
96
95
  * Existence-check (invariant 1): when base.json already exists for this session,
97
- * this is a no-op: resume and compact must not move the base.
96
+ * an already-tracked key never moves: resume and compact must not move the
97
+ * base for a target this session has already seen.
98
+ *
99
+ * Late enrollment: `relPaths` can differ across calls in the SAME session. The
100
+ * caller that motivated this (hypo-session-start.mjs) resolves the project from
101
+ * `cwd`, which can miss on the session's first SessionStart and hit on a later
102
+ * resume or compact with the SAME session_id. `overwriteTargets(project)`
103
+ * then names two more paths than the first call did. Without this, those two
104
+ * targets would never get a key at all for the rest of the session (not even
105
+ * an 'unknown' one): `advanceBaseForWrite` and `recordObserved` are both
106
+ * scoped to `hasOwnProperty`, so an untracked key can never accept a write's
107
+ * provenance, and close falls back to `base-unknown` forever. So a key this
108
+ * call names that the session has NOT already seen is added now, hashed off
109
+ * disk exactly like a fresh snapshot's targets are; a key already tracked,
110
+ * present or 'absent', is left exactly as it was.
98
111
  *
99
112
  * A target that does not exist on disk is recorded as `null` (observed-absent),
100
113
  * which is distinct from having no entry at all (observed-nothing). Close treats
101
114
  * the first as "I saw no file, creating it is safe" and the second as fail-safe.
102
115
  *
116
+ * `knownHashes` (review r5-w3 blocker 1): for a `relPath` this map covers, its
117
+ * value is written as the base AS-IS (a hex hash, or `null` for observed-absent)
118
+ * instead of a fresh `hashFile` disk read. Late enrollment (see above) is the
119
+ * caller that needs this: hypo-session-start.mjs's HIT branch reads
120
+ * `hit.hotPath`/`hit.statePath` to decide what to SHOW the model, then calls
121
+ * this function to enroll those same two targets. Root targets are always
122
+ * snapshotted first (line ~999 there), so `path` already exists by the time the
123
+ * HIT branch's own call lands here, which used to always take the "enroll"
124
+ * branch below and re-read disk fresh: a SEPARATE read from the one whose
125
+ * bytes the model was just shown. Between those two reads, another writer can
126
+ * change the file: the base would then describe bytes the model never saw, and
127
+ * a close that reapplies a payload built from the shown bytes reads
128
+ * disk-equals-base and overwrites the intervening write with no park. Passing
129
+ * the hash of the exact bytes just shown closes that window: the base can only
130
+ * ever describe what this session was actually shown, never a bystander read.
131
+ * A `relPath` NOT in `knownHashes` still gets the old fresh-disk-read behavior
132
+ * (the root-only call above has no "shown bytes" to pin to, since it runs
133
+ * before any injection decision).
134
+ *
103
135
  * @param {string} hypoDir
104
136
  * @param {string} sessionId
105
137
  * @param {string[]} relPaths vault-relative target paths
106
- * @returns {{created: boolean, reason?: string}}
138
+ * @param {Record<string, string|null>|null} [knownHashes] exact-bytes hash (or
139
+ * `null` for observed-absent) to record for a `relPath`, bypassing the disk
140
+ * re-read that would otherwise risk racing a concurrent writer
141
+ * @returns {{created: boolean, reason?: string, enrolled?: string[]}}
107
142
  */
108
- export function snapshotBase(hypoDir, sessionId, relPaths) {
143
+ export function snapshotBase(hypoDir, sessionId, relPaths, knownHashes = null) {
109
144
  if (!sessionId) return { created: false, reason: 'no-session-id' };
110
145
  const path = basePath(hypoDir, sessionId);
111
- if (existsSync(path)) return { created: false, reason: 'already-snapshotted' };
146
+ // A hash this call was TOLD to use always wins over a disk read: `undefined`
147
+ // (unreadable) is the disk-read fallback's own "leave this key out" signal, so
148
+ // it must never mask an explicit known value.
149
+ const resolveHash = (rel) =>
150
+ knownHashes && Object.prototype.hasOwnProperty.call(knownHashes, rel)
151
+ ? knownHashes[rel]
152
+ : hashFile(join(hypoDir, rel));
112
153
 
113
- const targets = {};
114
- for (const rel of relPaths) {
115
- if (!rel) continue;
116
- const h = hashFile(join(hypoDir, rel));
117
- // `undefined` (unreadable) is left OUT of the map on purpose: no entry means
118
- // "unknown", and close fails safe into a proposal rather than assuming a
119
- // file it could not read was unchanged.
120
- if (h !== undefined) targets[rel] = h;
121
- }
154
+ try {
155
+ // Whole function under one lock (major 1, review r5-w3): the "does path
156
+ // exist" branch below and the write that follows it must not interleave
157
+ // with another RMW on this same session's base.json (resume, compact, and
158
+ // this session's own close can all touch it concurrently).
159
+ return withFileLock(path, () => {
160
+ if (existsSync(path)) {
161
+ const parsed = readBaseFile(hypoDir, sessionId);
162
+ // Unreadable/malformed base.json: nothing safe to enroll into, same
163
+ // fallback the pre-enrollment code always gave for an existing file.
164
+ if (!parsed) return { created: false, reason: 'already-snapshotted' };
165
+ const missing = (relPaths || []).filter(
166
+ (rel) => rel && !Object.prototype.hasOwnProperty.call(parsed.targets, rel),
167
+ );
168
+ if (missing.length === 0) return { created: false, reason: 'already-snapshotted' };
169
+ for (const rel of missing) {
170
+ const h = resolveHash(rel);
171
+ if (h !== undefined) parsed.targets[rel] = h;
172
+ }
173
+ try {
174
+ atomicWrite(path, JSON.stringify(parsed, null, 2));
175
+ return { created: false, reason: 'enrolled', enrolled: missing };
176
+ } catch (e) {
177
+ return { created: false, reason: `write-failed: ${e && e.message}` };
178
+ }
179
+ }
122
180
 
123
- const body = JSON.stringify(
124
- { session_id: String(sessionId), created_at: new Date().toISOString(), targets },
125
- null,
126
- 2,
127
- );
181
+ const targets = {};
182
+ for (const rel of relPaths) {
183
+ if (!rel) continue;
184
+ const h = resolveHash(rel);
185
+ // `undefined` (unreadable) is left OUT of the map on purpose: no entry
186
+ // means "unknown", and close fails safe into a proposal rather than
187
+ // assuming a file it could not read was unchanged.
188
+ if (h !== undefined) targets[rel] = h;
189
+ }
128
190
 
129
- try {
130
- mkdirSync(dirname(path), { recursive: true });
131
- // Exclusive create IS the existence-check, closing the gap between the
132
- // existsSync above and the write below when two hooks race on one session.
133
- const fd = openSync(path, 'wx');
134
- try {
135
- writeSync(fd, body);
136
- } finally {
137
- closeSync(fd);
138
- }
139
- return { created: true };
191
+ const body = JSON.stringify(
192
+ { session_id: String(sessionId), created_at: new Date().toISOString(), targets },
193
+ null,
194
+ 2,
195
+ );
196
+
197
+ try {
198
+ mkdirSync(dirname(path), { recursive: true });
199
+ // Exclusive create IS the existence-check, closing the gap between the
200
+ // existsSync above and the write below when two hooks race on one
201
+ // session. Redundant with the outer lock now, kept as a second,
202
+ // cheaper guard: it costs nothing when the lock already serialized us.
203
+ const fd = openSync(path, 'wx');
204
+ try {
205
+ writeSync(fd, body);
206
+ } finally {
207
+ closeSync(fd);
208
+ }
209
+ return { created: true };
210
+ } catch (e) {
211
+ if (e && e.code === 'EEXIST') return { created: false, reason: 'already-snapshotted' };
212
+ // best-effort: a hook must never break a session start over a cache write
213
+ return { created: false, reason: `write-failed: ${e && e.message}` };
214
+ }
215
+ });
140
216
  } catch (e) {
141
- if (e && e.code === 'EEXIST') return { created: false, reason: 'already-snapshotted' };
142
- // best-effort: a hook must never break a session start over a cache write
217
+ // withFileLock throws on timeout/failure to acquire: best-effort, same as
218
+ // every other outcome here: a hook must never break a session start over
219
+ // a cache write.
143
220
  return { created: false, reason: `write-failed: ${e && e.message}` };
144
221
  }
145
222
  }
@@ -182,12 +259,19 @@ export function readBaseEntry(hypoDir, sessionId, relPath) {
182
259
  */
183
260
  export function advanceBase(hypoDir, sessionId, relPath, hash) {
184
261
  if (!sessionId) return false;
185
- const parsed = readBaseFile(hypoDir, sessionId);
186
- if (!parsed) return false;
187
- parsed.targets[relPath] = hash;
262
+ const path = basePath(hypoDir, sessionId);
188
263
  try {
189
- atomicWrite(basePath(hypoDir, sessionId), JSON.stringify(parsed, null, 2));
190
- return true;
264
+ return withFileLock(path, () => {
265
+ const parsed = readBaseFile(hypoDir, sessionId);
266
+ if (!parsed) return false;
267
+ parsed.targets[relPath] = hash;
268
+ try {
269
+ atomicWrite(path, JSON.stringify(parsed, null, 2));
270
+ return true;
271
+ } catch {
272
+ return false;
273
+ }
274
+ });
191
275
  } catch {
192
276
  return false;
193
277
  }
@@ -234,17 +318,24 @@ export function advanceBase(hypoDir, sessionId, relPath, hash) {
234
318
  */
235
319
  export function advanceBaseForWrite(hypoDir, sessionId, relPath, absPath, knownHash = null) {
236
320
  if (!sessionId) return false;
237
- const parsed = readBaseFile(hypoDir, sessionId);
238
- if (!parsed) return false;
239
- // Only a tracked target advances. hasOwnProperty, not truthiness: an
240
- // observed-absent entry is `null` but still a legitimate key to advance from.
241
- if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
242
- const hash = typeof knownHash === 'string' ? knownHash : hashFile(absPath);
243
- if (typeof hash !== 'string') return false; // absent/unreadable → leave base as-is
244
- parsed.targets[relPath] = hash;
321
+ const path = basePath(hypoDir, sessionId);
245
322
  try {
246
- atomicWrite(basePath(hypoDir, sessionId), JSON.stringify(parsed, null, 2));
247
- return true;
323
+ return withFileLock(path, () => {
324
+ const parsed = readBaseFile(hypoDir, sessionId);
325
+ if (!parsed) return false;
326
+ // Only a tracked target advances. hasOwnProperty, not truthiness: an
327
+ // observed-absent entry is `null` but still a legitimate key to advance from.
328
+ if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
329
+ const hash = typeof knownHash === 'string' ? knownHash : hashFile(absPath);
330
+ if (typeof hash !== 'string') return false; // absent/unreadable → leave base as-is
331
+ parsed.targets[relPath] = hash;
332
+ try {
333
+ atomicWrite(path, JSON.stringify(parsed, null, 2));
334
+ return true;
335
+ } catch {
336
+ return false;
337
+ }
338
+ });
248
339
  } catch {
249
340
  return false;
250
341
  }
@@ -300,13 +391,20 @@ export function advanceBaseForWrite(hypoDir, sessionId, relPath, absPath, knownH
300
391
  */
301
392
  export function beginObservedGeneration(hypoDir, sessionId) {
302
393
  if (!sessionId) return false;
303
- const parsed = readBaseFile(hypoDir, sessionId);
304
- if (!parsed) return false;
305
- const current = typeof parsed.observedGeneration === 'number' ? parsed.observedGeneration : 0;
306
- parsed.observedGeneration = current + 1;
394
+ const path = basePath(hypoDir, sessionId);
307
395
  try {
308
- atomicWrite(basePath(hypoDir, sessionId), JSON.stringify(parsed, null, 2));
309
- return true;
396
+ return withFileLock(path, () => {
397
+ const parsed = readBaseFile(hypoDir, sessionId);
398
+ if (!parsed) return false;
399
+ const current = typeof parsed.observedGeneration === 'number' ? parsed.observedGeneration : 0;
400
+ parsed.observedGeneration = current + 1;
401
+ try {
402
+ atomicWrite(path, JSON.stringify(parsed, null, 2));
403
+ return true;
404
+ } catch {
405
+ return false;
406
+ }
407
+ });
310
408
  } catch {
311
409
  return false;
312
410
  }
@@ -326,7 +424,7 @@ export function beginObservedGeneration(hypoDir, sessionId) {
326
424
  * refuses to hand it out as a licence: seeing 5% of a file is not seeing it.
327
425
  *
328
426
  * No-op, in order: no snapshot for this session; `relPath` is not one of the
329
- * four tracked overwrite targets (mirrors `advanceBaseForWrite`'s scoping —
427
+ * tracked overwrite targets (mirrors `advanceBaseForWrite`'s scoping --
330
428
  * this must not be able to mint a new guarded key). Stamped with the CURRENT
331
429
  * `observedGeneration`, never advancing it: the caller advances once via
332
430
  * `beginObservedGeneration`, not once per recorded target.
@@ -345,18 +443,30 @@ function safeGeneration(v) {
345
443
 
346
444
  export function recordObserved(hypoDir, sessionId, relPath, hash, truncated = false) {
347
445
  if (!sessionId) return false;
348
- const parsed = readBaseFile(hypoDir, sessionId);
349
- if (!parsed) return false;
350
- if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
351
- if (typeof hash !== 'string') return false;
352
- const generation = typeof parsed.observedGeneration === 'number' ? parsed.observedGeneration : 0;
353
- if (!parsed.observed || typeof parsed.observed !== 'object' || Array.isArray(parsed.observed)) {
354
- parsed.observed = {};
355
- }
356
- parsed.observed[relPath] = { generation, hash, truncated: !!truncated };
446
+ const path = basePath(hypoDir, sessionId);
357
447
  try {
358
- atomicWrite(basePath(hypoDir, sessionId), JSON.stringify(parsed, null, 2));
359
- return true;
448
+ return withFileLock(path, () => {
449
+ const parsed = readBaseFile(hypoDir, sessionId);
450
+ if (!parsed) return false;
451
+ if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
452
+ if (typeof hash !== 'string') return false;
453
+ const generation =
454
+ typeof parsed.observedGeneration === 'number' ? parsed.observedGeneration : 0;
455
+ if (
456
+ !parsed.observed ||
457
+ typeof parsed.observed !== 'object' ||
458
+ Array.isArray(parsed.observed)
459
+ ) {
460
+ parsed.observed = {};
461
+ }
462
+ parsed.observed[relPath] = { generation, hash, truncated: !!truncated };
463
+ try {
464
+ atomicWrite(path, JSON.stringify(parsed, null, 2));
465
+ return true;
466
+ } catch {
467
+ return false;
468
+ }
469
+ });
360
470
  } catch {
361
471
  return false;
362
472
  }
@@ -434,7 +544,208 @@ export function wasObservedTruncated(hypoDir, sessionId, relPath) {
434
544
  return entry.truncated !== false;
435
545
  }
436
546
 
437
- // The four whole-file overwrite targets are prose-and-table markdown documents, and
547
+ // ── applied set ──────────────────────────────────────────────────────────────
548
+ //
549
+ // `targets` moves on TWO kinds of legitimate write: crystallize's own overwrite
550
+ // (invariant 2, `advanceBase`) and a direct hand edit through the session's own
551
+ // Write/Edit tool (`advanceBaseForWrite`, wired from hypo-auto-stage.mjs). Both
552
+ // are correct for the guard's original question, "did somebody ELSE change this
553
+ // since I last looked": a hand edit is this session's own work, not a foreign
554
+ // write, and must not park a close that folds it in.
555
+ //
556
+ // But the two are NOT the same fact, and `targets` alone cannot tell them
557
+ // apart after the fact: once a hand edit advances `targets[relPath]` to match
558
+ // its own new bytes, a stale close-apply payload from BEFORE that edit reads
559
+ // disk-equals-base and rewrites over the edit with no record it ever
560
+ // existed: the payload never touched the edit, and the edit was never
561
+ // re-checked against it. `applied` is the narrower, second fact this closes:
562
+ // not "what did any write show as the target's provenance", but "what did
563
+ // THIS OVERWRITE CALL itself last put down for this key, this session": a
564
+ // value only `recordAppliedHash` ever sets, so a hand edit through any other
565
+ // path leaves it exactly where the last successful apply left it. When disk no
566
+ // longer matches it, something moved the target after that apply without
567
+ // going through it again, and the next apply of the same (or same-shaped)
568
+ // payload must not silently bury that move.
569
+ //
570
+ // One entry per path, unconditionally overwritten by the next apply: there is
571
+ // no generation to expire here, only ever "what did I last apply", so the
572
+ // newest apply is always the only one that matters.
573
+
574
+ /**
575
+ * Record that THIS session's own overwrite() just wrote `hash` for `relPath`.
576
+ * Scoped by tracked-ness like `advanceBaseForWrite`/`recordObserved`: a no-op
577
+ * for a key outside `targets`, so this can never mint a new guarded target.
578
+ *
579
+ * @returns {boolean} true when base.json was updated
580
+ */
581
+ export function recordAppliedHash(hypoDir, sessionId, relPath, hash) {
582
+ if (!sessionId) return false;
583
+ const path = basePath(hypoDir, sessionId);
584
+ try {
585
+ return withFileLock(path, () => {
586
+ const parsed = readBaseFile(hypoDir, sessionId);
587
+ if (!parsed) return false;
588
+ if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
589
+ if (typeof hash !== 'string') return false;
590
+ if (
591
+ !parsed.appliedHashes ||
592
+ typeof parsed.appliedHashes !== 'object' ||
593
+ Array.isArray(parsed.appliedHashes)
594
+ ) {
595
+ parsed.appliedHashes = {};
596
+ }
597
+ parsed.appliedHashes[relPath] = hash;
598
+ try {
599
+ atomicWrite(path, JSON.stringify(parsed, null, 2));
600
+ return true;
601
+ } catch {
602
+ return false;
603
+ }
604
+ });
605
+ } catch {
606
+ return false;
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Clear THIS session's recorded applied-hash for `relPath`, without touching
612
+ * `targets`. For a writer that legitimately moves `targets[relPath]` (via
613
+ * `advanceBase`) but has no "applied by an overwrite() call" fact of its own to
614
+ * record.
615
+ *
616
+ * **No production caller right now.** The only one was
617
+ * `hooks/hypo-hot-rebuild.mjs`'s canonical rebuild of the root `hot.md`, and
618
+ * that file left `overwriteTargets` when it became a hook-generated projection,
619
+ * taking the whole base-vs-rebuild contention with it. Kept, with its unit
620
+ * coverage, for the next generated-file target that does get snapshotted; the
621
+ * scenario below is what it is for. Left stale, such a rebuild's `advanceBase`
622
+ * moves `targets`
623
+ * to the rebuilt bytes while `appliedHashes` still names whatever crystallize
624
+ * last applied; a retry of that same stale close payload then reads disk (the
625
+ * rebuilt bytes) as diverged from `appliedHash`, and blocker 2's "any drift
626
+ * from appliedHash parks" guard (`overwriteConflictReason`) mistakes the
627
+ * hook's own routine rebuild for a hand edit and parks it. Clearing removes the
628
+ * stale fact instead of leaving it to misfire: a retry after this sees no
629
+ * `appliedHash` for the target at all, and falls back to the plain base-vs-disk
630
+ * compare, same as a target crystallize has never applied to this session.
631
+ *
632
+ * The cost this accepts: a stale close payload that WOULD have been caught by
633
+ * the applied-hash guard can now overwrite the rebuild's canonical bytes
634
+ * outright, same as it could before blocker 2 existed. Rebuilt content is
635
+ * regenerated on the next Stop hook regardless, so this only trades one park
636
+ * for a page that self-heals a run later, not a real loss.
637
+ *
638
+ * @returns {boolean} true when base.json was updated
639
+ */
640
+ export function clearAppliedHash(hypoDir, sessionId, relPath) {
641
+ if (!sessionId) return false;
642
+ const path = basePath(hypoDir, sessionId);
643
+ try {
644
+ return withFileLock(path, () => {
645
+ const parsed = readBaseFile(hypoDir, sessionId);
646
+ if (!parsed) return false;
647
+ if (
648
+ !parsed.appliedHashes ||
649
+ typeof parsed.appliedHashes !== 'object' ||
650
+ Array.isArray(parsed.appliedHashes) ||
651
+ !Object.prototype.hasOwnProperty.call(parsed.appliedHashes, relPath)
652
+ ) {
653
+ return false; // nothing recorded for this target: a no-op, not a failure
654
+ }
655
+ delete parsed.appliedHashes[relPath];
656
+ try {
657
+ atomicWrite(path, JSON.stringify(parsed, null, 2));
658
+ return true;
659
+ } catch {
660
+ return false;
661
+ }
662
+ });
663
+ } catch {
664
+ return false;
665
+ }
666
+ }
667
+
668
+ /**
669
+ * `advanceBase` and `recordAppliedHash`, done as ONE read-modify-write instead
670
+ * of two. crystallize's own overwrite() used to call them back to back: if the
671
+ * first write landed and the second then failed (disk full, a permissions
672
+ * change mid-session, any `atomicWrite` failure), `targets[relPath]` had
673
+ * already moved to the new bytes while `appliedHashes[relPath]` silently kept
674
+ * its OLD value (the exact partial state `overwriteConflictReason`'s
675
+ * `appliedHash` check exists to catch, except now self-inflicted): the next
676
+ * apply would read disk-equals-base (base moved), see a stale `appliedHash`
677
+ * that does not match either, and park a WRITE that should have gone through,
678
+ * or (the direction review r4-w4 major 2 flagged) silently clear a
679
+ * genuinely stale reapply's park because the failed record made `appliedHash`
680
+ * read as if this call had never run at all. A `false` return here leaves
681
+ * BOTH fields exactly where they were (the single `atomicWrite` either lands
682
+ * whole or not at all): base does not move, so the next look sees the same
683
+ * `base-mismatch`/`base-hash-target-missing` fail-safe a foreign write would
684
+ * get, never a silent clean read. This is the only caller-visible difference
685
+ * from calling `advanceBase` then `recordAppliedHash` separately: a failure
686
+ * here now costs the base advance too, on purpose, because "recorded the
687
+ * write but forgot who wrote it" is worse than "didn't record it at all".
688
+ *
689
+ * Not a replacement for either function: `advanceBase` alone is the right call
690
+ * for a writer that moves a tracked target but has no "applied by THIS
691
+ * overwrite call" fact to record and must not manufacture one. Its last such
692
+ * caller was hypo-hot-rebuild.mjs, which stopped touching the base when the
693
+ * root `hot.md` left `overwriteTargets`.
694
+ *
695
+ * @param {{ write?: (path: string, content: string) => void }} [testHooks]
696
+ * `write` is test-only: it stands in for `atomicWrite`, so a test can let the
697
+ * first save land and fail a second one. A regression back to two saves then
698
+ * shows up as a moved base with a stale applied hash. Production callers
699
+ * never pass this.
700
+ * @returns {boolean} true when base.json was updated (both fields together)
701
+ */
702
+ export function advanceBaseAndRecordApplied(hypoDir, sessionId, relPath, hash, testHooks) {
703
+ const write = testHooks?.write ?? atomicWrite;
704
+ if (!sessionId) return false;
705
+ const path = basePath(hypoDir, sessionId);
706
+ try {
707
+ return withFileLock(path, () => {
708
+ const parsed = readBaseFile(hypoDir, sessionId);
709
+ if (!parsed) return false;
710
+ if (typeof hash !== 'string') return false;
711
+ parsed.targets[relPath] = hash;
712
+ if (
713
+ !parsed.appliedHashes ||
714
+ typeof parsed.appliedHashes !== 'object' ||
715
+ Array.isArray(parsed.appliedHashes)
716
+ ) {
717
+ parsed.appliedHashes = {};
718
+ }
719
+ parsed.appliedHashes[relPath] = hash;
720
+ try {
721
+ write(path, JSON.stringify(parsed, null, 2));
722
+ return true;
723
+ } catch {
724
+ return false;
725
+ }
726
+ });
727
+ } catch {
728
+ return false;
729
+ }
730
+ }
731
+
732
+ /**
733
+ * The hash THIS session's own overwrite() last wrote for `relPath`, or null
734
+ * when it never has (no snapshot, no session, or no recorded entry).
735
+ *
736
+ * @returns {string|null}
737
+ */
738
+ export function readAppliedHash(hypoDir, sessionId, relPath) {
739
+ if (!sessionId) return null;
740
+ const parsed = readBaseFile(hypoDir, sessionId);
741
+ if (!parsed) return null;
742
+ const applied = parsed.appliedHashes;
743
+ if (!applied || typeof applied !== 'object' || Array.isArray(applied)) return null;
744
+ const hash = applied[relPath];
745
+ return typeof hash === 'string' && hash ? hash : null;
746
+ }
747
+
748
+ // The whole-file overwrite targets are prose-and-table markdown documents, and
438
749
  // a base-mismatch on one of them always parks. Five predicates lived here that tried
439
750
  // to skip the park when a payload "provably" lost nothing, and four rounds of review
440
751
  // broke all five against the real vault. The last one accepted an insertion between a
@@ -443,18 +754,26 @@ export function wasObservedTruncated(hypoDir, sessionId, relPath) {
443
754
  // from block context that begins far above the line under judgement, and a hook that
444
755
  // may use Node built-ins only is not the place to own a block parser.
445
756
  //
446
- // The pointer table this was built for should stop being a shared whole-file
447
- // overwrite target and become a locally generated projection of the per-project files
448
- // that already own those facts. Then two machines never contend over it, and nothing
449
- // here needs to prove anything.
757
+ // The pointer table those predicates were built for is gone from this list, which
758
+ // is the fix they were reaching for: the root `hot.md` is now a projection the
759
+ // SessionStart and Stop hooks regenerate from `projects/*/hot.md`, not a shared
760
+ // whole-file overwrite a close composes by hand. Two machines never contend over
761
+ // it, and nothing here needs to prove anything about it.
450
762
 
451
763
  /**
452
- * The four overwrite targets crystallize replaces wholesale. `project` may be
764
+ * The overwrite targets crystallize replaces wholesale. `project` may be
453
765
  * null when cwd resolves to no project; the two project-scoped paths are then
454
766
  * omitted and close falls back to proposal for them.
767
+ *
768
+ * The root `hot.md` is deliberately NOT here. Snapshotting it made the hooks'
769
+ * own regeneration look like a foreign edit, so a close parked a `base-mismatch`
770
+ * on a file no human had touched. Off the list, there is no base for the guard
771
+ * to compare and no park to false-raise; the projection self-heals on the next
772
+ * Stop hook regardless. Do not add it back without first taking the regeneration
773
+ * out of the hooks.
455
774
  */
456
775
  export function overwriteTargets(project) {
457
- const targets = ['hot.md', join('pages', 'open-questions.md')];
776
+ const targets = [join('pages', 'open-questions.md')];
458
777
  if (project) {
459
778
  targets.unshift(join('projects', project, 'session-state.md'));
460
779
  targets.unshift(join('projects', project, 'hot.md'));