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