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