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
@@ -32,9 +32,11 @@
32
32
  // own doc comment for the caller contract.
33
33
 
34
34
  import { createHash } from 'node:crypto';
35
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
36
- import { dirname, join } from 'node:path';
37
- import { walkCloseGate } from './hypo-shared.mjs';
35
+ import { existsSync, readFileSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { sessionClosedMarkerPath, walkCloseGate } from './hypo-shared.mjs';
38
+ import { receiptPath } from './close-receipt.mjs';
39
+ import { atomicWrite } from './atomic-write.mjs';
38
40
 
39
41
  /** `<hypoDir>/.cache/close-gate/<session-id>.json`. */
40
42
  export function closeGatePath(hypoDir, sessionId) {
@@ -121,14 +123,6 @@ export function resolutionStamp(rawTranscript, upToIndex = Infinity) {
121
123
  return { index, prefixSha };
122
124
  }
123
125
 
124
- /** Atomic overwrite via tmp+rename, mirroring base-store's atomicWrite. */
125
- function atomicWrite(path, content) {
126
- mkdirSync(dirname(path), { recursive: true });
127
- const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
128
- writeFileSync(tmp, content);
129
- renameSync(tmp, path);
130
- }
131
-
132
126
  /**
133
127
  * Record that this session's close gate is resolved, at the point `stamp`
134
128
  * describes. Best-effort: a hook must never fail a close over a cache write,
@@ -382,20 +376,62 @@ export function readResolution(hypoDir, sessionId, rawTranscript) {
382
376
  * doing so.
383
377
  *
384
378
  * @param {{transcriptPath: string|null, hypoDir: string, sessionId: string|null}} args
385
- * @returns {{ok: boolean, open: boolean, reason: string|null}}
379
+ * @returns {{ok: boolean, open: boolean, reason: string|null, hostTagWarning?: string}}
380
+ * `hostTagWarning` is present on any result whose gate reads OPEN and whose
381
+ * `walkCloseGate` reported a `neutralizedHostTagName`: the close in effect
382
+ * survived a HOST_TAG_NAMES-shaped queue item reading as neutral rather
383
+ * than a retraction. Not gated on `ok`, because the two `ok: false` returns
384
+ * below that still read open (transcript-rewrite-detected,
385
+ * no-new-open-since-resolution) reach a caller which keeps going on the raw
386
+ * `isCloseGateOpen` boolean (`runMarkSessionClosed`), and that caller needs
387
+ * the same residual named. See the field's own doc comment in
388
+ * hypo-shared.mjs for why that reading is accepted rather than closed on.
389
+ * It states WHAT happened and nothing about undoing it: the undo differs per
390
+ * entry point, so a caller pairs this with `hostTagWarningWithUndo` below
391
+ * and surfaces the pair exactly once per run.
386
392
  */
387
393
  export function closeGateStatus({ transcriptPath, hypoDir, sessionId }) {
388
- const { open, openedAtIndex } = walkCloseGate(transcriptPath ?? null);
394
+ const { open, openedAtIndex, retractedByUnknownTagName, neutralizedHostTagName } = walkCloseGate(
395
+ transcriptPath ?? null,
396
+ );
389
397
  if (!open) {
390
398
  return {
391
399
  ok: false,
392
400
  open: false,
393
401
  reason:
394
402
  'no-open: this session carries no close signal in its transcript yet — ' +
395
- 'ask the user whether they actually want to close before treating this as one.',
403
+ 'ask the user whether they actually want to close before treating this as one.' +
404
+ // Names the retraction's shape when walkCloseGate found one. A tag
405
+ // shaped queue item only ever retracts here when it is NOT one of
406
+ // HOST_TAG_NAMES (a known host tag reads as neutral instead, see the
407
+ // enqueue-branch reversion note in hypo-shared.mjs), so this is
408
+ // always the "which tag to add" diagnostic, never a known tag
409
+ // retracting a close that was already judged expected.
410
+ (retractedByUnknownTagName
411
+ ? ` A queued item shaped like a host tag ("<${retractedByUnknownTagName}...>") retracted ` +
412
+ 'the last close BECAUSE that tag is not in HOST_TAG_NAMES (hooks/hypo-shared.mjs). ' +
413
+ 'That is fail-closed and applies even when the item itself reads like a close phrase: ' +
414
+ 'an unregistered tag cannot be attributed to the host, so it counts as the user taking ' +
415
+ 'the close back. If it is a genuine host notification rather than something the user ' +
416
+ 'typed, add it to HOST_TAG_NAMES; until then the user has to confirm the close again.'
417
+ : ''),
396
418
  };
397
419
  }
398
420
 
421
+ // Computed once here, past the `!open` return above, so every return that
422
+ // still reads the gate as open can carry it without repeating the message.
423
+ // Not itself a reason to refuse anything: walkCloseGate already chose to
424
+ // read this shape as neutral, not a retraction (see its own doc comment).
425
+ // This is the one-time surfacing of the residual that reading accepts, and
426
+ // it stops at WHAT happened; see hostTagWarningWithUndo below for why the
427
+ // undo instructions are not baked in here.
428
+ const hostTagWarning = neutralizedHostTagName
429
+ ? `this close was granted while a queued item shaped like a known host tag ` +
430
+ `("<${neutralizedHostTagName}...>") stayed neutral instead of retracting it. If a person ` +
431
+ 'typed or pasted that tag verbatim, rather than the host sending it, this close may not ' +
432
+ 'reflect a decision the user actually made.'
433
+ : null;
434
+
399
435
  let rawTranscript = null;
400
436
  try {
401
437
  rawTranscript = transcriptPath ? readFileSync(transcriptPath) : null;
@@ -407,7 +443,7 @@ export function closeGateStatus({ transcriptPath, hypoDir, sessionId }) {
407
443
  if (closedAtIndex === null) {
408
444
  // NO_CONSTRAINT: no valid resolution record exists for this session, so
409
445
  // the open found above is unconstrained.
410
- return { ok: true, open: true, reason: null };
446
+ return { ok: true, open: true, reason: null, ...(hostTagWarning ? { hostTagWarning } : {}) };
411
447
  }
412
448
  if (prefixMatches === false) {
413
449
  // Checked ahead of the index comparison on purpose, even though
@@ -421,10 +457,11 @@ export function closeGateStatus({ transcriptPath, hypoDir, sessionId }) {
421
457
  'transcript-rewrite-detected: the recorded resolution no longer matches this ' +
422
458
  "transcript's history — treat this session's prior resolution as untrustworthy " +
423
459
  'and confirm the close with the user again.',
460
+ ...(hostTagWarning ? { hostTagWarning } : {}),
424
461
  };
425
462
  }
426
463
  if (openedAtIndex >= closedAtIndex) {
427
- return { ok: true, open: true, reason: null };
464
+ return { ok: true, open: true, reason: null, ...(hostTagWarning ? { hostTagWarning } : {}) };
428
465
  }
429
466
  return {
430
467
  ok: false,
@@ -432,5 +469,83 @@ export function closeGateStatus({ transcriptPath, hypoDir, sessionId }) {
432
469
  reason:
433
470
  'no-new-open-since-resolution: this session already resolved its last close signal — ' +
434
471
  'a fresh close phrase from the user is needed before this can pass again.',
472
+ ...(hostTagWarning ? { hostTagWarning } : {}),
435
473
  };
436
474
  }
475
+
476
+ // The undo half of a `hostTagWarning`, which closeGateStatus deliberately
477
+ // leaves out: what a run can actually take back differs by entry point, and a
478
+ // message naming a commit the run never made sends the reader after nothing.
479
+ // `kind` says what THIS run did:
480
+ // 'commit-and-marker' a close apply that committed and landed its marker
481
+ // 'commit-only' a close apply that committed, marker never landed
482
+ // 'marker-only' --mark-session-closed: a marker, no commit at all
483
+ // 'uncommitted-writes' bytes on disk, no commit and no marker
484
+ // A null or unrecognized `kind` means the run left nothing to undo, and so
485
+ // nothing to warn about either: the answer is null and the caller stays
486
+ // quiet. `commitSha` is the commit hash for the two committing kinds
487
+ // (commitWikiChanges returns it as `sha`); see the comment inside for what
488
+ // each of its three states produces. The marker path is derived from
489
+ // sessionClosedMarkerPath rather than
490
+ // written out here, so it cannot drift from the file the writer actually
491
+ // creates; an earlier version of this text hardcoded a `.cache/
492
+ // session-closed-<id>.json` path that no writer has ever produced (the
493
+ // `.json` under `.cache/close-gate/` is this file's own resolution record, a
494
+ // different file), and the model relaying it sent users to delete something
495
+ // that was not there.
496
+ //
497
+ // A close now also files a receipt (`hooks/close-receipt.mjs`) alongside the
498
+ // compat marker, and deleting the marker alone would leave that receipt
499
+ // standing: readReceiptStrict still reads it as valid, so a new Stop would
500
+ // keep treating the session as closed even after the marker it was told to
501
+ // delete is gone. Undo has to name both files or it does not actually undo
502
+ // anything a receipt-aware reader honors. `commit-and-marker` and
503
+ // `marker-only` are the two kinds whose write path always lands the receipt
504
+ // BEFORE the marker, so by the time either kind's text
505
+ // is composed the receipt is guaranteed to exist and the message just names
506
+ // it. `commit-only` is the one kind where the receipt write and the marker
507
+ // write can fail independently, so its wording checks the receipt path on
508
+ // disk rather than assume either way.
509
+ export function hostTagWarningWithUndo(warning, kind, hypoDir, sessionId, commitSha) {
510
+ if (!warning || !kind) return null;
511
+ const marker = sessionClosedMarkerPath(hypoDir, sessionId);
512
+ const receipt = receiptPath(hypoDir, sessionId);
513
+ // MAJOR fix: "git revert" with no target was an instruction a reader could
514
+ // only carry out by guessing, and the obvious guess (HEAD) is wrong the
515
+ // moment a concurrent session on the same vault commits after this one:
516
+ // reverting HEAD then undoes that session's work instead. `commitSha` is
517
+ // commitWikiChanges's own `sha` for the commit THIS close made, so the
518
+ // instruction names it. Three states, deliberately distinguished:
519
+ // a string that commit exists; name it in a runnable command
520
+ // null the commit was a no-op (`scoped: 0`), nothing to revert
521
+ // undefined the caller did not say, so say what is known and no more,
522
+ // rather than inventing a target
523
+ const revert =
524
+ typeof commitSha === 'string' && commitSha
525
+ ? `revert the commit this close just made (\`git -C ${hypoDir} revert ${commitSha}\`)`
526
+ : commitSha === null
527
+ ? 'nothing needs reverting (this close changed no tracked file, so git created no commit)'
528
+ : `revert the commit this close just made in the wiki repo (find it with \`git -C ${hypoDir} log -1\` and check it is this close's own commit before reverting: on a shared vault another session may have committed after it)`;
529
+ // Named only when there is a valid receipt path to name at all (a bad
530
+ // session id has no receipt directory to file one under, same edge case
531
+ // `receiptPath` itself refuses). `both` reads naturally whether or not
532
+ // that path is reachable, so the two kinds below stay correct either way.
533
+ const both = receipt ? `${marker} and ${receipt}` : marker;
534
+ const undo = {
535
+ 'commit-and-marker':
536
+ `To undo it: ${revert}, delete ` + `${both}, and ask the user whether they meant to close.`,
537
+ 'commit-only':
538
+ `To undo it: ${revert} and ask the user whether they meant to close. No marker was ` +
539
+ 'written, so there is none to delete.' +
540
+ (receipt && existsSync(receipt)
541
+ ? ` A close receipt landed at ${receipt} and should be deleted too.`
542
+ : ''),
543
+ 'marker-only':
544
+ `To undo it: delete ${both} and ask the user whether they meant to close. This run wrote ` +
545
+ 'no commit, so there is nothing to revert.',
546
+ 'uncommitted-writes':
547
+ 'Nothing to revert: this run made no commit and wrote no marker. The files it did write are ' +
548
+ 'still uncommitted in the wiki working tree, so check git status there before closing again.',
549
+ }[kind];
550
+ return undo ? `${warning} ${undo}` : null;
551
+ }
@@ -28,22 +28,34 @@
28
28
  // NARROW what a retry is allowed to restage, never widen it — the same
29
29
  // direction close-gate-store's NO_CONSTRAINT keeps for its own file.
30
30
 
31
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
32
- import { dirname, join } from 'node:path';
31
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+ import { atomicWrite } from './atomic-write.mjs';
34
+ import { isValidSessionId } from './proposal-store.mjs';
35
+ import { withFileLock } from './hypo-shared.mjs';
33
36
 
34
- /** `<hypoDir>/.cache/close-journal/<sessionId>.json`. */
37
+ /**
38
+ * `<hypoDir>/.cache/close-journal/<sessionId>.json`, or null when the id is not
39
+ * a shape that is safe to use as a filename.
40
+ *
41
+ * The check lives HERE, at the sink, not only at the CLI entry points that
42
+ * happen to call this today. Every entry point did validate, and the hole
43
+ * opened anyway the moment a new caller (reconcile) fed this an id read back
44
+ * out of `.cache/proposals/applied.log` rather than typed by a person: a
45
+ * `closeSessionId` of `../../../../../Users/<you>/.claude/settings` resolves
46
+ * clean out of the vault, and the caller then atomically overwrites whatever
47
+ * is there. Guarding each caller is what produced that gap; guarding the one
48
+ * place the string becomes a path is what closes it for callers not written yet.
49
+ *
50
+ * Returns null rather than throwing so the read path can keep treating an
51
+ * unusable journal as "nothing recorded", which is the direction it already
52
+ * fails in for a corrupt or absent file.
53
+ */
35
54
  export function closeJournalPath(hypoDir, sessionId) {
55
+ if (!isValidSessionId(sessionId)) return null;
36
56
  return join(hypoDir, '.cache', 'close-journal', `${sessionId}.json`);
37
57
  }
38
58
 
39
- /** Atomic overwrite via tmp+rename, mirroring close-gate-store's atomicWrite. */
40
- function atomicWrite(path, content) {
41
- mkdirSync(dirname(path), { recursive: true });
42
- const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
43
- writeFileSync(tmp, content);
44
- renameSync(tmp, path);
45
- }
46
-
47
59
  /**
48
60
  * Read this session's journal. Absent, corrupt, or wrong-shaped reads as
49
61
  * `{}` — never partially trusted. A caller that widened a retry off a
@@ -56,7 +68,9 @@ function atomicWrite(path, content) {
56
68
  export function readJournal(hypoDir, sessionId) {
57
69
  if (!sessionId) return {};
58
70
  const path = closeJournalPath(hypoDir, sessionId);
59
- if (!existsSync(path)) return {};
71
+ // An id that cannot be a filename has no journal, which reads the same as an
72
+ // absent one. Same direction this function already fails in.
73
+ if (path === null || !existsSync(path)) return {};
60
74
  try {
61
75
  const parsed = JSON.parse(readFileSync(path, 'utf-8'));
62
76
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
@@ -92,20 +106,119 @@ export function readJournal(hypoDir, sessionId) {
92
106
  * `appliedPaths` / `commitWikiChanges`
93
107
  * @param {string} hash sha256 hex of the bytes just written (hashContent)
94
108
  */
109
+ /**
110
+ * The read half of a read-modify-write, for callers that already hold the
111
+ * journal's lock. Public readJournal takes no lock: atomicWrite's rename means
112
+ * a reader sees one whole version or another, never a torn one. Re-entering the
113
+ * lock from inside it would deadlock the writers below.
114
+ */
115
+ function readJournalAt(path) {
116
+ if (!existsSync(path)) return {};
117
+ try {
118
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
119
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
120
+ if (parsed.v !== 1 || !parsed.paths || typeof parsed.paths !== 'object') return {};
121
+ const out = {};
122
+ for (const [k, v] of Object.entries(parsed.paths)) {
123
+ if (typeof k === 'string' && k && typeof v === 'string' && v) out[k] = v;
124
+ }
125
+ return out;
126
+ } catch {
127
+ return {};
128
+ }
129
+ }
130
+
95
131
  export function recordJournalEntry(hypoDir, sessionId, relPath, hash) {
96
132
  if (!sessionId) return;
133
+ const path = closeJournalPath(hypoDir, sessionId);
134
+ // Unusable id: nothing to record against, and nowhere safe to put it. This
135
+ // one is best-effort by contract, so it declines the same way it declines a
136
+ // failed write.
137
+ if (path === null) return;
97
138
  try {
98
- const paths = readJournal(hypoDir, sessionId);
99
- paths[relPath] = hash;
100
- atomicWrite(
101
- closeJournalPath(hypoDir, sessionId),
102
- JSON.stringify({ v: 1, sessionId: String(sessionId), paths }, null, 2),
103
- );
139
+ // Read-modify-write under the journal's own lock. atomicWrite already stops
140
+ // a reader seeing a torn file; what it cannot stop is two writers each
141
+ // reading the same `paths` and the second saving a copy that never had the
142
+ // first one's entry. Every mutator here takes the same lock, so they
143
+ // serialise against each other and against clearJournal.
144
+ withFileLock(path, () => {
145
+ const paths = readJournalAt(path);
146
+ paths[relPath] = hash;
147
+ atomicWrite(path, JSON.stringify({ v: 1, sessionId: String(sessionId), paths }, null, 2));
148
+ });
104
149
  } catch {
105
150
  // best-effort — see doc comment above
106
151
  }
107
152
  }
108
153
 
154
+ /**
155
+ * Record ONE close handoff receipt: a target that a DIFFERENT session's
156
+ * approved `proposal resolve` just wrote, credited back to the close whose
157
+ * park produced it.
158
+ *
159
+ * Same on-disk shape as recordJournalEntry (readJournal reads either one the
160
+ * same way), but not best-effort. recordJournalEntry swallows a write
161
+ * failure on purpose: the bytes it describes were ALSO written by this same
162
+ * close's own overwrite step moments earlier, so a lost entry only costs a
163
+ * future retry its fast restage and falls back to the ordinary gate block,
164
+ * the safe direction to fail toward. A handoff receipt has no such fallback.
165
+ * The bytes it describes were written by a HUMAN approval in a possibly
166
+ * unrelated session, and the proposal artifact that once pointed at them is
167
+ * deleted the moment that write succeeds (the T7 CLI's writeApprovedProposal,
168
+ * step 12), so if this call fails silently, the owning close's retry can
169
+ * never find those bytes again by any means. The T7 CLI must see the failure
170
+ * and hold the artifact and audit entry instead of deleting them
171
+ * (`close-receipt-failed`), or the failure disappears along with the
172
+ * artifact.
173
+ *
174
+ * @param {string} hypoDir
175
+ * @param {string} closeSessionId the ORIGINATING close's session id, not the
176
+ * session that approved the resolve. Legacy challenges/artifacts that
177
+ * predate this field are read as this being the SAME session that resolved
178
+ * them (see the T7 CLI), so this parameter is never called with
179
+ * an empty value from that path.
180
+ * @param {string} relPath vault-relative path, exactly as it appears in the
181
+ * proposal artifact's `target`
182
+ * @param {string} hash sha256 hex of the bytes just written, the SAME
183
+ * hashContent() crystallize's own overwrite step hashes with (base-store.mjs),
184
+ * so the retry's `journalHash === hashContent(disk)` comparison in
185
+ * crystallize-close-apply.mjs trusts a receipt exactly as much as its own
186
+ * same-session entries.
187
+ * @returns {{ok: true} | {ok: false, error: string}}
188
+ */
189
+ export function recordHandoffReceipt(hypoDir, closeSessionId, relPath, hash) {
190
+ if (!closeSessionId) return { ok: false, error: 'no closeSessionId to journal against' };
191
+ const path = closeJournalPath(hypoDir, closeSessionId);
192
+ // Strict by contract, so an unusable id is a reported failure rather than a
193
+ // quiet decline. This is the call reconcile makes with an id it read out of
194
+ // the audit log, so it is the one that must not treat a hostile string as
195
+ // merely unlucky.
196
+ if (path === null) {
197
+ return {
198
+ ok: false,
199
+ error: `closeSessionId is not a usable session id: ${JSON.stringify(closeSessionId)}`,
200
+ };
201
+ }
202
+ try {
203
+ // Same lock as every other mutator. Without it, two resolves handing off to
204
+ // the same close, or a resolve racing that close's own clearJournal, could
205
+ // each return ok:true while one receipt silently vanished. The artifact is
206
+ // deleted the moment this returns ok, and reconcile only ever considers
207
+ // artifacts, so a lost receipt would have no way back.
208
+ withFileLock(path, () => {
209
+ const paths = readJournalAt(path);
210
+ paths[relPath] = hash;
211
+ atomicWrite(
212
+ path,
213
+ JSON.stringify({ v: 1, sessionId: String(closeSessionId), paths }, null, 2),
214
+ );
215
+ });
216
+ return { ok: true };
217
+ } catch (e) {
218
+ return { ok: false, error: e && e.message ? e.message : String(e) };
219
+ }
220
+ }
221
+
109
222
  /**
110
223
  * Clear this session's journal once its close has actually committed. A
111
224
  * journal entry that outlives a successful commit would keep offering to
@@ -120,8 +233,17 @@ export function recordJournalEntry(hypoDir, sessionId, relPath, hash) {
120
233
  */
121
234
  export function clearJournal(hypoDir, sessionId) {
122
235
  if (!sessionId) return;
236
+ const path = closeJournalPath(hypoDir, sessionId);
237
+ // An id that never produced a path never produced a file to remove, and an
238
+ // rmSync on a traversed path would be the destructive half of the same hole.
239
+ if (path === null) return;
123
240
  try {
124
- rmSync(closeJournalPath(hypoDir, sessionId), { force: true });
241
+ // Under the same lock: a clear landing between another writer's read and
242
+ // its save would be undone by that save, resurrecting a journal the close
243
+ // had already consumed.
244
+ withFileLock(path, () => {
245
+ rmSync(path, { force: true });
246
+ });
125
247
  } catch {
126
248
  // best-effort — see doc comment above
127
249
  }