hypomnema 1.7.4 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +1202 -0
  4. package/README.ko.md +4 -4
  5. package/README.md +5 -5
  6. package/commands/audit.md +1 -1
  7. package/commands/capture.md +1 -1
  8. package/commands/crystallize.md +3 -3
  9. package/commands/doctor.md +15 -1
  10. package/commands/feedback.md +1 -1
  11. package/commands/graph.md +1 -1
  12. package/commands/ingest.md +1 -1
  13. package/commands/init.md +2 -2
  14. package/commands/lint.md +1 -1
  15. package/commands/query.md +1 -1
  16. package/commands/rename.md +1 -1
  17. package/commands/resume.md +1 -1
  18. package/commands/stats.md +1 -1
  19. package/commands/uninstall.md +1 -1
  20. package/commands/upgrade.md +2 -2
  21. package/commands/verify.md +1 -1
  22. package/docs/ARCHITECTURE.md +8 -3
  23. package/docs/CONTRIBUTING.md +2 -1
  24. package/hooks/base-store.mjs +198 -0
  25. package/hooks/close-gate-store.mjs +3 -2
  26. package/hooks/hypo-auto-minimal-crystallize.mjs +10 -0
  27. package/hooks/hypo-compact-guard.mjs +5 -3
  28. package/hooks/hypo-cwd-change.mjs +8 -0
  29. package/hooks/hypo-personal-check.mjs +127 -67
  30. package/hooks/hypo-session-start.mjs +91 -10
  31. package/hooks/hypo-shared.mjs +241 -19
  32. package/hooks/version-check.mjs +44 -6
  33. package/package.json +9 -3
  34. package/scripts/capture.mjs +49 -17
  35. package/scripts/crystallize.mjs +20 -2092
  36. package/scripts/doctor.mjs +334 -14
  37. package/scripts/init.mjs +106 -81
  38. package/scripts/lib/crystallize-args.mjs +50 -0
  39. package/scripts/lib/crystallize-close-apply.mjs +1830 -0
  40. package/scripts/lib/crystallize-close-check.mjs +238 -0
  41. package/scripts/lib/crystallize-close-gate.mjs +58 -0
  42. package/scripts/lib/crystallize-helpers.mjs +55 -0
  43. package/scripts/lib/extensions.mjs +175 -49
  44. package/scripts/lib/git-hooks-dir.mjs +88 -3
  45. package/scripts/lib/plugin-detect.mjs +176 -21
  46. package/scripts/lint.mjs +12 -6
  47. package/scripts/proposal.mjs +3 -3
  48. package/scripts/upgrade.mjs +243 -20
  49. package/skills/crystallize/SKILL.md +15 -13
  50. package/skills/graph/SKILL.md +3 -3
  51. package/skills/ingest/SKILL.md +2 -2
  52. package/skills/lint/SKILL.md +3 -3
  53. package/skills/query/SKILL.md +3 -3
  54. package/skills/verify/SKILL.md +3 -3
  55. package/templates/hypo-automation.md +59 -17
  56. package/templates/hypo-config.md +1 -1
  57. package/templates/hypo-guide.md +12 -9
  58. package/templates/hypo-help.md +1 -1
@@ -0,0 +1,1830 @@
1
+ import {
2
+ existsSync,
3
+ statSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ mkdirSync,
7
+ renameSync,
8
+ openSync,
9
+ writeSync,
10
+ closeSync,
11
+ } from 'fs';
12
+ import { join, dirname } from 'path';
13
+ import { spawnSync } from 'child_process';
14
+ import { fileURLToPath } from 'url';
15
+ import { expandHome } from './hypo-root.mjs';
16
+ import { isValidProjectName, substituteTokens, TEMPLATE_DIR } from './project-create.mjs';
17
+ import { appendPendingTags, checkForbidden } from './schema-vocab.mjs';
18
+ import {
19
+ sessionCloseFileStatus,
20
+ sessionCloseGlobalStatus,
21
+ precompactGateStatus,
22
+ writeSessionClosedMarker,
23
+ sessionClosedMarkerPath,
24
+ partitionLintScope,
25
+ isUnderProjectDirs,
26
+ sessionLogReadCandidates,
27
+ sessionLogScopePath,
28
+ rootLogEntry,
29
+ hasSessionLogHeading,
30
+ hasLogEntry,
31
+ resolveTranscriptBySessionId,
32
+ isCloseGateOpen,
33
+ commitWikiChanges,
34
+ vaultCommitLockTarget,
35
+ currentDevice,
36
+ withFileLock,
37
+ resolveGateProjectOverride,
38
+ } from '../../hooks/hypo-shared.mjs';
39
+ import {
40
+ hashContent,
41
+ readBaseEntry,
42
+ advanceBase,
43
+ readObservedHash,
44
+ wasObservedTruncated,
45
+ } from '../../hooks/base-store.mjs';
46
+ import { writeProposal } from '../../hooks/proposal-store.mjs';
47
+ import {
48
+ recordGateClosed,
49
+ resolutionStamp,
50
+ closeGateStatus,
51
+ } from '../../hooks/close-gate-store.mjs';
52
+ import { requireProjectDir } from './crystallize-close-gate.mjs';
53
+ import { summarizeLintForOutput } from './crystallize-helpers.mjs';
54
+
55
+ // LINT_SCRIPT is resolved relative to this lib file rather than via
56
+ // crystallize.mjs's own import.meta.url, so it keeps pointing at the sibling
57
+ // scripts/lint.mjs — same absolute path either way.
58
+ const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), '..', 'lint.mjs');
59
+
60
+ // Spawn lint.mjs --json against `hypoDir` and return parsed result.
61
+ // We shell out instead of refactoring lint.mjs into a library because lint.mjs
62
+ // keeps issues in module scope (scripts/lint.mjs:139,250) — a programmatic
63
+ // extraction is its own chore. spawnSync is the minimum-invasive path for #40.
64
+ // Throws only on JSON parse failure (lint crashed mid-run); a lint that exits 1
65
+ // with valid JSON is a normal "errors present" signal, not a crash.
66
+ // maxBuffer raised to 64 MiB: warn-only output on a large wiki can otherwise
67
+ // trip Node's 1 MiB default, truncate stdout, and turn a clean wiki into a
68
+ // JSON.parse crash (codex P3 follow-up).
69
+ function runLint(hypoDir) {
70
+ const r = spawnSync(process.execPath, [LINT_SCRIPT, `--hypo-dir=${hypoDir}`, '--json'], {
71
+ encoding: 'utf-8',
72
+ maxBuffer: 64 * 1024 * 1024,
73
+ });
74
+ try {
75
+ return JSON.parse(r.stdout);
76
+ } catch {
77
+ // Report diagnostic metadata (sizes, exit/signal, spawn error code, a stderr
78
+ // tail) instead of dumping the whole — possibly huge, possibly truncated —
79
+ // stdout. lint.mjs now sets exitCode and exits naturally so its stdout is no
80
+ // longer cut at the 64 KiB pipe boundary; if this still fires it signals a
81
+ // genuine crash, and these fields say which kind.
82
+ const stderrTail = (r.stderr || '').slice(-2000);
83
+ throw new Error(
84
+ `lint helper produced unparseable output ` +
85
+ `(exit=${r.status}, signal=${r.signal || 'none'}, ` +
86
+ `stdoutBytes=${(r.stdout || '').length}, spawnError=${r.error?.code || 'none'})` +
87
+ (stderrTail ? `\nstderr tail:\n${stderrTail}` : ''),
88
+ );
89
+ }
90
+ }
91
+
92
+ // ── session-close apply ────────────────────────────────────────────
93
+ // Idempotent payload-driven application of the 5 mandatory session-close memory
94
+ // files (+ optional open-questions). Used by the LLM session-close flow as the
95
+ // canonical entrypoint instead of issuing 5+ Write tool calls by hand.
96
+ //
97
+ // Idempotency:
98
+ // • full-content fields (sessionState/projectHot/rootHot/openQuestions): write
99
+ // only when on-disk bytes differ — re-running with same payload is a no-op.
100
+ // • append fields (sessionLog/log): skip when the dated heading/entry is
101
+ // already present (regex shared with sessionCloseFileStatus via hypo-shared).
102
+ //
103
+ // Validation: never auto-fixes the payload. The final sessionCloseFileStatus
104
+ // check fails fast on stale `updated:` or missing entries so the caller fixes
105
+ // the payload and retries — silent rewrites would hide payload bugs (advisor #3).
106
+
107
+ function readPayload(source) {
108
+ if (!source)
109
+ throw new Error('--payload is required with --apply-session-close (path or `-` for stdin)');
110
+ let raw;
111
+ if (source === '-') {
112
+ // Synchronous stdin read; payloads are tiny (a few hundred KB at most).
113
+ raw = readFileSync(0, 'utf-8');
114
+ } else {
115
+ const path = expandHome(source);
116
+ if (!existsSync(path)) throw new Error(`payload file not found: ${path}`);
117
+ raw = readFileSync(path, 'utf-8');
118
+ }
119
+ try {
120
+ return JSON.parse(raw);
121
+ } catch (e) {
122
+ throw new Error(`payload is not valid JSON: ${e.message}`);
123
+ }
124
+ }
125
+
126
+ // How long an append waits for its per-target lock before withholding to the
127
+ // proposal-pending gate (see withFileLock). A withheld append blocks the close but
128
+ // is NOT parked as a proposal artifact — the next close re-appends. Default 5s is
129
+ // generous for a real close;
130
+ // the env override exists ONLY so tests can force a fast timeout instead of
131
+ // spinning the full 5s. Not a documented production knob.
132
+ const APPEND_LOCK_TIMEOUT_MS = Number(process.env.HYPO_APPEND_LOCK_TIMEOUT_MS) || 5000;
133
+
134
+ /** Atomic write via tmp+rename. `<path>.<pid>.<rand>.tmp` so concurrent helpers
135
+ * don't fight over the same shared `<path>.tmp` slot. */
136
+ function atomicWrite(path, content) {
137
+ mkdirSync(dirname(path), { recursive: true });
138
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
139
+ writeFileSync(tmp, content);
140
+ renameSync(tmp, path);
141
+ }
142
+
143
+ /**
144
+ * Read a target's current bytes, distinguishing "absent" from "unreadable" the
145
+ * same way base-store's hashFile does. The overwrite guard needs all three
146
+ * answers: content to compare, `null` to know creating is safe, `undefined` to
147
+ * refuse to guess.
148
+ * @returns {string|null|undefined} bytes, `null` if absent, `undefined` if unreadable
149
+ */
150
+ function readTarget(path) {
151
+ if (!existsSync(path)) return null;
152
+ try {
153
+ return readFileSync(path, 'utf-8');
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Has the target drifted away from what this session observed at start?
161
+ *
162
+ * Branches on base-store's `state` discriminator, never on the truthiness of
163
+ * `hash`: 'absent' and 'unknown' both carry `hash: null`, and collapsing them
164
+ * would read never-observed as safe-to-write, defeating the guard entirely.
165
+ *
166
+ * `observed` is this session's observed set for THIS target: the
167
+ * hash(es) a later SessionStart actually showed it, on top of the original
168
+ * base. It only ever widens what may be written, never narrows or replaces
169
+ * `entry` — a disk hash matching `entry.hash` still passes with `observed`
170
+ * empty, exactly as before this parameter existed. `case 'unknown'` does not
171
+ * consult it: with no snapshot at all there is no base for an observation to
172
+ * extend.
173
+ *
174
+ * @param {{state: 'hash'|'absent'|'unknown', hash: string|null}} entry
175
+ * @param {string|null|undefined} disk current bytes / absent / unreadable
176
+ * @param {{hash: string|null, truncated: boolean}} observed what this session
177
+ * was shown for THIS target after its first snapshot: the exact hash (or
178
+ * `null` when it was never shown anything current), plus whether the one
179
+ * shown-but-not-licensing case (a truncated injection) applies — used only
180
+ * to pick which park reason to report, never to license a write on its own.
181
+ * @returns {string|null} a conflict reason, or null when this session may write
182
+ */
183
+ export function overwriteConflictReason(entry, disk, observed = { hash: null, truncated: false }) {
184
+ // Cannot read what we are about to replace: fail safe, never assume unchanged.
185
+ if (disk === undefined) return 'target-unreadable';
186
+ const observedHash = observed && observed.hash;
187
+ const observedTruncated = !!(observed && observed.truncated);
188
+ switch (entry.state) {
189
+ case 'unknown':
190
+ // No snapshot for this (session, target). Someone else's edits could be
191
+ // sitting on disk and we would have no way to tell.
192
+ return 'base-unknown';
193
+ case 'absent':
194
+ // We observed no file. Creating it is safe; finding one now means another
195
+ // writer got there first, UNLESS this session was later shown exactly
196
+ // those bytes by a resume/compact SessionStart.
197
+ if (disk === null) return null;
198
+ if (observedHash && observedHash === hashContent(disk)) return null;
199
+ return observedTruncated
200
+ ? 'base-mismatch-truncated-observation'
201
+ : 'base-absent-target-exists';
202
+ case 'hash':
203
+ if (disk === null) return 'base-hash-target-missing';
204
+ if (hashContent(disk) === entry.hash) return null;
205
+ // Drifted from the original base — still allowed when this session was
206
+ // shown these exact drifted bytes by a later SessionStart. A truncated
207
+ // injection never reaches `observedHash` (readObservedHash refuses it),
208
+ // so it falls through here and gets its own reason instead of the plain
209
+ // `base-mismatch` a no-observation-at-all case reports.
210
+ if (observedHash && observedHash === hashContent(disk)) return null;
211
+ return observedTruncated ? 'base-mismatch-truncated-observation' : 'base-mismatch';
212
+ default:
213
+ return 'base-unknown';
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Append `entry` to `path` only if `alreadyPresent(content)` is false.
219
+ * Atomic: rebuilds the full file content and writes via atomicWrite — a crash
220
+ * mid-append cannot leave log.md or session-log/YYYY-MM-DD.md half-written, which
221
+ * matters for these append-only history files.
222
+ */
223
+ function appendIfAbsent(path, entry, alreadyPresent) {
224
+ let content = '';
225
+ if (existsSync(path)) {
226
+ try {
227
+ content = readFileSync(path, 'utf-8');
228
+ } catch (err) {
229
+ // ENOENT here is a narrow existsSync-then-readFileSync race (the file
230
+ // vanished between the two calls) — content='' is safe, we would create
231
+ // it fresh anyway. Anything else (EACCES/EISDIR/...) is a PERSISTENT
232
+ // read failure: retrying (the caller's lock-timeout conflict path) would
233
+ // never fix it, and swallowing it here would fall through to the
234
+ // atomicWrite below and silently replace the existing file's bytes with
235
+ // just this one entry — a data-loss overwrite. So rethrow and let the
236
+ // withFileLock/call-site catch hard-fail the close instead.
237
+ if (err?.code !== 'ENOENT') throw err;
238
+ }
239
+ }
240
+ if (alreadyPresent(content)) return false;
241
+ // Ensure single blank line between existing tail and new entry, no trailing dup.
242
+ const sep =
243
+ content === '' ? '' : content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n';
244
+ const next = entry.endsWith('\n') ? entry : entry + '\n';
245
+ atomicWrite(path, content + sep + next);
246
+ return true;
247
+ }
248
+
249
+ function todayLocal() {
250
+ const d = new Date();
251
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
252
+ }
253
+
254
+ // Spec §5.2.7 / §8.3: 4 mandatory + 2 optional (`log`, `openQuestions`).
255
+ // The payload shape MUST mirror that contract — missing a mandatory field is a
256
+ // payload bug, not a no-op. Caller is the LLM session-close flow, which composes
257
+ // the payload deliberately; partial payloads must fail loudly so caller fixes
258
+ // them rather than silently relying on yesterday's freshness state. (Codex review
259
+ // of the apply path — Worker 1 finding 1.) `log` left the mandatory set in B-1:
260
+ // the root log.md entry is a DERIVABLE artifact (rootLogEntry over this close's
261
+ // sessionLog heading), so apply auto-fills it when the field is absent.
262
+ const REQUIRED_PAYLOAD_FIELDS = [
263
+ ['sessionState', 'content'],
264
+ ['projectHot', 'content'],
265
+ ['rootHot', 'content'],
266
+ ['sessionLog', 'entry'],
267
+ ];
268
+
269
+ function validatePayloadShape(payload) {
270
+ const errs = [];
271
+ if (!payload || typeof payload !== 'object') {
272
+ errs.push('payload must be a JSON object');
273
+ return errs;
274
+ }
275
+ for (const [field, key] of REQUIRED_PAYLOAD_FIELDS) {
276
+ const slot = payload[field];
277
+ if (!slot || typeof slot !== 'object') {
278
+ errs.push(`payload.${field} is required (object with .${key})`);
279
+ continue;
280
+ }
281
+ if (typeof slot[key] !== 'string') {
282
+ errs.push(`payload.${field}.${key} must be a string`);
283
+ }
284
+ }
285
+ if (payload.openQuestions !== undefined) {
286
+ if (
287
+ !payload.openQuestions ||
288
+ typeof payload.openQuestions !== 'object' ||
289
+ typeof payload.openQuestions.content !== 'string'
290
+ ) {
291
+ errs.push('payload.openQuestions, when present, must be { content: string }');
292
+ }
293
+ }
294
+ if (payload.log !== undefined) {
295
+ if (!payload.log || typeof payload.log !== 'object' || typeof payload.log.entry !== 'string') {
296
+ errs.push('payload.log, when present, must be { entry: string }');
297
+ }
298
+ }
299
+ if (payload.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(payload.date))) {
300
+ errs.push('payload.date, when present, must be YYYY-MM-DD');
301
+ }
302
+ if (
303
+ payload.sessionId !== undefined &&
304
+ payload.sessionId !== null &&
305
+ typeof payload.sessionId !== 'string'
306
+ ) {
307
+ errs.push('payload.sessionId, when present, must be a string');
308
+ }
309
+ return errs;
310
+ }
311
+
312
+ // ── session-close marker (amendment 2026-05-19) ───────────────
313
+ // Standalone marker writer. Used when the LLM closes the session via direct
314
+ // Write tool calls (not --apply-session-close). Hook `hypo-auto-minimal-
315
+ // crystallize` is the only Reader; writer authority is intentionally split
316
+ // between this CLI and the auto-write at the tail of applySessionClose.
317
+ //
318
+ // Contract: the marker is written only when the FULL /compact gate
319
+ // (precompactGateStatus) is green. A failed gate exits 1 with no
320
+ // marker — the next Stop hook re-blocks.
321
+
322
+ export function runMarkSessionClosed(args) {
323
+ if (!args.sessionId) {
324
+ const msg = '--session-id=<id> is required with --mark-session-closed';
325
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
326
+ process.exit(1);
327
+ }
328
+ // --project=<slug> on --mark names the project THIS session closed. It sets the
329
+ // marker's `project` field AND enters the close scope, so an incomplete close in a
330
+ // project this session did not touch is demoted to a notice instead of refusing the
331
+ // marker. It is NOT a gate narrow in the old sense: the marker records the same slug
332
+ // it scoped by, and PreCompact re-derives its own scope FROM that marker — so the
333
+ // two can never disagree, which is what the earlier "attribution only, gate stays
334
+ // global" rule was protecting (it assumed PreCompact stayed global; it no longer
335
+ // does). Everything else the gate checks stays global. Validate the slug exists as a
336
+ // directory, exactly as --check does, but only when it is actually used (a
337
+ // --log-only mark attributes to no project, so --project is moot there).
338
+ if (args.project && !args.logOnly) requireProjectDir(args, args.project);
339
+ // The per-session marker is the THIRD session-close completion
340
+ // signal (after the PreCompact gate and `--check-session-close`). It must use
341
+ // the SAME gate that governs /compact — precompactGateStatus — so the marker
342
+ // can never attest "closed" while /compact would still block. This subsumes
343
+ // the prior (sessionCloseGlobalStatus + hypoIsClean + scoped-lint) gate and
344
+ // additionally enforces feedback projection (over-cap/conflict), W8 design-
345
+ // history staleness, and root hot.md structure — the checks that the narrower
346
+ // marker gate skipped (the divergence behind this fix). git-clean is now a
347
+ // `git` blocker inside the gate. Pass --transcript-path to widen the lint
348
+ // scope to this session's edited files exactly as the interactive hook does;
349
+ // without it the scope is the mandatory close files only.
350
+ // --log-only marks a non-project (tooling / wiki-only) session as
351
+ // closed without attributing it to any project. The gate runs in log-only mode
352
+ // (project-close invariant → a today log.md entry; lint/W8 scoped to shared +
353
+ // touched files, never the active/phantom project), but git / hot / feedback
354
+ // still apply — log-only is NOT a global-gate bypass.
355
+ // Resolve the close transcript once from the session id (glob, never a CLI
356
+ // arg): it both widens the lint scope inside the gate AND is the evidence
357
+ // source for the user-close hard gate below.
358
+ const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
359
+ // resolveGateProjectOverride (session-close-scope-boundary spec §2/§3): closeScope
360
+ // above only widens THIS session's accountability set (evidence union for the
361
+ // marker's attribution) -- it does not narrow closeAccountableScope's base, so a
362
+ // DIFFERENT project's dangling close files still land in the git-dirty check and
363
+ // block a `--project=<mine>` mark. Passed below as `attributionScope`: this is
364
+ // the value that feeds the mine/foreign partition in precompactGateStatus and
365
+ // demotes the foreign project's own close-file debt to a notice instead of
366
+ // block, and it also decides which projects the marker this call may go on to
367
+ // write ends up attesting -- so passing `projectOverride` instead would narrow
368
+ // sessionCloseGlobalStatus to args.project alone and turn the partition off,
369
+ // which is the bug this key split fixes (a marker then claimed the demoted
370
+ // foreign project was closed too, because close.scope was never narrowed).
371
+ // --project has already passed requireProjectDir's slug + directory check above
372
+ // when !args.logOnly, so resolveGateProjectOverride's own validation is a
373
+ // formality here, not the only guard. The `sessionCwd` argument below is dead:
374
+ // resolveGateProjectOverride returns from its `if (project)` branch before
375
+ // sessionCwd is ever read, and that branch is exactly the one this ternary's
376
+ // `args.project && !args.logOnly` guard takes. Kept only because removing it
377
+ // would suggest sessionCwd narrows this call, which it never has. Scoped to
378
+ // the explicit --project case only (matching closeScope's own condition
379
+ // above): with no --project, this call has no attribution evidence to narrow
380
+ // to yet (markerProjects is decided further down from the transcript), so
381
+ // leaving attributionScope unset here keeps the global judgment that P2's
382
+ // sessionCwd close-cwd check already depends on for a no-argument
383
+ // `--mark-session-closed`.
384
+ const attributionScope =
385
+ args.project && !args.logOnly
386
+ ? resolveGateProjectOverride(args.hypoDir, {
387
+ project: args.project,
388
+ sessionCwd: args.sessionCwd || null,
389
+ })
390
+ : null;
391
+ const gate = precompactGateStatus(args.hypoDir, {
392
+ ...(args.project && !args.logOnly ? { closeScope: [args.project] } : {}),
393
+ ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
394
+ ...(args.logOnly ? { logOnly: true } : {}),
395
+ // P2 (session-close attribution): the marker gate must refuse to attest compact-ready while the
396
+ // session's cwd project has an unstarted close. logOnly exempts it in-gate.
397
+ ...(args.sessionCwd ? { sessionCwd: args.sessionCwd } : {}),
398
+ ...(attributionScope ? { attributionScope } : {}),
399
+ });
400
+ const status = gate.close;
401
+ if (!gate.ok) {
402
+ const result = {
403
+ ok: false,
404
+ session_id: args.sessionId,
405
+ project: status.project,
406
+ missing: status.missing,
407
+ stale: status.stale,
408
+ blockers: gate.blockers,
409
+ error: 'session-close gate not satisfied — marker not written',
410
+ };
411
+ if (args.json) {
412
+ console.log(JSON.stringify(result, null, 2));
413
+ } else {
414
+ console.log(
415
+ `✗ session-close gate not satisfied — marker not written (project: ${status.project || '(unresolved)'}):`,
416
+ );
417
+ for (const b of gate.blockers) console.log(` ✗ ${b.reason}`);
418
+ }
419
+ process.exit(1);
420
+ }
421
+ // User-close hard gate: the compact gate above only proves the wiki
422
+ // is compact-ready; it does NOT prove the USER asked to close. Refuse the marker
423
+ // unless the transcript carries a genuine user close signal (NL close phrase,
424
+ // /compact, or an AskUserQuestion close answer). This is the hard backstop for
425
+ // model over-close, where prose guidance lost to a conflicting global rule.
426
+ // Fail-closed when the transcript can't be resolved.
427
+ if (!closeTranscript || !isCloseGateOpen(closeTranscript)) {
428
+ const reason = !closeTranscript
429
+ ? `cannot resolve a transcript for session ${args.sessionId} — the session-closed marker requires a verifiable user close signal`
430
+ : "no user close signal in this session's transcript — marker refused (the user did not signal session close)";
431
+ const result = {
432
+ ok: false,
433
+ session_id: args.sessionId,
434
+ project: status.project,
435
+ skipReason: 'no-user-close-signal',
436
+ error: reason,
437
+ };
438
+ console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
439
+ process.exit(1);
440
+ }
441
+ // Marker attribution comes from EVIDENCE, never from the gate's global
442
+ // `primary` (which is recency-derived, hypo-shared.mjs). The marker is what
443
+ // PreCompact later re-derives its own scope from, so attributing it to a project
444
+ // this session did not close hands PreCompact a scope the marker never cleared.
445
+ // The evidence set is the close scope — explicit --project ∪ the transcript's
446
+ // touched close files ∪ any prior marker for this session — all of which
447
+ // resolveCloseScope already unioned into status.scope. The recency primary is
448
+ // deliberately excluded: an empty scope means this session has no proof it closed
449
+ // any project (evidence-based close attribution), so we FAIL CLOSED rather than misattribute to recency.
450
+ const closeScope = status.scope || [];
451
+ const markerProjects = [
452
+ ...new Set([...(!args.logOnly && args.project ? [args.project] : []), ...closeScope]),
453
+ ];
454
+ if (!args.logOnly && markerProjects.length === 0) {
455
+ const err =
456
+ 'cannot attribute this close to a project — no evidence (no --project, no transcript close-file edits, no prior marker). ' +
457
+ 'Pass --project=<slug> for the project this session closed, or --log-only for a non-project (tooling/wiki-only) session.';
458
+ console.log(
459
+ args.json
460
+ ? JSON.stringify(
461
+ {
462
+ ok: false,
463
+ session_id: args.sessionId,
464
+ skipReason: 'no-attribution-evidence',
465
+ error: err,
466
+ },
467
+ null,
468
+ 2,
469
+ )
470
+ : `✗ ${err}`,
471
+ );
472
+ process.exit(1);
473
+ }
474
+ const markerProject = !args.logOnly && args.project ? args.project : markerProjects[0];
475
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, {
476
+ project: markerProject,
477
+ projects: args.logOnly ? [] : markerProjects,
478
+ ...(args.logOnly ? { scope: 'log-only' } : {}),
479
+ });
480
+ // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
481
+ // the file actually landed before claiming success — otherwise CLI exits 0
482
+ // while next Stop re-blocks, hiding a permission/disk problem.
483
+ // Codex Worker-2 CONCERN (pre-commit review).
484
+ if (!existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
485
+ const err = 'marker file did not land after write (likely .cache permission/disk issue)';
486
+ console.log(
487
+ args.json
488
+ ? JSON.stringify({ ok: false, session_id: args.sessionId, error: err }, null, 2)
489
+ : `✗ ${err}`,
490
+ );
491
+ process.exit(1);
492
+ }
493
+ const result = {
494
+ ok: true,
495
+ session_id: args.sessionId,
496
+ project: markerProject,
497
+ scope: args.logOnly ? 'log-only' : 'project',
498
+ date: status.dates[0],
499
+ notices: gate.notices,
500
+ // pure feedback-projection drift is a non-blocker: the marker
501
+ // attests "compact-ready (no human-fixable blocker)", and the PreCompact
502
+ // hook self-heals the projection (feedback-sync --write) at /compact. Surface
503
+ // the deferral so the caller knows MEMORY/CLAUDE sync is pending, not lost.
504
+ drift_deferred: gate.driftTargets,
505
+ };
506
+ if (args.json) {
507
+ console.log(JSON.stringify(result, null, 2));
508
+ } else {
509
+ console.log(
510
+ args.logOnly
511
+ ? `✓ session-closed marker written (session_id: ${args.sessionId}, scope: log-only — no project attribution).`
512
+ : `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${markerProject}).`,
513
+ );
514
+ if (gate.driftTargets.length > 0) {
515
+ console.log(
516
+ ` · feedback projection drift (${gate.driftTargets.join(', ')}) — will self-heal at /compact.`,
517
+ );
518
+ }
519
+ }
520
+ process.exit(0);
521
+ }
522
+
523
+ // The close pipeline's marker decision as a PURE function of pre-resolved
524
+ // signals. The caller (applySessionClose) keeps the IO lazy (commit first, then
525
+ // transcript resolve, then gate, then user-signal scan only once the gate
526
+ // passes) and feeds the resulting booleans here; this function owns only the
527
+ // branch PRIORITY and the reason strings, so a deterministic table test can
528
+ // exercise the state machine without spawning the
529
+ // CLI. Returns { write, skipReason }: `write` true means the caller should
530
+ // attempt writeSessionClosedMarker; `skipReason` (non-null on every non-write
531
+ // branch) is the surfaced reason the marker was withheld.
532
+ // - !ok / no session id → not a marker-write path (skipReason null)
533
+ // - commit failed → commit-failed: <reason>
534
+ // - compact gate not ok → compact-gate-not-ok
535
+ // - no transcript → transcript-unresolved
536
+ // - transcript, no signal → no-user-close-signal
537
+ // - all clear → write:true
538
+ export function planMarkerDecision({
539
+ ok,
540
+ hasSessionId,
541
+ committed,
542
+ commitReason,
543
+ gateOk,
544
+ transcriptResolved,
545
+ hasUserSignal,
546
+ }) {
547
+ if (!ok || !hasSessionId) return { write: false, skipReason: null };
548
+ if (!committed) return { write: false, skipReason: `commit-failed: ${commitReason}` };
549
+ if (!gateOk) return { write: false, skipReason: 'compact-gate-not-ok' };
550
+ if (!transcriptResolved || !hasUserSignal) {
551
+ return {
552
+ write: false,
553
+ skipReason: transcriptResolved ? 'no-user-close-signal' : 'transcript-unresolved',
554
+ };
555
+ }
556
+ return { write: true, skipReason: null };
557
+ }
558
+
559
+ // The runtime close-result invariant self-check. Given
560
+ // the settled marker fields, return a non-null contradiction tag when the result
561
+ // is internally inconsistent, else null. This is a REGRESSION GUARD: every
562
+ // non-write branch of planMarkerDecision already records a reason today, so the
563
+ // contradictions below are unreachable — the seam exists so a future branch that
564
+ // forgets to record a reason (or double-sets a written marker with a skip
565
+ // reason) fails LOUDLY (ok flipped false, exit 1) instead of silently emitting a
566
+ // misleading ok:true that a skill-following model reads as "session closed".
567
+ // A "real reason" is a non-blank STRING — every legitimate skip reason is one.
568
+ // Anything else (null, a blank string, or a non-string like false/0/{}) is not a
569
+ // surfaced reason and must not suppress the check, so a future bad assignment
570
+ // cannot hide a withheld marker behind a bogus value.
571
+ // A: ok:true but the marker was withheld with no reason recorded.
572
+ // B: a marker was written AND a skip reason was also set (mutually exclusive).
573
+ export function closeResultContradiction({ ok, markerWritten, markerSkipReason }) {
574
+ const hasRealReason = typeof markerSkipReason === 'string' && markerSkipReason.trim() !== '';
575
+ if (ok === true && markerWritten === false && !hasRealReason) {
576
+ return 'internal-contradiction:marker-withheld-without-reason';
577
+ }
578
+ if (markerWritten === true && hasRealReason) {
579
+ return 'internal-contradiction:marker-written-with-skip-reason';
580
+ }
581
+ return null;
582
+ }
583
+
584
+ // What the model should do when the close is refused. Deliberately does NOT name
585
+ // a flag: the way out of this gate cannot be an argument the model can add, or
586
+ // the gate is decorative. The way out is the user, which is the one input the
587
+ // model does not author.
588
+ const CLOSE_REFUSAL_HELP = [
589
+ 'Nothing was written and nothing was committed.',
590
+ '',
591
+ 'Do NOT add a bypass flag, and do NOT write the close files directly with an editor',
592
+ 'or a shell — that is the same close without the check, and it is the thing this gate',
593
+ 'exists to stop.',
594
+ '',
595
+ 'If the user has not asked to close: do not close. Session-close is not a reward for',
596
+ 'finishing a task, and a long session is not a close signal. Keep working, or ask ONCE',
597
+ 'whether to wrap up, and take no for an answer.',
598
+ '',
599
+ 'If the user HAS asked: pass the current main-conversation --session-id (not a',
600
+ 'background-task or agent uuid from a /tmp path) and re-run this exact payload. The',
601
+ 'writes are idempotent.',
602
+ ].join('\n');
603
+
604
+ /**
605
+ * May this session apply a close at all?
606
+ *
607
+ * Authority comes from the transcript, because the transcript is the one input the
608
+ * model cannot author: the user's own words are in it, and nothing the model says
609
+ * counts (extractUserMessages drops injected, tool, and hook-feedback text).
610
+ *
611
+ * { ok: true }
612
+ * { ok: false, reason, error } reason: session-id-required | transcript-unresolved
613
+ * | no-user-close-signal
614
+ */
615
+ function verifyCloseAuthority(sessionId, hypoDir) {
616
+ if (!sessionId) {
617
+ return {
618
+ ok: false,
619
+ reason: 'session-id-required',
620
+ error:
621
+ 'session-close apply refused before any wiki write or commit: --session-id is required, ' +
622
+ "because the close signal is verified against that session's transcript. Omitting it does " +
623
+ 'not skip the check, it fails it.',
624
+ };
625
+ }
626
+ const transcript = resolveTranscriptBySessionId(sessionId);
627
+ if (!transcript) {
628
+ return {
629
+ ok: false,
630
+ reason: 'transcript-unresolved',
631
+ error:
632
+ `session-close apply refused before any wiki write or commit: no transcript resolves for ` +
633
+ `session ${sessionId}. Pass the MAIN conversation's session id — a uuid taken from a ` +
634
+ `background-task output path or an agent thread is not it, and --transcript-path is not ` +
635
+ `authority here.`,
636
+ };
637
+ }
638
+ const gateStatus = closeGateStatus({ transcriptPath: transcript, hypoDir, sessionId });
639
+ if (!gateStatus.ok) {
640
+ return {
641
+ ok: false,
642
+ reason: 'no-user-close-signal',
643
+ error:
644
+ "session-close apply refused before any wiki write or commit: this session's transcript " +
645
+ 'carries no user close signal. The user did not ask to close. ' +
646
+ `Gate detail: ${gateStatus.reason}`,
647
+ };
648
+ }
649
+ return { ok: true };
650
+ }
651
+
652
+ // A-1 (project index lifecycle): seed projects/<project>/index.md from the
653
+ // template the first time this project closes without one. SCHEMA.md declares
654
+ // project-index at projects/*/index.md and templates/projects/_template/ ships
655
+ // one, but createProject (the auto-project-offer path) is the only writer of
656
+ // it today — a project directory created any other way (a manual mkdir, an
657
+ // older vault, direct Write tool calls) never gets one. Idempotent: an
658
+ // existing index.md is left untouched; this only fills the gap, and only the
659
+ // three tokens the template defines are substituted — the rest (one-line
660
+ // description, Progress checklist) stays human-authored prose exactly as
661
+ // createProject already leaves it.
662
+ //
663
+ // `working_dir` has no authoritative source in this flow: apply never
664
+ // receives the session's cwd (see hooks/hypo-shared.mjs's precompactGateStatus
665
+ // doc — process.cwd() is explicitly non-authoritative here, since it reflects
666
+ // this script's own launch directory, not the session's). It is left EMPTY,
667
+ // not filled with a placeholder string: hooks/hypo-shared.mjs's collector
668
+ // (collectProjectWorkingDirs) and findBackfillCandidate both treat any truthy
669
+ // `working_dir` as "already anchored" and stop offering to backfill the real
670
+ // cwd — a fake placeholder is truthy, so it would silently and permanently
671
+ // swallow the exact anchor-recovery path a human would otherwise get. Empty
672
+ // stays falsy there, so the project surfaces as a genuine backfill candidate
673
+ // until a person (or the auto-project-offer flow) fills in a real path. The
674
+ // key itself stays present in the frontmatter (substituted to an empty value,
675
+ // not omitted) so the shape matches every other index.md and a human sees
676
+ // exactly where to type the answer.
677
+ // "Never overwrite an existing index" is this feature's explicit contract, so
678
+ // creation uses an EXCLUSIVE create (`wx`), not the existsSync-then-atomicWrite
679
+ // shape every other target in this file uses. atomicWrite's tmp+rename
680
+ // replaces whatever sits at `dest` the instant rename fires — existing or not —
681
+ // so a plain `if (existsSync(dest)) return null` beforehand only narrows the
682
+ // race, it does not close it: another writer (a human, or a concurrent close)
683
+ // can land real bytes at `dest` between that check and this function's rename.
684
+ // `wx` makes the OS do the check-and-create atomically, mirroring
685
+ // hooks/base-store.mjs's snapshotBase (`openSync(path, 'wx')` + EEXIST ==
686
+ // "someone already got there, leave it"). No tmp+rename is needed here: unlike
687
+ // atomicWrite's use case (replacing bytes a reader might already be mid-read
688
+ // of), a `wx` create can never observably tear — the file either doesn't
689
+ // exist yet (nothing to tear) or the open fails outright.
690
+ export function ensureProjectIndex(hypoDir, project, relPath, today) {
691
+ const dest = join(hypoDir, relPath);
692
+ const src = join(TEMPLATE_DIR, 'index.md');
693
+ if (!existsSync(src)) return null; // template missing — nothing to scaffold from
694
+ const content = substituteTokens(readFileSync(src, 'utf-8'), {
695
+ name: project,
696
+ started: today,
697
+ workingDir: '',
698
+ today,
699
+ });
700
+ mkdirSync(dirname(dest), { recursive: true });
701
+ let fd;
702
+ try {
703
+ fd = openSync(dest, 'wx');
704
+ } catch (e) {
705
+ if (e && e.code === 'EEXIST') return null; // another writer already created it
706
+ throw e;
707
+ }
708
+ try {
709
+ writeSync(fd, content);
710
+ } finally {
711
+ closeSync(fd);
712
+ }
713
+ return relPath;
714
+ }
715
+
716
+ export function applySessionClose(args) {
717
+ // Option D: early-exit fires only when NO payload was supplied.
718
+ // Rationale: payload presence is explicit close intent and must always run
719
+ // the full apply path — the per-entry idempotency (overwrite's step-1 skip +
720
+ // exact-entry append dedup) keeps re-apply cheap without short-circuiting,
721
+ // and avoids silent-success when a same-day second close brings new bytes.
722
+ // Payload-less invocation is treated as a cheap "already complete?" probe.
723
+ // --force opts out of that probe shortcut only — payload remains required
724
+ // for any actual apply work (readPayload below surfaces "payload is
725
+ // required" the same way it always has).
726
+ if (!args.force && !args.payload) {
727
+ // No-payload "already complete?" probe uses the
728
+ // global invariant, not a recency pick.
729
+ const probe = sessionCloseGlobalStatus(args.hypoDir);
730
+ if (probe.ok) {
731
+ const result = {
732
+ ok: true,
733
+ alreadyComplete: true,
734
+ project: probe.project,
735
+ date: probe.dates[0],
736
+ message: '오늘 이미 close 완료로 보임 (probe 모드 — payload 미지정).',
737
+ };
738
+ if (args.json) {
739
+ console.log(JSON.stringify(result, null, 2));
740
+ } else {
741
+ console.log(`✓ ${result.message}`);
742
+ console.log(` project: ${result.project} / date: ${result.date}`);
743
+ }
744
+ process.exit(0);
745
+ }
746
+ // gate not ok → fall through to readPayload, which surfaces
747
+ // "payload is required" with the same error shape as before.
748
+ }
749
+
750
+ // ── close authority: BEFORE any write ──────────────────────────────────────
751
+ // A payload is a claim that the user asked to close. Verify it here, ahead of
752
+ // readPayload and every byte that follows, because everything after this point
753
+ // is authoritative: session-state.md and the hot files are overwritten, the
754
+ // session-log and log.md are appended, and hypo-auto-commit lands the lot in git
755
+ // on the next Stop whether or not this command commits.
756
+ //
757
+ // The gate used to stand at the END, next to the marker (planMarkerDecision).
758
+ // It refused the MARKER on a close the user never asked for, and let the writes
759
+ // and the commit through. Worse, it was opt-in BY THE CALLER: with no
760
+ // --session-id there was no transcript to check, so the check simply did not
761
+ // run, and the apply reported ok:true with no skip reason at all. The model
762
+ // chose whether the model got checked. A model that has already decided to close
763
+ // does not reach for the flag that would stop it.
764
+ //
765
+ // So: no verifiable close signal, no close. Zero bytes, no commit, exit 1.
766
+ //
767
+ // What this does and does not promise. It promises that THIS entrypoint refuses
768
+ // a close with no recognized transcript evidence behind it. It does not promise
769
+ // the model cannot write these files at all: Write and Bash remain outside this
770
+ // gate, and the evidence predicate itself is coarse (any close signal anywhere in
771
+ // the transcript counts, so a stale one from earlier in a long session still
772
+ // passes, and it is the model that authors the AskUserQuestion option labels the
773
+ // user picks from). Narrowing that (current-turn binding, revocation, consent
774
+ // that the model did not word) is the follow-up this ADR names; it is not a
775
+ // reason to leave the default open in the meantime.
776
+ // Only a payload-bearing call can write. A payload-less one falls through to the
777
+ // "payload is required" error below without touching a byte, so gating it here
778
+ // would just replace one refusal with a less accurate one.
779
+ const closeAuth = args.payload
780
+ ? verifyCloseAuthority(args.sessionId, args.hypoDir)
781
+ : { ok: true };
782
+ if (!closeAuth.ok) {
783
+ const out = {
784
+ ok: false,
785
+ stage: 'no-user-close-signal',
786
+ reason: closeAuth.reason,
787
+ applied: [],
788
+ // `null`, not `false`: this refusal fires before the commit step is ever
789
+ // reached (see the general result's own `committed` contract below).
790
+ // `false` is reserved for a commit that actually ran and failed.
791
+ committed: null,
792
+ error: closeAuth.error,
793
+ };
794
+ console.log(
795
+ args.json ? JSON.stringify(out, null, 2) : `✗ ${closeAuth.error}\n\n${CLOSE_REFUSAL_HELP}`,
796
+ );
797
+ process.exit(1);
798
+ }
799
+
800
+ let payload;
801
+ try {
802
+ payload = readPayload(args.payload);
803
+ } catch (e) {
804
+ const out = { ok: false, error: e.message };
805
+ console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${e.message}`);
806
+ process.exit(1);
807
+ }
808
+
809
+ const schemaErrs = validatePayloadShape(payload);
810
+ if (schemaErrs.length > 0) {
811
+ const out = { ok: false, error: 'payload schema invalid', details: schemaErrs };
812
+ console.log(
813
+ args.json
814
+ ? JSON.stringify(out, null, 2)
815
+ : `✗ payload schema invalid:\n ${schemaErrs.join('\n ')}`,
816
+ );
817
+ process.exit(1);
818
+ }
819
+
820
+ // Payload↔session binding (cross-session payload collision). The payload temp
821
+ // file is now written to a session-scoped path (see commands/crystallize.md), so
822
+ // two same-day sessions no longer share a file. This is the belt to that
823
+ // suspenders: if the payload names
824
+ // the session it was authored for, it must be THIS one — the --session-id whose
825
+ // transcript already cleared close authority above. A mismatch means the file on
826
+ // disk is not this session's close (a stray or hand-reused path handed us another
827
+ // session's payload), and applying it would stamp that content with this session's
828
+ // marker while the original session's record vanishes — the exact loss this
829
+ // guard exists to prevent. Refuse before a byte is written.
830
+ //
831
+ // Absent field → fail open: older payloads predate this field, and Part 1's unique
832
+ // path already prevents the collision. So the check only ever tightens; it never
833
+ // rejects a close it would otherwise have allowed on a matching (or absent) id. It
834
+ // is deliberately identity-based, not cwd-based: closing project B from a session
835
+ // whose cwd is project A stays supported (payload.project is authoritative), so a
836
+ // legitimately cross-project close is untouched.
837
+ if (
838
+ payload.sessionId !== undefined &&
839
+ payload.sessionId !== null &&
840
+ payload.sessionId !== args.sessionId
841
+ ) {
842
+ const msg =
843
+ `payload.sessionId ${JSON.stringify(payload.sessionId)} does not match --session-id ` +
844
+ `${JSON.stringify(args.sessionId)}: this payload was authored for a different session, so ` +
845
+ `it is not this session's close. Refusing before any write (cross-session guard).`;
846
+ const out = {
847
+ ok: false,
848
+ stage: 'session-id-mismatch',
849
+ error: msg,
850
+ applied: [],
851
+ // `null`, not `false` — refused before the commit step, same contract as
852
+ // the `no-user-close-signal` refusal above.
853
+ committed: null,
854
+ };
855
+ console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
856
+ process.exit(1);
857
+ }
858
+
859
+ // Resolve project: payload.project is REQUIRED (B-3, close-gate-hardening). The
860
+ // old recency fallback (payload.project || probe.project) could, on a same-date
861
+ // root-hot.md tie, resolve a DIFFERENT project than the one the payload's files
862
+ // belong to — apply would then write the close into the wrong project (silent
863
+ // data loss). Validate fail-fast, BEFORE the probe is consulted:
864
+ // - missing → no target to write; abort rather than infer.
865
+ // - invalid name → reject (non-string, wrong charset, or dot-only) BEFORE the
866
+ // existsSync(join(...)) path build, so a `../`-style value
867
+ // never reaches a path builder (traversal guard — order is
868
+ // the guard). isValidProjectName is SHARED with createProject
869
+ // so apply accepts exactly the namespace the repo can
870
+ // scaffold (A-Za-z0-9._-, single segment) — no narrower.
871
+ // - non-existent → projects/<slug>/ absent; abort rather than create.
872
+ // A payload.project that merely DIFFERS from the inferred active project is NOT an
873
+ // error — it is surfaced as a stderr note below and the close proceeds.
874
+ if (payload.project === undefined || payload.project === null) {
875
+ const msg = 'payload.project is required (apply must not infer the close target project)';
876
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
877
+ process.exit(1);
878
+ }
879
+ if (!isValidProjectName(payload.project)) {
880
+ const msg = `payload.project ${JSON.stringify(payload.project)} is not a valid project name (single segment, charset A-Za-z0-9._-, ≥1 alnum, not "."/"..")`;
881
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
882
+ process.exit(1);
883
+ }
884
+ // existsSync alone is not enough: a regular FILE at projects/<slug> would pass,
885
+ // then apply would build child paths under it and fail with an unstructured
886
+ // filesystem error (codex re-review). Require it to be a directory.
887
+ const projectDir = join(args.hypoDir, 'projects', payload.project);
888
+ if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
889
+ const msg = `payload.project "${payload.project}" does not exist as a directory (no projects/${payload.project}/ directory)`;
890
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
891
+ process.exit(1);
892
+ }
893
+ const project = payload.project;
894
+ // probe (the recency-inferred active project) is now consulted ONLY to surface a
895
+ // divergence note — never to resolve the target. Computed AFTER validation so a
896
+ // malformed/missing payload.project fails fast without a pointer-table read.
897
+ // Resolved BEFORE preflight because preflight needs overwrite-target paths
898
+ // (which require the project slug) to filter out errors in files this apply
899
+ // is about to replace — see the filter rationale below.
900
+ const probe = sessionCloseFileStatus(args.hypoDir);
901
+ // The freshness verification below (and at the post-apply check) already honors
902
+ // payload.project — `project` wins over the inferred active project, and the
903
+ // post-apply sessionCloseFileStatus call passes it as projectOverride. But when the
904
+ // payload targets a DIFFERENT project than the one active-project resolution infers
905
+ // (probe.project), that divergence used to be silent, so an operator couldn't tell
906
+ // which project the close actually verified. Surface it on stderr (the stdout JSON
907
+ // contract is untouched) so the verified/closed project is always explicit.
908
+ if (probe.project && probe.project !== payload.project) {
909
+ process.stderr.write(
910
+ `note: payload.project="${payload.project}" differs from the inferred active ` +
911
+ `project "${probe.project}"; verifying and closing "${payload.project}".\n`,
912
+ );
913
+ }
914
+ const date = payload.date || todayLocal();
915
+
916
+ // Pre-apply freshness-contract gate: the post-apply verification holds
917
+ // sessionCloseFileStatus's hasSessionLogHeading / hasLogEntry as the
918
+ // definition of "closed today". Enforce that SAME contract on the payload
919
+ // BEFORE writing a byte, so a heading the gate won't recognize is rejected
920
+ // here as a format mismatch — not written and then misdiagnosed downstream as
921
+ // "stale" (the "not updated" vs "format mismatch" conflation). All checks
922
+ // exit 1 with stage='pre-apply-verification' and leave the tree untouched.
923
+ const failPreApply = (msg) => {
924
+ console.log(
925
+ args.json
926
+ ? JSON.stringify({ ok: false, stage: 'pre-apply-verification', error: msg }, null, 2)
927
+ : `✗ ${msg}`,
928
+ );
929
+ process.exit(1);
930
+ };
931
+ // (a) The session-log entry must carry a dated `## [<date>] …` ATX heading. The
932
+ // post-apply gate checks the session-log file for exactly this heading, so a
933
+ // headingless entry would write then false-fail as "stale". This also doubles
934
+ // as the B-1 derive precondition: when `log` is omitted the root log.md entry
935
+ // is reconstructed from THIS heading, and on a same-day SECOND close the
936
+ // date-level verifier would still pass on the earlier entry, so a no-derive
937
+ // would slip through as ok:true. The `!payload.log` branch keeps the original
938
+ // derive-specific wording (a test asserts it).
939
+ if (!hasSessionLogHeading(payload.sessionLog.entry || '', date)) {
940
+ failPreApply(
941
+ !payload.log
942
+ ? `payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
943
+ `entry from. Give it a dated heading, or supply payload.log explicitly.`
944
+ : `payload.sessionLog.entry has no "## [${date}] …" heading. The close gate ` +
945
+ `identifies a session-log by its dated ATX heading; give the entry a ` +
946
+ `"## [${date}] <title>" heading (the brackets are required).`,
947
+ );
948
+ }
949
+ // (b) An explicit payload.log entry must match the canonical
950
+ // `## [<date>] session | <project>` line the gate looks for (colon or space
951
+ // delimiter after the slug). Otherwise the write lands but post-apply
952
+ // verification reports log.md as stale. When `log` is omitted the line is
953
+ // derived canonically (rootLogEntry) so this cannot mismatch.
954
+ if (payload.log && !hasLogEntry(payload.log.entry || '', date, project)) {
955
+ failPreApply(
956
+ `payload.log.entry has no "## [${date}] session | ${project}" heading that the ` +
957
+ `close gate recognizes. Fix the entry heading, or omit payload.log to derive it.`,
958
+ );
959
+ }
960
+
961
+ // Preflight: lint the wiki BEFORE writing any payload bytes. If lint
962
+ // has blockers (errors) in files this apply WON'T overwrite, the wiki is in
963
+ // a degraded state and apply would mask the root cause — abort fail-fast.
964
+ //
965
+ // Overwrite-target filter (codex P2 follow-up): errors in files we're about
966
+ // to fully replace are IGNORED at preflight. Otherwise a bad payload
967
+ // (post-apply-lint fail) would leave the broken file on disk and the very
968
+ // next retry — even with a corrected payload — gets dead-locked here. The
969
+ // post-apply lint is the authoritative check on payload content.
970
+ //
971
+ // Append targets (session-log, log.md) are NOT filtered: appending can't
972
+ // repair existing corruption, so a corrupt session-log must still block.
973
+ // Warns are informational (not gated) in either pass.
974
+ //
975
+ // The filter says "about to be replaced", and the observed-base guard can later
976
+ // decline to replace one of these. Preflight runs before the guard, so it cannot
977
+ // know. Harmless: post-apply lint re-scopes the same file and blocks on it there.
978
+ const overwriteTargets = new Set();
979
+ if (payload.sessionState) overwriteTargets.add(join('projects', project, 'session-state.md'));
980
+ if (payload.projectHot) overwriteTargets.add(join('projects', project, 'hot.md'));
981
+ if (payload.rootHot) overwriteTargets.add('hot.md');
982
+ if (payload.openQuestions) overwriteTargets.add(join('pages', 'open-questions.md'));
983
+
984
+ // Bug B: the documented close path must not be blocked by lint debt OUTSIDE
985
+ // the files it writes (other projects, shared pages this close did not author).
986
+ // payloadScope = every file this apply writes or appends. Both lint passes are
987
+ // judged against it; errors elsewhere are surfaced as notices, never blocking.
988
+ //
989
+ // session-log needs TWO entries: the daily WRITE target (what this
990
+ // apply creates/appends, judged by post-apply lint) AND the freshness EVIDENCE
991
+ // file. They coincide except in the hybrid cutover month, where a fallback-
992
+ // aware no-op (the identical entry already lives in the legacy monthly file)
993
+ // writes no daily shard, leaving the monthly as the proof of freshness. Scope
994
+ // must then include that monthly file, or a CORRUPT monthly evidence file would
995
+ // pass the gate with its lint error demoted to a non-blocking notice.
996
+ // sessionLogScopePath returns the monthly ONLY when it carries today's heading
997
+ // (otherwise the daily write target), so unrelated monthly debt stays a notice.
998
+ // join() (platform-native), not the POSIX helper output: payloadScope membership
999
+ // is tested against lint's raw `e.file` (path.relative) WITHOUT posix
1000
+ // normalization, so it must use the OS-native separator the sibling entries use.
1001
+ const sessionLogWriteTarget = join('projects', project, 'session-log', `${date}.md`);
1002
+ const sessionLogEvidence = join(...sessionLogScopePath(args.hypoDir, project, date).split('/'));
1003
+ // A-1: known here (read-only check, no write yet) so a freshly-scaffolded
1004
+ // index.md is scoped to THIS close's own payloadScope below, rather than
1005
+ // showing up as an unrelated pre-existing-content notice.
1006
+ const indexRelPath = join('projects', project, 'index.md');
1007
+ const indexMissing = !existsSync(join(args.hypoDir, indexRelPath));
1008
+ const payloadScope = new Set([
1009
+ join('projects', project, 'session-state.md'),
1010
+ join('projects', project, 'hot.md'),
1011
+ 'hot.md',
1012
+ sessionLogWriteTarget,
1013
+ sessionLogEvidence, // == write target, except a hybrid-month monthly fallback
1014
+ 'log.md',
1015
+ ...(payload.openQuestions ? [join('pages', 'open-questions.md')] : []),
1016
+ ...(indexMissing ? [indexRelPath] : []),
1017
+ ]);
1018
+
1019
+ let preflightLint;
1020
+ try {
1021
+ preflightLint = runLint(args.hypoDir);
1022
+ } catch (e) {
1023
+ const out = { ok: false, stage: 'preflight-lint', error: e.message };
1024
+ console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${e.message}`);
1025
+ process.exit(1);
1026
+ }
1027
+ // Block only on errors in payload files we are NOT about to overwrite (append
1028
+ // targets — session-log, log.md — can't be repaired by appending, so existing
1029
+ // corruption there must block). Overwrite targets are about to be replaced;
1030
+ // out-of-scope debt is not this close's concern (Bug B).
1031
+ const blockingErrors = preflightLint.errors.filter(
1032
+ (e) => payloadScope.has(e.file) && !overwriteTargets.has(e.file),
1033
+ );
1034
+ if (blockingErrors.length > 0) {
1035
+ const out = {
1036
+ ok: false,
1037
+ stage: 'preflight-lint',
1038
+ error: 'lint preflight failed — apply aborted (no payload bytes written)',
1039
+ lint: { ...summarizeLintForOutput(preflightLint), blockingErrors },
1040
+ };
1041
+ if (args.json) {
1042
+ console.log(JSON.stringify(out, null, 2));
1043
+ } else {
1044
+ console.log('✗ lint preflight failed — apply aborted (no payload bytes written):');
1045
+ for (const e of blockingErrors) console.log(` ✗ ${e.file}: ${e.message}`);
1046
+ console.log(' Fix the wiki (run `node scripts/lint.mjs`) and retry.');
1047
+ }
1048
+ process.exit(1);
1049
+ }
1050
+
1051
+ const applied = [];
1052
+ const skipped = [];
1053
+ // The ACTUAL vault-relative paths this apply wrote, kept separate
1054
+ // from `applied` (whose entries are display strings like `key (relPath)`,
1055
+ // not bare paths). This is the scope handed to commitWikiChanges below;
1056
+ // never the broader `payloadScope` above, which also includes lint/evidence
1057
+ // candidates this apply may not have written a byte to.
1058
+ const appliedPaths = [];
1059
+ // Overwrite targets this apply refused to write because the page moved under
1060
+ // it. T6 turns these into `.cache/proposals/` artifacts; here they are already
1061
+ // enough to withhold the bytes and fail the close.
1062
+ const conflicts = [];
1063
+
1064
+ /**
1065
+ * Replace a whole page, guarded by the base this session observed at start.
1066
+ *
1067
+ * The step order is load-bearing, not stylistic:
1068
+ *
1069
+ * 1. idempotent skip (disk already equals the payload)
1070
+ * 2. conflict (base unknown, or disk drifted away from base)
1071
+ * 3. direct write, then advance the base
1072
+ *
1073
+ * Step 1 must come first for two reasons. It keeps every existing
1074
+ * `--apply-session-close --session-id` test green (they read the payload
1075
+ * straight off disk, so they land here before any base lookup). And it breaks
1076
+ * the apply-then-reclose loop: once a human applies proposal P, disk == proposed
1077
+ * == payload.content, so the next close skips before it can re-raise a conflict.
1078
+ *
1079
+ * There is no caller here without a `--session-id`. verifyCloseAuthority refuses
1080
+ * that at the door, before a byte is written, so a session id is always present
1081
+ * by the time this runs and the base lookup always has something to look up.
1082
+ */
1083
+ const overwrite = (key, relPath, field) => {
1084
+ if (!field || typeof field.content !== 'string') return; // optional / absent
1085
+ const full = join(args.hypoDir, relPath);
1086
+ const disk = readTarget(full);
1087
+
1088
+ // (1) idempotent skip — preserves writeIfChanged's contract
1089
+ if (disk === field.content) {
1090
+ skipped.push(`${key} (${relPath})`);
1091
+ return;
1092
+ }
1093
+
1094
+ // (2) conflict, only where a session context makes a base observable
1095
+ if (args.sessionId) {
1096
+ const entry = readBaseEntry(args.hypoDir, args.sessionId, relPath);
1097
+ // EVERY mismatch parks. A drifted whole-file overwrite is resolved by a
1098
+ // human through `proposal challenge` and `proposal resolve`, which writes
1099
+ // exactly the bytes that were shown.
1100
+ //
1101
+ // Five predicates were written to skip the park when the payload "provably"
1102
+ // lost nothing, and four rounds of review broke all five against the real
1103
+ // vault: table rows only, a trimmed multiset, an ordered verbatim
1104
+ // subsequence, that plus a row-shaped-insert rule, and that plus a placement
1105
+ // rule. Each fell to the same thing. A markdown document's meaning is set by
1106
+ // block context that starts far above the line being judged, so a predicate
1107
+ // reading lines and their neighbours cannot see a payload that preserves
1108
+ // every byte and still fences the file into code, merges two paragraphs,
1109
+ // invalidates the frontmatter, or cuts a table off from its separator.
1110
+ // Proving it needs a block parser, and this guard is not where a markdown
1111
+ // parser should live.
1112
+ //
1113
+ // The narrowing is not coming back in this shape. The pointer table it was
1114
+ // built for should stop being a shared whole-file overwrite target at all and
1115
+ // become a locally generated projection of the project files that already own
1116
+ // those facts; then two machines never contend over it.
1117
+ const observedHash = readObservedHash(args.hypoDir, args.sessionId, relPath);
1118
+ // A truncated observation never comes back as `observedHash` (readObservedHash
1119
+ // refuses it), so this second read is what lets the park reason below tell
1120
+ // "never shown anything current" apart from "shown, but only a slice of it" —
1121
+ // see base-store.mjs's recordObserved/readObservedHash docs.
1122
+ const observedTruncated =
1123
+ !observedHash && wasObservedTruncated(args.hypoDir, args.sessionId, relPath);
1124
+ const reason = overwriteConflictReason(entry, disk, {
1125
+ hash: observedHash,
1126
+ truncated: observedTruncated,
1127
+ });
1128
+ if (reason) {
1129
+ conflicts.push({
1130
+ key,
1131
+ target: relPath,
1132
+ reason,
1133
+ baseHash: entry.hash,
1134
+ currentHash: typeof disk === 'string' ? hashContent(disk) : null,
1135
+ proposedContent: field.content,
1136
+ });
1137
+ return; // target bytes untouched
1138
+ }
1139
+ }
1140
+
1141
+ // (3) write, then the content we just wrote IS this session's new base
1142
+ atomicWrite(full, field.content);
1143
+ if (args.sessionId)
1144
+ advanceBase(args.hypoDir, args.sessionId, relPath, hashContent(field.content));
1145
+ applied.push(`${key} (${relPath})`);
1146
+ appliedPaths.push(relPath);
1147
+ };
1148
+
1149
+ overwrite('sessionState', join('projects', project, 'session-state.md'), payload.sessionState);
1150
+ overwrite('projectHot', join('projects', project, 'hot.md'), payload.projectHot);
1151
+ overwrite('rootHot', 'hot.md', payload.rootHot);
1152
+ overwrite('openQuestions', join('pages', 'open-questions.md'), payload.openQuestions);
1153
+
1154
+ // A-1: fill a missing project index as part of this close's writes (after
1155
+ // preflight passed, so an aborted close never leaves a half-applied side
1156
+ // effect on disk).
1157
+ if (indexMissing) {
1158
+ const createdIndex = ensureProjectIndex(args.hypoDir, project, indexRelPath, date);
1159
+ if (createdIndex) {
1160
+ applied.push(`projectIndex (${createdIndex})`);
1161
+ appliedPaths.push(createdIndex);
1162
+ }
1163
+ }
1164
+
1165
+ // Append idempotency: dedup by exact-entry presence, not by "any heading
1166
+ // dated today". The freshness gate (sessionCloseFileStatus) is what answers
1167
+ // "was this file touched today?"; that's a different concern and must not
1168
+ // be reused for apply-time dedup, or a legitimate same-day second close gets
1169
+ // silently dropped (Codex review of the apply path — Worker 1 finding 2).
1170
+ const entryAlreadyPresent = (entry) => (content) =>
1171
+ content.includes(entry.endsWith('\n') ? entry.replace(/\n+$/, '') : entry);
1172
+
1173
+ {
1174
+ const rel = join('projects', project, 'session-log', `${date}.md`);
1175
+ const full = join(args.hypoDir, rel);
1176
+ const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
1177
+ // Serialize dedup + create/append on the daily shard so two concurrent
1178
+ // closes never lose an entry: the second closer takes the lock only after
1179
+ // the first committed, re-reads the shard under the lock, and appends onto
1180
+ // the committed bytes (temp+rename write-isolation is preserved — a partial
1181
+ // write never tears the target). Create and append share ONE lock, so the
1182
+ // "seed a new shard" and "append to an existing shard" branches can't race
1183
+ // each other — only one closer is ever in the create path (closes the
1184
+ // wx-window a bare exclusive-create would leave open).
1185
+ try {
1186
+ const outcome = withFileLock(
1187
+ full,
1188
+ () => {
1189
+ // Fallback-aware idempotency (hybrid cutover): during the month the
1190
+ // shard takes over, today's entry may already live in the legacy monthly
1191
+ // file from an earlier (pre-cutover) close. Treat presence in EITHER the
1192
+ // daily shard or the legacy monthly file as "already written" so a same-day
1193
+ // second close does not duplicate an identical entry across both files —
1194
+ // and so an idempotent re-apply stays a true no-op (no shard is created).
1195
+ for (const cand of sessionLogReadCandidates(project, date)) {
1196
+ const cf = join(args.hypoDir, cand);
1197
+ if (!existsSync(cf)) continue;
1198
+ try {
1199
+ if (isPresent(readFileSync(cf, 'utf-8'))) return 'skipped';
1200
+ } catch {
1201
+ /* unreadable candidate — fall through to the write path */
1202
+ }
1203
+ }
1204
+ if (!existsSync(full)) {
1205
+ // A daily shard is a new file most days. Seed minimal valid frontmatter
1206
+ // (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
1207
+ // wiki page rather than a W1 "no frontmatter" warning, and write the header
1208
+ // AND the first entry in ONE atomic write — never leave a header-only shard
1209
+ // on disk, which freshness would skip (no dated heading) while derive could
1210
+ // otherwise mistake it for the evidence file. The dated `## [date] ...`
1211
+ // heading lives inside the entry, so freshness / derive / design-history
1212
+ // are unchanged.
1213
+ // Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
1214
+ // `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
1215
+ // docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
1216
+ // machine that first seeds the daily shard is recorded; later same-day
1217
+ // appends do not touch it. The per-session-accurate store is the LOCAL
1218
+ // (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
1219
+ // `session_id` is honest naming: the value is the Claude session UUID, and
1220
+ // it is present only on the Stop-chain close path that passes --session-id.
1221
+ const device = currentDevice();
1222
+ const auditFm =
1223
+ (args.sessionId
1224
+ ? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n`
1225
+ : '') + `device: ${device}\n`;
1226
+ const header =
1227
+ `---\ntitle: Session Log ${date} (${project})\n` +
1228
+ `type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
1229
+ `# Session Log ${date} (${project})\n`;
1230
+ const entry = payload.sessionLog.entry;
1231
+ const body = entry.endsWith('\n') ? entry : `${entry}\n`;
1232
+ atomicWrite(full, `${header}\n${body}`);
1233
+ return 'created';
1234
+ }
1235
+ return appendIfAbsent(full, payload.sessionLog.entry, isPresent) ? 'appended' : 'skipped';
1236
+ },
1237
+ { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1238
+ );
1239
+ (outcome === 'skipped' ? skipped : applied).push(`sessionLog (${rel})`);
1240
+ if (outcome !== 'skipped') appliedPaths.push(rel);
1241
+ } catch (err) {
1242
+ // Only a lock-TIMEOUT is withheld as a conflict. A real fn() write error
1243
+ // (disk-full, EACCES, mkdir failure) must NOT be masked as a proposal-
1244
+ // pending timeout — rethrow so it hard-fails like the overwrite path does.
1245
+ if (err?.code !== 'ELOCKTIMEOUT') throw err;
1246
+ // Lock-timeout: withhold rather than lose the entry. Recorded as a conflict
1247
+ // so the close goes proposal-pending (ok:false, no marker) and the next
1248
+ // close re-applies. `kind: 'append'` is what T6 branches on to SKIP parking
1249
+ // this: an append conflict never becomes a `.cache/proposals/` artifact —
1250
+ // the lock-timeout is transient and the next close self-heals by
1251
+ // re-appending, whereas a whole-file re-apply would drop this shard's other
1252
+ // entries. It still blocks the close; it just gets no artifact.
1253
+ conflicts.push({
1254
+ key: 'sessionLog',
1255
+ target: rel,
1256
+ reason: 'append-lock-timeout',
1257
+ kind: 'append',
1258
+ baseHash: null,
1259
+ currentHash: null,
1260
+ proposedContent: payload.sessionLog.entry,
1261
+ });
1262
+ }
1263
+ }
1264
+
1265
+ // log.md: `payload.log` is OPTIONAL (B-1). When the caller supplies it, keep
1266
+ // the explicit appendIfAbsent path (backward-compat: a custom log line, with
1267
+ // the same idempotent dedup). When it is ABSENT, the root log.md entry is a
1268
+ // DERIVABLE artifact: reconstruct the canonical `## [date] session | <project>`
1269
+ // line directly from THIS close's session-log heading (`payload.sessionLog`),
1270
+ // not by re-reading the session-log files. Deriving from the payload is what
1271
+ // makes the per-close entry exact: a same-day second close lands its distinct
1272
+ // heading, and a hybrid daily/monthly session-log split can't hide it (apply
1273
+ // never reads those files for this). The global scan-based deriveRootLogEntries
1274
+ // (the Stop hook) still backfills OTHER projects; calling it here would either
1275
+ // miss the current entry (single-candidate read) or, with a loosened guard,
1276
+ // append onto a deliberately custom payload.log (codex pre-commit review). The
1277
+ // two payload paths are mutually exclusive: deriving on top of a present-but-
1278
+ // malformed payload.log would mask it and weaken the verifier's fail-loud.
1279
+ // log.md is shared across projects and also written by deriveRootLogEntries
1280
+ // (the Stop-hook backfill in hypo-shared.mjs). Both take the SAME lock on
1281
+ // log.md, so a concurrent close's append and this close's append serialize
1282
+ // instead of overwriting each other.
1283
+ const logFull = join(args.hypoDir, 'log.md');
1284
+ if (payload.log) {
1285
+ try {
1286
+ const wrote = withFileLock(
1287
+ logFull,
1288
+ () => appendIfAbsent(logFull, payload.log.entry, entryAlreadyPresent(payload.log.entry)),
1289
+ { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1290
+ );
1291
+ (wrote ? applied : skipped).push('log (log.md)');
1292
+ if (wrote) appliedPaths.push('log.md');
1293
+ } catch (err) {
1294
+ if (err?.code !== 'ELOCKTIMEOUT') throw err;
1295
+ // proposedContent is append-ready root-log bytes (the custom log line).
1296
+ conflicts.push({
1297
+ key: 'log',
1298
+ target: 'log.md',
1299
+ reason: 'append-lock-timeout',
1300
+ kind: 'append',
1301
+ baseHash: null,
1302
+ currentHash: null,
1303
+ proposedContent: payload.log.entry,
1304
+ });
1305
+ }
1306
+ } else {
1307
+ // matchAll (not exec) mirrors deriveRootLogEntries: a payload that carried
1308
+ // more than one dated heading derives one canonical line each, symmetric with
1309
+ // the global path. Exact-line dedup on the heading keeps a second apply (or a
1310
+ // titleless vs titled same-day pair) from duplicating.
1311
+ const headingRe = new RegExp(`^#{1,6} \\[${date}\\]\\s*(.*)$`, 'gm');
1312
+ try {
1313
+ const wroteAny = withFileLock(
1314
+ logFull,
1315
+ () => {
1316
+ let w = false;
1317
+ for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
1318
+ const { heading, block } = rootLogEntry(project, date, m[1]);
1319
+ const wrote = appendIfAbsent(logFull, block, (c) =>
1320
+ (c || '').split(/\r?\n/).includes(heading),
1321
+ );
1322
+ w = w || wrote;
1323
+ }
1324
+ return w;
1325
+ },
1326
+ { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1327
+ );
1328
+ (wroteAny ? applied : skipped).push('log (log.md, derived)');
1329
+ if (wroteAny) appliedPaths.push('log.md');
1330
+ } catch (err) {
1331
+ if (err?.code !== 'ELOCKTIMEOUT') throw err;
1332
+ // `derived: true` discriminates this from the payload.log conflict above:
1333
+ // here proposedContent is the session-log entry to RE-DERIVE root-log lines
1334
+ // from (via rootLogEntry over its dated headings), NOT append-ready bytes.
1335
+ // Like every `kind: 'append'` conflict, this is NOT parked as an artifact —
1336
+ // it blocks the close and the next close re-derives + re-appends. The derived
1337
+ // shape only matters to the retry, never to T6's overwrite proposal store.
1338
+ conflicts.push({
1339
+ key: 'log',
1340
+ target: 'log.md',
1341
+ reason: 'append-lock-timeout',
1342
+ kind: 'append',
1343
+ derived: true,
1344
+ baseHash: null,
1345
+ currentHash: null,
1346
+ proposedContent: payload.sessionLog.entry,
1347
+ });
1348
+ }
1349
+ }
1350
+
1351
+ // T6: park drifted OVERWRITE targets as `.cache/proposals/` artifacts.
1352
+ //
1353
+ // Runs regardless of --json AND regardless of args.sessionId: writing the
1354
+ // artifact is a SIDE EFFECT, not output, so it is conditioned on neither the
1355
+ // report format nor a session context. (In practice an overwrite conflict only
1356
+ // arises with a session id, so a session-less apply just finds no overwrite
1357
+ // conflicts to park — but the loop is unconditional to match that contract.)
1358
+ // Only overwrite conflicts (`kind !== 'append'`) become artifacts. An append
1359
+ // conflict is a transient lock-timeout the NEXT close self-heals by
1360
+ // re-appending; parking it as a whole-file artifact and later re-applying it
1361
+ // (T7 replaces the whole target) would drop every OTHER entry in that
1362
+ // append-only history file. Append conflicts still sit in `conflicts`, so the
1363
+ // close still goes proposal-pending — they just get no artifact and no
1364
+ // human-apply step.
1365
+ const proposals = [];
1366
+ const proposalStoreFailures = [];
1367
+ const device = currentDevice();
1368
+ for (const c of conflicts) {
1369
+ if (c.kind === 'append') continue; // append conflicts are never parked (see above)
1370
+ try {
1371
+ const saved = writeProposal(args.hypoDir, {
1372
+ target: c.target,
1373
+ baseHash: c.baseHash,
1374
+ currentAtProposalHash: c.currentHash,
1375
+ proposedContent: c.proposedContent, // internal (pre-drop) full page bytes
1376
+ sessionId: args.sessionId, // may be null; writeProposal coerces it
1377
+ device,
1378
+ });
1379
+ proposals.push({ id: saved.id, target: saved.target, path: saved.path });
1380
+ // Supersede-delete failure is NON-fatal: the new artifact IS parked, only
1381
+ // a stale sibling lingers (superseded next close). Surface it, don't fail.
1382
+ for (const w of saved.supersedeWarnings) {
1383
+ process.stderr.write(`\n⚠️ ${w} (stale artifact left behind, not fatal)\n`);
1384
+ }
1385
+ } catch (err) {
1386
+ // fail-loud: the target was withheld AND its bytes are now on neither disk
1387
+ // NOR a proposal artifact — a genuine data-loss risk. Never swallow this.
1388
+ const error = (err && err.message) || String(err);
1389
+ proposalStoreFailures.push({ target: c.target, key: c.key, error });
1390
+ process.stderr.write(
1391
+ `\n🛑 PROPOSAL STORE FAILED for ${c.key} (${c.target}): ${error}\n` +
1392
+ ` This close WITHHELD the target (it drifted from your observed base) but\n` +
1393
+ ` could NOT write the .cache/proposals/ artifact either. The payload bytes\n` +
1394
+ ` are on NEITHER disk NOR a proposal — re-run the close once the .cache/\n` +
1395
+ ` directory is writable so the withheld content is not lost.\n`,
1396
+ );
1397
+ }
1398
+ }
1399
+ const proposalStoreFailed = proposalStoreFailures.length > 0;
1400
+
1401
+ // Same-date-tie fix: verify against the SAME project this apply just wrote
1402
+ // (`project` = payload.project || probe.project, resolved at the top). Without
1403
+ // the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
1404
+ // on a same-date root-hot.md tie, can pick a different project — false-failing
1405
+ // a completed close (the 2026-06-09 security-ops-kb incident).
1406
+ const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
1407
+
1408
+ // B-4 auto-register: lift unknown (non-forbidden) tags surfaced by the PREFLIGHT
1409
+ // lint into SCHEMA.md's `### Pending` section so the post-apply lint sees them as
1410
+ // known and the close never stalls on a vocabulary gap. The W10 id is hidden in
1411
+ // non-strict --json output (lint.mjs toOut), so the unknown-tag warns are matched
1412
+ // and the tag extracted from the message string itself — kept in lockstep with
1413
+ // lint.mjs's W10 emit (a copy-edit there breaks this; the close-path round-trip
1414
+ // test guards it). Forbidden patterns stay hard errors and are filtered out.
1415
+ // SCOPE (eventual consistency, intended): this registers PRE-EXISTING wiki debt
1416
+ // visible at preflight, NOT a novel tag this very close's payload introduces —
1417
+ // that one would surface only at post-apply and lands on the NEXT close. The
1418
+ // contract is "must not stall", which warns (not errors) already satisfy; the
1419
+ // registration just keeps the vocabulary catching up.
1420
+ // The capture is anchored on the FULL message suffix (not `[^"]+`) so a tag that
1421
+ // itself contains a `"` — non-forbidden, so reachable — is captured whole rather
1422
+ // than truncated at its first quote (codex stage-2 CONCERN).
1423
+ const unknownTagRe = /^Unknown tag: "(.+)" \(not in SCHEMA\.md Tag Vocabulary\)/;
1424
+ const pendingTags = [];
1425
+ for (const w of preflightLint.warns || []) {
1426
+ const m = unknownTagRe.exec(w.message || '');
1427
+ if (m && !checkForbidden(m[1])) pendingTags.push(m[1]);
1428
+ }
1429
+ // Withheld a target? Then register nothing. This close is going to be re-run
1430
+ // once a human resolves the conflict, and its `ok:false` skips the commit below
1431
+ // (`if (ok && args.sessionId)`) — so registering here would leave SCHEMA.md
1432
+ // mutated, uncommitted, and absent from `applied`, i.e. a silent side effect of
1433
+ // a close whose whole point was to write nothing. Registration is
1434
+ // eventually-consistent by design, so deferring it to the next close costs
1435
+ // nothing (codex W2 CONCERN).
1436
+ if (pendingTags.length > 0 && conflicts.length === 0) {
1437
+ appendPendingTags(args.hypoDir, pendingTags);
1438
+ }
1439
+
1440
+ // Post-apply lint: payload may have introduced a malformed body or
1441
+ // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
1442
+ // broke" apart from "frontmatter stale". This runs even if the freshness gate
1443
+ // also failed — both failure modes are useful to the caller.
1444
+ let postApplyLint;
1445
+ let postApplyCrashed = false;
1446
+ try {
1447
+ postApplyLint = runLint(args.hypoDir);
1448
+ } catch (e) {
1449
+ // A lint crash (unparseable output) after writes is NOT scopeable — there is
1450
+ // no reliable `file` to classify — and must stay a HARD failure, exactly as
1451
+ // before scoping was introduced.
1452
+ postApplyCrashed = true;
1453
+ postApplyLint = {
1454
+ ok: false,
1455
+ errors: [{ file: '(lint crash)', message: e.message }],
1456
+ warns: [],
1457
+ };
1458
+ }
1459
+
1460
+ // Scope post-apply lint to payload files (Bug B): a payload-introduced error
1461
+ // lands in a file this apply wrote, so it blocks; pre-existing debt elsewhere
1462
+ // is a non-blocking notice. A lint crash bypasses scoping and blocks outright.
1463
+ let postBlocking;
1464
+ let postNotice;
1465
+ if (postApplyCrashed) {
1466
+ postBlocking = postApplyLint.errors;
1467
+ postNotice = [];
1468
+ } else {
1469
+ ({ blocking: postBlocking, notice: postNotice } = partitionLintScope(
1470
+ postApplyLint.errors || [],
1471
+ payloadScope,
1472
+ ));
1473
+ }
1474
+ const postLintOk = !postApplyCrashed && postBlocking.length === 0;
1475
+ // `let` (not const): the close-result invariant self-check below may flip this
1476
+ // to false when the settled close result is internally contradictory.
1477
+ //
1478
+ // A withheld conflict target must fail the close on its own, not merely via the
1479
+ // freshness gate. If the other session already touched that page TODAY, freshness
1480
+ // sees a fresh file and passes — and the close would report ok:true, write the
1481
+ // marker, and drop this session's payload silently. `conflicts` closes that hole.
1482
+ let ok = verification.ok && postLintOk && conflicts.length === 0;
1483
+
1484
+ // Scope the non-blocking notice to the close-target project: debt under
1485
+ // projects/<project>/ stays listed; debt elsewhere folds to a count so the
1486
+ // same untouched-file debt does not re-list its filenames on every close.
1487
+ const closeScopeNotice = postNotice.filter((e) => isUnderProjectDirs(e.file, [project]));
1488
+ const otherDebtCount = postNotice.length - closeScopeNotice.length;
1489
+
1490
+ // Amendment 2026-05-19: auto-write the per-session
1491
+ // closed marker on a verified close. Hook authority is read-only; this is
1492
+ // one of the two writer paths (the other is --mark-session-closed standalone).
1493
+ //
1494
+ // The marker write is governed by the SAME gate as standalone
1495
+ // --mark-session-closed and /compact (precompactGateStatus), NOT just apply's
1496
+ // `ok` + git-clean. Apply's payload preflight/post-apply lint and `ok` still
1497
+ // govern apply SUCCESS (exit code below), but the marker must additionally
1498
+ // clear feedback projection / W8 design-history / hot.md structure, else this
1499
+ // path could issue a marker the standalone path would refuse (the second
1500
+ // divergence codex flagged).
1501
+ //
1502
+ // Apply just wrote the payload, so the tree is dirty by its OWN
1503
+ // writes: the gate's `uncommitted` git blocker would always trip and the
1504
+ // marker would be skipped, deferring the close to a manual --mark-session-closed
1505
+ // ("done but still blocked" regression). Commit the payload HERE, via
1506
+ // the SAME .hypoignore-aware helper the auto-commit Stop hook uses, so the gate sees
1507
+ // a committed tree. Push stays deferred to the Stop hook; the resulting
1508
+ // committed-but-unpushed state is a gate notice, not a blocker, so
1509
+ // this still marks. A commit failure (not a repo / pre-commit reject / git error)
1510
+ // skips the marker WITH a surfaced reason — today's behavior was also "no marker",
1511
+ // but silently.
1512
+ let markerWritten = false;
1513
+ let markerSkipReason = null;
1514
+ // Hoisted so the result JSON below can report it: `null` when this apply never
1515
+ // reached the commit step at all (ok:false before the writes were even
1516
+ // verified), distinct from a commit that ran and reported `committed:false`.
1517
+ let commitOutcome = null;
1518
+ if (ok && args.sessionId) {
1519
+ // Close-gate resolution: apply succeeding (`ok`) IS the resolution, not
1520
+ // whether the per-session marker below happens to land. The marker can
1521
+ // be withheld for reasons that have nothing to do with whether this
1522
+ // apply's own writes were valid (a stale git tree, a feedback-projection
1523
+ // cap, W8 design-history staleness) — none of that should leave the
1524
+ // resolution unrecorded, because the wiki writes already happened, and
1525
+ // re-running the SAME apply with no fresh user close signal is exactly
1526
+ // what this record exists to block. So this sits OUTSIDE and ahead of
1527
+ // the marker's own commit-gated logic below, resolving its own
1528
+ // transcript rather than sharing the marker's `closeTranscript` (which
1529
+ // stays null whenever the commit fails) — a commit failure withholds
1530
+ // the marker but must not also withhold the resolution.
1531
+ //
1532
+ // Best-effort like every other write in this store: resolutionStamp
1533
+ // returns null on anything it cannot read as a Buffer, recordGateClosed
1534
+ // refuses a null stamp, and both fail silently, so a transcript that
1535
+ // vanishes mid-read (or a cache-write failure) can never turn an
1536
+ // otherwise-successful apply into a failure.
1537
+ try {
1538
+ const resolutionTranscriptPath = resolveTranscriptBySessionId(args.sessionId);
1539
+ if (resolutionTranscriptPath) {
1540
+ recordGateClosed(
1541
+ args.hypoDir,
1542
+ args.sessionId,
1543
+ resolutionStamp(readFileSync(resolutionTranscriptPath)),
1544
+ );
1545
+ }
1546
+ } catch {
1547
+ // Unreadable at the moment of a successful close is not this apply's
1548
+ // problem to surface — the resolution just stays unrecorded, same as
1549
+ // if this session had never resolved at all (NO_CONSTRAINT).
1550
+ }
1551
+
1552
+ // IO stays lazy so this preserves the exact side-effect order (codex design
1553
+ // review): commit first (the only mutation), then resolve the
1554
+ // transcript, then run the compact gate with that transcript, then scan the
1555
+ // user-close signal ONLY once the gate passes. planMarkerDecision owns the
1556
+ // branch priority + reason strings; the booleans below are computed in that
1557
+ // same short-circuiting order so no read runs earlier than it does today.
1558
+ // Scope this commit to the paths THIS apply actually wrote
1559
+ // (appliedPaths), never the broader payloadScope, which also
1560
+ // names lint/evidence candidates apply may not have touched a byte of.
1561
+ // Locked against the SAME target the auto-commit Stop hook holds, so a
1562
+ // concurrent Stop-chain commit on this vault can't interleave with this
1563
+ // apply's stage+commit. A lock-timeout is treated exactly like any other
1564
+ // commit failure below (skip the marker, surface the reason) rather than
1565
+ // crashing the apply.
1566
+ try {
1567
+ commitOutcome = withFileLock(vaultCommitLockTarget(args.hypoDir), () =>
1568
+ commitWikiChanges(args.hypoDir, appliedPaths),
1569
+ );
1570
+ } catch (err) {
1571
+ commitOutcome = { committed: false, reason: `vault-commit-lock: ${err?.message || err}` };
1572
+ }
1573
+ let closeTranscript = null;
1574
+ let gateOk = false;
1575
+ if (commitOutcome.committed) {
1576
+ closeTranscript = resolveTranscriptBySessionId(args.sessionId);
1577
+ // closeScope: apply KNOWS which project it just closed, and it wrote
1578
+ // that project's files from inside this process, they never appear in the
1579
+ // transcript as Edit/Write, so `payload.project` is the only signal that puts
1580
+ // this close in scope. Without it, apply's own incomplete close could be demoted
1581
+ // to a foreign-debt notice and marked green.
1582
+ //
1583
+ // attributionScope (session-close-scope-boundary spec §2/§3): the same
1584
+ // `payload.project` signal, resolved through the shared validator. apply's
1585
+ // launch cwd can differ from payload.project (a supported cross-project
1586
+ // close), so unlike PreCompact/Stop there is no cwd to fall back on here,
1587
+ // and passing one would risk narrowing to the wrong project. Passed as
1588
+ // `attributionScope`, never `projectOverride` — this is closeScope's own
1589
+ // project already, so it changes nothing about which projects end up in
1590
+ // scope, but it is what turns the mine/foreign partition ON: with
1591
+ // `projectOverride` the gate would also narrow sessionCloseGlobalStatus to
1592
+ // `project` alone, going green on a foreign project's incomplete close
1593
+ // instead of demoting it to a notice.
1594
+ const autoMarkerOverride = resolveGateProjectOverride(args.hypoDir, { project });
1595
+ gateOk = precompactGateStatus(args.hypoDir, {
1596
+ closeScope: [project],
1597
+ ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
1598
+ ...(autoMarkerOverride ? { attributionScope: autoMarkerOverride } : {}),
1599
+ }).ok;
1600
+ }
1601
+ const decision = planMarkerDecision({
1602
+ ok,
1603
+ hasSessionId: true,
1604
+ committed: commitOutcome.committed,
1605
+ commitReason: commitOutcome.reason,
1606
+ gateOk,
1607
+ transcriptResolved: !!closeTranscript,
1608
+ // Scan the signal only when the gate passed AND a transcript resolved —
1609
+ // isCloseGateOpen never runs earlier than the original nested `else if`.
1610
+ // Reads the raw walkCloseGate open, not closeGateStatus: this apply's
1611
+ // OWN recordGateClosed call above already ran with this transcript's
1612
+ // full record count as closedAtIndex, and openedAtIndex can never reach
1613
+ // or pass a count taken from the very same transcript (see
1614
+ // closeGateStatus's doc comment) — so gating this diagnostic on .ok
1615
+ // would read false on every apply, unconditionally, not just a stale
1616
+ // one. This field asks a narrower question than closeGateStatus
1617
+ // answers: "did the transcript carry a close signal", not "is this
1618
+ // apply itself still authorized" (verifyCloseAuthority already settled
1619
+ // that, before any byte was written).
1620
+ hasUserSignal: gateOk && !!closeTranscript && isCloseGateOpen(closeTranscript),
1621
+ });
1622
+ markerSkipReason = decision.skipReason;
1623
+ if (decision.write) {
1624
+ // apply KNOWS its authoritative payload.project — stamp it as the v4
1625
+ // evidence set so PreCompact trusts this marker's scope directly (session-close attribution).
1626
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, { project, projects: [project] });
1627
+ // Codex CONCERN: the writer swallows IO errors (best-effort).
1628
+ // Verify the file actually landed — mirroring the standalone path — instead of
1629
+ // asserting markerWritten=true, so a .cache permission/disk problem surfaces
1630
+ // rather than the caller reporting "closed" while the next Stop re-blocks.
1631
+ if (existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
1632
+ markerWritten = true;
1633
+ } else {
1634
+ markerSkipReason = 'marker-did-not-land';
1635
+ }
1636
+ }
1637
+ }
1638
+ // A conflict outranks the downstream gates: verification and lint both describe
1639
+ // a tree this apply declined to finish writing, so naming them would point the
1640
+ // reader at the wrong repair. A proposal-STORE failure outranks even that: the
1641
+ // withheld bytes never reached an artifact, so it is the most urgent repair.
1642
+ let stage = ok
1643
+ ? null
1644
+ : proposalStoreFailed
1645
+ ? 'proposal-store-failed'
1646
+ : conflicts.length > 0
1647
+ ? 'proposal-pending'
1648
+ : !verification.ok && !postLintOk
1649
+ ? 'post-apply-verification+lint'
1650
+ : !verification.ok
1651
+ ? 'post-apply-verification'
1652
+ : 'post-apply-lint';
1653
+ // Runtime close-result invariant self-check. When a
1654
+ // marker-write path (args.sessionId present) settles into an internally
1655
+ // contradictory shape — ok:true with the marker silently withheld and no
1656
+ // reason, or a written marker that also carries a skip reason — flip ok:false
1657
+ // and stage-tag it so the existing `process.exit(ok ? 0 : 1)` yields exit 1.
1658
+ // That non-zero exit is the discriminator that separates a genuine
1659
+ // contradiction (a code bug) from a legitimate withhold (exit 0, e.g.
1660
+ // no-user-close-signal). apply is idempotent, so a non-zero re-run is safe.
1661
+ // Unreachable today; this is a regression guard for future refactors.
1662
+ if (args.sessionId) {
1663
+ const contradiction = closeResultContradiction({ ok, markerWritten, markerSkipReason });
1664
+ if (contradiction) {
1665
+ ok = false;
1666
+ stage = contradiction;
1667
+ process.stderr.write(
1668
+ `\n🛑 INTERNAL CONTRADICTION in session-close result: ${contradiction}\n` +
1669
+ ` markerWritten=${markerWritten}, markerSkipReason=${JSON.stringify(markerSkipReason)}.\n` +
1670
+ ` This is a close-pipeline bug, not a normal withhold. Exiting non-zero so it\n` +
1671
+ ` cannot masquerade as a successful close. The applied payload files stand;\n` +
1672
+ ` re-running apply is idempotent once the pipeline is fixed.\n`,
1673
+ );
1674
+ }
1675
+ }
1676
+ const result = {
1677
+ ok,
1678
+ stage,
1679
+ project,
1680
+ date,
1681
+ applied,
1682
+ skipped,
1683
+ // Was the general-shape sibling of the two early-refusal `committed:null`
1684
+ // fields (no-user-close-signal / session-id-mismatch), which this path never
1685
+ // carried before: a reader of `applied:[]` on a no-op re-run had no
1686
+ // `committed` value to check against and no way to tell it apart from a run
1687
+ // that never reached the commit step. `null` here means exactly that: `ok`
1688
+ // came back false before the commit ever ran (see `stage` for which check
1689
+ // failed: post-apply-verification, post-apply-lint, or proposal-pending). It
1690
+ // does NOT mean nothing was written — an overwrite/append can already be on
1691
+ // disk (see `applied` / `appliedUncommitted`) while `committed` stays `null`.
1692
+ // `true` covers both an actual commit and the legitimate "nothing to stage"
1693
+ // no-op (commitWikiChanges' own contract, see hooks/hypo-shared.mjs); `false`
1694
+ // is a real commit failure, surfaced together with markerSkipReason below.
1695
+ committed: commitOutcome ? commitOutcome.committed : null,
1696
+ // Targets withheld: an overwrite drifted from this session's observed base, or
1697
+ // an append could not take the file lock in time (`kind: 'append'`). Two
1698
+ // channels resolve these, and `proposals` vs `conflicts[].kind` are the sole
1699
+ // discriminators: an OVERWRITE conflict is parked as a `.cache/proposals/`
1700
+ // artifact (below) for a human to review and re-apply; an APPEND conflict gets
1701
+ // NO artifact and is re-tried automatically by the next close. `proposedContent`
1702
+ // is dropped from the reported shape either way (the artifact / the next close
1703
+ // holds the bytes; a whole page or an append entry does not belong in the JSON).
1704
+ conflicts: conflicts.map(({ proposedContent: _drop, ...rest }) => rest),
1705
+ // Parked overwrite proposals (id/target/path), one per drifted overwrite
1706
+ // target. Empty when only append conflicts (or none) occurred. The T7 CLI
1707
+ // lists and applies these; append conflicts never appear here.
1708
+ proposals,
1709
+ ...(proposalStoreFailed ? { proposalStoreFailures } : {}),
1710
+ // Partial close: some overwrite direct-writes (and/or appends) already landed
1711
+ // on disk while at least one conflict withheld the rest. Because the close is
1712
+ // ok:false, commitWikiChanges + the marker are skipped — so those written
1713
+ // files sit on disk UNCOMMITTED until the conflict is resolved and the close
1714
+ // re-runs. Named honestly: `appliedUncommitted` covers every write that landed
1715
+ // (overwrite or append), not overwrites alone.
1716
+ ...(applied.length > 0 && conflicts.length > 0
1717
+ ? { partialConflict: true, appliedUncommitted: [...applied] }
1718
+ : {}),
1719
+ verification,
1720
+ // Surface the marker outcome instead of skipping silently, so the
1721
+ // caller can tell "closed" from "applied but not marked".
1722
+ ...(args.sessionId ? { markerWritten, markerSkipReason } : {}),
1723
+ lint: {
1724
+ preflight: summarizeLintForOutput(preflightLint),
1725
+ postApply: summarizeLintForOutput(postApplyLint),
1726
+ },
1727
+ // Pre-existing lint debt in files this close did not author: surfaced for
1728
+ // visibility, never gated. Empty on a clean vault. Scoped to the close-target
1729
+ // project's own dir — debt under projects/<project>/ is this close's
1730
+ // neighborhood and stays listed; debt elsewhere (other projects, shared
1731
+ // pages, root files) folds into otherDebtCount so the same untouched-file
1732
+ // debt does not re-list its filenames on every close (run `node
1733
+ // scripts/lint.mjs` for the full list).
1734
+ notices: [...new Set(closeScopeNotice.map((e) => e.file))],
1735
+ otherDebtCount,
1736
+ };
1737
+
1738
+ if (args.json) {
1739
+ console.log(JSON.stringify(result, null, 2));
1740
+ } else {
1741
+ console.log(`Session-close apply (project: ${project}, date: ${date}):`);
1742
+ for (const a of applied) console.log(` ✓ wrote ${a}`);
1743
+ for (const s of skipped) console.log(` · skipped ${s} (already current)`);
1744
+ // Never let a withheld target read as a skip: `skipped` means "already current",
1745
+ // this means "your bytes are NOT on disk". Overwrite conflicts drifted from base;
1746
+ // an append conflict is a lock-timeout (someone else held the file's lock), which
1747
+ // is transient — the next close re-applies.
1748
+ for (const c of conflicts) {
1749
+ const why =
1750
+ c.kind === 'append'
1751
+ ? 'could not acquire the append lock in time; the next close re-applies'
1752
+ : 'the page changed since this session read it';
1753
+ console.log(` ⚠ WITHHELD ${c.key} (${c.target}) — ${c.reason}; ${why}`);
1754
+ }
1755
+ for (const p of proposals) {
1756
+ console.log(
1757
+ ` · parked proposal ${p.id} for ${p.target} (review with \`hypomnema proposal\`)`,
1758
+ );
1759
+ }
1760
+ if (applied.length > 0 && conflicts.length > 0) {
1761
+ console.log(
1762
+ '\n· partial close: the writes above ARE on disk but NOT committed — this close is\n' +
1763
+ ' ok:false, so no commit and no session-close marker run until the withheld\n' +
1764
+ ' target(s) are resolved and the close re-runs.',
1765
+ );
1766
+ }
1767
+ if (ok) {
1768
+ // When the marker was withheld, qualify the success line so a reader scanning
1769
+ // stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
1770
+ // is non-null exactly when args.sessionId is set and the marker did not land.
1771
+ if (markerSkipReason) {
1772
+ console.log(
1773
+ '\n✓ session-close files verified (all 5 mandatory files fresh, lint clean).' +
1774
+ '\n session NOT fully closed: the Stop-chain marker was not written (see warning below).',
1775
+ );
1776
+ } else {
1777
+ console.log('\n✓ session-close verified — all 5 mandatory files fresh, lint clean.');
1778
+ }
1779
+ }
1780
+ // When ok:true but the session-close marker was NOT written, the Stop-chain
1781
+ // still sees an open session and will re-prompt at the next Stop. Surface this
1782
+ // loudly so neither the human nor a skill-following model reads "ok:true" as
1783
+ // "session fully closed". Gate on `!markerWritten` too so this "marker NOT
1784
+ // written" line cannot fire on the contradiction-B path (a written marker that
1785
+ // also carried a skip reason) — there the invariant's own 🛑 line already
1786
+ // explains the failure, and this message would contradict markerWritten:true.
1787
+ if (markerSkipReason && !markerWritten) {
1788
+ process.stderr.write(
1789
+ `\n⚠️ session-close marker NOT written (reason: ${markerSkipReason})\n` +
1790
+ ` The 5 mandatory files were applied and verified (ok:true), but the\n` +
1791
+ ` per-session Stop-chain marker was withheld. The session is NOT fully\n` +
1792
+ ` closed: the Stop hook will re-prompt until the marker is present.\n` +
1793
+ ` To fix: re-run with the correct main-conversation --session-id (NOT\n` +
1794
+ ` a background task or Agent UUID from a /tmp/... path).\n` +
1795
+ ` Example: crystallize.mjs --apply-session-close --payload=<path>\n` +
1796
+ ` --session-id=<main-conversation-id> --hypo-dir=<path>\n`,
1797
+ );
1798
+ }
1799
+ if (!ok) {
1800
+ if (!verification.ok) {
1801
+ const bad = [
1802
+ ...verification.missing.map((f) => `${f} (missing)`),
1803
+ ...verification.stale.map((f) => `${f} (stale)`),
1804
+ ].join(', ');
1805
+ console.log(`\n✗ session-close still incomplete after apply: ${bad}`);
1806
+ console.log(' Fix the payload (likely an `updated:` field) and retry.');
1807
+ }
1808
+ if (!postLintOk) {
1809
+ console.log('\n✗ post-apply lint failed:');
1810
+ for (const e of postBlocking) console.log(` ✗ ${e.file}: ${e.message}`);
1811
+ console.log(' Payload introduced a lint blocker — fix the payload content and retry.');
1812
+ }
1813
+ }
1814
+ if (closeScopeNotice.length > 0) {
1815
+ console.log(
1816
+ `\n· ${closeScopeNotice.length} pre-existing lint issue(s) in untouched files (not blocking): ${[
1817
+ ...new Set(closeScopeNotice.map((e) => e.file)),
1818
+ ]
1819
+ .slice(0, 5)
1820
+ .join(', ')}${closeScopeNotice.length > 5 ? ', …' : ''}`,
1821
+ );
1822
+ }
1823
+ if (otherDebtCount > 0) {
1824
+ console.log(
1825
+ `\n· +${otherDebtCount} pre-existing lint issue(s) elsewhere in the vault (other projects / shared pages, not blocking) — run \`node scripts/lint.mjs\` for the full list.`,
1826
+ );
1827
+ }
1828
+ }
1829
+ process.exit(ok ? 0 : 1);
1830
+ }