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
@@ -2,19 +2,22 @@ import {
2
2
  existsSync,
3
3
  statSync,
4
4
  readFileSync,
5
- writeFileSync,
6
5
  mkdirSync,
7
- renameSync,
8
6
  openSync,
9
7
  writeSync,
10
8
  closeSync,
9
+ unlinkSync,
10
+ readdirSync,
11
11
  } from 'fs';
12
12
  import { join, dirname } from 'path';
13
13
  import { spawnSync } from 'child_process';
14
14
  import { fileURLToPath } from 'url';
15
+ import { randomBytes, createHash } from 'crypto';
15
16
  import { expandHome } from './hypo-root.mjs';
17
+ import { parseStrictDate } from './time.mjs';
16
18
  import { isValidProjectName, substituteTokens, TEMPLATE_DIR } from './project-create.mjs';
17
19
  import { appendPendingTags, checkForbidden } from './schema-vocab.mjs';
20
+ import { atomicWrite } from '../../hooks/atomic-write.mjs';
18
21
  import {
19
22
  sessionCloseFileStatus,
20
23
  sessionCloseGlobalStatus,
@@ -36,22 +39,58 @@ import {
36
39
  withFileLock,
37
40
  resolveGateProjectOverride,
38
41
  } from '../../hooks/hypo-shared.mjs';
42
+ import {
43
+ CERT_CHECKPOINT,
44
+ CERT_CLOSE_FILES,
45
+ RECEIPT_SCHEMA_VERSION,
46
+ invalidateCloseArtifacts,
47
+ receiptPath,
48
+ verifyEntriesInCommit,
49
+ writeReceiptAtomic,
50
+ } from '../../hooks/close-receipt.mjs';
39
51
  import {
40
52
  hashContent,
41
53
  readBaseEntry,
42
- advanceBase,
43
54
  readObservedHash,
44
55
  wasObservedTruncated,
56
+ readAppliedHash,
57
+ advanceBaseAndRecordApplied,
45
58
  } from '../../hooks/base-store.mjs';
46
- import { writeProposal } from '../../hooks/proposal-store.mjs';
59
+ import {
60
+ writeProposal,
61
+ listProposalsChecked,
62
+ isValidSessionId,
63
+ } from '../../hooks/proposal-store.mjs';
47
64
  import {
48
65
  recordGateClosed,
49
66
  resolutionStamp,
50
67
  closeGateStatus,
68
+ hostTagWarningWithUndo,
51
69
  } from '../../hooks/close-gate-store.mjs';
70
+ import { readJournal, recordJournalEntry, clearJournal } from '../../hooks/close-journal.mjs';
52
71
  import { requireProjectDir } from './crystallize-close-gate.mjs';
53
72
  import { summarizeLintForOutput } from './crystallize-helpers.mjs';
54
73
 
74
+ // Raw-byte sha256 of a string written to disk, hashed exactly the way
75
+ // close-receipt.mjs's verifyEntriesInCommit hashes the committed blob (a
76
+ // Buffer, never a decoded JS string): see that module's own comment on why
77
+ // an overwrite/create proof entry is never trusted on a UTF-8 round trip.
78
+ function bytesSha256(content) {
79
+ return createHash('sha256').update(Buffer.from(content, 'utf-8')).digest('hex');
80
+ }
81
+
82
+ // The commit this apply's marker phase just made (or confirmed already
83
+ // clean), read under the SAME vault-commit lock the commit itself ran
84
+ // inside, never a second, unlocked `git rev-parse HEAD` afterwards, which a
85
+ // concurrent close landing in the gap could move out from under this read
86
+ // (the commit a receipt certifies is `commitOutcome.sha`, or, when the commit
87
+ // was a `scoped: 0` no-op, this locked HEAD read).
88
+ function readHeadShaLocked(hypoDir) {
89
+ const res = spawnSync('git', ['-C', hypoDir, 'rev-parse', 'HEAD'], { encoding: 'utf-8' });
90
+ if (res.error || res.status !== 0) return null;
91
+ return (res.stdout || '').trim() || null;
92
+ }
93
+
55
94
  // LINT_SCRIPT is resolved relative to this lib file rather than via
56
95
  // crystallize.mjs's own import.meta.url, so it keeps pointing at the sibling
57
96
  // scripts/lint.mjs — same absolute path either way.
@@ -90,12 +129,19 @@ function runLint(hypoDir) {
90
129
  }
91
130
 
92
131
  // ── session-close apply ────────────────────────────────────────────
93
- // Idempotent payload-driven application of the 5 mandatory session-close memory
132
+ // Idempotent payload-driven application of the 4 mandatory session-close memory
94
133
  // files (+ optional open-questions). Used by the LLM session-close flow as the
95
134
  // canonical entrypoint instead of issuing 5+ Write tool calls by hand.
96
135
  //
136
+ // The root pointer table (`hot.md`) used to be a fifth file here, composed by
137
+ // hand into every payload. It is now a projection the SessionStart and Stop
138
+ // hooks regenerate from `projects/*/hot.md`, so a close that wrote it would
139
+ // only be overwritten by its own turn's Stop hook. It left the payload, the
140
+ // overwrite set, and the base snapshot together; a payload that still carries
141
+ // `rootHot` is reported, not applied (see `obsoleteFieldNotices`).
142
+ //
97
143
  // Idempotency:
98
- // • full-content fields (sessionState/projectHot/rootHot/openQuestions): write
144
+ // • full-content fields (sessionState/projectHot/openQuestions): write
99
145
  // only when on-disk bytes differ — re-running with same payload is a no-op.
100
146
  // • append fields (sessionLog/log): skip when the dated heading/entry is
101
147
  // already present (regex shared with sessionCloseFileStatus via hypo-shared).
@@ -131,14 +177,12 @@ function readPayload(source) {
131
177
  // spinning the full 5s. Not a documented production knob.
132
178
  const APPEND_LOCK_TIMEOUT_MS = Number(process.env.HYPO_APPEND_LOCK_TIMEOUT_MS) || 5000;
133
179
 
134
- /** Atomic write via tmp+rename. `<path>.<pid>.<rand>.tmp` so concurrent helpers
135
- * don't fight over the same shared `<path>.tmp` slot. */
136
- function atomicWrite(path, content) {
137
- mkdirSync(dirname(path), { recursive: true });
138
- const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
139
- writeFileSync(tmp, content);
140
- renameSync(tmp, path);
141
- }
180
+ // atomicWrite is now the shared hooks/atomic-write.mjs implementation
181
+ // (imported above). Six byte-identical copies of this function used to live
182
+ // in hooks/ and scripts/lib/, and a fix landed in only two of them (#296).
183
+ // this file was one of the four that kept the bug (a rename failure leaked
184
+ // its tmp file forever, because the cleanup called `rmSync` without ever
185
+ // importing it). See hooks/atomic-write.mjs's own doc comment.
142
186
 
143
187
  /**
144
188
  * Read a target's current bytes, distinguishing "absent" from "unreadable" the
@@ -178,40 +222,113 @@ function readTarget(path) {
178
222
  * `null` when it was never shown anything current), plus whether the one
179
223
  * shown-but-not-licensing case (a truncated injection) applies — used only
180
224
  * to pick which park reason to report, never to license a write on its own.
225
+ * @param {string|null} appliedHash what THIS overwrite call itself last wrote
226
+ * for this target, this session (base-store's `readAppliedHash`): narrower
227
+ * than `entry.hash`, which also moves on a same-session hand edit
228
+ * (`advanceBaseForWrite`) and so cannot by itself tell "nothing moved since
229
+ * my own last apply" from "my own hand edit moved it and I re-observed my
230
+ * own bytes as the new base".
231
+ * @param {string|null} payloadHash hash of the content THIS call is about to
232
+ * write. Unused by the guard below (kept as a parameter for callers and
233
+ * tests that already pass it, and because a future narrower predicate may
234
+ * need it again): see the guard's own comment for why r5-w3 blocker 2
235
+ * stopped comparing it to `appliedHash`.
181
236
  * @returns {string|null} a conflict reason, or null when this session may write
182
237
  */
183
- export function overwriteConflictReason(entry, disk, observed = { hash: null, truncated: false }) {
238
+ export function overwriteConflictReason(
239
+ entry,
240
+ disk,
241
+ observed = { hash: null, truncated: false },
242
+ appliedHash = null,
243
+ payloadHash = null,
244
+ ) {
184
245
  // Cannot read what we are about to replace: fail safe, never assume unchanged.
185
246
  if (disk === undefined) return 'target-unreadable';
186
247
  const observedHash = observed && observed.hash;
187
248
  const observedTruncated = !!(observed && observed.truncated);
249
+ let reason;
188
250
  switch (entry.state) {
189
251
  case 'unknown':
190
- // No snapshot for this (session, target). Someone else's edits could be
191
- // sitting on disk and we would have no way to tell.
192
- return 'base-unknown';
252
+ // No snapshot for this (session, target): someone else's edits could be
253
+ // sitting on disk with no way to tell, so this always parks. An earlier
254
+ // cut of this guard let a session's own touched-paths record
255
+ // (hooks/hypo-auto-stage.mjs) waive that park. It was removed
256
+ // 2026-09-11: hypo-auto-commit clears touched-paths.json at every Stop
257
+ // once a commit lands (even a no-op commit), so by the time a close
258
+ // reads it here it is empty in every real session that has crossed a
259
+ // Stop since its last Write/Edit — the escape never actually fired.
260
+ reason = 'base-unknown';
261
+ break;
193
262
  case 'absent':
194
263
  // We observed no file. Creating it is safe; finding one now means another
195
264
  // writer got there first, UNLESS this session was later shown exactly
196
265
  // those bytes by a resume/compact SessionStart.
197
- if (disk === null) return null;
198
- if (observedHash && observedHash === hashContent(disk)) return null;
199
- return observedTruncated
200
- ? 'base-mismatch-truncated-observation'
201
- : 'base-absent-target-exists';
266
+ if (disk === null) {
267
+ reason = null;
268
+ } else if (observedHash && observedHash === hashContent(disk)) {
269
+ reason = null;
270
+ } else {
271
+ reason = observedTruncated
272
+ ? 'base-mismatch-truncated-observation'
273
+ : 'base-absent-target-exists';
274
+ }
275
+ break;
202
276
  case 'hash':
203
- if (disk === null) return 'base-hash-target-missing';
204
- if (hashContent(disk) === entry.hash) return null;
205
- // Drifted from the original base — still allowed when this session was
206
- // shown these exact drifted bytes by a later SessionStart. A truncated
207
- // injection never reaches `observedHash` (readObservedHash refuses it),
208
- // so it falls through here and gets its own reason instead of the plain
209
- // `base-mismatch` a no-observation-at-all case reports.
210
- if (observedHash && observedHash === hashContent(disk)) return null;
211
- return observedTruncated ? 'base-mismatch-truncated-observation' : 'base-mismatch';
277
+ if (disk === null) {
278
+ reason = 'base-hash-target-missing';
279
+ } else if (hashContent(disk) === entry.hash) {
280
+ reason = null;
281
+ } else if (observedHash && observedHash === hashContent(disk)) {
282
+ // Drifted from the original base: still allowed when this session was
283
+ // shown these exact drifted bytes by a later SessionStart. A truncated
284
+ // injection never reaches `observedHash` (readObservedHash refuses it),
285
+ // so it falls through here and gets its own reason instead of the plain
286
+ // `base-mismatch` a no-observation-at-all case reports.
287
+ reason = null;
288
+ } else {
289
+ reason = observedTruncated ? 'base-mismatch-truncated-observation' : 'base-mismatch';
290
+ }
291
+ break;
212
292
  default:
213
- return 'base-unknown';
293
+ reason = 'base-unknown';
214
294
  }
295
+ // `entry`/`disk` alone read this as clean whenever a same-session hand edit
296
+ // is what last advanced the base: that write IS this session's own, so it
297
+ // rightly does not park on its own account. But if THIS overwrite call
298
+ // previously applied different bytes and disk has since moved away from
299
+ // them (a hand edit landed AFTER that apply, with no new apply since), the
300
+ // caller is about to write OVER that edit with no proposal, no notice,
301
+ // nothing, whether or not the payload happens to be the exact old bytes.
302
+ //
303
+ // review r4-w4 major 1 narrowed this to `payloadHash === appliedHash` (only
304
+ // the exact stale reapply parks), so that a payload which folds the hand
305
+ // edit back in and adds a legitimate close on top would not also park.
306
+ // review r5-w3 blocker 2 found the hole that narrowing opened: this
307
+ // function cannot tell "folded the edit in" from "unrelated new bytes that
308
+ // still ignore the edit" from the hashes alone, so ANY payload change at
309
+ // all (including one that drops the edit entirely) passed the narrowed
310
+ // check just by being different from the old applied bytes. Comparing
311
+ // `payloadHash` here can only ever answer "is this the exact same payload
312
+ // as before", never "does this payload account for the edit", so it is
313
+ // dropped rather than replaced: there is no hash-only predicate between
314
+ // those two questions for a whole-file markdown overwrite (the same
315
+ // block-context problem the section-loss guard's own comment names above).
316
+ // Reverting to "any drift from appliedHash parks" is a straight tradeoff,
317
+ // not a fix without a cost: a close that DOES fold the edit in now also
318
+ // parks, and needs the same human approval
319
+ // (`hypomnema proposal challenge`/`proposal resolve`) as one that does not.
320
+ // That friction is accepted because the alternative (a hand edit silently
321
+ // discarded with no record it ever existed) is the worse failure for a
322
+ // guard whose whole purpose is not losing someone's edit.
323
+ if (
324
+ reason === null &&
325
+ typeof disk === 'string' &&
326
+ appliedHash &&
327
+ hashContent(disk) !== appliedHash
328
+ ) {
329
+ return 'will-overwrite-local-change';
330
+ }
331
+ return reason;
215
332
  }
216
333
 
217
334
  /**
@@ -251,7 +368,7 @@ function todayLocal() {
251
368
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
252
369
  }
253
370
 
254
- // Spec §5.2.7 / §8.3: 4 mandatory + 2 optional (`log`, `openQuestions`).
371
+ // Spec §5.2.7 / §8.3: 3 mandatory + 2 optional (`log`, `openQuestions`).
255
372
  // The payload shape MUST mirror that contract — missing a mandatory field is a
256
373
  // payload bug, not a no-op. Caller is the LLM session-close flow, which composes
257
374
  // the payload deliberately; partial payloads must fail loudly so caller fixes
@@ -262,7 +379,6 @@ function todayLocal() {
262
379
  const REQUIRED_PAYLOAD_FIELDS = [
263
380
  ['sessionState', 'content'],
264
381
  ['projectHot', 'content'],
265
- ['rootHot', 'content'],
266
382
  ['sessionLog', 'entry'],
267
383
  ];
268
384
 
@@ -296,8 +412,14 @@ function validatePayloadShape(payload) {
296
412
  errs.push('payload.log, when present, must be { entry: string }');
297
413
  }
298
414
  }
299
- if (payload.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(payload.date))) {
300
- errs.push('payload.date, when present, must be YYYY-MM-DD');
415
+ // A format-only check reads `2026-09-31` as a valid YYYY-MM-DD literal, but
416
+ // there is no such day: `new Date('2026-09-31')` silently normalizes to
417
+ // October 1 instead of failing, so the old regex-only check would have
418
+ // let a calendar-overflow payload.date through to stamp a session-log
419
+ // heading and shard filename with a date that does not exist.
420
+ // parseStrictDate (lib/time.mjs) rejects that case too.
421
+ if (payload.date !== undefined && parseStrictDate(payload.date) == null) {
422
+ errs.push('payload.date, when present, must be a real calendar date in YYYY-MM-DD form');
301
423
  }
302
424
  if (
303
425
  payload.sessionId !== undefined &&
@@ -309,6 +431,218 @@ function validatePayloadShape(payload) {
309
431
  return errs;
310
432
  }
311
433
 
434
+ // Payload fields this apply no longer applies. `rootHot` is the only one so far:
435
+ // the root pointer table became a hook-generated projection of `projects/*/hot.md`,
436
+ // so nothing composed by hand for it can survive the same turn's Stop hook.
437
+ //
438
+ // An installed copy that still carries the OLD `commands/crystallize.md` keeps
439
+ // sending the field, and that payload must neither fail (the close is otherwise
440
+ // correct, and refusing it would strand every not-yet-upgraded install) nor pass
441
+ // in silence (the author believed a file got written). One reported line is the
442
+ // middle: the close proceeds, and the reason the bytes are not on disk is in the
443
+ // result where the author looks for it.
444
+ //
445
+ // @returns {string[]} one line per obsolete field present, empty when there is none
446
+ function obsoleteFieldNotices(payload) {
447
+ if (!payload || typeof payload !== 'object') return [];
448
+ const out = [];
449
+ if (payload.rootHot !== undefined) {
450
+ out.push(
451
+ 'payload.rootHot was ignored: the root hot.md pointer table is regenerated by the ' +
452
+ 'SessionStart and Stop hooks from projects/*/hot.md, so close no longer writes it. ' +
453
+ 'Drop the field from your payload.',
454
+ );
455
+ }
456
+ return out;
457
+ }
458
+
459
+ // `--mark-session-closed` has no payload, so it has no operation-supplied
460
+ // expected bytes to prove against, only "a fresh version is committed"
461
+ // (CERT_CLOSE_FILES, weaker than the apply path's CERT_CHECKPOINT). This confirms exactly that for one path: worktree,
462
+ // index, and HEAD all agree (`git status --porcelain` empty means tracked,
463
+ // nothing staged, nothing unstaged, nothing untracked at this path), then
464
+ // hashes the disk bytes for the proof entry. `null` on anything else (dirty,
465
+ // untracked, unreadable, or not a git repo at all): the caller's contract is
466
+ // to treat that as "this target is not provably closed", never as "clean".
467
+ function markCloseWorktreeProofEntry(hypoDir, relPath) {
468
+ const st = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '--', relPath], {
469
+ encoding: 'utf-8',
470
+ });
471
+ if (st.error || st.status !== 0 || (st.stdout || '').trim() !== '') return null;
472
+ let content;
473
+ try {
474
+ content = readFileSync(join(hypoDir, relPath));
475
+ } catch {
476
+ return null;
477
+ }
478
+ return { path: relPath, kind: 'overwrite', expected: { bytesSha256: bytesSha256(content) } };
479
+ }
480
+
481
+ /**
482
+ * The certification proof for `--mark-session-closed`. `--log-only` needs
483
+ * only `log.md` committed fresh; a project mark needs, for EVERY project in
484
+ * `markerProjects`, session-state.md, hot.md, the exact session-log evidence
485
+ * file `sessionCloseFileStatus` accepted for freshness (never a different
486
+ * hybrid-cutover candidate), and log.md, all committed, not merely present.
487
+ * A project whose status is not ok, or that has no session-log evidence file
488
+ * to name, is incomplete: dropping the session-log target from the proof
489
+ * would certify a close whose session-log was never checked. One incomplete
490
+ * project withholds the WHOLE certification and names which project failed;
491
+ * this never partially certifies.
492
+ * @returns {{ok: boolean, entries: object[], incompleteProjects: string[]}}
493
+ */
494
+ export function buildMarkCloseProof(hypoDir, markerProjects, logOnly) {
495
+ if (logOnly) {
496
+ const e = markCloseWorktreeProofEntry(hypoDir, 'log.md');
497
+ return e
498
+ ? { ok: true, entries: [e], incompleteProjects: [] }
499
+ : { ok: false, entries: [], incompleteProjects: ['(log-only)'] };
500
+ }
501
+ const byPath = new Map();
502
+ const incompleteProjects = [];
503
+ for (const p of markerProjects) {
504
+ const status = sessionCloseFileStatus(hypoDir, { projectOverride: p });
505
+ if (!status.ok || !status.sessionLogEvidence?.path) {
506
+ incompleteProjects.push(p);
507
+ continue;
508
+ }
509
+ const targets = [
510
+ join('projects', p, 'session-state.md'),
511
+ join('projects', p, 'hot.md'),
512
+ status.sessionLogEvidence.path,
513
+ 'log.md',
514
+ ];
515
+ const projectEntries = [];
516
+ let projectOk = true;
517
+ for (const t of targets) {
518
+ const e = markCloseWorktreeProofEntry(hypoDir, t);
519
+ if (!e) {
520
+ projectOk = false;
521
+ break;
522
+ }
523
+ projectEntries.push(e);
524
+ }
525
+ if (!projectOk) {
526
+ incompleteProjects.push(p);
527
+ continue;
528
+ }
529
+ for (const e of projectEntries) byPath.set(e.path, e);
530
+ }
531
+ return {
532
+ ok: incompleteProjects.length === 0 && markerProjects.length > 0,
533
+ entries: [...byPath.values()],
534
+ incompleteProjects,
535
+ };
536
+ }
537
+
538
+ /**
539
+ * Take back the receipt at this session's path, but only if it is the one this
540
+ * close filed. A different `generation` on disk belongs to another close of the
541
+ * same session that landed in the meantime, and removing it would undo a close
542
+ * that did succeed. An absent file is left alone (nothing of ours to take back).
543
+ * A file that cannot be read or parsed is treated as ours: the rename that put
544
+ * it there is what made it unreadable to the re-read, and no other close writes
545
+ * a partial receipt (all writers are temp+rename).
546
+ * @returns {{ok: true, withdrawn: boolean} | {ok: false, reason: string}}
547
+ */
548
+ export function withdrawOwnReceipt(hypoDir, sessionId, generation) {
549
+ const path = receiptPath(hypoDir, sessionId);
550
+ if (!path || !existsSync(path)) return { ok: true, withdrawn: false };
551
+ try {
552
+ const onDisk = JSON.parse(readFileSync(path, 'utf-8'));
553
+ if (typeof onDisk?.generation === 'string' && onDisk.generation !== generation) {
554
+ return { ok: true, withdrawn: false };
555
+ }
556
+ } catch {
557
+ // Unreadable or unparseable: fall through and withdraw.
558
+ }
559
+ const retracted = invalidateCloseArtifacts(hypoDir, sessionId);
560
+ return retracted.ok ? { ok: true, withdrawn: true } : { ok: false, reason: retracted.reason };
561
+ }
562
+
563
+ /**
564
+ * File the close checkpoint receipt, then the compat marker that projects it,
565
+ * and take the receipt back if the marker does not land. The two writers
566
+ * (`--apply-session-close` and `--mark-session-closed`) share this so they
567
+ * cannot disagree about what a half-landed close looks like.
568
+ *
569
+ * Why the take-back: a receipt that stays valid while the run reports the
570
+ * marker as not landed (exit 1, close signal unspent) splits the readers. A
571
+ * receipt-first Stop reads the session as closed, a marker-only reader reads
572
+ * it as open, and the operator who was told "not closed" is contradicted by
573
+ * the newer reader. Withdrawing the receipt makes "marker did not land" mean
574
+ * the same thing to every reader.
575
+ *
576
+ * `writeMarker` is injected so a test can make it fail deterministically. It
577
+ * returns truthy when it wrote; a throw counts as not written. Landing also
578
+ * requires the marker file to exist on disk: the writer says it wrote, the
579
+ * disk says it is there, and a leftover file from an earlier attempt satisfies
580
+ * neither on its own.
581
+ *
582
+ * A receipt write that reports failure after its rename (the re-read failed or
583
+ * did not match) may still have left this close's receipt on disk, and a
584
+ * transient re-read error would let it stand as valid with no marker. That
585
+ * branch takes the receipt back too, through `withdrawOwnReceipt`.
586
+ *
587
+ * @returns {{ok: true} |
588
+ * {ok: false, reason: 'receipt-write-failed', writeReason: string, retractFailed?: string} |
589
+ * {ok: false, reason: 'marker-did-not-land', retractFailed?: string}}
590
+ * `retractFailed` is set only when withdrawing the receipt failed too, which
591
+ * leaves a valid receipt behind a run that reports failure.
592
+ */
593
+ export function landReceiptThenMarker(hypoDir, sessionId, receipt, writeMarker) {
594
+ const written = writeReceiptAtomic(hypoDir, sessionId, receipt);
595
+ if (!written.ok) {
596
+ // Only the re-read reasons mean the rename happened. 'write-failed' and
597
+ // 'invalid-session-id' never put this close's bytes at the path.
598
+ const withdrawal = written.reason.startsWith('reread-')
599
+ ? withdrawOwnReceipt(hypoDir, sessionId, receipt.generation)
600
+ : { ok: true };
601
+ return {
602
+ ok: false,
603
+ reason: 'receipt-write-failed',
604
+ writeReason: written.reason,
605
+ ...(withdrawal.ok ? {} : { retractFailed: withdrawal.reason }),
606
+ };
607
+ }
608
+ let wrote = false;
609
+ try {
610
+ wrote = !!writeMarker();
611
+ } catch {
612
+ // A writer that throws did not write. Same outcome as returning false.
613
+ }
614
+ if (wrote && existsSync(sessionClosedMarkerPath(hypoDir, sessionId))) return { ok: true };
615
+ const retracted = invalidateCloseArtifacts(hypoDir, sessionId);
616
+ return {
617
+ ok: false,
618
+ reason: 'marker-did-not-land',
619
+ ...(retracted.ok ? {} : { retractFailed: retracted.reason }),
620
+ };
621
+ }
622
+
623
+ // The commit a session's OLD close receipt certified, when that commit is no
624
+ // longer an ancestor of HEAD (a `git reset --hard` or rebase dropped it).
625
+ // Reads the receipt JSON itself instead of readReceiptStrict: that returns no
626
+ // receipt object once the commit is unreachable, which is exactly this case.
627
+ // A missing or unparsable file, a commit field that is not an object id, or a
628
+ // repository git cannot resolve HEAD in all return null, so --mark keeps its
629
+ // old behavior there.
630
+ function priorReceiptCommitRewritten(hypoDir, sessionId) {
631
+ const path = receiptPath(hypoDir, sessionId);
632
+ if (!path || !existsSync(path)) return null;
633
+ let commit;
634
+ try {
635
+ commit = JSON.parse(readFileSync(path, 'utf-8'))?.commit;
636
+ } catch {
637
+ return null;
638
+ }
639
+ if (typeof commit !== 'string' || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(commit)) return null;
640
+ const git = (...a) => spawnSync('git', ['-C', hypoDir, ...a], { encoding: 'utf-8' });
641
+ if (git('rev-parse', '--verify', '--quiet', 'HEAD').status !== 0) return null;
642
+ const ancestry = git('merge-base', '--is-ancestor', commit, 'HEAD');
643
+ return ancestry.error || ancestry.status === 0 ? null : commit;
644
+ }
645
+
312
646
  // ── session-close marker (amendment 2026-05-19) ───────────────
313
647
  // Standalone marker writer. Used when the LLM closes the session via direct
314
648
  // Write tool calls (not --apply-session-close). Hook `hypo-auto-minimal-
@@ -337,14 +671,16 @@ export function runMarkSessionClosed(args) {
337
671
  // --log-only mark attributes to no project, so --project is moot there).
338
672
  if (args.project && !args.logOnly) requireProjectDir(args, args.project);
339
673
  // The per-session marker is the THIRD session-close completion
340
- // signal (after the PreCompact gate and `--check-session-close`). It must use
341
- // the SAME gate that governs /compact — precompactGateStatus — so the marker
342
- // can never attest "closed" while /compact would still block. This subsumes
343
- // the prior (sessionCloseGlobalStatus + hypoIsClean + scoped-lint) gate and
344
- // additionally enforces feedback projection (over-cap/conflict), W8 design-
345
- // history staleness, and root hot.md structure — the checks that the narrower
346
- // marker gate skipped (the divergence behind this fix). git-clean is now a
347
- // `git` blocker inside the gate. Pass --transcript-path to widen the lint
674
+ // signal (after the PreCompact gate and `--check-session-close`). It uses
675
+ // precompactGateStatus, the gate /compact uses, with one deliberate
676
+ // difference: `checkpointMode` (set below) replaces the git axis. /compact
677
+ // blocks on any uncommitted vault change, while the checkpoint blocks only
678
+ // on an uncommitted write THIS session still owns, so another session's
679
+ // dirty file can make /compact wait while this checkpoint still lands.
680
+ // Every other axis is the same, so the marker still enforces feedback
681
+ // projection (over-cap/conflict), W8 design-history staleness, and root
682
+ // hot.md structure, the checks the earlier narrower marker gate skipped.
683
+ // Pass --transcript-path to widen the lint
348
684
  // scope to this session's edited files exactly as the interactive hook does;
349
685
  // without it the scope is the mandatory close files only.
350
686
  // --log-only marks a non-project (tooling / wiki-only) session as
@@ -388,6 +724,51 @@ export function runMarkSessionClosed(args) {
388
724
  sessionCwd: args.sessionCwd || null,
389
725
  })
390
726
  : null;
727
+ // --mark invalidates right at its own gate call (there is no payload or
728
+ // preflight to race, unlike the apply path's authority check). A prior
729
+ // receipt/marker for THIS session must not keep certifying a close once a
730
+ // fresh `--mark-session-closed` is attempted, whether or not this attempt
731
+ // goes on to succeed.
732
+ // The one exception is a prior receipt whose commit history no longer
733
+ // contains (reset/rebase): invalidating it and certifying today's other
734
+ // close files would report "closed" while the original close record is gone.
735
+ // Refuse before anything is touched so the old receipt stays as evidence.
736
+ const rewritten = priorReceiptCommitRewritten(args.hypoDir, args.sessionId);
737
+ if (rewritten) {
738
+ const msg =
739
+ `--mark-session-closed refused: the commit this session's earlier close proved ` +
740
+ `(${rewritten}) is no longer in the branch history. Restore that commit, or ask the ` +
741
+ `user to close again and run --apply-session-close with a new payload.`;
742
+ console.log(
743
+ args.json
744
+ ? JSON.stringify(
745
+ {
746
+ ok: false,
747
+ session_id: args.sessionId,
748
+ reason: 'prior-checkpoint-rewritten',
749
+ prior_commit: rewritten,
750
+ error: msg,
751
+ },
752
+ null,
753
+ 2,
754
+ )
755
+ : `✗ ${msg}`,
756
+ );
757
+ process.exit(1);
758
+ }
759
+ const invalidated = invalidateCloseArtifacts(args.hypoDir, args.sessionId);
760
+ if (!invalidated.ok) {
761
+ const msg =
762
+ `--mark-session-closed refused before the gate ran: could not invalidate this session's ` +
763
+ `prior close receipt/marker (${invalidated.reason}). Fix the underlying problem (usually a ` +
764
+ `permission or disk issue under .cache/) and retry.`;
765
+ console.log(
766
+ args.json
767
+ ? JSON.stringify({ ok: false, session_id: args.sessionId, error: msg }, null, 2)
768
+ : `✗ ${msg}`,
769
+ );
770
+ process.exit(1);
771
+ }
391
772
  const gate = precompactGateStatus(args.hypoDir, {
392
773
  ...(args.project && !args.logOnly ? { closeScope: [args.project] } : {}),
393
774
  ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
@@ -396,6 +777,11 @@ export function runMarkSessionClosed(args) {
396
777
  // session's cwd project has an unstarted close. logOnly exempts it in-gate.
397
778
  ...(args.sessionCwd ? { sessionCwd: args.sessionCwd } : {}),
398
779
  ...(attributionScope ? { attributionScope } : {}),
780
+ // One of the two marker-writing paths, which must share ONE `ok`
781
+ // invariant with the apply path's own marker phase (see that call
782
+ // site's identical comment).
783
+ checkpointMode: true,
784
+ sessionId: args.sessionId,
399
785
  });
400
786
  const status = gate.close;
401
787
  if (!gate.ok) {
@@ -424,18 +810,59 @@ export function runMarkSessionClosed(args) {
424
810
  // /compact, or an AskUserQuestion close answer). This is the hard backstop for
425
811
  // model over-close, where prose guidance lost to a conflicting global rule.
426
812
  // Fail-closed when the transcript can't be resolved.
813
+ // One call, read by BOTH paths below. It used to sit inside the refusal
814
+ // branch, which left the success path with no way to see a hostTagWarning at
815
+ // all: a close that survived a pasted host tag wrote its marker, unblocked
816
+ // the Stop chain, and said nothing, on the one entry point a model reaches
817
+ // after closing by hand. Hoisting changes no gate decision: the decision
818
+ // below still reads the raw `isCloseGateOpen` boolean per this file's
819
+ // "runMarkSessionClosed stays on isCloseGateOpen" contract, and this result
820
+ // is only ever read for its two strings. `null` when there is no transcript
821
+ // to read either from.
822
+ const gateStatus = closeTranscript
823
+ ? closeGateStatus({
824
+ transcriptPath: closeTranscript,
825
+ hypoDir: args.hypoDir,
826
+ sessionId: args.sessionId,
827
+ })
828
+ : null;
829
+ // This path writes one thing, the marker, and makes no commit, so the undo
830
+ // it offers is the marker alone. A revert instruction here would point at
831
+ // something this run never created. No 5th argument: kind 'marker-only'
832
+ // never reads commitSha (its message names only the marker file, see
833
+ // hostTagWarningWithUndo's own `undo` table), and this call site made no
834
+ // commit at all to report one for, so leaving it `undefined` here is the
835
+ // honest answer, not a shortcut.
836
+ const hostTagWarning = hostTagWarningWithUndo(
837
+ gateStatus?.hostTagWarning,
838
+ 'marker-only',
839
+ args.hypoDir,
840
+ args.sessionId,
841
+ );
427
842
  if (!closeTranscript || !isCloseGateOpen(closeTranscript)) {
428
843
  const reason = !closeTranscript
429
844
  ? `cannot resolve a transcript for session ${args.sessionId} — the session-closed marker requires a verifiable user close signal`
430
845
  : "no user close signal in this session's transcript — marker refused (the user did not signal session close)";
846
+ // This path used to collapse straight to the bare `no-user-close-signal`
847
+ // string, so a stale HOST_TAG_NAMES allowlist was invisible here even
848
+ // though verifyCloseAuthority's own refusal (the apply path, before any
849
+ // write) already surfaces it via `gateReason`. Reusing that same field
850
+ // name and the same source (closeGateStatus, hoisted above) closes the
851
+ // gap. `null` when there is no transcript to read one from at all.
852
+ const gateReason = gateStatus?.reason ?? null;
431
853
  const result = {
432
854
  ok: false,
433
855
  session_id: args.sessionId,
434
856
  project: status.project,
435
857
  skipReason: 'no-user-close-signal',
858
+ ...(gateReason ? { gateReason } : {}),
436
859
  error: reason,
437
860
  };
438
- console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
861
+ console.log(
862
+ args.json
863
+ ? JSON.stringify(result, null, 2)
864
+ : `✗ ${reason}${gateReason ? `\nGate detail: ${gateReason}` : ''}`,
865
+ );
439
866
  process.exit(1);
440
867
  }
441
868
  // Marker attribution comes from EVIDENCE, never from the gate's global
@@ -492,21 +919,105 @@ export function runMarkSessionClosed(args) {
492
919
  const verifiedScope = args.logOnly
493
920
  ? { kind: 'log-only' }
494
921
  : { kind: 'global', projects: evaluatedProjects };
495
- writeSessionClosedMarker(args.hypoDir, args.sessionId, {
496
- project: markerProject,
497
- projects: args.logOnly ? [] : markerProjects,
498
- ...(args.logOnly ? { scope: 'log-only' } : {}),
499
- verifiedScope,
500
- });
501
- // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
502
- // the file actually landed before claiming success — otherwise CLI exits 0
503
- // while next Stop re-blocks, hiding a permission/disk problem.
504
- // Codex Worker-2 CONCERN (pre-commit review).
505
- if (!existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
506
- const err = 'marker file did not land after write (likely .cache permission/disk issue)';
922
+ // Build and verify the CERT_CLOSE_FILES proof, read the C this proof is
923
+ // verified against, and land the receipt and its marker, all inside the
924
+ // vault-commit lock, the same lock the apply path's own commit step holds,
925
+ // so a concurrent close cannot land a commit in the gap between this
926
+ // proof's worktree reads and the HEAD this call certifies against.
927
+ // A lock timeout is contention, not a crash: it gets the same JSON and
928
+ // exit 1 as every other refusal here (the apply path maps the same timeout
929
+ // to commit-failed). Any other throw is a real fault and still propagates.
930
+ let receiptResult;
931
+ try {
932
+ receiptResult = withFileLock(vaultCommitLockTarget(args.hypoDir), () => {
933
+ const proof = buildMarkCloseProof(args.hypoDir, markerProjects, args.logOnly);
934
+ if (!proof.ok)
935
+ return { ok: false, reason: 'incomplete', incompleteProjects: proof.incompleteProjects };
936
+ const head = readHeadShaLocked(args.hypoDir);
937
+ const repo = head ? repoIdentity(args.hypoDir) : null;
938
+ if (!head || !repo) return { ok: false, reason: 'no-commit-identity' };
939
+ const verify = verifyEntriesInCommit(args.hypoDir, head, proof.entries);
940
+ if (!verify.ok) return { ok: false, reason: 'mismatch', mismatches: verify.mismatches };
941
+ const generation = randomBytes(16).toString('hex');
942
+ const receipt = {
943
+ schemaVersion: RECEIPT_SCHEMA_VERSION,
944
+ certification: CERT_CLOSE_FILES,
945
+ generation,
946
+ sessionId: args.sessionId,
947
+ repo,
948
+ commit: head,
949
+ scope: args.logOnly
950
+ ? { mode: 'log-only', projects: [] }
951
+ : { mode: 'project', projects: markerProjects },
952
+ entries: proof.entries,
953
+ skipped: gate.skipped || { lint: false, feedback: false },
954
+ createdAt: new Date().toISOString(),
955
+ };
956
+ const landed = landReceiptThenMarker(args.hypoDir, args.sessionId, receipt, () =>
957
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, {
958
+ project: markerProject,
959
+ projects: args.logOnly ? [] : markerProjects,
960
+ ...(args.logOnly ? { scope: 'log-only' } : {}),
961
+ verifiedScope,
962
+ // Same residual the console line below reports, kept in the marker so a
963
+ // reader auditing this close later sees the same thing the operator saw
964
+ // at the time. The apply path stamps its own marker the same way.
965
+ ...(hostTagWarning ? { hostTagWarning } : {}),
966
+ // Names the receipt this marker projects. See writeSessionClosedMarker's
967
+ // own doc comment for what a new Stop does with this field.
968
+ receiptGeneration: generation,
969
+ }),
970
+ );
971
+ if (landed.ok) return { ok: true };
972
+ return landed.reason === 'receipt-write-failed'
973
+ ? {
974
+ ok: false,
975
+ reason: 'write-failed',
976
+ writeReason: landed.writeReason,
977
+ ...(landed.retractFailed ? { retractFailed: landed.retractFailed } : {}),
978
+ }
979
+ : {
980
+ ok: false,
981
+ reason: 'marker-did-not-land',
982
+ ...(landed.retractFailed ? { retractFailed: landed.retractFailed } : {}),
983
+ };
984
+ });
985
+ } catch (e) {
986
+ if (e?.code !== 'ELOCKTIMEOUT') throw e;
987
+ receiptResult = { ok: false, reason: 'vault-commit-lock-timeout' };
988
+ }
989
+ if (!receiptResult.ok) {
990
+ const detail =
991
+ receiptResult.reason === 'incomplete'
992
+ ? `incomplete for project(s): ${receiptResult.incompleteProjects.join(', ')} (session-state.md, hot.md, the session-log evidence file, and log.md must all be committed, not merely present)`
993
+ : receiptResult.reason === 'mismatch'
994
+ ? `proof mismatch: ${JSON.stringify(receiptResult.mismatches)}`
995
+ : receiptResult.reason === 'write-failed'
996
+ ? `receipt write failed: ${receiptResult.writeReason}` +
997
+ (receiptResult.retractFailed
998
+ ? `; withdrawing what it may have left failed too (${receiptResult.retractFailed}), so a stale receipt may remain`
999
+ : '')
1000
+ : receiptResult.reason === 'vault-commit-lock-timeout'
1001
+ ? 'another close or commit held the vault-commit lock past the timeout; re-run shortly'
1002
+ : 'no commit identity (not a git repository, or no commit exists yet)';
1003
+ // A marker that did not land after a certified receipt keeps its own
1004
+ // wording: it is a disk problem under .cache/, not a proof problem, and the
1005
+ // receipt filed for it has already been withdrawn.
1006
+ const err =
1007
+ receiptResult.reason === 'marker-did-not-land'
1008
+ ? 'marker file did not land after write (likely .cache permission/disk issue); ' +
1009
+ 'the close checkpoint receipt filed for it was withdrawn' +
1010
+ (receiptResult.retractFailed
1011
+ ? `, but withdrawing it failed (${receiptResult.retractFailed}), so a stale receipt may remain`
1012
+ : '')
1013
+ : `session-close checkpoint could not be certified, marker not written (${detail})`;
507
1014
  console.log(
508
1015
  args.json
509
- ? JSON.stringify({ ok: false, session_id: args.sessionId, error: err }, null, 2)
1016
+ ? JSON.stringify(
1017
+ { ok: false, session_id: args.sessionId, error: err, ...receiptResult },
1018
+ null,
1019
+ 2,
1020
+ )
510
1021
  : `✗ ${err}`,
511
1022
  );
512
1023
  process.exit(1);
@@ -518,6 +1029,10 @@ export function runMarkSessionClosed(args) {
518
1029
  scope: args.logOnly ? 'log-only' : 'project',
519
1030
  date: status.dates[0],
520
1031
  notices: gate.notices,
1032
+ // The close-time residual, on the success path this entry point owns.
1033
+ // Present exactly when closeGateStatus named a neutralized host tag, and
1034
+ // carrying the marker-only undo (no commit exists on this path).
1035
+ ...(hostTagWarning ? { hostTagWarning } : {}),
521
1036
  // pure feedback-projection drift is a non-blocker: the marker
522
1037
  // attests "compact-ready (no human-fixable blocker)", and the PreCompact
523
1038
  // hook self-heals the projection (feedback-sync --write) at /compact. Surface
@@ -537,6 +1052,10 @@ export function runMarkSessionClosed(args) {
537
1052
  ` · feedback projection drift (${gate.driftTargets.join(', ')}) — will self-heal at /compact.`,
538
1053
  );
539
1054
  }
1055
+ // Once per run, next to the marker this run just wrote. The apply path
1056
+ // prints the same string next to its own commit; both read one value
1057
+ // computed once per run, so neither repeats on a later gate read.
1058
+ if (hostTagWarning) console.log(`\n⚠ ${hostTagWarning}`);
540
1059
  }
541
1060
  process.exit(0);
542
1061
  }
@@ -602,6 +1121,40 @@ export function closeResultContradiction({ ok, markerWritten, markerSkipReason }
602
1121
  return null;
603
1122
  }
604
1123
 
1124
+ // A withheld marker with a legitimate reason is not, by itself, a
1125
+ // reason to fail the apply. Most of planMarkerDecision's skip branches
1126
+ // (compact-gate-not-ok, no-user-close-signal, transcript-unresolved,
1127
+ // commit-failed) name a condition THIS SAME SESSION can clear and retry,
1128
+ // see the "a marker withheld by a real vault-commit failure leaves the close
1129
+ // signal unspent for a retry" test, whose whole point is that ok stays true
1130
+ // there and the close signal is not burned. Forcing ok:false on every one of
1131
+ // those would resurrect that exact regression's failure mode from the other
1132
+ // direction.
1133
+ //
1134
+ // 'marker-did-not-land' is not one of those. runMarkerPhase only reaches it
1135
+ // after `planMarkerDecision` has already returned `write: true`, meaning the
1136
+ // compact gate passed, a user close signal was present, and the commit
1137
+ // landed. Every precondition `--mark-session-closed` also requires before
1138
+ // writing the SAME marker was already satisfied here; the only thing that
1139
+ // then failed is the write itself (a `.cache` permission/disk issue), which
1140
+ // `--mark-session-closed` already treats as fatal (exit 1). Before this
1141
+ // check, `--apply-session-close` was the one entry point that swallowed that
1142
+ // exact failure as ok:true. The same failure landing on two different exit
1143
+ // codes depending on which command hit it.
1144
+ export function markerWriteGenuinelyFailed({ markerWritten, markerSkipReason }) {
1145
+ // The two new receipt-stage skip reasons are exactly as fatal
1146
+ // as 'marker-did-not-land': every precondition cleared (gate, commit,
1147
+ // user signal) and the close still could not certify itself. A caller
1148
+ // reading only `$?` must see this as unclosed, same as a marker write
1149
+ // that never landed.
1150
+ return (
1151
+ markerWritten !== true &&
1152
+ (markerSkipReason === 'marker-did-not-land' ||
1153
+ markerSkipReason === 'receipt-proof-mismatch' ||
1154
+ markerSkipReason === 'receipt-write-failed')
1155
+ );
1156
+ }
1157
+
605
1158
  // What the model should do when the close is refused. Deliberately does NOT name
606
1159
  // a flag: the way out of this gate cannot be an argument the model can add, or
607
1160
  // the gate is decorative. The way out is the user, which is the one input the
@@ -629,7 +1182,11 @@ const CLOSE_REFUSAL_HELP = [
629
1182
  * model cannot author: the user's own words are in it, and nothing the model says
630
1183
  * counts (extractUserMessages drops injected, tool, and hook-feedback text).
631
1184
  *
632
- * { ok: true }
1185
+ * { ok: true, hostTagWarning? } `hostTagWarning` is present only when
1186
+ * closeGateStatus reported one: this close survived a HOST_TAG_NAMES-shaped
1187
+ * queue item reading as neutral rather than a retraction (see that field's
1188
+ * own doc comment). Threaded through by the caller so it is surfaced once,
1189
+ * at commit time, rather than guessed against on every gate read.
633
1190
  * { ok: false, reason, error, gateReason? } reason: session-id-required |
634
1191
  * transcript-unresolved | no-user-close-signal. `gateReason` is only present
635
1192
  * when `reason` is `no-user-close-signal`, and carries closeGateStatus's own
@@ -678,7 +1235,10 @@ function verifyCloseAuthority(sessionId, hypoDir) {
678
1235
  `Gate detail: ${gateStatus.reason}`,
679
1236
  };
680
1237
  }
681
- return { ok: true };
1238
+ return {
1239
+ ok: true,
1240
+ ...(gateStatus.hostTagWarning ? { hostTagWarning: gateStatus.hostTagWarning } : {}),
1241
+ };
682
1242
  }
683
1243
 
684
1244
  // A-1 (project index lifecycle): seed projects/<project>/index.md from the
@@ -719,7 +1279,15 @@ function verifyCloseAuthority(sessionId, hypoDir) {
719
1279
  // atomicWrite's use case (replacing bytes a reader might already be mid-read
720
1280
  // of), a `wx` create can never observably tear — the file either doesn't
721
1281
  // exist yet (nothing to tear) or the open fails outright.
722
- export function ensureProjectIndex(hypoDir, project, relPath, today) {
1282
+ // `sessionId` (optional, added for the close journal) records this create in
1283
+ // hooks/close-journal.mjs immediately after the bytes land, so a retry of the
1284
+ // SAME close that finds index.md already present (applyOverwrites' retry
1285
+ // branch) can tell "I created this and it is still exactly what I left it"
1286
+ // apart from a hand edit. Omitted entirely by callers outside a close
1287
+ // (tests/crystallize-apply.test.mjs's race-condition check), where there is
1288
+ // no session to journal against and recordJournalEntry's own `!sessionId`
1289
+ // guard makes the call a no-op.
1290
+ export function ensureProjectIndex(hypoDir, project, relPath, today, sessionId) {
723
1291
  const dest = join(hypoDir, relPath);
724
1292
  const src = join(TEMPLATE_DIR, 'index.md');
725
1293
  if (!existsSync(src)) return null; // template missing — nothing to scaffold from
@@ -742,6 +1310,7 @@ export function ensureProjectIndex(hypoDir, project, relPath, today) {
742
1310
  } finally {
743
1311
  closeSync(fd);
744
1312
  }
1313
+ recordJournalEntry(hypoDir, sessionId, relPath, hashContent(content));
745
1314
  return relPath;
746
1315
  }
747
1316
 
@@ -777,7 +1346,11 @@ export function ensureProjectIndex(hypoDir, project, relPath, today) {
777
1346
  //
778
1347
  // Refuses in place (console.log + process.exit(1)) instead of returning a
779
1348
  // verdict: process.exit never returns, so the caller's flow stops exactly where
780
- // it stopped before this section was a function of its own.
1349
+ // it stopped before this section was a function of its own. On success it
1350
+ // returns `hostTagWarning` (string, or null) instead of exiting, so the
1351
+ // caller can carry it through the rest of this apply and surface it exactly
1352
+ // once, alongside the commit it is now about to make, rather than here
1353
+ // before anything has actually happened yet.
781
1354
  function refuseUnlessCloseRequested(args) {
782
1355
  const closeAuth = args.payload
783
1356
  ? verifyCloseAuthority(args.sessionId, args.hypoDir)
@@ -804,6 +1377,32 @@ function refuseUnlessCloseRequested(args) {
804
1377
  );
805
1378
  process.exit(1);
806
1379
  }
1380
+ // Invalidate this session's prior close receipt and compat marker the
1381
+ // moment a NEW close is authorized, before payload validation or preflight,
1382
+ // not later at writeCloseIntent. A close authorized now that later fails preflight must not leave a stale
1383
+ // "closed" proof standing for Stop to trust; the user asked to close again,
1384
+ // so the old proof is spent the instant that request is accepted, whether
1385
+ // or not this particular attempt goes on to succeed. `--mark-session-closed`
1386
+ // invalidates at its own gate call instead (no payload/preflight to race).
1387
+ if (args.payload && args.sessionId) {
1388
+ const invalidated = invalidateCloseArtifacts(args.hypoDir, args.sessionId);
1389
+ if (!invalidated.ok) {
1390
+ const msg =
1391
+ `session-close apply refused before any wiki write or commit: could not invalidate ` +
1392
+ `this session's prior close receipt/marker (${invalidated.reason}). Fix the underlying ` +
1393
+ `problem (usually a permission or disk issue under .cache/) and retry; nothing was written.`;
1394
+ const out = {
1395
+ ok: false,
1396
+ stage: 'invalidate-failed',
1397
+ error: msg,
1398
+ applied: [],
1399
+ committed: null,
1400
+ };
1401
+ console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
1402
+ process.exit(1);
1403
+ }
1404
+ }
1405
+ return closeAuth.hostTagWarning || null;
807
1406
  }
808
1407
 
809
1408
  // Read the payload, check its shape, and bind it to THIS session. Exits 1 on any
@@ -1002,7 +1601,6 @@ function runPreflight(args, payload, project, date) {
1002
1601
  const overwriteTargets = new Set();
1003
1602
  if (payload.sessionState) overwriteTargets.add(join('projects', project, 'session-state.md'));
1004
1603
  if (payload.projectHot) overwriteTargets.add(join('projects', project, 'hot.md'));
1005
- if (payload.rootHot) overwriteTargets.add('hot.md');
1006
1604
  if (payload.openQuestions) overwriteTargets.add(join('pages', 'open-questions.md'));
1007
1605
 
1008
1606
  // Bug B: the documented close path must not be blocked by lint debt OUTSIDE
@@ -1102,6 +1700,200 @@ function runPreflight(args, payload, project, date) {
1102
1700
  return { preflightLint, payloadScope, indexRelPath, indexMissing };
1103
1701
  }
1104
1702
 
1703
+ // ── section-loss guard (2026-08-10 incident) ────────────────────────────────
1704
+ //
1705
+ // The base-store guard above answers "did someone ELSE change this page since
1706
+ // I looked at it". It cannot answer "did the payload I am about to write throw
1707
+ // away structure that was already here" — a session that legitimately observed
1708
+ // its own prior base (no drift, no conflict) can still overwrite a multi-track
1709
+ // session-state.md or hot.md with a payload that only carries the ONE track it
1710
+ // was working on, silently dropping the others. That is exactly what happened
1711
+ // to security-backoffice: three tracks, two of them vanished, and the base
1712
+ // guard had nothing to say about it because it was never a conflict in the
1713
+ // guard's sense — it was a normal, unopposed overwrite.
1714
+ //
1715
+ // This is deliberately a COUNT of `##` headings that vanish between disk and
1716
+ // payload, not a markdown-aware diff. The block-parser lesson from the base
1717
+ // guard above applies here too: a predicate that reads content and claims to
1718
+ // know what was "provably" preserved is the thing four review rounds already
1719
+ // broke. Counting exact-line survival is cheap, has no false negatives worth
1720
+ // chasing (a heading either survives verbatim or it does not), and its one
1721
+ // failure mode (a legitimately reworded heading reads as "lost") is exactly
1722
+ // what the escape hatch below is for.
1723
+ // A ratio floor alone gets LOOSER as a file grows, exactly backwards from what
1724
+ // this guard is for: a file running more tracks in parallel is bigger (a bigger
1725
+ // denominator), and that is the one where losing a fixed handful of sections
1726
+ // should trip sooner, not later. A distribution was counted against the real
1727
+ // vault on 2026-09-11 with `grep -c '^## ' <file>` (every LINE starting with
1728
+ // `## `, duplicates included) against every hot.md / session-state.md /
1729
+ // open-questions.md: project hot.md ran 4-9 such lines (harness's was 9),
1730
+ // project session-state.md ran 1-12 (harness's was 12), pages/open-questions.md
1731
+ // had 8, root hot.md had 2. That is a different measurement than this guard's
1732
+ // own denominator: `h2Headings` below dedupes into a `Set`, so a file that
1733
+ // repeats one `## ` heading verbatim reports a smaller count here than the grep
1734
+ // tally did. The two agree on every file this repo actually has (none repeats a
1735
+ // heading), but the grep number is not proof of what `h2Headings` counts.
1736
+ //
1737
+ // At a ratio-only gate, losing 4 of a real 12-section session-state.md
1738
+ // (4/12 = 0.333) or 3 of a real 9-section hot.md (3/9 = 0.333) both stayed just
1739
+ // under a 0.34 floor and passed through untouched — real files, real sizes, a
1740
+ // real miss. An absolute floor was added so a bigger file could not buy a bigger
1741
+ // free pass just by being bigger, but the first cut of that floor (3) missed the
1742
+ // shape it was named for: the security-backoffice incident itself lost 2 of 3
1743
+ // tracks, and 2 lost sections clears neither a 3-floor nor, on a 6-12 section
1744
+ // file, the 0.34 ratio (2/12 = 0.167). So the floor is 2, matching
1745
+ // SECTION_LOSS_MIN_COUNT below — and once the two are equal, the ratio branch
1746
+ // can no longer change the outcome: past the MIN_COUNT guard, `lost.length` is
1747
+ // always >= 2, which trips the absolute floor unconditionally, so
1748
+ // `!ratioTrips && !absTrips` can never be true. The two thresholds and the ratio
1749
+ // check that used to sit between them are folded into the one count check below
1750
+ // rather than kept as a branch that reads as live but never decides anything.
1751
+ const SECTION_LOSS_MIN_COUNT = 2; // an ordinary single-section edit (finishing one track,
1752
+ // retiring one open question) stays under this and must not park; 2 or more is
1753
+ // the incident's own shape and always trips, at any file size.
1754
+
1755
+ // A fence marker line: 0-3 leading spaces (CommonMark still calls that "unindented"),
1756
+ // then a run of 3+ backticks or 3+ tildes, then the rest of the line. `m[1]` is the
1757
+ // marker run itself (so its first char and length identify what closes it); `m[2]` is
1758
+ // whatever follows, an info string on the opening line, and required to be blank
1759
+ // (after trim) on a line being checked as a close.
1760
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
1761
+
1762
+ /**
1763
+ * Which line indices are inside a fenced code block, for one file's lines.
1764
+ *
1765
+ * A fence opens on any line FENCE_RE matches while not already inside one, and
1766
+ * closes only on a later line whose marker is the SAME character and AT LEAST as
1767
+ * long (a 4-backtick open is not closed by 3 backticks, a CommonMark rule, and the
1768
+ * one this guard's predecessor ignored: the section-loss bypass this closes moved
1769
+ * two `##` headings into a properly-closed ```md fence and the old line-scan still
1770
+ * counted them as real headings because it never looked for a fence at all).
1771
+ *
1772
+ * An opening fence that never finds a matching close before EOF is treated as
1773
+ * NEVER HAVING OPENED (every line from that marker to EOF is unhidden here). That
1774
+ * is the safe direction for a guard whose entire job is "did content silently
1775
+ * disappear": the same function extracts headings from both disk and payload, so
1776
+ * treating an unclosed run as fenced would let it swallow real headings on
1777
+ * whichever side has the malformed markdown: undercounting disk (hiding sections
1778
+ * the guard should have protected) or undercounting payload (reporting a section
1779
+ * as lost when the payload never actually removed it). Treating it as prose
1780
+ * instead only risks the opposite: an occasional false park on a document with a
1781
+ * genuinely broken fence, which is recoverable through the same
1782
+ * `restructure: true` / proposal-resolve door every other park in this guard
1783
+ * already uses, not a silent loss.
1784
+ *
1785
+ * Declined on purpose, not CommonMark-complete: an opening line's info string is
1786
+ * never checked for a stray backtick (CommonMark forbids one in a backtick fence's
1787
+ * info string; this scan does not care), and a fence inside a blockquote or list
1788
+ * item is scanned exactly like a top-level one. Both would need block-context
1789
+ * tracking this guard's own doc comment (above, the base-conflict guard section)
1790
+ * already argues against building here. Getting the two reproduced bypasses closed
1791
+ * cheaply matters more than a complete parser.
1792
+ *
1793
+ * @returns {boolean[]} same length as `lines`, true where the line is fenced
1794
+ */
1795
+ function fencedLineMask(lines) {
1796
+ const hidden = new Array(lines.length).fill(false);
1797
+ let openIdx = -1;
1798
+ let fenceChar = null;
1799
+ let fenceLen = 0;
1800
+ for (let i = 0; i < lines.length; i++) {
1801
+ if (openIdx === -1) {
1802
+ const m = lines[i].match(FENCE_RE);
1803
+ if (m) {
1804
+ openIdx = i;
1805
+ fenceChar = m[1][0];
1806
+ fenceLen = m[1].length;
1807
+ hidden[i] = true; // tentative, unhidden below if this never closes
1808
+ }
1809
+ continue;
1810
+ }
1811
+ hidden[i] = true; // tentative, unhidden below if this never closes
1812
+ const m = lines[i].match(FENCE_RE);
1813
+ if (m && m[1][0] === fenceChar && m[1].length >= fenceLen && m[2].trim() === '') {
1814
+ openIdx = -1;
1815
+ fenceChar = null;
1816
+ fenceLen = 0;
1817
+ }
1818
+ }
1819
+ if (openIdx !== -1) {
1820
+ for (let i = openIdx; i < lines.length; i++) hidden[i] = false;
1821
+ }
1822
+ return hidden;
1823
+ }
1824
+
1825
+ /**
1826
+ * Extract this file's `##` section headings, in order, as a MULTISET (every
1827
+ * occurrence kept, none deduped) with fenced-code lines excluded. Only `##`
1828
+ * (not `#`/`###`), the granularity the section-loss incident was measured at.
1829
+ *
1830
+ * Multiset, not a `Set`, because a dedup here silently halves the denominator
1831
+ * a file that legitimately repeats one `## ` heading twice: the old `Set`-based
1832
+ * version counted "## TODO" appearing twice on disk as ONE section, so a
1833
+ * payload that kept only one copy compared as "the heading is still present"
1834
+ * with nothing lost at all: the second bypass this pass closes.
1835
+ *
1836
+ * Known limit, left as-is (see fencedLineMask's own doc comment for the fuller
1837
+ * case against building a real parser here): this still reads every non-fenced
1838
+ * line as prose, so a `## ` line inside an indented (non-fenced) code block, a
1839
+ * blockquote, or a list item is still counted as a real heading. That is a
1840
+ * false positive (an occasional unnecessary park), not the silent-loss failure
1841
+ * mode this guard exists to close, so it is accepted rather than fixed here.
1842
+ * @returns {string[]}
1843
+ */
1844
+ function h2Headings(content) {
1845
+ const lines = (content || '').split(/\r?\n/);
1846
+ const hidden = fencedLineMask(lines);
1847
+ const out = [];
1848
+ for (let i = 0; i < lines.length; i++) {
1849
+ if (!hidden[i] && /^##\s+\S/.test(lines[i])) out.push(lines[i]);
1850
+ }
1851
+ return out;
1852
+ }
1853
+
1854
+ /**
1855
+ * Whether `payloadContent` drops enough of `diskContent`'s `##` sections to
1856
+ * warrant withholding the write. Compared as a multiset: each disk occurrence
1857
+ * is matched off against one still-unconsumed payload occurrence of the exact
1858
+ * same line, in disk order, so losing one copy of a heading that appears twice
1859
+ * on disk is visible even though the same title still appears once in the
1860
+ * payload. A "lost" occurrence is one with no remaining payload copy to match,
1861
+ * reworded, split, or genuinely deleted headings all read the same way here
1862
+ * (see the module doc comment above for why that is the accepted
1863
+ * false-positive, not a defect to fix), and a heading moved into a fenced code
1864
+ * block no longer counts as a payload occurrence at all (h2Headings excludes
1865
+ * fenced lines on both sides).
1866
+ *
1867
+ * An ordinary edit that drops a single section (finishing one track, retiring
1868
+ * one open question) must not park; losing 2 or more is the incident's own
1869
+ * shape (security-backoffice lost 2 of its 3 tracks) and trips regardless of
1870
+ * how big the file is. See SECTION_LOSS_MIN_COUNT's comment above for why this
1871
+ * is now a single count check rather than a count-and-ratio pair.
1872
+ *
1873
+ * @returns {{lost: string[], diskCount: number}|null} the lost occurrences
1874
+ * (duplicates repeated once per lost copy) and how many `##` heading
1875
+ * occurrences disk had (also a multiset count, not deduped; see
1876
+ * h2Headings), or null when the write is fine
1877
+ */
1878
+ export function sectionLossReason(diskContent, payloadContent) {
1879
+ const diskHeadings = h2Headings(diskContent);
1880
+ if (diskHeadings.length === 0) return null; // nothing to lose
1881
+ const payloadHeadings = h2Headings(payloadContent);
1882
+ const remaining = new Map();
1883
+ for (const h of payloadHeadings) remaining.set(h, (remaining.get(h) || 0) + 1);
1884
+ const lost = [];
1885
+ for (const h of diskHeadings) {
1886
+ const n = remaining.get(h) || 0;
1887
+ if (n > 0) {
1888
+ remaining.set(h, n - 1);
1889
+ } else {
1890
+ lost.push(h);
1891
+ }
1892
+ }
1893
+ if (lost.length < SECTION_LOSS_MIN_COUNT) return null;
1894
+ return { lost, diskCount: diskHeadings.length };
1895
+ }
1896
+
1105
1897
  /**
1106
1898
  * Replace every whole-page overwrite target, then fill a missing project index.
1107
1899
  *
@@ -1116,7 +1908,10 @@ function runPreflight(args, payload, project, date) {
1116
1908
  *
1117
1909
  * 1. idempotent skip (disk already equals the payload)
1118
1910
  * 2. conflict (base unknown, or disk drifted away from base)
1119
- * 3. direct write, then advance the base
1911
+ * 3. section-loss guard (payload drops most of disk's `## `
1912
+ * sections: parks either way; `restructure: true` only changes WHICH
1913
+ * park reason is recorded, it no longer lets the write through)
1914
+ * 4. direct write, then advance the base
1120
1915
  *
1121
1916
  * Step 1 must come first for two reasons. It keeps every existing
1122
1917
  * `--apply-session-close --session-id` test green (they read the payload
@@ -1124,21 +1919,421 @@ function runPreflight(args, payload, project, date) {
1124
1919
  * the apply-then-reclose loop: once a human applies proposal P, disk == proposed
1125
1920
  * == payload.content, so the next close skips before it can re-raise a conflict.
1126
1921
  *
1922
+ * Step 3 runs only once step 2 has already cleared: a base conflict already
1923
+ * withholds the write on its own, and reporting BOTH reasons for the same
1924
+ * withheld byte would tell a resolving human two different stories about why
1925
+ * their proposal review matters.
1926
+ *
1127
1927
  * There is no caller here without a `--session-id`. verifyCloseAuthority refuses
1128
1928
  * that at the door, before a byte is written, so a session id is always present
1129
1929
  * by the time this runs and the base lookup always has something to look up.
1130
1930
  */
1131
- function applyOverwrites(args, payload, project, date, indexRelPath, indexMissing, acc) {
1132
- const { applied, skipped, appliedPaths, conflicts } = acc;
1931
+ // ── close-intent durability (major finding: close's file set is not atomic) ──
1932
+ // applyOverwrites below writes up to 3 target files (session-state.md,
1933
+ // project hot.md, open-questions.md) as 3 SEPARATE atomicWrite calls. Each
1934
+ // individual write is torn-proof (temp + rename), but the SET is not: a
1935
+ // SIGKILL between the first rename and the second write leaves one target
1936
+ // holding new bytes and the next holding whatever was there before, with
1937
+ // nothing on disk saying this is an in-progress set rather than a finished
1938
+ // one. sessionCloseFileStatus's freshness check is content-blind by design,
1939
+ // so if the untouched target already happened to carry today's date (an
1940
+ // unrelated earlier edit, not this close), a later freshness-only read sees
1941
+ // the set as complete when it never was.
1942
+ //
1943
+ // This intent file is the missing witness. `writeCloseIntent` records every
1944
+ // target this apply is about to attempt and the hash each one is expected to
1945
+ // land at, BEFORE the first overwrite runs, with `phase: 'writing'`.
1946
+ // `markCloseIntentApplied` moves it to `phase: 'applied'` once applyOverwrites
1947
+ // has returned, and `clearCloseIntent` removes it only once the commit that
1948
+ // follows has landed. It used to be cleared right after applyOverwrites,
1949
+ // before the log appends and the commit: a process that died in that window
1950
+ // left every target at today's date with no record and no commit, and the
1951
+ // next probe read that as a finished close. A leftover record therefore means
1952
+ // the LAST close that started this set never got its bytes committed, whether
1953
+ // it died mid-write or later. `hasTornCloseIntent` is what the no-payload
1954
+ // probe branch (`alreadyComplete`) checks before trusting a green freshness
1955
+ // read, since that is the one path in THIS file that could otherwise report
1956
+ // success purely on freshness.
1957
+ //
1958
+ // A close that ends ok:false (a withheld conflict, a lint failure, a failed
1959
+ // commit) never reaches the commit, so it leaves its record behind on
1960
+ // purpose. Its bytes are on disk and uncommitted, and that is exactly what
1961
+ // the probe must not call complete. The cost is bounded: the retry that
1962
+ // re-runs the payload writes its own record over this one and clears it once
1963
+ // its commit lands, and CLOSE_INTENT_MAX_AGE_MS expires it otherwise.
1964
+ //
1965
+ // Every record also carries an `attemptId` (major fix): the path this file
1966
+ // lives at is keyed on `sessionId` alone, so a retry of the SAME session
1967
+ // (a resume after a crash, or an overlapping second apply) writes a SECOND
1968
+ // record to the exact same path a first attempt is still holding open. With
1969
+ // no identity beyond the path, the first attempt's own `clearCloseIntent`
1970
+ // (or `hasTornCloseIntent`'s expiry sweep) cannot tell "the record I am
1971
+ // about to delete is still the one I wrote" from "someone else's live
1972
+ // record now sits where mine used to be": deleting on path alone would
1973
+ // silently erase a second attempt's still-in-progress witness. `attemptId`
1974
+ // is this call's own identity: a clear only ever removes the file when a
1975
+ // FRESH re-read, taken under the same lock, still shows this exact
1976
+ // `attemptId`. A record with no `attemptId` at all (written by a build of
1977
+ // this file from before this fix) is handled by the same comparison, not a
1978
+ // special case: `undefined === undefined` still matches, so a legacy record
1979
+ // clears exactly when nothing newer has since overwritten it, and stops
1980
+ // matching the instant a NEW (attemptId-bearing) record replaces it, the
1981
+ // same protection this fix gives every record going forward.
1982
+ export function closeIntentPath(hypoDir, sessionId) {
1983
+ if (!isValidSessionId(sessionId)) return null;
1984
+ return join(hypoDir, '.cache', 'close-intent', `${sessionId}.json`);
1985
+ }
1986
+
1987
+ function closeIntentTargetsFor(payload, project) {
1988
+ const targets = [
1989
+ {
1990
+ relPath: join('projects', project, 'session-state.md'),
1991
+ hash: hashContent(payload.sessionState.content),
1992
+ },
1993
+ {
1994
+ relPath: join('projects', project, 'hot.md'),
1995
+ hash: hashContent(payload.projectHot.content),
1996
+ },
1997
+ ];
1998
+ if (payload.openQuestions) {
1999
+ targets.push({
2000
+ relPath: join('pages', 'open-questions.md'),
2001
+ hash: hashContent(payload.openQuestions.content),
2002
+ });
2003
+ }
2004
+ return targets;
2005
+ }
2006
+
2007
+ // MAJOR fix: this used to swallow every failure (lock timeout, EACCES, a
2008
+ // full disk under .cache/) and let applyOverwrites start writing target
2009
+ // files anyway. The witness this record exists to provide never got
2010
+ // written, and the first target rename could still be the last thing this
2011
+ // process does before it dies, leaving hasTornCloseIntent's next probe
2012
+ // with literally nothing to find, so a torn set reads as a clean, finished
2013
+ // close. The whole point of writing this BEFORE the first byte moves is
2014
+ // defeated if a failure to write it is not itself fatal to the close: the
2015
+ // caller now checks `.ok` and refuses before applyOverwrites ever runs (see
2016
+ // applySessionClose's own call site), so a transient lock/permission/disk
2017
+ // problem here blocks the close instead of silently disabling its own
2018
+ // safety net.
2019
+ // @returns {{ok: true, attemptId: string} | {ok: false, reason: string}}
2020
+ export function writeCloseIntent(hypoDir, sessionId, targets) {
2021
+ const path = closeIntentPath(hypoDir, sessionId);
2022
+ // No usable session id: there is no path to key a witness to, the same
2023
+ // gap `isValidSessionId` has always left open elsewhere in this file. Not
2024
+ // a failure of THIS write: there was nothing for it to attempt, so it
2025
+ // reports ok, degrading this run back to freshness-alone exactly as
2026
+ // before this fix, never worse.
2027
+ if (!path) return { ok: true, attemptId: null };
2028
+ const attemptId = randomBytes(8).toString('hex');
2029
+ try {
2030
+ withFileLock(path, () => {
2031
+ // Carry the PRIOR record's `sideEffects` forward into this new
2032
+ // attempt, before overwriting it. A side effect (SCHEMA.md's
2033
+ // Pending registration, a seeded projects/*/index.md) an earlier,
2034
+ // uncommitted attempt at this SAME close already wrote survives this
2035
+ // rewrite so a retry can still recognize it and restage it; only the
2036
+ // read below (not a write) happens on the OLD bytes, so a corrupt or
2037
+ // missing prior record just starts this attempt with none, same as
2038
+ // if it had never existed.
2039
+ let priorSideEffects = [];
2040
+ try {
2041
+ const prior = JSON.parse(readFileSync(path, 'utf-8'));
2042
+ if (prior && Array.isArray(prior.sideEffects)) priorSideEffects = prior.sideEffects;
2043
+ } catch {
2044
+ // no prior record, or unreadable: nothing to carry forward
2045
+ }
2046
+ atomicWrite(
2047
+ path,
2048
+ JSON.stringify({
2049
+ v: 1,
2050
+ attemptId,
2051
+ phase: 'writing',
2052
+ targets,
2053
+ sideEffects: priorSideEffects,
2054
+ startedAt: new Date().toISOString(),
2055
+ }),
2056
+ );
2057
+ });
2058
+ return { ok: true, attemptId };
2059
+ } catch (err) {
2060
+ return { ok: false, reason: err?.message || String(err) };
2061
+ }
2062
+ }
2063
+
2064
+ /**
2065
+ * Every side-effect entry `readCloseIntentSideEffects`/`recordCloseIntentSideEffect`
2066
+ * carry: `{ path, kind: 'create' | 'schema-pending', bytesSha256, tags?: string[] }`.
2067
+ * `tags` is present only for `kind: 'schema-pending'` (the exact tags a prior
2068
+ * attempt registered into SCHEMA.md's Pending block, needed to rebuild the
2069
+ * proof entry on restage without re-parsing anything).
2070
+ *
2071
+ * Record one side effect this attempt just made, under the SAME
2072
+ * attemptId-matching discipline as `markCloseIntentApplied`: a write here
2073
+ * only lands when the record at this path still shows the attemptId this
2074
+ * call was given, so a newer attempt (or a stale one racing after its own
2075
+ * clear) can never have its record corrupted by an older writer. Best-effort:
2076
+ * a lost side-effect record just means a later retry treats this path as
2077
+ * unexplained dirt again, the same fail-closed outcome as no record at all.
2078
+ * @param {{path: string, kind: 'create'|'schema-pending', bytesSha256: string, tags?: string[]}} entry
2079
+ */
2080
+ export function recordCloseIntentSideEffect(hypoDir, sessionId, attemptId, entry) {
2081
+ const path = closeIntentPath(hypoDir, sessionId);
2082
+ if (!path) return;
2083
+ try {
2084
+ withFileLock(path, () => {
2085
+ let current;
2086
+ try {
2087
+ current = JSON.parse(readFileSync(path, 'utf-8'));
2088
+ } catch {
2089
+ return;
2090
+ }
2091
+ if (!current || current.attemptId !== attemptId) return; // a newer record owns this path now
2092
+ const rest = (Array.isArray(current.sideEffects) ? current.sideEffects : []).filter(
2093
+ (e) => e && e.path !== entry.path,
2094
+ );
2095
+ atomicWrite(path, JSON.stringify({ ...current, sideEffects: [...rest, entry] }));
2096
+ });
2097
+ } catch {
2098
+ // best-effort, see doc comment above
2099
+ }
2100
+ }
2101
+
2102
+ /**
2103
+ * This session's own close-intent record's `sideEffects`, whatever attempt
2104
+ * (this one, or one carried forward by `writeCloseIntent`) last wrote them.
2105
+ * `[]` on any read failure or when no record exists, never thrown: a caller
2106
+ * treats an empty result exactly like "no side effect to restage", which is
2107
+ * the safe, fail-closed default.
2108
+ * @returns {Array<{path: string, kind: string, bytesSha256: string, tags?: string[]}>}
2109
+ */
2110
+ export function readCloseIntentSideEffects(hypoDir, sessionId) {
2111
+ const path = closeIntentPath(hypoDir, sessionId);
2112
+ if (!path) return [];
2113
+ try {
2114
+ const current = JSON.parse(readFileSync(path, 'utf-8'));
2115
+ return Array.isArray(current?.sideEffects) ? current.sideEffects : [];
2116
+ } catch {
2117
+ return [];
2118
+ }
2119
+ }
2120
+
2121
+ // Records that applyOverwrites returned: every target is now written, skipped,
2122
+ // or withheld, and what is still missing is the commit. Rewrites this call's
2123
+ // OWN record only (same attemptId check as the clear below), so a newer
2124
+ // attempt's record at the same path is left alone. Best-effort: a failed
2125
+ // rewrite leaves `phase: 'writing'`, which blocks the probe just the same.
2126
+ export function markCloseIntentApplied(hypoDir, sessionId, attemptId) {
2127
+ const path = closeIntentPath(hypoDir, sessionId);
2128
+ if (!path) return;
2129
+ try {
2130
+ withFileLock(path, () => {
2131
+ const current = JSON.parse(readFileSync(path, 'utf-8'));
2132
+ if (!current || current.attemptId !== attemptId) return;
2133
+ atomicWrite(path, JSON.stringify({ ...current, phase: 'applied' }));
2134
+ });
2135
+ } catch {
2136
+ // see above: the record stays at 'writing', still a live record
2137
+ }
2138
+ }
2139
+
2140
+ // Shared by `clearCloseIntent` (a finished apply removing its own record)
2141
+ // and `hasTornCloseIntent`'s expiry sweep (deleting a record too old to
2142
+ // trust): both need the SAME protection, re-read `path` UNDER THE SAME LOCK
2143
+ // a write would take and unlink only when the bytes still show exactly
2144
+ // `attemptId`, never on the strength of what an earlier, lock-free read
2145
+ // already saw. Without this, a second writer racing in between that earlier
2146
+ // read and the unlink (a same-session retry sharing this path, major fix
2147
+ // 3, or simply a session that starts a brand-new close on the same
2148
+ // sessionId right as an old record expires) has its still-live record
2149
+ // deleted as collateral by a caller that only ever meant to remove the
2150
+ // stale bytes it originally read.
2151
+ // `expectedAttemptId` compares with `===`, so `undefined` matches
2152
+ // `undefined`: a record from before this fix shipped (no `attemptId` field
2153
+ // at all) still clears correctly as long as nothing newer has since
2154
+ // overwritten it. That is the legacy-record policy this file's header
2155
+ // comment documents, not a special case bolted on here.
2156
+ function unlinkCloseIntentIfMatching(path, expectedAttemptId) {
2157
+ withFileLock(path, () => {
2158
+ let current;
2159
+ try {
2160
+ current = JSON.parse(readFileSync(path, 'utf-8'));
2161
+ } catch {
2162
+ return; // gone, or unreadable/corrupt: not provably still the record we read, leave it
2163
+ }
2164
+ if (!current || current.attemptId !== expectedAttemptId) return; // a newer record owns this path now
2165
+ try {
2166
+ unlinkSync(path);
2167
+ } catch (e) {
2168
+ if (e?.code !== 'ENOENT') throw e;
2169
+ }
2170
+ });
2171
+ }
2172
+
2173
+ // Removes this call's OWN record, never a path on trust alone (major fix,
2174
+ // same-session-retry race). Two attempts for the SAME sessionId share one
2175
+ // path (`closeIntentPath` keys on sessionId, not on attempt): if a first
2176
+ // attempt's clear ran on the path alone, a second attempt's still-live
2177
+ // record sitting at that same path afterwards would be deleted as
2178
+ // collateral the instant the first attempt finishes, even though the
2179
+ // second attempt never crashed and is still relying on that witness.
2180
+ export function clearCloseIntent(hypoDir, sessionId, attemptId) {
2181
+ const path = closeIntentPath(hypoDir, sessionId);
2182
+ if (!path) return;
2183
+ try {
2184
+ unlinkCloseIntentIfMatching(path, attemptId);
2185
+ } catch {
2186
+ // A leftover record after a run that actually finished reads back as
2187
+ // "died mid-set" by the next check: a false alarm, not a silent pass,
2188
+ // which is the safe direction for a best-effort clear to fail in.
2189
+ }
2190
+ }
2191
+
2192
+ // A record older than this never gets a second chance to finish: the session
2193
+ // that wrote it is treated as dead, not merely slow. applyOverwrites (the
2194
+ // only writer between writeCloseIntent and clearCloseIntent) is a bounded
2195
+ // sequence of small synchronous file writes that normally lands in low
2196
+ // seconds; this is not tuned to that, it is tuned to the failure mode a
2197
+ // missed expiry causes. Without one, a single crashed session's leftover
2198
+ // record blocks EVERY later no-payload probe for this vault forever (the
2199
+ // writing session never retries, so nothing ever calls clearCloseIntent for
2200
+ // it). 30 minutes is arbitrary, chosen only to be comfortably longer than any
2201
+ // real apply (even a slow disk or a huge payload) while still bounding that
2202
+ // "every close treated as torn" window to well under a day. Same role as
2203
+ // PROPOSAL_TMP_GRACE_MS in hooks/proposal-store.mjs, different scale because
2204
+ // this window is bounding a stuck DIAGNOSIS, not a stuck RENAME.
2205
+ const CLOSE_INTENT_MAX_AGE_MS = 30 * 60 * 1000;
2206
+
2207
+ /**
2208
+ * Scan `.cache/close-intent/` for a leftover record whose promised targets no
2209
+ * longer match disk. Returns one of three states, not a boolean, because
2210
+ * "cannot tell" and "confirmed clean" are different answers a caller must not
2211
+ * collapse into each other:
2212
+ *
2213
+ * - `'torn'`: some earlier close began writing this set and never reached
2214
+ * `clearCloseIntent`, exactly the case a bare freshness check cannot tell
2215
+ * apart from a normal finished close.
2216
+ * - `'unreadable'`: the directory, a record, or one of a record's targets
2217
+ * could not be read or parsed, so no comparison against disk was possible
2218
+ * for it. This used to read as `false` (fail open, like every other cache
2219
+ * read in this file), but this function exists SPECIFICALLY to catch what
2220
+ * freshness alone cannot, so failing open here means an EACCES or a
2221
+ * half-written record makes a torn set look clean to the one check built to
2222
+ * catch it. A caller must treat this the same as `'torn'`: fall through to
2223
+ * a real apply rather than trust the freshness probe.
2224
+ * - `'uncommitted'`: a live record whose targets all match disk. The files
2225
+ * landed but the commit that clears the record never did (a crash after
2226
+ * the writes, or a close that ended ok:false). This used to read as
2227
+ * `'clean'`, which is how a close that died between its writes and its
2228
+ * commit passed the probe. A caller treats it like `'torn'`.
2229
+ * - `'clean'`: every record read fine and had expired, or there were no
2230
+ * records at all.
2231
+ *
2232
+ * A record older than CLOSE_INTENT_MAX_AGE_MS is treated as neither torn nor
2233
+ * unreadable and is deleted here: the session that wrote it is gone, and
2234
+ * without this an EACCES-free crash from months ago would still be judged
2235
+ * torn (or worse, unreadable, if it happens to also be malformed) on every
2236
+ * single close from here on. Deletion is best-effort; a failed unlink just
2237
+ * means the next call tries again.
2238
+ *
2239
+ * MAJOR fix (same-session-retry race, #3): the delete used to unlink `path`
2240
+ * straight off the bytes this scan already read, with no lock held between
2241
+ * the read and the unlink. If a NEW session (or the same sessionId retrying)
2242
+ * writes a fresh record at that exact path in that window, this deletes the
2243
+ * new, still-live record as collateral: the file this scan judged expired
2244
+ * is not necessarily the file still sitting there by the time the unlink
2245
+ * runs. `unlinkCloseIntentIfMatching` closes that window: it re-reads under
2246
+ * the same lock a write would take and only unlinks when the bytes still
2247
+ * show the exact `attemptId` this scan read a moment ago.
2248
+ */
2249
+ function hasTornCloseIntent(hypoDir) {
2250
+ const dir = join(hypoDir, '.cache', 'close-intent');
2251
+ let names;
2252
+ try {
2253
+ names = readdirSync(dir);
2254
+ } catch (e) {
2255
+ // ENOENT means no close has ever written a record here: the ordinary,
2256
+ // overwhelmingly common state, and genuinely clean, not merely
2257
+ // unreadable. Every other errno (EACCES, ENOTDIR, ...) means the
2258
+ // directory exists but this could not examine it, which is the case
2259
+ // this function must not fail open on (see the doc comment above).
2260
+ return e && e.code === 'ENOENT' ? 'clean' : 'unreadable';
2261
+ }
2262
+ let sawUnreadable = false;
2263
+ let sawLive = false;
2264
+ for (const name of names) {
2265
+ if (!name.endsWith('.json')) continue;
2266
+ const path = join(dir, name);
2267
+ let parsed;
2268
+ try {
2269
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
2270
+ } catch {
2271
+ sawUnreadable = true;
2272
+ continue;
2273
+ }
2274
+ if (!parsed || !Array.isArray(parsed.targets)) {
2275
+ sawUnreadable = true;
2276
+ continue;
2277
+ }
2278
+ const startedAt = Date.parse(parsed.startedAt);
2279
+ if (Number.isFinite(startedAt) && Date.now() - startedAt > CLOSE_INTENT_MAX_AGE_MS) {
2280
+ try {
2281
+ unlinkCloseIntentIfMatching(path, parsed.attemptId);
2282
+ } catch {
2283
+ // best-effort: a leftover expired record just gets re-evaluated (and
2284
+ // re-attempted) on the next call
2285
+ }
2286
+ continue;
2287
+ }
2288
+ for (const t of parsed.targets) {
2289
+ if (!t || typeof t.relPath !== 'string' || typeof t.hash !== 'string') {
2290
+ sawUnreadable = true;
2291
+ continue;
2292
+ }
2293
+ const disk = readTarget(join(hypoDir, t.relPath));
2294
+ if (typeof disk !== 'string' || hashContent(disk) !== t.hash) return 'torn';
2295
+ }
2296
+ sawLive = true;
2297
+ }
2298
+ if (sawUnreadable) return 'unreadable';
2299
+ return sawLive ? 'uncommitted' : 'clean';
2300
+ }
2301
+
2302
+ function applyOverwrites(args, payload, project, date, indexRelPath, indexMissing, acc, attemptId) {
2303
+ const { applied, skipped, appliedPaths, conflicts, restructureWaivers, proofEntries } = acc;
2304
+ // Read once per close, not once per field: it is a single small JSON read,
2305
+ // and every skip branch below needs the same session-scoped record.
2306
+ const journal = readJournal(args.hypoDir, args.sessionId);
1133
2307
 
1134
2308
  const overwrite = (key, relPath, field) => {
1135
2309
  if (!field || typeof field.content !== 'string') return; // optional / absent
1136
2310
  const full = join(args.hypoDir, relPath);
1137
2311
  const disk = readTarget(full);
1138
2312
 
1139
- // (1) idempotent skip — preserves writeIfChanged's contract
2313
+ // (1) idempotent skip — preserves writeIfChanged's contract. "Already
2314
+ // current" collapses two different histories that look identical from
2315
+ // here: disk always held these bytes, or THIS session wrote them in an
2316
+ // earlier, uncommitted attempt at this same close. Only the journal tells
2317
+ // them apart. A record for this path whose hash still matches what is on
2318
+ // disk means the second history — restage it so the retry's commit picks
2319
+ // up bytes an earlier attempt already paid for. No record, or a hash that
2320
+ // no longer matches (someone touched the file since), leaves it out: the
2321
+ // gate should keep blocking on drift it cannot attribute to this close.
1140
2322
  if (disk === field.content) {
2323
+ const journalHash = journal[relPath];
2324
+ if (journalHash && journalHash === hashContent(field.content)) {
2325
+ appliedPaths.push(relPath);
2326
+ }
1141
2327
  skipped.push(`${key} (${relPath})`);
2328
+ // Already current: the close proof still needs an entry naming this
2329
+ // target (skipped targets are included), since the receipt
2330
+ // certifies the payload's own version is IN the commit, not that this
2331
+ // apply wrote it just now.
2332
+ proofEntries.push({
2333
+ path: relPath,
2334
+ kind: 'overwrite',
2335
+ expected: { bytesSha256: bytesSha256(field.content) },
2336
+ });
1142
2337
  return;
1143
2338
  }
1144
2339
 
@@ -1172,10 +2367,14 @@ function applyOverwrites(args, payload, project, date, indexRelPath, indexMissin
1172
2367
  // see base-store.mjs's recordObserved/readObservedHash docs.
1173
2368
  const observedTruncated =
1174
2369
  !observedHash && wasObservedTruncated(args.hypoDir, args.sessionId, relPath);
1175
- const reason = overwriteConflictReason(entry, disk, {
1176
- hash: observedHash,
1177
- truncated: observedTruncated,
1178
- });
2370
+ const appliedHash = readAppliedHash(args.hypoDir, args.sessionId, relPath);
2371
+ const reason = overwriteConflictReason(
2372
+ entry,
2373
+ disk,
2374
+ { hash: observedHash, truncated: observedTruncated },
2375
+ appliedHash,
2376
+ hashContent(field.content),
2377
+ );
1179
2378
  if (reason) {
1180
2379
  conflicts.push({
1181
2380
  key,
@@ -1189,27 +2388,194 @@ function applyOverwrites(args, payload, project, date, indexRelPath, indexMissin
1189
2388
  }
1190
2389
  }
1191
2390
 
1192
- // (3) write, then the content we just wrote IS this session's new base
2391
+ // (3) Section-loss guard: this overwrite would drop most of disk's `## ` sections.
2392
+ // Computed regardless of `restructure`, so a `true` value set on a field that
2393
+ // tripped a REAL loss can be told apart from one set on a field that never had
2394
+ // a loss to waive.
2395
+ //
2396
+ // `field.restructure === true` used to be a full escape hatch: it let the
2397
+ // write through immediately, with only `restructureWaivers` left behind as an
2398
+ // after-the-fact audit trail. That made the flag self-approving:
2399
+ // the party the guard exists to check (the model composing the payload) was
2400
+ // also the only party who could clear it, with no human between the claim and
2401
+ // the disk write. `restructure: true` no longer bypasses anything. It only
2402
+ // picks WHICH park reason applies; either way the bytes are withheld and go
2403
+ // through the SAME human-approval door a base conflict already uses
2404
+ // (`hypomnema proposal challenge` / `proposal resolve`, a nonce a human types
2405
+ // after reviewing the diff). A field that never had a loss to waive still adds
2406
+ // no entry to `restructureWaivers`. The flag did nothing there, which is not
2407
+ // this field's job to flag.
2408
+ if (typeof disk === 'string') {
2409
+ const loss = sectionLossReason(disk, field.content);
2410
+ if (loss) {
2411
+ const restructureRequested = field.restructure === true;
2412
+ conflicts.push({
2413
+ key,
2414
+ target: relPath,
2415
+ reason: restructureRequested
2416
+ ? 'section-loss-guard-restructure-pending'
2417
+ : 'section-loss-guard',
2418
+ lostSections: loss.lost,
2419
+ diskSectionCount: loss.diskCount,
2420
+ baseHash: args.sessionId
2421
+ ? readBaseEntry(args.hypoDir, args.sessionId, relPath).hash
2422
+ : null,
2423
+ currentHash: hashContent(disk),
2424
+ proposedContent: field.content,
2425
+ });
2426
+ if (restructureRequested) {
2427
+ // The model's claim is still worth recording, just not as approval:
2428
+ // it tells the human reviewing the parked proposal that the payload
2429
+ // author believed this restructure was intentional, which is exactly
2430
+ // the context `hypomnema proposal challenge` shows them before they
2431
+ // type the nonce.
2432
+ restructureWaivers.push({ target: relPath, lostSections: loss.lost });
2433
+ }
2434
+ return; // target bytes untouched either way
2435
+ }
2436
+ }
2437
+
2438
+ // (4) write, then the content we just wrote IS this session's new base.
2439
+ // This target's expected post-write hash is already recorded in this
2440
+ // session's close-intent file (written before applyOverwrites started,
2441
+ // see closeIntentPath above), so a crash right here (between this
2442
+ // rename landing and a sibling field's own write) leaves that record
2443
+ // behind for hasTornCloseIntent to find, rather than a silent gap.
1193
2444
  atomicWrite(full, field.content);
1194
- if (args.sessionId)
1195
- advanceBase(args.hypoDir, args.sessionId, relPath, hashContent(field.content));
2445
+ if (args.sessionId) {
2446
+ // Base and applied-hash move together, in ONE base.json write
2447
+ // (advanceBaseAndRecordApplied), not two calls back to back. Two
2448
+ // separate writes (advanceBase, then recordAppliedHash) had a window
2449
+ // where the first landed and the second failed, leaving the base moved
2450
+ // but the applied record stale (review r4-w4 major 2's silent
2451
+ // fall-back to the pre-guard overwrite). A merged write either lands
2452
+ // whole or leaves both fields exactly as they were, so a failure here
2453
+ // is a normal fail-safe: the base does not move, and the next look at
2454
+ // this target sees the same base-mismatch a foreign write would get.
2455
+ //
2456
+ // The page bytes above are already on disk by the time either call
2457
+ // below runs, so neither failure can be undone from here: the guard
2458
+ // this section exists to keep honest depends on base.json, not on the
2459
+ // page itself. review r5-w3 major 1 named the silent-ignore of these
2460
+ // two return values; surfaced to stderr rather than to the result JSON,
2461
+ // since the latter is a wider change to `acc`'s shape than this
2462
+ // overwrite step owns. A failure here is loud but not fatal: the next
2463
+ // close on this target degrades to `base-unknown`/a journal-less retry,
2464
+ // both of which already fail safe into a park rather than a silent
2465
+ // overwrite.
2466
+ if (
2467
+ !advanceBaseAndRecordApplied(
2468
+ args.hypoDir,
2469
+ args.sessionId,
2470
+ relPath,
2471
+ hashContent(field.content),
2472
+ )
2473
+ ) {
2474
+ process.stderr.write(
2475
+ `[crystallize] warning: wrote ${relPath} but could not record its new base/applied hash (base.json write failed): the next close on this target may park as base-unknown\n`,
2476
+ );
2477
+ }
2478
+ // Record what THIS write just put down, so a retry after a partial
2479
+ // close (a sibling field conflicts, the commit fails, the process
2480
+ // dies) can tell its own uncommitted bytes apart from someone else's —
2481
+ // see the journal read in step (1) above and the doc comment on
2482
+ // hooks/close-journal.mjs. `recordJournalEntry` itself returns nothing
2483
+ // to check (best-effort, swallows its own failures): that function
2484
+ // lives in hooks/close-journal.mjs, outside this fix's write scope, so
2485
+ // its silent-ignore half of review r5-w3 major 1 is not closed here.
2486
+ recordJournalEntry(args.hypoDir, args.sessionId, relPath, hashContent(field.content));
2487
+ }
1196
2488
  applied.push(`${key} (${relPath})`);
1197
2489
  appliedPaths.push(relPath);
2490
+ proofEntries.push({
2491
+ path: relPath,
2492
+ kind: 'overwrite',
2493
+ expected: { bytesSha256: bytesSha256(field.content) },
2494
+ });
1198
2495
  };
1199
2496
 
1200
2497
  overwrite('sessionState', join('projects', project, 'session-state.md'), payload.sessionState);
1201
2498
  overwrite('projectHot', join('projects', project, 'hot.md'), payload.projectHot);
1202
- overwrite('rootHot', 'hot.md', payload.rootHot);
1203
2499
  overwrite('openQuestions', join('pages', 'open-questions.md'), payload.openQuestions);
1204
2500
 
1205
2501
  // A-1: fill a missing project index as part of this close's writes (after
1206
2502
  // preflight passed, so an aborted close never leaves a half-applied side
1207
2503
  // effect on disk).
1208
2504
  if (indexMissing) {
1209
- const createdIndex = ensureProjectIndex(args.hypoDir, project, indexRelPath, date);
2505
+ const createdIndex = ensureProjectIndex(
2506
+ args.hypoDir,
2507
+ project,
2508
+ indexRelPath,
2509
+ date,
2510
+ args.sessionId,
2511
+ );
1210
2512
  if (createdIndex) {
1211
2513
  applied.push(`projectIndex (${createdIndex})`);
1212
2514
  appliedPaths.push(createdIndex);
2515
+ // Read the bytes back rather than re-deriving substituteTokens' output a
2516
+ // second time here: ensureProjectIndex just landed them via atomic
2517
+ // create, and re-reading is the same "trust what's actually on disk"
2518
+ // discipline every other proof entry in this file follows.
2519
+ const createdContent = readTarget(join(args.hypoDir, createdIndex));
2520
+ if (typeof createdContent === 'string') {
2521
+ const bytesSha = bytesSha256(createdContent);
2522
+ proofEntries.push({
2523
+ path: createdIndex,
2524
+ kind: 'create',
2525
+ expected: { bytesSha256: bytesSha },
2526
+ });
2527
+ // Witness this create as a close-intent side effect
2528
+ // too, alongside the journal record ensureProjectIndex already wrote.
2529
+ // A retry that (for any reason) cannot trust the journal still has
2530
+ // this to fall back on.
2531
+ recordCloseIntentSideEffect(args.hypoDir, args.sessionId, attemptId, {
2532
+ path: createdIndex,
2533
+ kind: 'create',
2534
+ bytesSha256: bytesSha,
2535
+ });
2536
+ }
2537
+ }
2538
+ } else {
2539
+ // The retry path. A first attempt that seeds index.md and then fails to
2540
+ // commit leaves it dirty; this run finds it already there, so the branch
2541
+ // above does nothing and the file would drop out of the commit scope
2542
+ // entirely, blocking the gate forever with no retry ever picking it back
2543
+ // up. Restaging it is only safe when the journal says THIS session wrote
2544
+ // exactly the bytes still on disk — the same rule step (1)'s idempotent
2545
+ // skip applies, reused here because ensureProjectIndex never reaches
2546
+ // step (1) at all (it is a template-seeded create, not a payload
2547
+ // overwrite field). A hand-edited index.md (no journal record, or a
2548
+ // journal record whose hash no longer matches) is left OUT of
2549
+ // appliedPaths on purpose: those are bytes this close never wrote, and
2550
+ // sweeping them into its commit would ship an edit the payload never
2551
+ // carried.
2552
+ const full = join(args.hypoDir, indexRelPath);
2553
+ const disk = readTarget(full);
2554
+ const journalHash = journal[indexRelPath];
2555
+ if (journalHash && typeof disk === 'string' && journalHash === hashContent(disk)) {
2556
+ appliedPaths.push(indexRelPath);
2557
+ proofEntries.push({
2558
+ path: indexRelPath,
2559
+ kind: 'create',
2560
+ expected: { bytesSha256: bytesSha256(disk) },
2561
+ });
2562
+ } else if (typeof disk === 'string') {
2563
+ // Fallback witness: no journal record (or a stale one), but
2564
+ // this session's own close-intent still names this exact create as a
2565
+ // side effect, and the bytes on disk still match it byte for byte.
2566
+ // Same restage judgment as the journal branch above, from a second
2567
+ // witness.
2568
+ const priorIndex = readCloseIntentSideEffects(args.hypoDir, args.sessionId).find(
2569
+ (e) => e && e.path === indexRelPath && e.kind === 'create',
2570
+ );
2571
+ if (priorIndex && bytesSha256(disk) === priorIndex.bytesSha256) {
2572
+ appliedPaths.push(indexRelPath);
2573
+ proofEntries.push({
2574
+ path: indexRelPath,
2575
+ kind: 'create',
2576
+ expected: { bytesSha256: priorIndex.bytesSha256 },
2577
+ });
2578
+ }
1213
2579
  }
1214
2580
  }
1215
2581
  }
@@ -1225,10 +2591,17 @@ const entryAlreadyPresent = (entry) => (content) =>
1225
2591
  // Append this close's entry to the project's daily session-log shard, pushing the
1226
2592
  // outcome into the shared `acc` bag.
1227
2593
  function appendSessionLogEntry(args, payload, project, date, acc) {
1228
- const { applied, skipped, appliedPaths, conflicts } = acc;
2594
+ const { applied, skipped, appliedPaths, conflicts, proofEntries } = acc;
1229
2595
  const rel = join('projects', project, 'session-log', `${date}.md`);
1230
2596
  const full = join(args.hypoDir, rel);
1231
2597
  const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
2598
+ const journal = readJournal(args.hypoDir, args.sessionId);
2599
+ // Which file actually carries this close's entry: the daily shard `rel`
2600
+ // on a create/append, or whichever hybrid-cutover candidate the "already
2601
+ // present" scan below matched. The proof entry must name THAT path: an
2602
+ // append proof against `rel` when the entry in fact lives in the legacy
2603
+ // monthly file would never verify against the commit.
2604
+ let evidencePath = rel;
1232
2605
  // Serialize dedup + create/append on the daily shard so two concurrent
1233
2606
  // closes never lose an entry: the second closer takes the lock only after
1234
2607
  // the first committed, re-reads the shard under the lock, and appends onto
@@ -1251,7 +2624,10 @@ function appendSessionLogEntry(args, payload, project, date, acc) {
1251
2624
  const cf = join(args.hypoDir, cand);
1252
2625
  if (!existsSync(cf)) continue;
1253
2626
  try {
1254
- if (isPresent(readFileSync(cf, 'utf-8'))) return 'skipped';
2627
+ if (isPresent(readFileSync(cf, 'utf-8'))) {
2628
+ evidencePath = cand; // the candidate that actually carries the entry
2629
+ return 'skipped';
2630
+ }
1255
2631
  } catch {
1256
2632
  /* unreadable candidate — fall through to the write path */
1257
2633
  }
@@ -1292,7 +2668,40 @@ function appendSessionLogEntry(args, payload, project, date, acc) {
1292
2668
  { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1293
2669
  );
1294
2670
  (outcome === 'skipped' ? skipped : applied).push(`sessionLog (${rel})`);
1295
- if (outcome !== 'skipped') appliedPaths.push(rel);
2671
+ if (outcome !== 'skipped') {
2672
+ appliedPaths.push(rel);
2673
+ // Same journal contract as applyOverwrites: record the FULL file's hash
2674
+ // right after this write, not just the entry, since a retry's own
2675
+ // "already present" skip below reads the whole file back to compare.
2676
+ const written = readTarget(full);
2677
+ if (typeof written === 'string')
2678
+ recordJournalEntry(args.hypoDir, args.sessionId, rel, hashContent(written));
2679
+ } else {
2680
+ // "Already present" collapses the same two histories the overwrite
2681
+ // guard's step (1) does: this entry could have sat in the shard since
2682
+ // before this close ever ran, or THIS session appended it in an
2683
+ // earlier, uncommitted attempt at the same close. Restage only the
2684
+ // second — a journal record whose hash still matches the shard on
2685
+ // disk. (A hybrid-month fallback hit above never reaches here with
2686
+ // `full` matching the journal's recorded target, since the evidence in
2687
+ // that case lives in the legacy monthly file instead — nothing to
2688
+ // restore for the daily shard because this close never wrote one.)
2689
+ const journalHash = journal[rel];
2690
+ if (journalHash) {
2691
+ const disk = readTarget(full);
2692
+ if (typeof disk === 'string' && journalHash === hashContent(disk)) {
2693
+ appliedPaths.push(rel);
2694
+ }
2695
+ }
2696
+ }
2697
+ // session-log evidence (always included): the exact path
2698
+ // sessionCloseFileStatus's freshness check would accept for THIS
2699
+ // close's entry, whichever of the hybrid candidates actually carries it.
2700
+ proofEntries.push({
2701
+ path: evidencePath,
2702
+ kind: 'append',
2703
+ expected: { entryBlocks: [payload.sessionLog.entry] },
2704
+ });
1296
2705
  } catch (err) {
1297
2706
  // Only a lock-TIMEOUT is withheld as a conflict. A real fn() write error
1298
2707
  // (disk-full, EACCES, mkdir failure) must NOT be masked as a proposal-
@@ -1335,9 +2744,35 @@ function appendSessionLogEntry(args, payload, project, date, acc) {
1335
2744
  // (the Stop-hook backfill in hypo-shared.mjs). Both take the SAME lock on
1336
2745
  // log.md, so a concurrent close's append and this close's append serialize
1337
2746
  // instead of overwriting each other.
2747
+ // log.md is a single shared file both branches below append to, so a
2748
+ // "wrote nothing new" outcome from either one needs the same journal-based
2749
+ // restore-vs-leave-dirty judgment applyOverwrites' step (1) already makes:
2750
+ // this session's own prior, uncommitted append restages; anything else does
2751
+ // not. Centralized here rather than duplicated per branch, and rather than
2752
+ // merely commented twice, because a fix to one copy silently drifting from
2753
+ // the other is exactly the failure mode two near-identical blocks invite.
2754
+ function restageOrRecordLogMd(args, logFull, journal, wroteNew, acc) {
2755
+ const { appliedPaths } = acc;
2756
+ if (wroteNew) {
2757
+ appliedPaths.push('log.md');
2758
+ const written = readTarget(logFull);
2759
+ if (typeof written === 'string')
2760
+ recordJournalEntry(args.hypoDir, args.sessionId, 'log.md', hashContent(written));
2761
+ return;
2762
+ }
2763
+ const journalHash = journal['log.md'];
2764
+ if (journalHash) {
2765
+ const disk = readTarget(logFull);
2766
+ if (typeof disk === 'string' && journalHash === hashContent(disk)) {
2767
+ appliedPaths.push('log.md');
2768
+ }
2769
+ }
2770
+ }
2771
+
1338
2772
  function appendRootLogEntry(args, payload, project, date, acc) {
1339
- const { applied, skipped, appliedPaths, conflicts } = acc;
2773
+ const { applied, skipped, conflicts, proofEntries } = acc;
1340
2774
  const logFull = join(args.hypoDir, 'log.md');
2775
+ const journal = readJournal(args.hypoDir, args.sessionId);
1341
2776
  if (payload.log) {
1342
2777
  try {
1343
2778
  const wrote = withFileLock(
@@ -1346,7 +2781,14 @@ function appendRootLogEntry(args, payload, project, date, acc) {
1346
2781
  { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1347
2782
  );
1348
2783
  (wrote ? applied : skipped).push('log (log.md)');
1349
- if (wrote) appliedPaths.push('log.md');
2784
+ restageOrRecordLogMd(args, logFull, journal, wrote, acc);
2785
+ // Append proof is the ENTRY, never just a heading: a sibling body
2786
+ // under the same heading must not verify.
2787
+ proofEntries.push({
2788
+ path: 'log.md',
2789
+ kind: 'append',
2790
+ expected: { entryBlocks: [payload.log.entry] },
2791
+ });
1350
2792
  } catch (err) {
1351
2793
  if (err?.code !== 'ELOCKTIMEOUT') throw err;
1352
2794
  // proposedContent is append-ready root-log bytes (the custom log line).
@@ -1366,6 +2808,11 @@ function appendRootLogEntry(args, payload, project, date, acc) {
1366
2808
  // the global path. Exact-line dedup on the heading keeps a second apply (or a
1367
2809
  // titleless vs titled same-day pair) from duplicating.
1368
2810
  const headingRe = new RegExp(`^#{1,6} \\[${date}\\]\\s*(.*)$`, 'gm');
2811
+ // A payload can carry more than one dated heading, and
2812
+ // each derives its OWN root-log block, the proof must cover every one,
2813
+ // not just the first, or a second heading's block could be missing from
2814
+ // the commit with nothing here to catch it.
2815
+ const derivedBlocks = [];
1369
2816
  try {
1370
2817
  const wroteAny = withFileLock(
1371
2818
  logFull,
@@ -1373,6 +2820,7 @@ function appendRootLogEntry(args, payload, project, date, acc) {
1373
2820
  let w = false;
1374
2821
  for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
1375
2822
  const { heading, block } = rootLogEntry(project, date, m[1]);
2823
+ derivedBlocks.push(block);
1376
2824
  const wrote = appendIfAbsent(logFull, block, (c) =>
1377
2825
  (c || '').split(/\r?\n/).includes(heading),
1378
2826
  );
@@ -1383,7 +2831,14 @@ function appendRootLogEntry(args, payload, project, date, acc) {
1383
2831
  { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1384
2832
  );
1385
2833
  (wroteAny ? applied : skipped).push('log (log.md, derived)');
1386
- if (wroteAny) appliedPaths.push('log.md');
2834
+ restageOrRecordLogMd(args, logFull, journal, wroteAny, acc);
2835
+ if (derivedBlocks.length > 0) {
2836
+ proofEntries.push({
2837
+ path: 'log.md',
2838
+ kind: 'append',
2839
+ expected: { entryBlocks: derivedBlocks },
2840
+ });
2841
+ }
1387
2842
  } catch (err) {
1388
2843
  if (err?.code !== 'ELOCKTIMEOUT') throw err;
1389
2844
  // `derived: true` discriminates this from the payload.log conflict above:
@@ -1420,6 +2875,64 @@ function appendRootLogEntry(args, payload, project, date, acc) {
1420
2875
  // append-only history file. Append conflicts still sit in `conflicts`, so the
1421
2876
  // close still goes proposal-pending — they just get no artifact and no
1422
2877
  // human-apply step.
2878
+ // Human-readable park reason, keyed by `c.reason`. Add a line here for every
2879
+ // new reason string a `conflicts.push(...)` call introduces (applyOverwrites,
2880
+ // the append-lock-timeout sites below) — before this lookup existed, the report
2881
+ // only branched on `c.kind === 'append'` and printed one fixed sentence
2882
+ // ("the page changed since this session read it") for every other reason,
2883
+ // which is a flat lie for `section-loss-guard`: nothing external changed
2884
+ // there, the PAYLOAD dropped its own sections. A reason with no entry here
2885
+ // falls through to the default below, worded to admit it does not know the
2886
+ // cause rather than repeat a specific wrong one.
2887
+ const CONFLICT_WHY = {
2888
+ 'append-lock-timeout': () =>
2889
+ 'could not acquire the append lock in time; the next close re-applies',
2890
+ 'base-unknown': () =>
2891
+ 'no base snapshot exists for this target for this session, so another writer could be sitting on disk with no way to tell',
2892
+ 'base-hash-target-missing': () =>
2893
+ 'the page changed since this session read it (it existed at base, and is missing now)',
2894
+ 'base-mismatch': () => 'the page changed since this session read it',
2895
+ 'base-mismatch-truncated-observation': () =>
2896
+ 'the page changed since this session read it, and the last resume/compact only showed a truncated slice of it',
2897
+ 'base-absent-target-exists': () =>
2898
+ 'the page changed since this session read it (nothing existed at base, another writer created it since)',
2899
+ 'target-unreadable': () =>
2900
+ 'the target could not be read just now; failing safe rather than assuming it is unchanged',
2901
+ 'will-overwrite-local-change': () =>
2902
+ 'this session applied a payload to this target before, and the disk bytes have since changed (a hand edit, most likely): writing this payload now, unmodified or not, would silently discard that edit, because there is no way from here to tell a payload that folds the edit in from one that does not. A human must review the diff and approve it with `proposal challenge` / `proposal resolve` (`/hypo:crystallize` runs them for you), whether the payload already accounts for the edit or not',
2903
+ // This message used to end with "or set \"restructure\": true after confirming
2904
+ // with the user that dropping them is intended". That instruction stopped
2905
+ // being true the moment the flag stopped authorising the write, and a park
2906
+ // message that hands back a step which no longer lands the bytes is worse
2907
+ // than one that offers nothing. The two real ways out are below.
2908
+ 'section-loss-guard': (c) =>
2909
+ `this payload drops ${c.lostSections.length} of ${c.diskSectionCount} \`##\` section(s) already on disk (${c.lostSections.join(', ')}). The page did not change, and the payload did not carry those sections forward. Either add the missing sections back into the payload, or, if dropping them is intended, have a human approve the parked write with \`proposal challenge\` / \`proposal resolve\` (\`/hypo:crystallize\` runs them for you). Setting "restructure": true records that intent for the reviewer; it does not land the write`,
2910
+ // `restructure: true` used to let this write straight through, a
2911
+ // model-set boolean approving its own destructive overwrite, with no human in
2912
+ // the loop. It still parks, exactly like the unset case above; the only
2913
+ // difference is this message, which tells the reviewing human the payload
2914
+ // author already claims the drop is intentional.
2915
+ 'section-loss-guard-restructure-pending': (c) =>
2916
+ `this payload drops ${c.lostSections.length} of ${c.diskSectionCount} \`##\` section(s) already on disk (${c.lostSections.join(', ')}) and set "restructure": true. The payload's own claim is not authority to drop them. A human must review the diff and approve it with \`proposal challenge\` / \`proposal resolve\` (\`/hypo:crystallize\` runs them for you) before this write lands`,
2917
+ };
2918
+
2919
+ export function conflictWhy(c) {
2920
+ const fn = CONFLICT_WHY[c.reason];
2921
+ if (!fn) return `unrecognized park reason "${c.reason}" — cause not determined`;
2922
+ // An entry reads whatever fields its own reason carries, and the section-loss
2923
+ // one needs two the others never set. That was harmless while this only fed
2924
+ // the text report; the JSON close path now calls it for every conflict, so a
2925
+ // future reason pushed without the fields its entry expects would throw
2926
+ // mid-close and take the whole apply with it. The explanation is the least
2927
+ // important thing happening here: degrade to the raw reason rather than lose
2928
+ // the close over a message.
2929
+ try {
2930
+ return fn(c);
2931
+ } catch {
2932
+ return `${c.reason} (details unavailable)`;
2933
+ }
2934
+ }
2935
+
1423
2936
  function parkOverwriteConflicts(args, conflicts) {
1424
2937
  const proposals = [];
1425
2938
  const proposalStoreFailures = [];
@@ -1434,6 +2947,20 @@ function parkOverwriteConflicts(args, conflicts) {
1434
2947
  proposedContent: c.proposedContent, // internal (pre-drop) full page bytes
1435
2948
  sessionId: args.sessionId, // may be null; writeProposal coerces it
1436
2949
  device,
2950
+ // The same human-readable cause the JSON result's conflicts[].why now
2951
+ // carries (buildCloseResult) — stored here too because a proposal
2952
+ // artifact outlives this close's own stdout, and `hypomnema proposal
2953
+ // list`/`apply` reads only the artifact, never this run's JSON. Without
2954
+ // it, the reviewer sees the raw `reason` code and nothing else (codex
2955
+ // 3rd-pass finding: the park-reason wording fix never reached this file).
2956
+ parkReason: conflictWhy(c),
2957
+ // Section-loss detail: only meaningful for those two reasons, so only
2958
+ // sent for them. An absent field on every other conflict is the correct
2959
+ // shape, not a gap.
2960
+ ...(c.reason === 'section-loss-guard' ||
2961
+ c.reason === 'section-loss-guard-restructure-pending'
2962
+ ? { lostSections: c.lostSections, diskSectionCount: c.diskSectionCount }
2963
+ : {}),
1437
2964
  });
1438
2965
  proposals.push({ id: saved.id, target: saved.target, path: saved.path });
1439
2966
  // Supersede-delete failure is NON-fatal: the new artifact IS parked, only
@@ -1448,7 +2975,7 @@ function parkOverwriteConflicts(args, conflicts) {
1448
2975
  proposalStoreFailures.push({ target: c.target, key: c.key, error });
1449
2976
  process.stderr.write(
1450
2977
  `\n🛑 PROPOSAL STORE FAILED for ${c.key} (${c.target}): ${error}\n` +
1451
- ` This close WITHHELD the target (it drifted from your observed base) but\n` +
2978
+ ` This close WITHHELD the target (${conflictWhy(c)}) but\n` +
1452
2979
  ` could NOT write the .cache/proposals/ artifact either. The payload bytes\n` +
1453
2980
  ` are on NEITHER disk NOR a proposal — re-run the close once the .cache/\n` +
1454
2981
  ` directory is writable so the withheld content is not lost.\n`,
@@ -1483,21 +3010,99 @@ const unknownTagRe = /^Unknown tag: "(.+)" \(not in SCHEMA\.md Tag Vocabulary\)/
1483
3010
  // check and runPostApplyLint's payload-scope promotion below.
1484
3011
  const INVALID_YAML_WARN_RE = /^Invalid YAML frontmatter: /;
1485
3012
 
1486
- function registerPendingTags(args, preflightLint, hasConflicts) {
3013
+ // SCHEMA.md registration is called from INSIDE runMarkerPhase's
3014
+ // vault-commit lock, once `ok` is already settled, right before the commit
3015
+ // that follows it, never at its old call site (before postApplyLint even
3016
+ // ran). Registering earlier used to mutate SCHEMA.md on a close that was
3017
+ // still going to fail (or park a conflict): the file changed, uncommitted,
3018
+ // on a run whose whole point was to write nothing.
3019
+ //
3020
+ // Also requires SCHEMA.md to be CLEAN against HEAD (not dirty, not
3021
+ // untracked) before touching it: a dirty SCHEMA.md may be someone else's
3022
+ // uncommitted edit, and layering a registration onto it would (a) fold
3023
+ // their bytes into THIS close's commit with no proof entry covering them,
3024
+ // and (b) make the registration itself uncommitted right alongside it.
3025
+ // Deferring costs nothing: the tag stays a non-blocking unknown-tag warn
3026
+ // until a later close finds SCHEMA.md clean.
3027
+ function registerPendingTagsLocked(
3028
+ args,
3029
+ preflightLint,
3030
+ hasConflicts,
3031
+ appliedPaths,
3032
+ proofEntries,
3033
+ attemptId,
3034
+ ) {
3035
+ const schemaPath = join(args.hypoDir, 'SCHEMA.md');
3036
+ if (!existsSync(schemaPath)) return;
3037
+
3038
+ // Retry restage: a PRIOR attempt at this SAME close already
3039
+ // registered pending tags into SCHEMA.md and left the write uncommitted
3040
+ // (the commit that was supposed to land it failed, or the process died
3041
+ // between the write and the commit). Once those tags sit in the file,
3042
+ // `preflightLint` below no longer flags them as unknown at all, so the
3043
+ // normal registration path never even sees them again. Without this check
3044
+ // that dirty SCHEMA.md would sit unexplained forever, deferred every close
3045
+ // for a reason the SESSION ITSELF already resolved. A byte-for-byte match
3046
+ // against the recorded side effect is what tells "my own unfinished write"
3047
+ // apart from someone else's unrelated edit landing on the same file in the
3048
+ // meantime; only the first restages.
3049
+ let disk;
3050
+ try {
3051
+ disk = readFileSync(schemaPath);
3052
+ } catch {
3053
+ disk = null;
3054
+ }
3055
+ if (disk) {
3056
+ const priorSchema = readCloseIntentSideEffects(args.hypoDir, args.sessionId).find(
3057
+ (e) => e && e.path === 'SCHEMA.md' && e.kind === 'schema-pending',
3058
+ );
3059
+ if (priorSchema && bytesSha256(disk) === priorSchema.bytesSha256) {
3060
+ appliedPaths.push('SCHEMA.md');
3061
+ proofEntries.push({
3062
+ path: 'SCHEMA.md',
3063
+ kind: 'schema-pending',
3064
+ expected: { tags: priorSchema.tags },
3065
+ });
3066
+ return;
3067
+ }
3068
+ }
3069
+
1487
3070
  const pendingTags = [];
1488
3071
  for (const w of preflightLint.warns || []) {
1489
3072
  const m = unknownTagRe.exec(w.message || '');
1490
3073
  if (m && !checkForbidden(m[1])) pendingTags.push(m[1]);
1491
3074
  }
1492
- // Withheld a target? Then register nothing. This close is going to be re-run
1493
- // once a human resolves the conflict, and its `ok:false` skips the commit below
1494
- // (`if (ok && args.sessionId)`) — so registering here would leave SCHEMA.md
1495
- // mutated, uncommitted, and absent from `applied`, i.e. a silent side effect of
1496
- // a close whose whole point was to write nothing. Registration is
1497
- // eventually-consistent by design, so deferring it to the next close costs
1498
- // nothing (codex W2 CONCERN).
1499
- if (pendingTags.length > 0 && !hasConflicts) {
1500
- appendPendingTags(args.hypoDir, pendingTags);
3075
+ if (pendingTags.length === 0 || hasConflicts) return;
3076
+ const diffRes = spawnSync('git', [
3077
+ '-C',
3078
+ args.hypoDir,
3079
+ 'diff',
3080
+ '--quiet',
3081
+ 'HEAD',
3082
+ '--',
3083
+ 'SCHEMA.md',
3084
+ ]);
3085
+ if (diffRes.error || diffRes.status !== 0) return; // dirty vs HEAD, or no HEAD yet: defer
3086
+ const statusRes = spawnSync(
3087
+ 'git',
3088
+ ['-C', args.hypoDir, 'status', '--porcelain', '--', 'SCHEMA.md'],
3089
+ { encoding: 'utf-8' },
3090
+ );
3091
+ if (statusRes.error || /^\?\?/.test((statusRes.stdout || '').trim())) return; // untracked: defer
3092
+ const result = appendPendingTags(args.hypoDir, pendingTags);
3093
+ if (result.tags.length > 0 && typeof result.content === 'string') {
3094
+ appliedPaths.push('SCHEMA.md');
3095
+ proofEntries.push({
3096
+ path: 'SCHEMA.md',
3097
+ kind: 'schema-pending',
3098
+ expected: { tags: result.tags },
3099
+ });
3100
+ recordCloseIntentSideEffect(args.hypoDir, args.sessionId, attemptId, {
3101
+ path: 'SCHEMA.md',
3102
+ kind: 'schema-pending',
3103
+ bytesSha256: bytesSha256(result.content),
3104
+ tags: result.tags,
3105
+ });
1501
3106
  }
1502
3107
  }
1503
3108
 
@@ -1589,44 +3194,83 @@ function runPostApplyLint(args, payloadScope) {
1589
3194
  // reported by the result JSON: `null` when this apply never reached the commit
1590
3195
  // step at all (ok:false before the writes were even verified), distinct from a
1591
3196
  // commit that ran and reported `committed:false`.
1592
- function runMarkerPhase(args, project, appliedPaths, ok) {
3197
+ // Which undo advice this run has earned, or null when it left nothing to undo
3198
+ // and so nothing to warn about. The hostTagWarning used to hang off `ok`,
3199
+ // which got it backwards on the one path that matters most: 'marker-did-not-
3200
+ // land' flips `ok` to false AFTER the payload is already committed, so a close
3201
+ // that may not reflect the user's decision sat in the vault's history with the
3202
+ // warning suppressed. What the warning is actually about is "this run put
3203
+ // something on disk you may want back", so that is the condition it reads.
3204
+ function hostTagUndoKind({ committed, markerWritten, wroteBytes }) {
3205
+ if (committed) return markerWritten ? 'commit-and-marker' : 'commit-only';
3206
+ return wroteBytes ? 'uncommitted-writes' : null;
3207
+ }
3208
+
3209
+ // commitWikiChanges' own `sha` field collapses two different histories to
3210
+ // the same falsy value: a real `scoped: 0` no-op (nothing was ever staged,
3211
+ // so the field is `undefined`) and a REAL commit whose `rev-parse HEAD`
3212
+ // afterwards failed (the field is `null`, per commitWikiChanges' own doc
3213
+ // comment). hostTagWarningWithUndo needs those told apart: `null` says
3214
+ // "nothing to revert", `undefined` says "a commit exists, look it up
3215
+ // yourself", and `commitOutcome.sha` alone cannot tell them apart, since
3216
+ // both land on a value that reads as "no sha". `scoped === 0` is the only
3217
+ // signal that actually distinguishes them: it is `0` on the no-op path and
3218
+ // a positive count on the failed-rev-parse path, regardless of what `sha`
3219
+ // itself holds.
3220
+ //
3221
+ // MAJOR fix: this function used to return `commitOutcome.sha` verbatim on
3222
+ // the `scoped !== 0` branch, so a real commit with a null `sha` (the
3223
+ // failed-rev-parse case) fell through unchanged and hostTagWarningWithUndo
3224
+ // read it exactly like the true no-op, "nothing needs reverting", for a
3225
+ // commit that had, in fact, just landed. `?? undefined` is what remaps that
3226
+ // one case: `sha` a string passes through untouched, `sha: null` (the only
3227
+ // other value `commitWikiChanges` ever returns here) becomes `undefined` so
3228
+ // the caller gets the honest "look it up yourself" wording instead of a
3229
+ // false "there is nothing here".
3230
+ export function commitShaForUndo(commitOutcome) {
3231
+ if (!commitOutcome) return undefined;
3232
+ if (commitOutcome.scoped === 0) return null;
3233
+ return commitOutcome.sha ?? undefined;
3234
+ }
3235
+
3236
+ // `git rev-parse --show-toplevel` / `--show-prefix`, for the receipt's
3237
+ // `repo` field. `null` on any git failure: the receipt writer treats a
3238
+ // missing repo identity the same as any other reason not to issue one.
3239
+ function repoIdentity(hypoDir) {
3240
+ const top = spawnSync('git', ['-C', hypoDir, 'rev-parse', '--show-toplevel'], {
3241
+ encoding: 'utf-8',
3242
+ });
3243
+ if (top.error || top.status !== 0) return null;
3244
+ const toplevel = (top.stdout || '').trim();
3245
+ if (!toplevel) return null;
3246
+ const pfx = spawnSync('git', ['-C', hypoDir, 'rev-parse', '--show-prefix'], {
3247
+ encoding: 'utf-8',
3248
+ });
3249
+ const prefix = !pfx.error && pfx.status === 0 ? (pfx.stdout || '').trim() : '';
3250
+ return { toplevel, prefix };
3251
+ }
3252
+
3253
+ function runMarkerPhase(args, project, appliedPaths, ok, hostTagWarning, receiptCtx = {}) {
3254
+ const {
3255
+ preflightLint = null,
3256
+ hasConflicts = false,
3257
+ proofEntries = [],
3258
+ attemptId = null,
3259
+ } = receiptCtx;
1593
3260
  let markerWritten = false;
1594
3261
  let markerSkipReason = null;
3262
+ let markerGateReason = null;
1595
3263
  let commitOutcome = null;
3264
+ let receiptMismatches = null;
3265
+ // What the gate waved through on the way to the marker. The demotions are
3266
+ // only honest if the operator can see them, and this is the path that runs
3267
+ // on a real close: `--mark-session-closed` already reported them, while
3268
+ // `--apply-session-close` dropped them on the floor.
3269
+ let gateNotices = [];
3270
+ // What the gate refused on, so a compact-gate-not-ok result names its own
3271
+ // blockers instead of sending the caller to a different, wider check.
3272
+ let gateBlockers = [];
1596
3273
  if (ok && args.sessionId) {
1597
- // Close-gate resolution: apply succeeding (`ok`) IS the resolution, not
1598
- // whether the per-session marker below happens to land. The marker can
1599
- // be withheld for reasons that have nothing to do with whether this
1600
- // apply's own writes were valid (a stale git tree, a feedback-projection
1601
- // cap, W8 design-history staleness) — none of that should leave the
1602
- // resolution unrecorded, because the wiki writes already happened, and
1603
- // re-running the SAME apply with no fresh user close signal is exactly
1604
- // what this record exists to block. So this sits OUTSIDE and ahead of
1605
- // the marker's own commit-gated logic below, resolving its own
1606
- // transcript rather than sharing the marker's `closeTranscript` (which
1607
- // stays null whenever the commit fails) — a commit failure withholds
1608
- // the marker but must not also withhold the resolution.
1609
- //
1610
- // Best-effort like every other write in this store: resolutionStamp
1611
- // returns null on anything it cannot read as a Buffer, recordGateClosed
1612
- // refuses a null stamp, and both fail silently, so a transcript that
1613
- // vanishes mid-read (or a cache-write failure) can never turn an
1614
- // otherwise-successful apply into a failure.
1615
- try {
1616
- const resolutionTranscriptPath = resolveTranscriptBySessionId(args.sessionId);
1617
- if (resolutionTranscriptPath) {
1618
- recordGateClosed(
1619
- args.hypoDir,
1620
- args.sessionId,
1621
- resolutionStamp(readFileSync(resolutionTranscriptPath)),
1622
- );
1623
- }
1624
- } catch {
1625
- // Unreadable at the moment of a successful close is not this apply's
1626
- // problem to surface — the resolution just stays unrecorded, same as
1627
- // if this session had never resolved at all (NO_CONSTRAINT).
1628
- }
1629
-
1630
3274
  // IO stays lazy so this preserves the exact side-effect order (codex design
1631
3275
  // review): commit first (the only mutation), then resolve the
1632
3276
  // transcript, then run the compact gate with that transcript, then scan the
@@ -1641,13 +3285,41 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1641
3285
  // apply's stage+commit. A lock-timeout is treated exactly like any other
1642
3286
  // commit failure below (skip the marker, surface the reason) rather than
1643
3287
  // crashing the apply.
3288
+ //
3289
+ // registerPendingTagsLocked runs INSIDE this same lock, right before the
3290
+ // commit: a SCHEMA.md registration it makes here
3291
+ // pushes 'SCHEMA.md' onto `appliedPaths` first, so commitWikiChanges below
3292
+ // picks it up in the SAME commit this apply is already making, atomically.
3293
+ // The HEAD read happens in this same critical section too, never a
3294
+ // second, unlocked `git rev-parse HEAD` afterwards, which a concurrent
3295
+ // close landing in the gap could move.
3296
+ let headAfterCommit = null;
1644
3297
  try {
1645
- commitOutcome = withFileLock(vaultCommitLockTarget(args.hypoDir), () =>
1646
- commitWikiChanges(args.hypoDir, appliedPaths),
1647
- );
3298
+ const locked = withFileLock(vaultCommitLockTarget(args.hypoDir), () => {
3299
+ registerPendingTagsLocked(
3300
+ args,
3301
+ preflightLint,
3302
+ hasConflicts,
3303
+ appliedPaths,
3304
+ proofEntries,
3305
+ attemptId,
3306
+ );
3307
+ const outcome = commitWikiChanges(args.hypoDir, appliedPaths);
3308
+ const head = readHeadShaLocked(args.hypoDir);
3309
+ return { outcome, head };
3310
+ });
3311
+ commitOutcome = locked.outcome;
3312
+ headAfterCommit = locked.head;
1648
3313
  } catch (err) {
1649
3314
  commitOutcome = { committed: false, reason: `vault-commit-lock: ${err?.message || err}` };
1650
3315
  }
3316
+ // Once these bytes are committed, the journal's only job (telling a
3317
+ // retry's own uncommitted work apart from someone else's) is done —
3318
+ // clear it rather than let a stale record outlive this close and later
3319
+ // match a coincidence it was never meant to license. A commit that
3320
+ // failed leaves the journal in place on purpose: that is exactly the
3321
+ // case the next retry needs it for.
3322
+ if (commitOutcome.committed) clearJournal(args.hypoDir, args.sessionId);
1651
3323
  let closeTranscript = null;
1652
3324
  let gateOk = false;
1653
3325
  // verified_scope evidence (session-close-scope-boundary spec §3, revised
@@ -1656,6 +3328,7 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1656
3328
  // (uncommitted, no transcript) — normalizeVerifiedScope drops an empty
1657
3329
  // 'global' scope to "field absent" rather than persist a false claim.
1658
3330
  let gateEvaluatedProjects = [];
3331
+ let gateSkipped = { lint: false, feedback: false };
1659
3332
  if (commitOutcome.committed) {
1660
3333
  closeTranscript = resolveTranscriptBySessionId(args.sessionId);
1661
3334
  // closeScope: apply KNOWS which project it just closed, and it wrote
@@ -1680,8 +3353,18 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1680
3353
  closeScope: [project],
1681
3354
  ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
1682
3355
  ...(autoMarkerOverride ? { attributionScope: autoMarkerOverride } : {}),
3356
+ // This is one of the two marker-writing paths that must share ONE
3357
+ // `ok` invariant with `--mark-session-closed`. checkpointMode narrows the git axis to "is
3358
+ // there an uncommitted write THIS session still owns" instead of
3359
+ // refusing on any unattributed dirty root file; every other axis
3360
+ // (close files, cwd, hot, lint, W8, feedback) is unchanged.
3361
+ checkpointMode: true,
3362
+ sessionId: args.sessionId,
1683
3363
  });
1684
3364
  gateOk = gateStatus.ok;
3365
+ gateNotices = gateStatus.notices || [];
3366
+ gateBlockers = gateStatus.ok ? [] : gateStatus.blockers || [];
3367
+ gateSkipped = gateStatus.skipped || gateSkipped;
1685
3368
  // `closeScope` above widens the partition, it never narrows
1686
3369
  // sessionCloseGlobalStatus (only opts.projectOverride does, and this
1687
3370
  // call never sets it) — so gate.close.projects is the actual evaluated
@@ -1699,19 +3382,37 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1699
3382
  transcriptResolved: !!closeTranscript,
1700
3383
  // Scan the signal only when the gate passed AND a transcript resolved —
1701
3384
  // isCloseGateOpen never runs earlier than the original nested `else if`.
1702
- // Reads the raw walkCloseGate open, not closeGateStatus: this apply's
1703
- // OWN recordGateClosed call above already ran with this transcript's
1704
- // full record count as closedAtIndex, and openedAtIndex can never reach
1705
- // or pass a count taken from the very same transcript (see
1706
- // closeGateStatus's doc comment) — so gating this diagnostic on .ok
1707
- // would read false on every apply, unconditionally, not just a stale
1708
- // one. This field asks a narrower question than closeGateStatus
1709
- // answers: "did the transcript carry a close signal", not "is this
1710
- // apply itself still authorized" (verifyCloseAuthority already settled
1711
- // that, before any byte was written).
3385
+ // Reads the raw walkCloseGate open, not closeGateStatus: closeGateStatus
3386
+ // would also weigh this session's recorded resolution, and the
3387
+ // resolution below is now written ONLY once the marker itself lands
3388
+ // (this change). A retry after a withheld marker (dirty wiki, a
3389
+ // failed commit, a lock timeout) has no resolution recorded yet, but it
3390
+ // still needs THIS check to see the transcript's existing close phrase
3391
+ // as authorization, not a fresh one. This field asks a narrower
3392
+ // question than closeGateStatus answers: "did the transcript carry a
3393
+ // close signal", not "is this apply itself still authorized to run at
3394
+ // all" (verifyCloseAuthority already settled that, before any byte was
3395
+ // written).
1712
3396
  hasUserSignal: gateOk && !!closeTranscript && isCloseGateOpen(closeTranscript),
1713
3397
  });
1714
3398
  markerSkipReason = decision.skipReason;
3399
+ // MAJOR FIX (codex cross-review round 5): the marker phase reads the raw
3400
+ // walkCloseGate boolean above (`hasUserSignal`) on purpose: see that
3401
+ // field's own comment for why closeGateStatus's resolution-aware answer
3402
+ // would be the wrong question here. That deliberate choice is exactly why
3403
+ // this path had no access to WHICH tag, if any, retracted the close: the
3404
+ // boolean alone cannot say. Compute closeGateStatus's reason string
3405
+ // separately, purely for diagnostics, only on the branch that actually
3406
+ // withheld the marker for lack of a signal: `!open` is guaranteed true
3407
+ // here too (same transcript, same walk), so closeGateStatus's resolution
3408
+ // check never runs and this cannot silently change `decision` above.
3409
+ if (markerSkipReason === 'no-user-close-signal' && closeTranscript) {
3410
+ markerGateReason = closeGateStatus({
3411
+ transcriptPath: closeTranscript,
3412
+ hypoDir: args.hypoDir,
3413
+ sessionId: args.sessionId,
3414
+ }).reason;
3415
+ }
1715
3416
  if (decision.write) {
1716
3417
  // apply KNOWS its authoritative payload.project — stamp it as the v4
1717
3418
  // evidence set so PreCompact trusts this marker's scope directly (session-close attribution).
@@ -1721,23 +3422,151 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1721
3422
  // call never sets it. The gate ran unnarrowed, so `kind` is 'global',
1722
3423
  // with `projects` the set gate.close actually evaluated
1723
3424
  // (gateEvaluatedProjects), never `[project]` verbatim.
1724
- writeSessionClosedMarker(args.hypoDir, args.sessionId, {
1725
- project,
1726
- projects: [project],
1727
- verifiedScope: { kind: 'global', projects: gateEvaluatedProjects },
1728
- });
1729
- // Codex CONCERN: the writer swallows IO errors (best-effort).
1730
- // Verify the file actually landed — mirroring the standalone path — instead of
1731
- // asserting markerWritten=true, so a .cache permission/disk problem surfaces
1732
- // rather than the caller reporting "closed" while the next Stop re-blocks.
1733
- if (existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
1734
- markerWritten = true;
3425
+ // The marker is only ever written on a run whose commit already landed
3426
+ // (that commit is a precondition of reaching here), so the copy stored
3427
+ // in the marker is the one that can honestly name both halves of the
3428
+ // undo: revert that commit, and delete this file.
3429
+ // commitShaForUndo(commitOutcome) here is a string (the commit that
3430
+ // just landed above) or `null` (this round's scope was `scoped: 0`,
3431
+ // so there is nothing to revert). Never `undefined`: the commit
3432
+ // already ran by this point, so "caller did not say" would be a
3433
+ // worse answer than the real one this call already has.
3434
+ const markerHostTagWarning = hostTagWarningWithUndo(
3435
+ hostTagWarning,
3436
+ 'commit-and-marker',
3437
+ args.hypoDir,
3438
+ args.sessionId,
3439
+ commitShaForUndo(commitOutcome),
3440
+ );
3441
+ // Certify the close BEFORE writing the compat marker.
3442
+ // `commitOutcome.sha` is unset on a `scoped: 0` no-op (nothing this
3443
+ // apply wrote needed a fresh commit: every target was already
3444
+ // current), so C falls back to the HEAD this same lock read a moment
3445
+ // ago.
3446
+ const commitSha = commitOutcome.sha || headAfterCommit;
3447
+ const repo = commitSha ? repoIdentity(args.hypoDir) : null;
3448
+ if (commitSha && repo) {
3449
+ const verify = verifyEntriesInCommit(args.hypoDir, commitSha, proofEntries);
3450
+ if (verify.ok) {
3451
+ const receiptGeneration = randomBytes(16).toString('hex');
3452
+ const receipt = {
3453
+ schemaVersion: RECEIPT_SCHEMA_VERSION,
3454
+ certification: CERT_CHECKPOINT,
3455
+ generation: receiptGeneration,
3456
+ sessionId: args.sessionId,
3457
+ repo,
3458
+ commit: commitSha,
3459
+ // The project this apply proved, not the wider set the gate
3460
+ // evaluated: the entries above prove only this project's files.
3461
+ // The marker's verified_scope below still records the evaluated set.
3462
+ scope: { mode: 'project', projects: [project] },
3463
+ entries: proofEntries,
3464
+ skipped: gateSkipped,
3465
+ createdAt: new Date().toISOString(),
3466
+ };
3467
+ // Receipt, then marker, and the receipt comes back out if the marker
3468
+ // does not land (see landReceiptThenMarker). The writer swallows IO
3469
+ // errors (best-effort), so the helper checks the file actually
3470
+ // landed instead of this path asserting markerWritten=true: a
3471
+ // .cache permission/disk problem must surface rather than the
3472
+ // caller reporting "closed" while the next Stop re-blocks. That
3473
+ // check decides whether the close signal below gets spent, and
3474
+ // spending it on a marker this run did not write is the failure
3475
+ // this whole phase was reordered to avoid.
3476
+ const landed = landReceiptThenMarker(args.hypoDir, args.sessionId, receipt, () =>
3477
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, {
3478
+ project,
3479
+ projects: [project],
3480
+ verifiedScope: { kind: 'global', projects: gateEvaluatedProjects },
3481
+ // Carried through from refuseUnlessCloseRequested's gate check,
3482
+ // computed once before any write in this apply and unchanged since:
3483
+ // this close survived a HOST_TAG_NAMES-shaped queue item reading as
3484
+ // neutral, so the marker records the same residual the console
3485
+ // output also warns about once.
3486
+ ...(markerHostTagWarning ? { hostTagWarning: markerHostTagWarning } : {}),
3487
+ // Names the receipt this marker is a compat projection
3488
+ // of. A new Stop refuses to trust this marker alone once it sees
3489
+ // this field: it must find the matching, valid receipt too.
3490
+ receiptGeneration,
3491
+ }),
3492
+ );
3493
+ if (landed.ok) {
3494
+ markerWritten = true;
3495
+ // Close-gate resolution: record it here, ONLY now that the RECEIPT
3496
+ // has landed (a close approval is spent only after the receipt
3497
+ // lands), not merely once the compat marker exists.
3498
+ // Recording it earlier used to sit right after `ok && args.sessionId`,
3499
+ // ahead of commit, gate, and marker entirely, on the theory that the
3500
+ // wiki writes already happened so the resolution should stick
3501
+ // regardless. That let a run which committed the payload but then
3502
+ // had its marker withheld (compact-gate-not-ok on a dirty wiki, a
3503
+ // lock timeout, a disk failure) burn the session's one close signal
3504
+ // anyway: the next run hit closeGateStatus's
3505
+ // `no-new-open-since-resolution` and refused, with no marker ever
3506
+ // written and no way back short of a brand-new user close phrase.
3507
+ // Tying the record to a landed receipt+marker means a withheld one
3508
+ // leaves the signal untouched, so a retry (once the wiki is clean,
3509
+ // the proof mismatch is fixed, or the transient failure clears) is
3510
+ // still authorized by the same close phrase. `closeTranscript` is
3511
+ // reused here rather than re-resolved: `decision.write` can only be
3512
+ // true when `transcriptResolved` was true in `planMarkerDecision`'s
3513
+ // inputs above, so it is guaranteed non-null at this point.
3514
+ //
3515
+ // Best-effort like every other write in this store: resolutionStamp
3516
+ // returns null on anything it cannot read as a Buffer, recordGateClosed
3517
+ // refuses a null stamp, and both fail silently, so a transcript that
3518
+ // vanishes mid-read (or a cache-write failure) can never turn an
3519
+ // otherwise-successful close into a failure.
3520
+ try {
3521
+ recordGateClosed(
3522
+ args.hypoDir,
3523
+ args.sessionId,
3524
+ resolutionStamp(readFileSync(closeTranscript)),
3525
+ );
3526
+ } catch {
3527
+ // Unreadable at the moment of a successful close is not this
3528
+ // apply's problem to surface, the resolution just stays
3529
+ // unrecorded, same as if this session had never resolved at all
3530
+ // (NO_CONSTRAINT).
3531
+ }
3532
+ } else if (landed.reason === 'receipt-write-failed') {
3533
+ markerSkipReason = 'receipt-write-failed';
3534
+ receiptMismatches = [
3535
+ { path: '(receipt)', reason: landed.writeReason },
3536
+ ...(landed.retractFailed
3537
+ ? [{ path: '(receipt)', reason: `retract-failed: ${landed.retractFailed}` }]
3538
+ : []),
3539
+ ];
3540
+ } else {
3541
+ markerSkipReason = 'marker-did-not-land';
3542
+ // The receipt was withdrawn. Only when even that failed is there
3543
+ // something to report beside the stage, because a valid receipt is
3544
+ // then left standing behind a run that exits 1.
3545
+ if (landed.retractFailed) {
3546
+ receiptMismatches = [
3547
+ { path: '(receipt)', reason: `retract-failed: ${landed.retractFailed}` },
3548
+ ];
3549
+ }
3550
+ }
3551
+ } else {
3552
+ markerSkipReason = 'receipt-proof-mismatch';
3553
+ receiptMismatches = verify.mismatches;
3554
+ }
1735
3555
  } else {
1736
- markerSkipReason = 'marker-did-not-land';
3556
+ markerSkipReason = 'receipt-proof-mismatch';
3557
+ receiptMismatches = [{ path: '(commit)', reason: 'no-commit-identity' }];
1737
3558
  }
1738
3559
  }
1739
3560
  }
1740
- return { markerWritten, markerSkipReason, commitOutcome };
3561
+ return {
3562
+ markerWritten,
3563
+ markerSkipReason,
3564
+ markerGateReason,
3565
+ commitOutcome,
3566
+ gateNotices,
3567
+ gateBlockers,
3568
+ receiptMismatches,
3569
+ };
1741
3570
  }
1742
3571
 
1743
3572
  // A conflict outranks the downstream gates: verification and lint both describe
@@ -1773,14 +3602,24 @@ function buildCloseResult({
1773
3602
  proposals,
1774
3603
  proposalStoreFailed,
1775
3604
  proposalStoreFailures,
3605
+ parkedTotal,
3606
+ parkedUnreadable,
3607
+ parkedOrphanTmp,
1776
3608
  verification,
1777
3609
  sessionId,
1778
3610
  markerWritten,
1779
3611
  markerSkipReason,
3612
+ markerGateReason,
3613
+ receiptMismatches,
1780
3614
  preflightLint,
1781
3615
  postApplyLint,
1782
3616
  closeScopeNotice,
1783
3617
  otherDebtCount,
3618
+ gateNotices,
3619
+ gateBlockers,
3620
+ restructureWaivers,
3621
+ obsoleteNotices,
3622
+ hostTagWarning,
1784
3623
  }) {
1785
3624
  return {
1786
3625
  ok,
@@ -1810,12 +3649,36 @@ function buildCloseResult({
1810
3649
  // NO artifact and is re-tried automatically by the next close. `proposedContent`
1811
3650
  // is dropped from the reported shape either way (the artifact / the next close
1812
3651
  // holds the bytes; a whole page or an append entry does not belong in the JSON).
1813
- conflicts: conflicts.map(({ proposedContent: _drop, ...rest }) => rest),
3652
+ // `why` is the human-readable cause (conflictWhy), the same string
3653
+ // printCloseReport already prints in the non-JSON path — a `--json` close
3654
+ // used to carry only the raw `reason` code here, so the caller had no prose
3655
+ // to surface and the fix to conflictWhy's wording never reached a `--json`
3656
+ // close (which is how every real close runs; printCloseReport is a path a
3657
+ // normal apply never takes).
3658
+ conflicts: conflicts.map((c) => {
3659
+ const { proposedContent: _drop, ...rest } = c;
3660
+ return { ...rest, why: conflictWhy(c) };
3661
+ }),
1814
3662
  // Parked overwrite proposals (id/target/path), one per drifted overwrite
1815
3663
  // target. Empty when only append conflicts (or none) occurred. The T7 CLI
1816
3664
  // lists and applies these; append conflicts never appear here.
1817
3665
  proposals,
1818
3666
  ...(proposalStoreFailed ? { proposalStoreFailures } : {}),
3667
+ // Vault-wide count of every parked write-proposal artifact (this close's own
3668
+ // new ones included), same visibility contract as `otherDebtCount` below:
3669
+ // always present so a caller cannot mistake a missing key for zero, and
3670
+ // never gated (a growing pile of parks is a review backlog, not a close
3671
+ // failure). `null` means the inventory itself could not be enumerated, not
3672
+ // that it is empty; see the comment where this is computed. A caller must
3673
+ // check for `null` before treating this as a count.
3674
+ parkedTotal,
3675
+ // Filenames under `.cache/proposals` that exist as `.json` candidates but
3676
+ // could not be parsed into an artifact (see the comment where this is
3677
+ // computed). Always present, possibly empty, never folded into
3678
+ // parkedTotal above, and never allowed to silently disappear the way it
3679
+ // did before this field existed.
3680
+ parkedUnreadable,
3681
+ parkedOrphanTmp,
1819
3682
  // Partial close: some overwrite direct-writes (and/or appends) already landed
1820
3683
  // on disk while at least one conflict withheld the rest. Because the close is
1821
3684
  // ok:false, commitWikiChanges + the marker are skipped — so those written
@@ -1827,8 +3690,25 @@ function buildCloseResult({
1827
3690
  : {}),
1828
3691
  verification,
1829
3692
  // Surface the marker outcome instead of skipping silently, so the
1830
- // caller can tell "closed" from "applied but not marked".
1831
- ...(sessionId ? { markerWritten, markerSkipReason } : {}),
3693
+ // caller can tell "closed" from "applied but not marked". `markerGateReason`
3694
+ // (MAJOR FIX, codex cross-review round 5) is closeGateStatus's own reason
3695
+ // string, present only when `markerSkipReason` is 'no-user-close-signal'.
3696
+ // The raw-boolean decision this phase actually acts on (see runMarkerPhase's
3697
+ // `hasUserSignal` comment) stays unchanged; this only adds the diagnostic a
3698
+ // maintainer needs to tell a stale HOST_TAG_NAMES allowlist apart from an
3699
+ // ordinary retraction, the same detail verifyCloseAuthority's own
3700
+ // `gateReason` already carries for the earlier, whole-apply refusal.
3701
+ ...(sessionId
3702
+ ? {
3703
+ markerWritten,
3704
+ markerSkipReason,
3705
+ ...(markerGateReason ? { markerGateReason } : {}),
3706
+ // Present only when markerSkipReason is 'receipt-proof-mismatch'
3707
+ // or 'receipt-write-failed': the entries that failed to verify against
3708
+ // the commit, or the single reason the receipt write itself failed.
3709
+ ...(receiptMismatches ? { mismatches: receiptMismatches } : {}),
3710
+ }
3711
+ : {}),
1832
3712
  lint: {
1833
3713
  preflight: summarizeLintForOutput(preflightLint),
1834
3714
  postApply: summarizeLintForOutput(postApplyLint),
@@ -1840,11 +3720,54 @@ function buildCloseResult({
1840
3720
  // pages, root files) folds into otherDebtCount so the same untouched-file
1841
3721
  // debt does not re-list its filenames on every close (run `node
1842
3722
  // scripts/lint.mjs` for the full list).
1843
- notices: [...new Set(closeScopeNotice.map((e) => e.file))],
3723
+ // Obsolete-field lines lead, then the lint-debt filenames. The two share
3724
+ // this array because both mean "read this, nothing is blocked on it", and a
3725
+ // reader that expected only filenames still gets a string it can print.
3726
+ notices: [...(obsoleteNotices || []), ...new Set(closeScopeNotice.map((e) => e.file))],
1844
3727
  otherDebtCount,
3728
+ // Separate from `notices` above, which is lint debt. These are the close
3729
+ // GATE's demotions: what it declined to block on. `--mark-session-closed`
3730
+ // has always reported them and this path did not, so a demotion on the
3731
+ // canonical close path was invisible — the gate's promise is that it never
3732
+ // waves something through silently, and half the paths were breaking it.
3733
+ // A new key rather than a merge into `notices`, whose entries are filename
3734
+ // strings that an existing reader would choke on if they became objects.
3735
+ gateNotices: gateNotices || [],
3736
+ // The checkpoint gate's own blockers when it refused the marker
3737
+ // (markerSkipReason compact-gate-not-ok); empty otherwise. --check-session-close
3738
+ // judges the whole vault on a wider git axis, so it is not a substitute.
3739
+ gateBlockers: gateBlockers || [],
3740
+ // Always present (possibly empty), same visibility contract as `notices`/
3741
+ // `otherDebtCount` above — a caller should not have to guess whether the
3742
+ // key's absence means "none" or "this apply predates the field". One entry
3743
+ // per overwrite field where `restructure: true` was claimed on a REAL
3744
+ // section-loss trip (a field that carried the flag but never had a loss to
3745
+ // waive adds no entry here). The claim no longer applies the write:
3746
+ // the matching target is still in `conflicts` above, withheld
3747
+ // pending human approval via `proposal challenge`/`proposal resolve`; this
3748
+ // is only the audit trail of what the payload author asserted.
3749
+ restructureWaivers,
3750
+ // Present when this close survived a HOST_TAG_NAMES-shaped queue item
3751
+ // reading as neutral (see closeGateStatus's own field doc comment) AND
3752
+ // this run left something on disk to take back. NOT gated on `ok`: the
3753
+ // caller settled that question with hostTagUndoKind and passed null when
3754
+ // there was nothing to undo, which is what lets the warning still reach a
3755
+ // reader on 'marker-did-not-land' (committed, ok:false). This is the
3756
+ // `--json` half of the warn-once contract; printCloseReport below carries
3757
+ // the same string to the console path, and the marker (runMarkerPhase)
3758
+ // carries its own wording to a reader of that file after the fact. All of
3759
+ // them descend from ONE walk result computed before any write in this
3760
+ // apply, so there is one warning per run, not one per gate read.
3761
+ ...(hostTagWarning ? { hostTagWarning } : {}),
1845
3762
  };
1846
3763
  }
1847
3764
 
3765
+ // Where to send a person for doctor. Both forms, no runtime guess: the
3766
+ // `hypomnema` bin comes only with the npm install, so a plugin-only user has
3767
+ // `/hypo:doctor` and nothing else, and a shell user may have only the bin.
3768
+ const DOCTOR_HINT =
3769
+ 'doctor (`/hypo:doctor` in Claude Code, or `hypomnema doctor` with the npm CLI)';
3770
+
1848
3771
  // The human-readable (non --json) rendering of the same settled state.
1849
3772
  function printCloseReport({
1850
3773
  project,
@@ -1853,31 +3776,48 @@ function printCloseReport({
1853
3776
  skipped,
1854
3777
  conflicts,
1855
3778
  proposals,
3779
+ parkedTotal,
3780
+ parkedUnreadable,
3781
+ parkedOrphanTmp,
1856
3782
  ok,
1857
3783
  markerWritten,
1858
3784
  markerSkipReason,
3785
+ markerGateReason,
1859
3786
  verification,
1860
3787
  postLintOk,
1861
3788
  postBlocking,
1862
3789
  closeScopeNotice,
1863
3790
  otherDebtCount,
3791
+ restructureWaivers,
3792
+ obsoleteNotices,
3793
+ hostTagWarning,
1864
3794
  }) {
1865
3795
  console.log(`Session-close apply (project: ${project}, date: ${date}):`);
1866
3796
  for (const a of applied) console.log(` ✓ wrote ${a}`);
1867
3797
  for (const s of skipped) console.log(` · skipped ${s} (already current)`);
3798
+ // Not a skip and not a conflict: a field this apply no longer has a target
3799
+ // for. Printed before the rest so an old payload's author sees it even when
3800
+ // the close otherwise succeeds and they stop reading at the ✓ line.
3801
+ for (const n of obsoleteNotices || []) console.log(` · ${n}`);
3802
+ // Surfaced unconditionally, success or failure. `restructure: true` no
3803
+ // longer waives the guard: the target below is still WITHHELD
3804
+ // and shows up in the conflicts loop right after this one. This line only
3805
+ // flags that the payload author claimed the drop was intentional, so a human
3806
+ // reviewing the parked proposal has that context before typing the nonce.
3807
+ for (const w of restructureWaivers) {
3808
+ console.log(
3809
+ ` ⚠ restructure:true claimed for ${w.target}, parked pending human approval. Dropped: ${w.lostSections.join(', ')}`,
3810
+ );
3811
+ }
1868
3812
  // Never let a withheld target read as a skip: `skipped` means "already current",
1869
- // this means "your bytes are NOT on disk". Overwrite conflicts drifted from base;
1870
- // an append conflict is a lock-timeout (someone else held the file's lock), which
1871
- // is transient — the next close re-applies.
3813
+ // this means "your bytes are NOT on disk".
1872
3814
  for (const c of conflicts) {
1873
- const why =
1874
- c.kind === 'append'
1875
- ? 'could not acquire the append lock in time; the next close re-applies'
1876
- : 'the page changed since this session read it';
1877
- console.log(` ⚠ WITHHELD ${c.key} (${c.target}) — ${c.reason}; ${why}`);
3815
+ console.log(` ⚠ WITHHELD ${c.key} (${c.target}) — ${c.reason}; ${conflictWhy(c)}`);
1878
3816
  }
1879
3817
  for (const p of proposals) {
1880
- console.log(` · parked proposal ${p.id} for ${p.target} (review with \`hypomnema proposal\`)`);
3818
+ console.log(
3819
+ ` · parked proposal ${p.id} for ${p.target} (review it through \`/hypo:crystallize\`, or \`hypomnema proposal list\` with the npm CLI)`,
3820
+ );
1881
3821
  }
1882
3822
  if (applied.length > 0 && conflicts.length > 0) {
1883
3823
  console.log(
@@ -1886,19 +3826,73 @@ function printCloseReport({
1886
3826
  ' target(s) are resolved and the close re-runs.',
1887
3827
  );
1888
3828
  }
3829
+ // Surfaced unconditionally, success or failure. This used to sit inside the
3830
+ // `if (ok)` success branch below, so a close that just parked a NEW overwrite
3831
+ // conflict (`ok:false`, `stage: 'proposal-pending'`, exactly the case that
3832
+ // grows this total) never printed it: a terminal-only user saw the id(s)
3833
+ // this run just withheld (the `parked proposal <id>` lines above) but never
3834
+ // the vault-wide count they add to. `doctor`, not `proposal list`, is where
3835
+ // the breakdown lives: this total mixes pending, already-approved-but-
3836
+ // unreconciled, and evidence-broken artifacts (see `classifyProposals`), and
3837
+ // telling every one of them to "review" the whole number would send a human
3838
+ // back to artifacts `proposal reconcile` had already settled.
3839
+ if (parkedTotal === null) {
3840
+ console.log(
3841
+ ` · parked write-proposal count unavailable: \`.cache/proposals\` could not be listed (run ${DOCTOR_HINT}).`,
3842
+ );
3843
+ } else if (parkedTotal > 0) {
3844
+ console.log(
3845
+ ` · ${parkedTotal} parked write-proposal artifact(s) vault-wide: run ${DOCTOR_HINT} for a breakdown by state.`,
3846
+ );
3847
+ }
3848
+ // Surfaced unconditionally too, and NOT folded into the parkedTotal branch
3849
+ // above: this is exactly the case a reader would otherwise never see, a
3850
+ // vault whose only `.cache/proposals` contents are unreadable prints
3851
+ // `parkedTotal` as either 0 or null and, before this line existed, nothing
3852
+ // else. `doctor` is still where a human goes to act on this; `--json`
3853
+ // carries the same filenames in `parkedUnreadable`.
3854
+ if (parkedUnreadable.length > 0) {
3855
+ const shown = parkedUnreadable.slice(0, 3).join(', ');
3856
+ const rest = parkedUnreadable.length > 3 ? `, +${parkedUnreadable.length - 3} more` : '';
3857
+ console.log(
3858
+ ` ⚠ ${parkedUnreadable.length} file(s) in .cache/proposals could not be read or parsed (corrupt, permission-denied, or hand-edited): ${shown}${rest}. Not counted above; run ${DOCTOR_HINT} to inspect.`,
3859
+ );
3860
+ // This line is the one a person actually reads after a close; doctor is a
3861
+ // second command they may never run. Without this sentence the wording
3862
+ // above tells them a recoverable proposal body is corrupt, and the obvious
3863
+ // response to "corrupt" is to delete it. Naming the count here and the
3864
+ // exact restore command in doctor keeps this line short without leaving
3865
+ // the destructive reading as the only one available.
3866
+ if (parkedOrphanTmp.length > 0) {
3867
+ console.log(
3868
+ ` ↳ ${parkedOrphanTmp.length} of those are not corrupt: a writer finished but its rename never landed, so the body is probably intact. Do not delete them; ${DOCTOR_HINT} prints the command that restores each one.`,
3869
+ );
3870
+ }
3871
+ }
1889
3872
  if (ok) {
1890
3873
  // When the marker was withheld, qualify the success line so a reader scanning
1891
3874
  // stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
1892
3875
  // is non-null exactly when args.sessionId is set and the marker did not land.
1893
3876
  if (markerSkipReason) {
1894
3877
  console.log(
1895
- '\n✓ session-close files verified (all 5 mandatory files fresh, lint clean).' +
3878
+ '\n✓ session-close files verified (all 4 mandatory files fresh, lint clean).' +
1896
3879
  '\n session NOT fully closed: the Stop-chain marker was not written (see warning below).',
1897
3880
  );
1898
3881
  } else {
1899
- console.log('\n✓ session-close verified — all 5 mandatory files fresh, lint clean.');
3882
+ console.log('\n✓ session-close verified (all 4 mandatory files fresh, lint clean).');
1900
3883
  }
1901
3884
  }
3885
+ // Warn once per run, not on every gate read, which is exactly the
3886
+ // repeated-nag cost the enqueue-branch reversion in hooks/hypo-shared.mjs
3887
+ // was measured against. Deliberately outside the `ok` block above: the
3888
+ // caller already decided whether this run left anything to take back
3889
+ // (hostTagUndoKind) and passed null when it did not, and the string it
3890
+ // passed names only what this run actually did. Gating on `ok` here is what
3891
+ // silenced the warning on 'marker-did-not-land', where the payload was
3892
+ // committed and only the marker write failed.
3893
+ if (hostTagWarning) {
3894
+ console.log(`\n⚠ ${hostTagWarning}`);
3895
+ }
1902
3896
  // When ok:true but the session-close marker was NOT written, the Stop-chain
1903
3897
  // still sees an open session and will re-prompt at the next Stop. Surface this
1904
3898
  // loudly so neither the human nor a skill-following model reads "ok:true" as
@@ -1906,16 +3900,74 @@ function printCloseReport({
1906
3900
  // written" line cannot fire on the contradiction-B path (a written marker that
1907
3901
  // also carried a skip reason) — there the invariant's own 🛑 line already
1908
3902
  // explains the failure, and this message would contradict markerWritten:true.
3903
+ //
3904
+ // Two shapes reach here now, and they need different words. A policy withhold
3905
+ // still leaves ok:true and asks for the right --session-id. A marker write
3906
+ // that genuinely failed flips ok to false, so claiming "(ok:true)" there
3907
+ // would contradict the JSON this same run printed, and pointing at
3908
+ // --session-id would send the user after a problem they do not have.
3909
+ //
3910
+ // Note this whole block only runs on the non-JSON path; the documented
3911
+ // production call is --json, which prints nothing here.
1909
3912
  if (markerSkipReason && !markerWritten) {
3913
+ const diskFailure = markerSkipReason === 'marker-did-not-land';
3914
+ // The two receipt stages also flip ok to false, and their recoveries are
3915
+ // opposite: one is about what is committed, the other about writing under
3916
+ // .cache/. Neither is the policy withhold the fallback text below describes,
3917
+ // and neither is the gate refusal (compact-gate-not-ok), which stays ok:true.
3918
+ const receiptFailure =
3919
+ markerSkipReason === 'receipt-proof-mismatch'
3920
+ ? ` The 4 mandatory files were applied, but the close checkpoint receipt\n` +
3921
+ ` could not prove them against the commit (see mismatches[] in --json\n` +
3922
+ ` output). This run reports ok:false and exits 1. The session is NOT closed.\n` +
3923
+ ` To fix: check the listed paths with git status, commit the ones you\n` +
3924
+ ` have reviewed, then re-run the same close. No fresh close phrase is needed.\n`
3925
+ : markerSkipReason === 'receipt-write-failed'
3926
+ ? ` The 4 mandatory files were applied and committed, but writing the close\n` +
3927
+ ` checkpoint receipt under .cache/sessions/<session-id>/ failed. This run\n` +
3928
+ ` reports ok:false and exits 1. The session is NOT closed.\n` +
3929
+ ` To fix: clear whatever blocks that directory (permissions, disk space),\n` +
3930
+ ` then re-run the same close. No fresh close phrase is needed.\n`
3931
+ : // A gate refusal keeps ok:true (the files verified) but the marker was denied
3932
+ // by a compact-gate blocker. It is not a session-id problem and not a receipt
3933
+ // problem, so it gets its own words.
3934
+ markerSkipReason === 'compact-gate-not-ok'
3935
+ ? ` The 4 mandatory files were applied and verified, but the compact gate\n` +
3936
+ ` refused the per-session Stop-chain marker because a gate blocker\n` +
3937
+ ` remains. The session is NOT fully closed: the Stop hook will re-prompt\n` +
3938
+ ` until the marker is present. This is not a --session-id problem.\n` +
3939
+ ` To fix: resolve the gate blocker (run git status in the vault: commit\n` +
3940
+ ` or revert an uncommitted file in the project folder being closed, or\n` +
3941
+ ` clear whichever other blocker the gate names), then re-run the same\n` +
3942
+ ` close. No fresh close phrase is needed: a close signal is spent only\n` +
3943
+ ` once the marker lands.\n`
3944
+ : null;
1910
3945
  process.stderr.write(
1911
3946
  `\n⚠️ session-close marker NOT written (reason: ${markerSkipReason})\n` +
1912
- ` The 5 mandatory files were applied and verified (ok:true), but the\n` +
1913
- ` per-session Stop-chain marker was withheld. The session is NOT fully\n` +
1914
- ` closed: the Stop hook will re-prompt until the marker is present.\n` +
1915
- ` To fix: re-run with the correct main-conversation --session-id (NOT\n` +
1916
- ` a background task or Agent UUID from a /tmp/... path).\n` +
1917
- ` Example: crystallize.mjs --apply-session-close --payload=<path>\n` +
1918
- ` --session-id=<main-conversation-id> --hypo-dir=<path>\n`,
3947
+ // MAJOR FIX (codex cross-review round 5): only present when the reason
3948
+ // is 'no-user-close-signal', and only when a tag-shaped retraction
3949
+ // named itself; see closeGateStatus's own reason string for what a
3950
+ // maintainer does with it. Without this line, the non-JSON path told
3951
+ // the user only that the marker was withheld, never why the gate read
3952
+ // the transcript that way.
3953
+ (markerGateReason ? ` Gate detail: ${markerGateReason}\n` : '') +
3954
+ (receiptFailure ??
3955
+ (diskFailure
3956
+ ? ` The 4 mandatory files were applied and committed, but writing the\n` +
3957
+ ` per-session Stop-chain marker itself failed. This run reports\n` +
3958
+ ` ok:false and exits 1. The session is NOT closed: the Stop hook\n` +
3959
+ ` will re-prompt until the marker is present.\n` +
3960
+ ` To fix: clear whatever blocks the marker path under .cache/\n` +
3961
+ ` (permissions, a directory sitting where the marker file goes,\n` +
3962
+ ` disk space), then re-run the same close. No fresh close phrase\n` +
3963
+ ` is needed: a close signal is spent only once the marker lands.\n`
3964
+ : ` The 4 mandatory files were applied and verified (ok:true), but the\n` +
3965
+ ` per-session Stop-chain marker was withheld. The session is NOT fully\n` +
3966
+ ` closed: the Stop hook will re-prompt until the marker is present.\n` +
3967
+ ` To fix: re-run with the correct main-conversation --session-id (NOT\n` +
3968
+ ` a background task or Agent UUID from a /tmp/... path).\n` +
3969
+ ` Example: crystallize.mjs --apply-session-close --payload=<path>\n` +
3970
+ ` --session-id=<main-conversation-id> --hypo-dir=<path>\n`)),
1919
3971
  );
1920
3972
  }
1921
3973
  if (!ok) {
@@ -1963,7 +4015,16 @@ export function applySessionClose(args) {
1963
4015
  // No-payload "already complete?" probe uses the
1964
4016
  // global invariant, not a recency pick.
1965
4017
  const probe = sessionCloseGlobalStatus(args.hypoDir);
1966
- if (probe.ok) {
4018
+ // A leftover close-intent record (see closeIntentPath's doc comment)
4019
+ // means some earlier apply began this file set and never finished it.
4020
+ // `probe.ok` is a freshness read alone, so trusting it here would be
4021
+ // exactly the silent "torn set with an already-todayed leftover file"
4022
+ // pass this record exists to catch. Only 'clean' clears the probe:
4023
+ // 'unreadable' and 'uncommitted' fall through the same as 'torn' (see
4024
+ // hasTornCloseIntent's doc comment). A judgment this function could not
4025
+ // make is not evidence of a finished close, and neither are files that
4026
+ // landed without the commit that would have cleared their record.
4027
+ if (probe.ok && hasTornCloseIntent(args.hypoDir) === 'clean') {
1967
4028
  const result = {
1968
4029
  ok: true,
1969
4030
  alreadyComplete: true,
@@ -1983,8 +4044,10 @@ export function applySessionClose(args) {
1983
4044
  // "payload is required" with the same error shape as before.
1984
4045
  }
1985
4046
 
1986
- refuseUnlessCloseRequested(args);
4047
+ const hostTagWarning = refuseUnlessCloseRequested(args);
1987
4048
  const payload = loadValidatedPayload(args);
4049
+ // Computed off the payload as read, before any write phase can consume it.
4050
+ const obsoleteNotices = obsoleteFieldNotices(payload);
1988
4051
  const project = resolveCloseProject(args, payload);
1989
4052
  const date = payload.date || todayLocal();
1990
4053
  assertPayloadFreshnessContract(args, payload, project, date);
@@ -2007,17 +4070,107 @@ export function applySessionClose(args) {
2007
4070
  // it. T6 turns these into `.cache/proposals/` artifacts; here they are already
2008
4071
  // enough to withhold the bytes and fail the close.
2009
4072
  const conflicts = [];
2010
- // One bag for the four accumulators, passed to every write phase below. They
4073
+ // Overwrite fields where `restructure: true` was claimed on a REAL
4074
+ // section-loss trip (no longer a waiver, the matching target is
4075
+ // STILL in `conflicts` above, withheld pending human approval). Kept
4076
+ // separate so the audit trail of what the payload author asserted survives
4077
+ // even though it decided nothing.
4078
+ const restructureWaivers = [];
4079
+ // Close-receipt proof entries: one per write phase's own
4080
+ // target, whether this attempt wrote fresh bytes or found them already
4081
+ // current, never for a target withheld to `conflicts`. Consumed by
4082
+ // runMarkerPhase after the commit lands, to verify against the commit
4083
+ // itself rather than trust that a write happened.
4084
+ const proofEntries = [];
4085
+ // One bag for the six accumulators, passed to every write phase below. They
2011
4086
  // push into it in call order; nothing is merged back afterwards, so the
2012
4087
  // report lines keep the exact order the inline version produced.
2013
- const acc = { applied, skipped, appliedPaths, conflicts };
4088
+ const acc = { applied, skipped, appliedPaths, conflicts, restructureWaivers, proofEntries };
2014
4089
 
2015
- applyOverwrites(args, payload, project, date, indexRelPath, indexMissing, acc);
4090
+ // Record this set's targets BEFORE the first byte is written (see
4091
+ // closeIntentPath's doc comment above applyOverwrites), and remove the
4092
+ // record only once the commit in runMarkerPhase has landed. A crash
4093
+ // anywhere before that, or a close that ends ok:false, leaves the record
4094
+ // behind on purpose, for hasTornCloseIntent to find on a later probe.
4095
+ //
4096
+ // MAJOR fix: a failed write here used to be swallowed (writeCloseIntent's
4097
+ // own try/catch) and applyOverwrites ran regardless. The one call meant
4098
+ // to leave a witness before any target byte moves left none, silently,
4099
+ // and a crash between the first and second target write then had nothing
4100
+ // for hasTornCloseIntent to find. Refuse the close here instead, before
4101
+ // applyOverwrites is ever reached: zero target bytes are written on this
4102
+ // path, same contract as every other pre-write refusal in this function.
4103
+ const intentResult = writeCloseIntent(
4104
+ args.hypoDir,
4105
+ args.sessionId,
4106
+ closeIntentTargetsFor(payload, project),
4107
+ );
4108
+ if (!intentResult.ok) {
4109
+ const msg =
4110
+ `session-close apply refused before any target file was written: could not record the ` +
4111
+ `close-intent witness under .cache/close-intent/ (${intentResult.reason}). This is usually ` +
4112
+ `a transient lock, permission, or disk problem: fix it and retry; nothing was written.`;
4113
+ const out = {
4114
+ ok: false,
4115
+ stage: 'close-intent-write-failed',
4116
+ error: msg,
4117
+ applied: [],
4118
+ committed: null,
4119
+ };
4120
+ console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
4121
+ process.exit(1);
4122
+ }
4123
+ applyOverwrites(
4124
+ args,
4125
+ payload,
4126
+ project,
4127
+ date,
4128
+ indexRelPath,
4129
+ indexMissing,
4130
+ acc,
4131
+ intentResult.attemptId,
4132
+ );
4133
+ markCloseIntentApplied(args.hypoDir, args.sessionId, intentResult.attemptId);
2016
4134
  appendSessionLogEntry(args, payload, project, date, acc);
2017
4135
  appendRootLogEntry(args, payload, project, date, acc);
2018
4136
 
2019
4137
  const { proposals, proposalStoreFailures } = parkOverwriteConflicts(args, conflicts);
2020
4138
  const proposalStoreFailed = proposalStoreFailures.length > 0;
4139
+ // Vault-wide total, not just what THIS close just parked: `proposals` above is
4140
+ // this run's own new artifacts, and a close that never parks anything can still
4141
+ // sit behind dozens accumulated by earlier sessions with no path here that says
4142
+ // so. This used to reuse `doctor`'s `classifyProposals` judgment so the two
4143
+ // surfaces could never disagree about the number, but that judgment reads the
4144
+ // WHOLE audit log and re-hashes every recoverable candidate's target file, on
4145
+ // every close, whether or not anything here needs the pending/recoverable/
4146
+ // evidence-broken breakdown. `listProposalsChecked` counts the SAME artifacts
4147
+ // (classifyProposals emits exactly one entry per valid artifact, so the totals
4148
+ // agree) without either cost: a directory listing plus each artifact's own
4149
+ // JSON, nothing from `applied.log` and no target re-read. The breakdown by
4150
+ // state still lives in `hypomnema doctor`, which is what actually tells a
4151
+ // human whether a parked artifact needs review or just a `proposal reconcile`.
4152
+ // `ok: false` here means the inventory itself could not be enumerated
4153
+ // (permissions, or a file sitting where the directory should be): a real
4154
+ // "unmeasured", not "empty", so `parkedTotal` stays `null` rather than lie
4155
+ // that the backlog is zero.
4156
+ const proposalInventory = listProposalsChecked(args.hypoDir);
4157
+ const parkedTotal = proposalInventory.ok ? proposalInventory.proposals.length : null;
4158
+ // Candidate `.json` files inside a LISTABLE `.cache/proposals` that
4159
+ // listProposalsChecked could not parse into an artifact (corrupt, permission-
4160
+ // denied, or hand-edited into an unrecognizable shape). These are not folded
4161
+ // into parkedTotal above (that count stays "artifacts this close's callers
4162
+ // can actually act on"), but a close must never let one sit invisible: before
4163
+ // this field, an unreadable artifact simply vanished from the count, and a
4164
+ // vault with nothing BUT unreadable ones reported `parkedTotal: 0` with no
4165
+ // line anywhere naming the file. Always an array (empty when nothing is
4166
+ // wrong, or when the whole directory could not even be listed, that failure
4167
+ // already reads as `parkedTotal: null`, not a lying zero here).
4168
+ const parkedUnreadable = proposalInventory.unreadable;
4169
+ // Subset of the line above whose bytes are probably intact: the writer
4170
+ // finished and only the rename never landed. Carried as its own field
4171
+ // rather than carved out of parkedUnreadable, so a `--json` consumer that
4172
+ // already counts unreadable files keeps counting the same set.
4173
+ const parkedOrphanTmp = proposalInventory.orphanTmp || [];
2021
4174
 
2022
4175
  // Same-date-tie fix: verify against the SAME project this apply just wrote
2023
4176
  // (`project` = payload.project || probe.project, resolved at the top). Without
@@ -2026,8 +4179,6 @@ export function applySessionClose(args) {
2026
4179
  // a completed close (the 2026-06-09 security-ops-kb incident).
2027
4180
  const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
2028
4181
 
2029
- registerPendingTags(args, preflightLint, conflicts.length > 0);
2030
-
2031
4182
  const { postApplyLint, postBlocking, postNotice, postLintOk } = runPostApplyLint(
2032
4183
  args,
2033
4184
  payloadScope,
@@ -2048,11 +4199,42 @@ export function applySessionClose(args) {
2048
4199
  const closeScopeNotice = postNotice.filter((e) => isUnderProjectDirs(e.file, [project]));
2049
4200
  const otherDebtCount = postNotice.length - closeScopeNotice.length;
2050
4201
 
2051
- const { markerWritten, markerSkipReason, commitOutcome } = runMarkerPhase(
2052
- args,
2053
- project,
2054
- appliedPaths,
2055
- ok,
4202
+ const {
4203
+ markerWritten,
4204
+ markerSkipReason,
4205
+ markerGateReason,
4206
+ commitOutcome,
4207
+ gateNotices,
4208
+ gateBlockers,
4209
+ receiptMismatches,
4210
+ } = runMarkerPhase(args, project, appliedPaths, ok, hostTagWarning, {
4211
+ preflightLint,
4212
+ hasConflicts: conflicts.length > 0,
4213
+ proofEntries,
4214
+ attemptId: intentResult.attemptId,
4215
+ });
4216
+ // Only a landed commit retires the close-intent record. Everything before
4217
+ // this point (the appends, a withheld conflict, a lint or commit failure)
4218
+ // leaves it in place: see closeIntentPath's doc comment for why an ok:false
4219
+ // close keeps its record.
4220
+ if (commitOutcome?.committed === true) {
4221
+ clearCloseIntent(args.hypoDir, args.sessionId, intentResult.attemptId);
4222
+ }
4223
+ // The residual named by the gate walk (one value, computed before any write
4224
+ // in this apply) paired with the undo THIS run can honestly offer. A run
4225
+ // that neither committed nor wrote a byte gets null back and says nothing:
4226
+ // there is nothing on disk to take back, so a warning would only send the
4227
+ // reader looking for a change that is not there.
4228
+ const hostTagNotice = hostTagWarningWithUndo(
4229
+ hostTagWarning,
4230
+ hostTagUndoKind({
4231
+ committed: commitOutcome?.committed === true,
4232
+ markerWritten,
4233
+ wroteBytes: appliedPaths.length > 0,
4234
+ }),
4235
+ args.hypoDir,
4236
+ args.sessionId,
4237
+ commitShaForUndo(commitOutcome),
2056
4238
  );
2057
4239
 
2058
4240
  let stage = resolveCloseStage({ ok, proposalStoreFailed, conflicts, verification, postLintOk });
@@ -2077,6 +4259,16 @@ export function applySessionClose(args) {
2077
4259
  ` cannot masquerade as a successful close. The applied payload files stand;\n` +
2078
4260
  ` re-running apply is idempotent once the pipeline is fixed.\n`,
2079
4261
  );
4262
+ } else if (ok && markerWriteGenuinelyFailed({ markerWritten, markerSkipReason })) {
4263
+ // Unlike the contradiction above this is not a pipeline bug.
4264
+ // every precondition cleared and the marker write itself still failed, the
4265
+ // one skip reason `--mark-session-closed` also treats as fatal. Unify the
4266
+ // exit code so a caller reading only `$?` cannot see this as a closed
4267
+ // session. The `⚠️ session-close marker NOT written` warning below still
4268
+ // fires unconditionally and names the same reason; this only changes
4269
+ // whether the process exits 0 or 1 over it.
4270
+ ok = false;
4271
+ stage = markerSkipReason;
2080
4272
  }
2081
4273
  }
2082
4274
  const result = buildCloseResult({
@@ -2091,14 +4283,24 @@ export function applySessionClose(args) {
2091
4283
  proposals,
2092
4284
  proposalStoreFailed,
2093
4285
  proposalStoreFailures,
4286
+ parkedTotal,
4287
+ parkedUnreadable,
4288
+ parkedOrphanTmp,
2094
4289
  verification,
2095
4290
  sessionId: args.sessionId,
2096
4291
  markerWritten,
2097
4292
  markerSkipReason,
4293
+ markerGateReason,
4294
+ receiptMismatches,
2098
4295
  preflightLint,
2099
4296
  postApplyLint,
2100
4297
  closeScopeNotice,
2101
4298
  otherDebtCount,
4299
+ gateNotices,
4300
+ gateBlockers,
4301
+ restructureWaivers,
4302
+ obsoleteNotices,
4303
+ hostTagWarning: hostTagNotice,
2102
4304
  });
2103
4305
 
2104
4306
  if (args.json) {
@@ -2111,14 +4313,21 @@ export function applySessionClose(args) {
2111
4313
  skipped,
2112
4314
  conflicts,
2113
4315
  proposals,
4316
+ parkedTotal,
4317
+ parkedUnreadable,
4318
+ parkedOrphanTmp,
2114
4319
  ok,
2115
4320
  markerWritten,
2116
4321
  markerSkipReason,
4322
+ markerGateReason,
2117
4323
  verification,
2118
4324
  postLintOk,
2119
4325
  postBlocking,
2120
4326
  closeScopeNotice,
2121
4327
  otherDebtCount,
4328
+ restructureWaivers,
4329
+ obsoleteNotices,
4330
+ hostTagWarning: hostTagNotice,
2122
4331
  });
2123
4332
  }
2124
4333
  process.exit(ok ? 0 : 1);