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