hypomnema 1.8.2 → 1.8.4

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.
@@ -49,6 +49,7 @@ import {
49
49
  resolutionStamp,
50
50
  closeGateStatus,
51
51
  } from '../../hooks/close-gate-store.mjs';
52
+ import { readJournal, recordJournalEntry, clearJournal } from '../../hooks/close-journal.mjs';
52
53
  import { requireProjectDir } from './crystallize-close-gate.mjs';
53
54
  import { summarizeLintForOutput } from './crystallize-helpers.mjs';
54
55
 
@@ -137,7 +138,21 @@ function atomicWrite(path, content) {
137
138
  mkdirSync(dirname(path), { recursive: true });
138
139
  const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
139
140
  writeFileSync(tmp, content);
140
- renameSync(tmp, path);
141
+ try {
142
+ renameSync(tmp, path);
143
+ } catch (err) {
144
+ // The rename is what makes this atomic, so a failure here leaves the
145
+ // target untouched, which is the point. What it also leaves is the tmp
146
+ // file, and nothing else ever looks at that name again: the suffix
147
+ // carries this pid and a fresh random, so the next run picks a
148
+ // different one and this one sits in the vault forever, close after
149
+ // close. Take it back out before rethrowing, and do not let the
150
+ // cleanup hide the real error.
151
+ try {
152
+ rmSync(tmp, { force: true });
153
+ } catch {}
154
+ throw err;
155
+ }
141
156
  }
142
157
 
143
158
  /**
@@ -187,8 +202,14 @@ export function overwriteConflictReason(entry, disk, observed = { hash: null, tr
187
202
  const observedTruncated = !!(observed && observed.truncated);
188
203
  switch (entry.state) {
189
204
  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.
205
+ // No snapshot for this (session, target): someone else's edits could be
206
+ // sitting on disk with no way to tell, so this always parks. An earlier
207
+ // cut of this guard let a session's own touched-paths record
208
+ // (hooks/hypo-auto-stage.mjs) waive that park. It was removed
209
+ // 2026-09-11: hypo-auto-commit clears touched-paths.json at every Stop
210
+ // once a commit lands (even a no-op commit), so by the time a close
211
+ // reads it here it is empty in every real session that has crossed a
212
+ // Stop since its last Write/Edit — the escape never actually fired.
192
213
  return 'base-unknown';
193
214
  case 'absent':
194
215
  // We observed no file. Creating it is safe; finding one now means another
@@ -492,17 +513,19 @@ export function runMarkSessionClosed(args) {
492
513
  const verifiedScope = args.logOnly
493
514
  ? { kind: 'log-only' }
494
515
  : { kind: 'global', projects: evaluatedProjects };
495
- writeSessionClosedMarker(args.hypoDir, args.sessionId, {
516
+ const markerLanded = writeSessionClosedMarker(args.hypoDir, args.sessionId, {
496
517
  project: markerProject,
497
518
  projects: args.logOnly ? [] : markerProjects,
498
519
  ...(args.logOnly ? { scope: 'log-only' } : {}),
499
520
  verifiedScope,
500
521
  });
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))) {
522
+ // The writer reports whether THIS call landed, and that is the question here.
523
+ // Checking only that a marker file exists cannot tell a write that succeeded
524
+ // from a leftover, possibly corrupt, marker an earlier attempt left behind —
525
+ // and the reader drops one it cannot parse, so "it is there" and "the session
526
+ // is closed" are different claims. The existsSync below stays as the second
527
+ // half: the writer says it wrote, the disk says it is there.
528
+ if (!markerLanded || !existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
506
529
  const err = 'marker file did not land after write (likely .cache permission/disk issue)';
507
530
  console.log(
508
531
  args.json
@@ -719,7 +742,15 @@ function verifyCloseAuthority(sessionId, hypoDir) {
719
742
  // atomicWrite's use case (replacing bytes a reader might already be mid-read
720
743
  // of), a `wx` create can never observably tear — the file either doesn't
721
744
  // exist yet (nothing to tear) or the open fails outright.
722
- export function ensureProjectIndex(hypoDir, project, relPath, today) {
745
+ // `sessionId` (optional, added for the close journal) records this create in
746
+ // hooks/close-journal.mjs immediately after the bytes land, so a retry of the
747
+ // SAME close that finds index.md already present (applyOverwrites' retry
748
+ // branch) can tell "I created this and it is still exactly what I left it"
749
+ // apart from a hand edit. Omitted entirely by callers outside a close
750
+ // (tests/crystallize-apply.test.mjs's race-condition check), where there is
751
+ // no session to journal against and recordJournalEntry's own `!sessionId`
752
+ // guard makes the call a no-op.
753
+ export function ensureProjectIndex(hypoDir, project, relPath, today, sessionId) {
723
754
  const dest = join(hypoDir, relPath);
724
755
  const src = join(TEMPLATE_DIR, 'index.md');
725
756
  if (!existsSync(src)) return null; // template missing — nothing to scaffold from
@@ -742,6 +773,7 @@ export function ensureProjectIndex(hypoDir, project, relPath, today) {
742
773
  } finally {
743
774
  closeSync(fd);
744
775
  }
776
+ recordJournalEntry(hypoDir, sessionId, relPath, hashContent(content));
745
777
  return relPath;
746
778
  }
747
779
 
@@ -1102,6 +1134,200 @@ function runPreflight(args, payload, project, date) {
1102
1134
  return { preflightLint, payloadScope, indexRelPath, indexMissing };
1103
1135
  }
1104
1136
 
1137
+ // ── section-loss guard (2026-08-10 incident) ────────────────────────────────
1138
+ //
1139
+ // The base-store guard above answers "did someone ELSE change this page since
1140
+ // I looked at it". It cannot answer "did the payload I am about to write throw
1141
+ // away structure that was already here" — a session that legitimately observed
1142
+ // its own prior base (no drift, no conflict) can still overwrite a multi-track
1143
+ // session-state.md or hot.md with a payload that only carries the ONE track it
1144
+ // was working on, silently dropping the others. That is exactly what happened
1145
+ // to security-backoffice: three tracks, two of them vanished, and the base
1146
+ // guard had nothing to say about it because it was never a conflict in the
1147
+ // guard's sense — it was a normal, unopposed overwrite.
1148
+ //
1149
+ // This is deliberately a COUNT of `##` headings that vanish between disk and
1150
+ // payload, not a markdown-aware diff. The block-parser lesson from the base
1151
+ // guard above applies here too: a predicate that reads content and claims to
1152
+ // know what was "provably" preserved is the thing four review rounds already
1153
+ // broke. Counting exact-line survival is cheap, has no false negatives worth
1154
+ // chasing (a heading either survives verbatim or it does not), and its one
1155
+ // failure mode (a legitimately reworded heading reads as "lost") is exactly
1156
+ // what the escape hatch below is for.
1157
+ // A ratio floor alone gets LOOSER as a file grows, exactly backwards from what
1158
+ // this guard is for: a file running more tracks in parallel is bigger (a bigger
1159
+ // denominator), and that is the one where losing a fixed handful of sections
1160
+ // should trip sooner, not later. A distribution was counted against the real
1161
+ // vault on 2026-09-11 with `grep -c '^## ' <file>` (every LINE starting with
1162
+ // `## `, duplicates included) against every hot.md / session-state.md /
1163
+ // open-questions.md: project hot.md ran 4-9 such lines (harness's was 9),
1164
+ // project session-state.md ran 1-12 (harness's was 12), pages/open-questions.md
1165
+ // had 8, root hot.md had 2. That is a different measurement than this guard's
1166
+ // own denominator: `h2Headings` below dedupes into a `Set`, so a file that
1167
+ // repeats one `## ` heading verbatim reports a smaller count here than the grep
1168
+ // tally did. The two agree on every file this repo actually has (none repeats a
1169
+ // heading), but the grep number is not proof of what `h2Headings` counts.
1170
+ //
1171
+ // At a ratio-only gate, losing 4 of a real 12-section session-state.md
1172
+ // (4/12 = 0.333) or 3 of a real 9-section hot.md (3/9 = 0.333) both stayed just
1173
+ // under a 0.34 floor and passed through untouched — real files, real sizes, a
1174
+ // real miss. An absolute floor was added so a bigger file could not buy a bigger
1175
+ // free pass just by being bigger, but the first cut of that floor (3) missed the
1176
+ // shape it was named for: the security-backoffice incident itself lost 2 of 3
1177
+ // tracks, and 2 lost sections clears neither a 3-floor nor, on a 6-12 section
1178
+ // file, the 0.34 ratio (2/12 = 0.167). So the floor is 2, matching
1179
+ // SECTION_LOSS_MIN_COUNT below — and once the two are equal, the ratio branch
1180
+ // can no longer change the outcome: past the MIN_COUNT guard, `lost.length` is
1181
+ // always >= 2, which trips the absolute floor unconditionally, so
1182
+ // `!ratioTrips && !absTrips` can never be true. The two thresholds and the ratio
1183
+ // check that used to sit between them are folded into the one count check below
1184
+ // rather than kept as a branch that reads as live but never decides anything.
1185
+ const SECTION_LOSS_MIN_COUNT = 2; // an ordinary single-section edit (finishing one track,
1186
+ // retiring one open question) stays under this and must not park; 2 or more is
1187
+ // the incident's own shape and always trips, at any file size.
1188
+
1189
+ // A fence marker line: 0-3 leading spaces (CommonMark still calls that "unindented"),
1190
+ // then a run of 3+ backticks or 3+ tildes, then the rest of the line. `m[1]` is the
1191
+ // marker run itself (so its first char and length identify what closes it); `m[2]` is
1192
+ // whatever follows, an info string on the opening line, and required to be blank
1193
+ // (after trim) on a line being checked as a close.
1194
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
1195
+
1196
+ /**
1197
+ * Which line indices are inside a fenced code block, for one file's lines.
1198
+ *
1199
+ * A fence opens on any line FENCE_RE matches while not already inside one, and
1200
+ * closes only on a later line whose marker is the SAME character and AT LEAST as
1201
+ * long (a 4-backtick open is not closed by 3 backticks, a CommonMark rule, and the
1202
+ * one this guard's predecessor ignored: the section-loss bypass this closes moved
1203
+ * two `##` headings into a properly-closed ```md fence and the old line-scan still
1204
+ * counted them as real headings because it never looked for a fence at all).
1205
+ *
1206
+ * An opening fence that never finds a matching close before EOF is treated as
1207
+ * NEVER HAVING OPENED (every line from that marker to EOF is unhidden here). That
1208
+ * is the safe direction for a guard whose entire job is "did content silently
1209
+ * disappear": the same function extracts headings from both disk and payload, so
1210
+ * treating an unclosed run as fenced would let it swallow real headings on
1211
+ * whichever side has the malformed markdown: undercounting disk (hiding sections
1212
+ * the guard should have protected) or undercounting payload (reporting a section
1213
+ * as lost when the payload never actually removed it). Treating it as prose
1214
+ * instead only risks the opposite: an occasional false park on a document with a
1215
+ * genuinely broken fence, which is recoverable through the same
1216
+ * `restructure: true` / proposal-resolve door every other park in this guard
1217
+ * already uses, not a silent loss.
1218
+ *
1219
+ * Declined on purpose, not CommonMark-complete: an opening line's info string is
1220
+ * never checked for a stray backtick (CommonMark forbids one in a backtick fence's
1221
+ * info string; this scan does not care), and a fence inside a blockquote or list
1222
+ * item is scanned exactly like a top-level one. Both would need block-context
1223
+ * tracking this guard's own doc comment (above, the base-conflict guard section)
1224
+ * already argues against building here. Getting the two reproduced bypasses closed
1225
+ * cheaply matters more than a complete parser.
1226
+ *
1227
+ * @returns {boolean[]} same length as `lines`, true where the line is fenced
1228
+ */
1229
+ function fencedLineMask(lines) {
1230
+ const hidden = new Array(lines.length).fill(false);
1231
+ let openIdx = -1;
1232
+ let fenceChar = null;
1233
+ let fenceLen = 0;
1234
+ for (let i = 0; i < lines.length; i++) {
1235
+ if (openIdx === -1) {
1236
+ const m = lines[i].match(FENCE_RE);
1237
+ if (m) {
1238
+ openIdx = i;
1239
+ fenceChar = m[1][0];
1240
+ fenceLen = m[1].length;
1241
+ hidden[i] = true; // tentative, unhidden below if this never closes
1242
+ }
1243
+ continue;
1244
+ }
1245
+ hidden[i] = true; // tentative, unhidden below if this never closes
1246
+ const m = lines[i].match(FENCE_RE);
1247
+ if (m && m[1][0] === fenceChar && m[1].length >= fenceLen && m[2].trim() === '') {
1248
+ openIdx = -1;
1249
+ fenceChar = null;
1250
+ fenceLen = 0;
1251
+ }
1252
+ }
1253
+ if (openIdx !== -1) {
1254
+ for (let i = openIdx; i < lines.length; i++) hidden[i] = false;
1255
+ }
1256
+ return hidden;
1257
+ }
1258
+
1259
+ /**
1260
+ * Extract this file's `##` section headings, in order, as a MULTISET (every
1261
+ * occurrence kept, none deduped) with fenced-code lines excluded. Only `##`
1262
+ * (not `#`/`###`), the granularity the section-loss incident was measured at.
1263
+ *
1264
+ * Multiset, not a `Set`, because a dedup here silently halves the denominator
1265
+ * a file that legitimately repeats one `## ` heading twice: the old `Set`-based
1266
+ * version counted "## TODO" appearing twice on disk as ONE section, so a
1267
+ * payload that kept only one copy compared as "the heading is still present"
1268
+ * with nothing lost at all: the second bypass this pass closes.
1269
+ *
1270
+ * Known limit, left as-is (see fencedLineMask's own doc comment for the fuller
1271
+ * case against building a real parser here): this still reads every non-fenced
1272
+ * line as prose, so a `## ` line inside an indented (non-fenced) code block, a
1273
+ * blockquote, or a list item is still counted as a real heading. That is a
1274
+ * false positive (an occasional unnecessary park), not the silent-loss failure
1275
+ * mode this guard exists to close, so it is accepted rather than fixed here.
1276
+ * @returns {string[]}
1277
+ */
1278
+ function h2Headings(content) {
1279
+ const lines = (content || '').split(/\r?\n/);
1280
+ const hidden = fencedLineMask(lines);
1281
+ const out = [];
1282
+ for (let i = 0; i < lines.length; i++) {
1283
+ if (!hidden[i] && /^##\s+\S/.test(lines[i])) out.push(lines[i]);
1284
+ }
1285
+ return out;
1286
+ }
1287
+
1288
+ /**
1289
+ * Whether `payloadContent` drops enough of `diskContent`'s `##` sections to
1290
+ * warrant withholding the write. Compared as a multiset: each disk occurrence
1291
+ * is matched off against one still-unconsumed payload occurrence of the exact
1292
+ * same line, in disk order, so losing one copy of a heading that appears twice
1293
+ * on disk is visible even though the same title still appears once in the
1294
+ * payload. A "lost" occurrence is one with no remaining payload copy to match,
1295
+ * reworded, split, or genuinely deleted headings all read the same way here
1296
+ * (see the module doc comment above for why that is the accepted
1297
+ * false-positive, not a defect to fix), and a heading moved into a fenced code
1298
+ * block no longer counts as a payload occurrence at all (h2Headings excludes
1299
+ * fenced lines on both sides).
1300
+ *
1301
+ * An ordinary edit that drops a single section (finishing one track, retiring
1302
+ * one open question) must not park; losing 2 or more is the incident's own
1303
+ * shape (security-backoffice lost 2 of its 3 tracks) and trips regardless of
1304
+ * how big the file is. See SECTION_LOSS_MIN_COUNT's comment above for why this
1305
+ * is now a single count check rather than a count-and-ratio pair.
1306
+ *
1307
+ * @returns {{lost: string[], diskCount: number}|null} the lost occurrences
1308
+ * (duplicates repeated once per lost copy) and how many `##` heading
1309
+ * occurrences disk had (also a multiset count, not deduped; see
1310
+ * h2Headings), or null when the write is fine
1311
+ */
1312
+ export function sectionLossReason(diskContent, payloadContent) {
1313
+ const diskHeadings = h2Headings(diskContent);
1314
+ if (diskHeadings.length === 0) return null; // nothing to lose
1315
+ const payloadHeadings = h2Headings(payloadContent);
1316
+ const remaining = new Map();
1317
+ for (const h of payloadHeadings) remaining.set(h, (remaining.get(h) || 0) + 1);
1318
+ const lost = [];
1319
+ for (const h of diskHeadings) {
1320
+ const n = remaining.get(h) || 0;
1321
+ if (n > 0) {
1322
+ remaining.set(h, n - 1);
1323
+ } else {
1324
+ lost.push(h);
1325
+ }
1326
+ }
1327
+ if (lost.length < SECTION_LOSS_MIN_COUNT) return null;
1328
+ return { lost, diskCount: diskHeadings.length };
1329
+ }
1330
+
1105
1331
  /**
1106
1332
  * Replace every whole-page overwrite target, then fill a missing project index.
1107
1333
  *
@@ -1116,7 +1342,9 @@ function runPreflight(args, payload, project, date) {
1116
1342
  *
1117
1343
  * 1. idempotent skip (disk already equals the payload)
1118
1344
  * 2. conflict (base unknown, or disk drifted away from base)
1119
- * 3. direct write, then advance the base
1345
+ * 3. section-loss guard (payload drops most of disk's `## `
1346
+ * sections, and this field did not opt out via `restructure: true`)
1347
+ * 4. direct write, then advance the base
1120
1348
  *
1121
1349
  * Step 1 must come first for two reasons. It keeps every existing
1122
1350
  * `--apply-session-close --session-id` test green (they read the payload
@@ -1124,20 +1352,40 @@ function runPreflight(args, payload, project, date) {
1124
1352
  * the apply-then-reclose loop: once a human applies proposal P, disk == proposed
1125
1353
  * == payload.content, so the next close skips before it can re-raise a conflict.
1126
1354
  *
1355
+ * Step 3 runs only once step 2 has already cleared: a base conflict already
1356
+ * withholds the write on its own, and reporting BOTH reasons for the same
1357
+ * withheld byte would tell a resolving human two different stories about why
1358
+ * their proposal review matters.
1359
+ *
1127
1360
  * There is no caller here without a `--session-id`. verifyCloseAuthority refuses
1128
1361
  * that at the door, before a byte is written, so a session id is always present
1129
1362
  * by the time this runs and the base lookup always has something to look up.
1130
1363
  */
1131
1364
  function applyOverwrites(args, payload, project, date, indexRelPath, indexMissing, acc) {
1132
- const { applied, skipped, appliedPaths, conflicts } = acc;
1365
+ const { applied, skipped, appliedPaths, conflicts, restructureWaivers } = acc;
1366
+ // Read once per close, not once per field: it is a single small JSON read,
1367
+ // and every skip branch below needs the same session-scoped record.
1368
+ const journal = readJournal(args.hypoDir, args.sessionId);
1133
1369
 
1134
1370
  const overwrite = (key, relPath, field) => {
1135
1371
  if (!field || typeof field.content !== 'string') return; // optional / absent
1136
1372
  const full = join(args.hypoDir, relPath);
1137
1373
  const disk = readTarget(full);
1138
1374
 
1139
- // (1) idempotent skip — preserves writeIfChanged's contract
1375
+ // (1) idempotent skip — preserves writeIfChanged's contract. "Already
1376
+ // current" collapses two different histories that look identical from
1377
+ // here: disk always held these bytes, or THIS session wrote them in an
1378
+ // earlier, uncommitted attempt at this same close. Only the journal tells
1379
+ // them apart. A record for this path whose hash still matches what is on
1380
+ // disk means the second history — restage it so the retry's commit picks
1381
+ // up bytes an earlier attempt already paid for. No record, or a hash that
1382
+ // no longer matches (someone touched the file since), leaves it out: the
1383
+ // gate should keep blocking on drift it cannot attribute to this close.
1140
1384
  if (disk === field.content) {
1385
+ const journalHash = journal[relPath];
1386
+ if (journalHash && journalHash === hashContent(field.content)) {
1387
+ appliedPaths.push(relPath);
1388
+ }
1141
1389
  skipped.push(`${key} (${relPath})`);
1142
1390
  return;
1143
1391
  }
@@ -1189,10 +1437,58 @@ function applyOverwrites(args, payload, project, date, indexRelPath, indexMissin
1189
1437
  }
1190
1438
  }
1191
1439
 
1192
- // (3) write, then the content we just wrote IS this session's new base
1440
+ // (3) Section-loss guard: this overwrite would drop most of disk's `## ` sections.
1441
+ // Computed regardless of `restructure`, so a `true` value that waives a REAL
1442
+ // loss can be told apart from one set on a field that never had a loss to
1443
+ // waive. `field.restructure === true` is the escape hatch for a genuine
1444
+ // rewrite (the crystallize skill sets it only when the user confirmed the
1445
+ // sections are meant to go, per commands/crystallize.md) — it is per-FIELD,
1446
+ // not per-close, so consolidating session-state.md on purpose does not also
1447
+ // waive the check on hot.md in the same payload. Without it, this parks
1448
+ // exactly like a base conflict: the SAME human recovery path already
1449
+ // documented for base-mismatch (`hypomnema proposal challenge` /
1450
+ // `proposal resolve`) is the way a genuinely intended restructure gets
1451
+ // applied anyway, so this reuses that door rather than inventing a second
1452
+ // judgment surface for "should this write go through".
1453
+ if (typeof disk === 'string') {
1454
+ const loss = sectionLossReason(disk, field.content);
1455
+ if (loss) {
1456
+ if (field.restructure !== true) {
1457
+ conflicts.push({
1458
+ key,
1459
+ target: relPath,
1460
+ reason: 'section-loss-guard',
1461
+ lostSections: loss.lost,
1462
+ diskSectionCount: loss.diskCount,
1463
+ baseHash: args.sessionId
1464
+ ? readBaseEntry(args.hypoDir, args.sessionId, relPath).hash
1465
+ : null,
1466
+ currentHash: hashContent(disk),
1467
+ proposedContent: field.content,
1468
+ });
1469
+ return; // target bytes untouched
1470
+ }
1471
+ // The waiver is exercised by the party the guard exists to check (the
1472
+ // model composing the payload), so it must leave a trace instead of
1473
+ // vanishing the way an unconditional skip would. Reuses the result-field
1474
+ // shape and "report verbatim" reporting contract the removed
1475
+ // base-unknown touched-override notice used to carry (see git history
1476
+ // and commands/crystallize.md's close-result reporting section).
1477
+ restructureWaivers.push({ target: relPath, lostSections: loss.lost });
1478
+ }
1479
+ }
1480
+
1481
+ // (4) write, then the content we just wrote IS this session's new base
1193
1482
  atomicWrite(full, field.content);
1194
- if (args.sessionId)
1483
+ if (args.sessionId) {
1195
1484
  advanceBase(args.hypoDir, args.sessionId, relPath, hashContent(field.content));
1485
+ // Record what THIS write just put down, so a retry after a partial
1486
+ // close (a sibling field conflicts, the commit fails, the process
1487
+ // dies) can tell its own uncommitted bytes apart from someone else's —
1488
+ // see the journal read in step (1) above and the doc comment on
1489
+ // hooks/close-journal.mjs.
1490
+ recordJournalEntry(args.hypoDir, args.sessionId, relPath, hashContent(field.content));
1491
+ }
1196
1492
  applied.push(`${key} (${relPath})`);
1197
1493
  appliedPaths.push(relPath);
1198
1494
  };
@@ -1206,11 +1502,37 @@ function applyOverwrites(args, payload, project, date, indexRelPath, indexMissin
1206
1502
  // preflight passed, so an aborted close never leaves a half-applied side
1207
1503
  // effect on disk).
1208
1504
  if (indexMissing) {
1209
- const createdIndex = ensureProjectIndex(args.hypoDir, project, indexRelPath, date);
1505
+ const createdIndex = ensureProjectIndex(
1506
+ args.hypoDir,
1507
+ project,
1508
+ indexRelPath,
1509
+ date,
1510
+ args.sessionId,
1511
+ );
1210
1512
  if (createdIndex) {
1211
1513
  applied.push(`projectIndex (${createdIndex})`);
1212
1514
  appliedPaths.push(createdIndex);
1213
1515
  }
1516
+ } else {
1517
+ // The retry path. A first attempt that seeds index.md and then fails to
1518
+ // commit leaves it dirty; this run finds it already there, so the branch
1519
+ // above does nothing and the file would drop out of the commit scope
1520
+ // entirely, blocking the gate forever with no retry ever picking it back
1521
+ // up. Restaging it is only safe when the journal says THIS session wrote
1522
+ // exactly the bytes still on disk — the same rule step (1)'s idempotent
1523
+ // skip applies, reused here because ensureProjectIndex never reaches
1524
+ // step (1) at all (it is a template-seeded create, not a payload
1525
+ // overwrite field). A hand-edited index.md (no journal record, or a
1526
+ // journal record whose hash no longer matches) is left OUT of
1527
+ // appliedPaths on purpose: those are bytes this close never wrote, and
1528
+ // sweeping them into its commit would ship an edit the payload never
1529
+ // carried.
1530
+ const full = join(args.hypoDir, indexRelPath);
1531
+ const disk = readTarget(full);
1532
+ const journalHash = journal[indexRelPath];
1533
+ if (journalHash && typeof disk === 'string' && journalHash === hashContent(disk)) {
1534
+ appliedPaths.push(indexRelPath);
1535
+ }
1214
1536
  }
1215
1537
  }
1216
1538
 
@@ -1229,6 +1551,7 @@ function appendSessionLogEntry(args, payload, project, date, acc) {
1229
1551
  const rel = join('projects', project, 'session-log', `${date}.md`);
1230
1552
  const full = join(args.hypoDir, rel);
1231
1553
  const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
1554
+ const journal = readJournal(args.hypoDir, args.sessionId);
1232
1555
  // Serialize dedup + create/append on the daily shard so two concurrent
1233
1556
  // closes never lose an entry: the second closer takes the lock only after
1234
1557
  // the first committed, re-reads the shard under the lock, and appends onto
@@ -1292,7 +1615,32 @@ function appendSessionLogEntry(args, payload, project, date, acc) {
1292
1615
  { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1293
1616
  );
1294
1617
  (outcome === 'skipped' ? skipped : applied).push(`sessionLog (${rel})`);
1295
- if (outcome !== 'skipped') appliedPaths.push(rel);
1618
+ if (outcome !== 'skipped') {
1619
+ appliedPaths.push(rel);
1620
+ // Same journal contract as applyOverwrites: record the FULL file's hash
1621
+ // right after this write, not just the entry, since a retry's own
1622
+ // "already present" skip below reads the whole file back to compare.
1623
+ const written = readTarget(full);
1624
+ if (typeof written === 'string')
1625
+ recordJournalEntry(args.hypoDir, args.sessionId, rel, hashContent(written));
1626
+ } else {
1627
+ // "Already present" collapses the same two histories the overwrite
1628
+ // guard's step (1) does: this entry could have sat in the shard since
1629
+ // before this close ever ran, or THIS session appended it in an
1630
+ // earlier, uncommitted attempt at the same close. Restage only the
1631
+ // second — a journal record whose hash still matches the shard on
1632
+ // disk. (A hybrid-month fallback hit above never reaches here with
1633
+ // `full` matching the journal's recorded target, since the evidence in
1634
+ // that case lives in the legacy monthly file instead — nothing to
1635
+ // restore for the daily shard because this close never wrote one.)
1636
+ const journalHash = journal[rel];
1637
+ if (journalHash) {
1638
+ const disk = readTarget(full);
1639
+ if (typeof disk === 'string' && journalHash === hashContent(disk)) {
1640
+ appliedPaths.push(rel);
1641
+ }
1642
+ }
1643
+ }
1296
1644
  } catch (err) {
1297
1645
  // Only a lock-TIMEOUT is withheld as a conflict. A real fn() write error
1298
1646
  // (disk-full, EACCES, mkdir failure) must NOT be masked as a proposal-
@@ -1335,9 +1683,35 @@ function appendSessionLogEntry(args, payload, project, date, acc) {
1335
1683
  // (the Stop-hook backfill in hypo-shared.mjs). Both take the SAME lock on
1336
1684
  // log.md, so a concurrent close's append and this close's append serialize
1337
1685
  // instead of overwriting each other.
1686
+ // log.md is a single shared file both branches below append to, so a
1687
+ // "wrote nothing new" outcome from either one needs the same journal-based
1688
+ // restore-vs-leave-dirty judgment applyOverwrites' step (1) already makes:
1689
+ // this session's own prior, uncommitted append restages; anything else does
1690
+ // not. Centralized here rather than duplicated per branch, and rather than
1691
+ // merely commented twice, because a fix to one copy silently drifting from
1692
+ // the other is exactly the failure mode two near-identical blocks invite.
1693
+ function restageOrRecordLogMd(args, logFull, journal, wroteNew, acc) {
1694
+ const { appliedPaths } = acc;
1695
+ if (wroteNew) {
1696
+ appliedPaths.push('log.md');
1697
+ const written = readTarget(logFull);
1698
+ if (typeof written === 'string')
1699
+ recordJournalEntry(args.hypoDir, args.sessionId, 'log.md', hashContent(written));
1700
+ return;
1701
+ }
1702
+ const journalHash = journal['log.md'];
1703
+ if (journalHash) {
1704
+ const disk = readTarget(logFull);
1705
+ if (typeof disk === 'string' && journalHash === hashContent(disk)) {
1706
+ appliedPaths.push('log.md');
1707
+ }
1708
+ }
1709
+ }
1710
+
1338
1711
  function appendRootLogEntry(args, payload, project, date, acc) {
1339
- const { applied, skipped, appliedPaths, conflicts } = acc;
1712
+ const { applied, skipped, conflicts } = acc;
1340
1713
  const logFull = join(args.hypoDir, 'log.md');
1714
+ const journal = readJournal(args.hypoDir, args.sessionId);
1341
1715
  if (payload.log) {
1342
1716
  try {
1343
1717
  const wrote = withFileLock(
@@ -1346,7 +1720,7 @@ function appendRootLogEntry(args, payload, project, date, acc) {
1346
1720
  { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1347
1721
  );
1348
1722
  (wrote ? applied : skipped).push('log (log.md)');
1349
- if (wrote) appliedPaths.push('log.md');
1723
+ restageOrRecordLogMd(args, logFull, journal, wrote, acc);
1350
1724
  } catch (err) {
1351
1725
  if (err?.code !== 'ELOCKTIMEOUT') throw err;
1352
1726
  // proposedContent is append-ready root-log bytes (the custom log line).
@@ -1383,7 +1757,7 @@ function appendRootLogEntry(args, payload, project, date, acc) {
1383
1757
  { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1384
1758
  );
1385
1759
  (wroteAny ? applied : skipped).push('log (log.md, derived)');
1386
- if (wroteAny) appliedPaths.push('log.md');
1760
+ restageOrRecordLogMd(args, logFull, journal, wroteAny, acc);
1387
1761
  } catch (err) {
1388
1762
  if (err?.code !== 'ELOCKTIMEOUT') throw err;
1389
1763
  // `derived: true` discriminates this from the payload.log conflict above:
@@ -1420,6 +1794,50 @@ function appendRootLogEntry(args, payload, project, date, acc) {
1420
1794
  // append-only history file. Append conflicts still sit in `conflicts`, so the
1421
1795
  // close still goes proposal-pending — they just get no artifact and no
1422
1796
  // human-apply step.
1797
+ // Human-readable park reason, keyed by `c.reason`. Add a line here for every
1798
+ // new reason string a `conflicts.push(...)` call introduces (applyOverwrites,
1799
+ // the append-lock-timeout sites below) — before this lookup existed, the report
1800
+ // only branched on `c.kind === 'append'` and printed one fixed sentence
1801
+ // ("the page changed since this session read it") for every other reason,
1802
+ // which is a flat lie for `section-loss-guard`: nothing external changed
1803
+ // there, the PAYLOAD dropped its own sections. A reason with no entry here
1804
+ // falls through to the default below, worded to admit it does not know the
1805
+ // cause rather than repeat a specific wrong one.
1806
+ const CONFLICT_WHY = {
1807
+ 'append-lock-timeout': () =>
1808
+ 'could not acquire the append lock in time; the next close re-applies',
1809
+ 'base-unknown': () =>
1810
+ 'no base snapshot exists for this target for this session, so another writer could be sitting on disk with no way to tell',
1811
+ 'base-hash-target-missing': () =>
1812
+ 'the page changed since this session read it (it existed at base, and is missing now)',
1813
+ 'base-mismatch': () => 'the page changed since this session read it',
1814
+ 'base-mismatch-truncated-observation': () =>
1815
+ 'the page changed since this session read it, and the last resume/compact only showed a truncated slice of it',
1816
+ 'base-absent-target-exists': () =>
1817
+ 'the page changed since this session read it (nothing existed at base, another writer created it since)',
1818
+ 'target-unreadable': () =>
1819
+ 'the target could not be read just now; failing safe rather than assuming it is unchanged',
1820
+ 'section-loss-guard': (c) =>
1821
+ `this payload drops ${c.lostSections.length} of ${c.diskSectionCount} \`##\` section(s) already on disk (${c.lostSections.join(', ')}) — the page did not change, the payload did not carry those sections forward. Add the missing sections back into the payload, or set "restructure": true after confirming with the user that dropping them is intended`,
1822
+ };
1823
+
1824
+ export function conflictWhy(c) {
1825
+ const fn = CONFLICT_WHY[c.reason];
1826
+ if (!fn) return `unrecognized park reason "${c.reason}" — cause not determined`;
1827
+ // An entry reads whatever fields its own reason carries, and the section-loss
1828
+ // one needs two the others never set. That was harmless while this only fed
1829
+ // the text report; the JSON close path now calls it for every conflict, so a
1830
+ // future reason pushed without the fields its entry expects would throw
1831
+ // mid-close and take the whole apply with it. The explanation is the least
1832
+ // important thing happening here: degrade to the raw reason rather than lose
1833
+ // the close over a message.
1834
+ try {
1835
+ return fn(c);
1836
+ } catch {
1837
+ return `${c.reason} (details unavailable)`;
1838
+ }
1839
+ }
1840
+
1423
1841
  function parkOverwriteConflicts(args, conflicts) {
1424
1842
  const proposals = [];
1425
1843
  const proposalStoreFailures = [];
@@ -1434,6 +1852,19 @@ function parkOverwriteConflicts(args, conflicts) {
1434
1852
  proposedContent: c.proposedContent, // internal (pre-drop) full page bytes
1435
1853
  sessionId: args.sessionId, // may be null; writeProposal coerces it
1436
1854
  device,
1855
+ // The same human-readable cause the JSON result's conflicts[].why now
1856
+ // carries (buildCloseResult) — stored here too because a proposal
1857
+ // artifact outlives this close's own stdout, and `hypomnema proposal
1858
+ // list`/`apply` reads only the artifact, never this run's JSON. Without
1859
+ // it, the reviewer sees the raw `reason` code and nothing else (codex
1860
+ // 3rd-pass finding: the park-reason wording fix never reached this file).
1861
+ parkReason: conflictWhy(c),
1862
+ // Section-loss detail: only meaningful for that one reason, so only
1863
+ // sent for it — an absent field on every other conflict is the correct
1864
+ // shape, not a gap.
1865
+ ...(c.reason === 'section-loss-guard'
1866
+ ? { lostSections: c.lostSections, diskSectionCount: c.diskSectionCount }
1867
+ : {}),
1437
1868
  });
1438
1869
  proposals.push({ id: saved.id, target: saved.target, path: saved.path });
1439
1870
  // Supersede-delete failure is NON-fatal: the new artifact IS parked, only
@@ -1448,7 +1879,7 @@ function parkOverwriteConflicts(args, conflicts) {
1448
1879
  proposalStoreFailures.push({ target: c.target, key: c.key, error });
1449
1880
  process.stderr.write(
1450
1881
  `\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` +
1882
+ ` This close WITHHELD the target (${conflictWhy(c)}) but\n` +
1452
1883
  ` could NOT write the .cache/proposals/ artifact either. The payload bytes\n` +
1453
1884
  ` are on NEITHER disk NOR a proposal — re-run the close once the .cache/\n` +
1454
1885
  ` directory is writable so the withheld content is not lost.\n`,
@@ -1593,40 +2024,12 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1593
2024
  let markerWritten = false;
1594
2025
  let markerSkipReason = null;
1595
2026
  let commitOutcome = null;
2027
+ // What the gate waved through on the way to the marker. The demotions are
2028
+ // only honest if the operator can see them, and this is the path that runs
2029
+ // on a real close: `--mark-session-closed` already reported them, while
2030
+ // `--apply-session-close` dropped them on the floor.
2031
+ let gateNotices = [];
1596
2032
  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
2033
  // IO stays lazy so this preserves the exact side-effect order (codex design
1631
2034
  // review): commit first (the only mutation), then resolve the
1632
2035
  // transcript, then run the compact gate with that transcript, then scan the
@@ -1648,6 +2051,13 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1648
2051
  } catch (err) {
1649
2052
  commitOutcome = { committed: false, reason: `vault-commit-lock: ${err?.message || err}` };
1650
2053
  }
2054
+ // Once these bytes are committed, the journal's only job (telling a
2055
+ // retry's own uncommitted work apart from someone else's) is done —
2056
+ // clear it rather than let a stale record outlive this close and later
2057
+ // match a coincidence it was never meant to license. A commit that
2058
+ // failed leaves the journal in place on purpose: that is exactly the
2059
+ // case the next retry needs it for.
2060
+ if (commitOutcome.committed) clearJournal(args.hypoDir, args.sessionId);
1651
2061
  let closeTranscript = null;
1652
2062
  let gateOk = false;
1653
2063
  // verified_scope evidence (session-close-scope-boundary spec §3, revised
@@ -1682,6 +2092,7 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1682
2092
  ...(autoMarkerOverride ? { attributionScope: autoMarkerOverride } : {}),
1683
2093
  });
1684
2094
  gateOk = gateStatus.ok;
2095
+ gateNotices = gateStatus.notices || [];
1685
2096
  // `closeScope` above widens the partition, it never narrows
1686
2097
  // sessionCloseGlobalStatus (only opts.projectOverride does, and this
1687
2098
  // call never sets it) — so gate.close.projects is the actual evaluated
@@ -1699,16 +2110,17 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1699
2110
  transcriptResolved: !!closeTranscript,
1700
2111
  // Scan the signal only when the gate passed AND a transcript resolved —
1701
2112
  // 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).
2113
+ // Reads the raw walkCloseGate open, not closeGateStatus: closeGateStatus
2114
+ // would also weigh this session's recorded resolution, and the
2115
+ // resolution below is now written ONLY once the marker itself lands
2116
+ // (this change). A retry after a withheld marker (dirty wiki, a
2117
+ // failed commit, a lock timeout) has no resolution recorded yet, but it
2118
+ // still needs THIS check to see the transcript's existing close phrase
2119
+ // as authorization, not a fresh one. This field asks a narrower
2120
+ // question than closeGateStatus answers: "did the transcript carry a
2121
+ // close signal", not "is this apply itself still authorized to run at
2122
+ // all" (verifyCloseAuthority already settled that, before any byte was
2123
+ // written).
1712
2124
  hasUserSignal: gateOk && !!closeTranscript && isCloseGateOpen(closeTranscript),
1713
2125
  });
1714
2126
  markerSkipReason = decision.skipReason;
@@ -1721,7 +2133,7 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1721
2133
  // call never sets it. The gate ran unnarrowed, so `kind` is 'global',
1722
2134
  // with `projects` the set gate.close actually evaluated
1723
2135
  // (gateEvaluatedProjects), never `[project]` verbatim.
1724
- writeSessionClosedMarker(args.hypoDir, args.sessionId, {
2136
+ const wrote = writeSessionClosedMarker(args.hypoDir, args.sessionId, {
1725
2137
  project,
1726
2138
  projects: [project],
1727
2139
  verifiedScope: { kind: 'global', projects: gateEvaluatedProjects },
@@ -1730,14 +2142,55 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1730
2142
  // Verify the file actually landed — mirroring the standalone path — instead of
1731
2143
  // asserting markerWritten=true, so a .cache permission/disk problem surfaces
1732
2144
  // rather than the caller reporting "closed" while the next Stop re-blocks.
1733
- if (existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
2145
+ // Both halves, for the reason spelled out at the other call site: the
2146
+ // writer's own report rules out a leftover marker standing in for a
2147
+ // write that never happened, and that distinction decides whether the
2148
+ // close signal below gets spent. Spending it on a marker this run did
2149
+ // not write is the failure this whole phase was reordered to avoid.
2150
+ if (wrote && existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
1734
2151
  markerWritten = true;
2152
+ // Close-gate resolution: record it here, ONLY now
2153
+ // that the marker has actually landed on disk, not the moment this
2154
+ // apply's own writes succeeded. Recording it earlier used to sit
2155
+ // right after `ok && args.sessionId`, ahead of commit, gate, and
2156
+ // marker entirely, on the theory that the wiki writes already
2157
+ // happened so the resolution should stick regardless. That let a run
2158
+ // which committed the payload but then had its marker withheld
2159
+ // (compact-gate-not-ok on a dirty wiki, a lock timeout, a disk
2160
+ // failure) burn the session's one close signal anyway: the next run
2161
+ // hit closeGateStatus's `no-new-open-since-resolution` and refused,
2162
+ // with no marker ever written and no way back short of a brand-new
2163
+ // user close phrase. Tying the record to a landed marker means a
2164
+ // withheld marker leaves the signal untouched, so a retry (once the
2165
+ // wiki is clean, or the transient failure clears) is still
2166
+ // authorized by the same close phrase. `closeTranscript` is reused
2167
+ // here rather than re-resolved: `decision.write` can only be true
2168
+ // when `transcriptResolved` was true in `planMarkerDecision`'s inputs
2169
+ // above, so it is guaranteed non-null at this point.
2170
+ //
2171
+ // Best-effort like every other write in this store: resolutionStamp
2172
+ // returns null on anything it cannot read as a Buffer, recordGateClosed
2173
+ // refuses a null stamp, and both fail silently, so a transcript that
2174
+ // vanishes mid-read (or a cache-write failure) can never turn an
2175
+ // otherwise-successful close into a failure.
2176
+ try {
2177
+ recordGateClosed(
2178
+ args.hypoDir,
2179
+ args.sessionId,
2180
+ resolutionStamp(readFileSync(closeTranscript)),
2181
+ );
2182
+ } catch {
2183
+ // Unreadable at the moment of a successful close is not this
2184
+ // apply's problem to surface — the resolution just stays
2185
+ // unrecorded, same as if this session had never resolved at all
2186
+ // (NO_CONSTRAINT).
2187
+ }
1735
2188
  } else {
1736
2189
  markerSkipReason = 'marker-did-not-land';
1737
2190
  }
1738
2191
  }
1739
2192
  }
1740
- return { markerWritten, markerSkipReason, commitOutcome };
2193
+ return { markerWritten, markerSkipReason, commitOutcome, gateNotices };
1741
2194
  }
1742
2195
 
1743
2196
  // A conflict outranks the downstream gates: verification and lint both describe
@@ -1781,6 +2234,8 @@ function buildCloseResult({
1781
2234
  postApplyLint,
1782
2235
  closeScopeNotice,
1783
2236
  otherDebtCount,
2237
+ gateNotices,
2238
+ restructureWaivers,
1784
2239
  }) {
1785
2240
  return {
1786
2241
  ok,
@@ -1810,7 +2265,16 @@ function buildCloseResult({
1810
2265
  // NO artifact and is re-tried automatically by the next close. `proposedContent`
1811
2266
  // is dropped from the reported shape either way (the artifact / the next close
1812
2267
  // holds the bytes; a whole page or an append entry does not belong in the JSON).
1813
- conflicts: conflicts.map(({ proposedContent: _drop, ...rest }) => rest),
2268
+ // `why` is the human-readable cause (conflictWhy), the same string
2269
+ // printCloseReport already prints in the non-JSON path — a `--json` close
2270
+ // used to carry only the raw `reason` code here, so the caller had no prose
2271
+ // to surface and the fix to conflictWhy's wording never reached a `--json`
2272
+ // close (which is how every real close runs; printCloseReport is a path a
2273
+ // normal apply never takes).
2274
+ conflicts: conflicts.map((c) => {
2275
+ const { proposedContent: _drop, ...rest } = c;
2276
+ return { ...rest, why: conflictWhy(c) };
2277
+ }),
1814
2278
  // Parked overwrite proposals (id/target/path), one per drifted overwrite
1815
2279
  // target. Empty when only append conflicts (or none) occurred. The T7 CLI
1816
2280
  // lists and applies these; append conflicts never appear here.
@@ -1842,6 +2306,21 @@ function buildCloseResult({
1842
2306
  // scripts/lint.mjs` for the full list).
1843
2307
  notices: [...new Set(closeScopeNotice.map((e) => e.file))],
1844
2308
  otherDebtCount,
2309
+ // Separate from `notices` above, which is lint debt. These are the close
2310
+ // GATE's demotions: what it declined to block on. `--mark-session-closed`
2311
+ // has always reported them and this path did not, so a demotion on the
2312
+ // canonical close path was invisible — the gate's promise is that it never
2313
+ // waves something through silently, and half the paths were breaking it.
2314
+ // A new key rather than a merge into `notices`, whose entries are filename
2315
+ // strings that an existing reader would choke on if they became objects.
2316
+ gateNotices: gateNotices || [],
2317
+ // Always present (possibly empty), same visibility contract as `notices`/
2318
+ // `otherDebtCount` above — a caller should not have to guess whether the
2319
+ // key's absence means "none" or "this apply predates the field". One entry
2320
+ // per overwrite field where `restructure: true` waived a REAL section-loss
2321
+ // trip (a field that carried the flag but never had a loss to waive adds no
2322
+ // entry here — the flag did nothing, which is not this field's job to flag).
2323
+ restructureWaivers,
1845
2324
  };
1846
2325
  }
1847
2326
 
@@ -1861,20 +2340,23 @@ function printCloseReport({
1861
2340
  postBlocking,
1862
2341
  closeScopeNotice,
1863
2342
  otherDebtCount,
2343
+ restructureWaivers,
1864
2344
  }) {
1865
2345
  console.log(`Session-close apply (project: ${project}, date: ${date}):`);
1866
2346
  for (const a of applied) console.log(` ✓ wrote ${a}`);
1867
2347
  for (const s of skipped) console.log(` · skipped ${s} (already current)`);
2348
+ // Surfaced unconditionally, success or failure. A waiver is not a normal
2349
+ // write, and burying it behind `ok` would hide it on exactly the runs where
2350
+ // a human is most likely to be reading closely.
2351
+ for (const w of restructureWaivers) {
2352
+ console.log(
2353
+ ` ⚠ restructure:true waived the section-loss guard for ${w.target} — dropped: ${w.lostSections.join(', ')}`,
2354
+ );
2355
+ }
1868
2356
  // 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.
2357
+ // this means "your bytes are NOT on disk".
1872
2358
  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}`);
2359
+ console.log(` ⚠ WITHHELD ${c.key} (${c.target}) — ${c.reason}; ${conflictWhy(c)}`);
1878
2360
  }
1879
2361
  for (const p of proposals) {
1880
2362
  console.log(` · parked proposal ${p.id} for ${p.target} (review with \`hypomnema proposal\`)`);
@@ -2007,10 +2489,15 @@ export function applySessionClose(args) {
2007
2489
  // it. T6 turns these into `.cache/proposals/` artifacts; here they are already
2008
2490
  // enough to withhold the bytes and fail the close.
2009
2491
  const conflicts = [];
2010
- // One bag for the four accumulators, passed to every write phase below. They
2492
+ // Overwrite fields where `restructure: true` waived a REAL section-loss
2493
+ // trip. Kept separate from `conflicts` (these are NOT withheld — bytes were
2494
+ // written) and from `applied` (a plain display string there would drop the
2495
+ // "this was a waiver, not an ordinary write" fact on the floor).
2496
+ const restructureWaivers = [];
2497
+ // One bag for the five accumulators, passed to every write phase below. They
2011
2498
  // push into it in call order; nothing is merged back afterwards, so the
2012
2499
  // report lines keep the exact order the inline version produced.
2013
- const acc = { applied, skipped, appliedPaths, conflicts };
2500
+ const acc = { applied, skipped, appliedPaths, conflicts, restructureWaivers };
2014
2501
 
2015
2502
  applyOverwrites(args, payload, project, date, indexRelPath, indexMissing, acc);
2016
2503
  appendSessionLogEntry(args, payload, project, date, acc);
@@ -2048,7 +2535,7 @@ export function applySessionClose(args) {
2048
2535
  const closeScopeNotice = postNotice.filter((e) => isUnderProjectDirs(e.file, [project]));
2049
2536
  const otherDebtCount = postNotice.length - closeScopeNotice.length;
2050
2537
 
2051
- const { markerWritten, markerSkipReason, commitOutcome } = runMarkerPhase(
2538
+ const { markerWritten, markerSkipReason, commitOutcome, gateNotices } = runMarkerPhase(
2052
2539
  args,
2053
2540
  project,
2054
2541
  appliedPaths,
@@ -2099,6 +2586,8 @@ export function applySessionClose(args) {
2099
2586
  postApplyLint,
2100
2587
  closeScopeNotice,
2101
2588
  otherDebtCount,
2589
+ gateNotices,
2590
+ restructureWaivers,
2102
2591
  });
2103
2592
 
2104
2593
  if (args.json) {
@@ -2119,6 +2608,7 @@ export function applySessionClose(args) {
2119
2608
  postBlocking,
2120
2609
  closeScopeNotice,
2121
2610
  otherDebtCount,
2611
+ restructureWaivers,
2122
2612
  });
2123
2613
  }
2124
2614
  process.exit(ok ? 0 : 1);