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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +105 -51
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/commands/crystallize.md +14 -3
- package/docs/ARCHITECTURE.md +13 -5
- package/docs/CONTRIBUTING.md +21 -7
- package/hooks/close-journal.mjs +128 -0
- package/hooks/hooks.json +1 -9
- package/hooks/hypo-session-start.mjs +194 -11
- package/hooks/hypo-shared.mjs +117 -39
- package/hooks/proposal-store.mjs +35 -1
- package/hooks/shared.json +9 -0
- package/package.json +2 -1
- package/scripts/doctor.mjs +43 -91
- package/scripts/init.mjs +54 -106
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +569 -79
- package/scripts/lib/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +150 -0
- package/scripts/lib/pkg-provenance.mjs +11 -0
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/template-schema-version.mjs +47 -0
- package/scripts/uninstall.mjs +130 -66
- package/scripts/upgrade.mjs +243 -145
- package/templates/hypo-config.md +1 -1
|
@@ -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
|
-
|
|
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)
|
|
191
|
-
// sitting on disk
|
|
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
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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(
|
|
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')
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
1703
|
-
//
|
|
1704
|
-
//
|
|
1705
|
-
//
|
|
1706
|
-
//
|
|
1707
|
-
//
|
|
1708
|
-
// one. This field asks a narrower
|
|
1709
|
-
// answers: "did the transcript carry a
|
|
1710
|
-
// apply itself still authorized
|
|
1711
|
-
// that, before any byte was
|
|
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
|
-
|
|
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
|
-
|
|
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".
|
|
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
|
-
|
|
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
|
-
//
|
|
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);
|