hypomnema 1.6.2 → 1.7.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 (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +904 -128
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -75,7 +75,6 @@ import {
75
75
  realpathSync,
76
76
  } from 'fs';
77
77
  import { join, dirname } from 'path';
78
- import { hostname } from 'os';
79
78
  import { spawnSync } from 'child_process';
80
79
  import { fileURLToPath, pathToFileURL } from 'url';
81
80
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
@@ -102,7 +101,13 @@ import {
102
101
  resolveTranscriptBySessionId,
103
102
  hasUserCloseSignal,
104
103
  commitWikiChanges,
104
+ currentDevice,
105
+ scopeVisible,
106
+ readVisibilityScope,
107
+ withFileLock,
105
108
  } from '../hooks/hypo-shared.mjs';
109
+ import { hashContent, readBaseEntry, advanceBase } from '../hooks/base-store.mjs';
110
+ import { writeProposal } from '../hooks/proposal-store.mjs';
106
111
 
107
112
  // This script's own absolute path. Used to print copy-pasteable recovery
108
113
  // commands as `node <SELF_SCRIPT> ...` rather than a bare `crystallize` bin,
@@ -169,6 +174,7 @@ function parseArgs(argv) {
169
174
  else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
170
175
  else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
171
176
  else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
177
+ else if (arg.startsWith('--session-cwd=')) args.sessionCwd = expandHome(arg.slice(14));
172
178
  else if (arg.startsWith('--project=')) args.project = arg.slice(10);
173
179
  else if (arg === '--force') args.force = true;
174
180
  else if (arg === '--json') args.json = true;
@@ -240,13 +246,31 @@ function runSessionCloseCheck(args) {
240
246
  // would re-add that project's files to the lint scope and re-block the scoped
241
247
  // check, defeating the point. The global (no --project) check keeps widening.
242
248
  if (args.project) requireProjectDir(args, args.project);
249
+ // Resolve the transcript from --session-id when --transcript-path was not given,
250
+ // exactly as --mark and the apply auto-marker already do. The transcript is what
251
+ // attributes the close as well as widening the lint scope, and PreCompact
252
+ // always has one from its hook payload. A check without it would compute an EMPTY
253
+ // close scope, fall back to the global block, and report RED for debt that /compact
254
+ // demotes. Checklist step 14 tells the model to trust this command, so an over-red
255
+ // check is as harmful as an over-green one.
256
+ const checkTranscript =
257
+ args.transcriptPath ||
258
+ (args.sessionId ? resolveTranscriptBySessionId(args.sessionId) : null) ||
259
+ null;
243
260
  const status = precompactGateStatus(args.hypoDir, {
244
261
  ...(args.project
245
262
  ? { projectOverride: args.project }
246
- : args.transcriptPath
247
- ? { transcriptPath: args.transcriptPath }
263
+ : checkTranscript
264
+ ? { transcriptPath: checkTranscript }
248
265
  : {}),
249
266
  ...(args.sessionId ? { sessionId: args.sessionId } : {}),
267
+ // The P2 cwd close check (session-close attribution) applies to the GLOBAL gate only. Under
268
+ // --project the check is a project-scoped diagnostic, so a cwd blocker for a
269
+ // DIFFERENT project would muddy that scoped answer — pass sessionCwd only for
270
+ // the global form. process.cwd() is deliberately NOT a fallback: a check run
271
+ // after `cd ~/hypomnema` would map to the vault, not the session (authoritative
272
+ // enforcement lives in the PreCompact/Stop hooks, which carry payload.cwd).
273
+ ...(args.sessionCwd && !args.project ? { sessionCwd: args.sessionCwd } : {}),
250
274
  });
251
275
  const close = status.close;
252
276
 
@@ -281,11 +305,17 @@ function runSessionCloseCheck(args) {
281
305
  JSON.stringify(
282
306
  {
283
307
  ok: status.ok,
284
- // flat close fields preserved for back-compat with prior readers
308
+ // flat close fields preserved for back-compat with prior readers. They now
309
+ // describe what BLOCKS: a foreign project's incomplete close is demoted out
310
+ // of stale/missing into close_debt, so a reader that treats a
311
+ // non-empty `missing` as failure still agrees with `ok` instead of
312
+ // contradicting it.
285
313
  project: close.project,
286
314
  dates: close.dates,
287
315
  stale: close.stale,
288
316
  missing: close.missing,
317
+ ...(close.debt?.length ? { close_debt: close.debt } : {}),
318
+ ...(close.scope ? { close_scope: close.scope } : {}),
289
319
  blockers: status.blockers,
290
320
  notices: status.notices,
291
321
  skipped: status.skipped,
@@ -411,6 +441,14 @@ function readPayload(source) {
411
441
  }
412
442
  }
413
443
 
444
+ // How long an append waits for its per-target lock before withholding to the
445
+ // proposal-pending gate (see withFileLock). A withheld append blocks the close but
446
+ // is NOT parked as a proposal artifact — the next close re-appends. Default 5s is
447
+ // generous for a real close;
448
+ // the env override exists ONLY so tests can force a fast timeout instead of
449
+ // spinning the full 5s. Not a documented production knob.
450
+ const APPEND_LOCK_TIMEOUT_MS = Number(process.env.HYPO_APPEND_LOCK_TIMEOUT_MS) || 5000;
451
+
414
452
  /** Atomic write via tmp+rename. `<path>.<pid>.<rand>.tmp` so concurrent helpers
415
453
  * don't fight over the same shared `<path>.tmp` slot. */
416
454
  function atomicWrite(path, content) {
@@ -420,17 +458,51 @@ function atomicWrite(path, content) {
420
458
  renameSync(tmp, path);
421
459
  }
422
460
 
423
- /** Atomic write that skips when on-disk bytes already match `content`. */
424
- function writeIfChanged(path, content) {
425
- if (existsSync(path)) {
426
- try {
427
- if (readFileSync(path, 'utf-8') === content) return false; // idempotent skip
428
- } catch {
429
- /* fall through to overwrite */
430
- }
461
+ /**
462
+ * Read a target's current bytes, distinguishing "absent" from "unreadable" the
463
+ * same way base-store's hashFile does. The overwrite guard needs all three
464
+ * answers: content to compare, `null` to know creating is safe, `undefined` to
465
+ * refuse to guess.
466
+ * @returns {string|null|undefined} bytes, `null` if absent, `undefined` if unreadable
467
+ */
468
+ function readTarget(path) {
469
+ if (!existsSync(path)) return null;
470
+ try {
471
+ return readFileSync(path, 'utf-8');
472
+ } catch {
473
+ return undefined;
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Has the target drifted away from what this session observed at start?
479
+ *
480
+ * Branches on base-store's `state` discriminator, never on the truthiness of
481
+ * `hash`: 'absent' and 'unknown' both carry `hash: null`, and collapsing them
482
+ * would read never-observed as safe-to-write, defeating the guard entirely.
483
+ *
484
+ * @param {{state: 'hash'|'absent'|'unknown', hash: string|null}} entry
485
+ * @param {string|null|undefined} disk current bytes / absent / unreadable
486
+ * @returns {string|null} a conflict reason, or null when this session may write
487
+ */
488
+ function overwriteConflictReason(entry, disk) {
489
+ // Cannot read what we are about to replace: fail safe, never assume unchanged.
490
+ if (disk === undefined) return 'target-unreadable';
491
+ switch (entry.state) {
492
+ case 'unknown':
493
+ // No snapshot for this (session, target). Someone else's edits could be
494
+ // sitting on disk and we would have no way to tell.
495
+ return 'base-unknown';
496
+ case 'absent':
497
+ // We observed no file. Creating it is safe; finding one now means another
498
+ // writer got there first.
499
+ return disk === null ? null : 'base-absent-target-exists';
500
+ case 'hash':
501
+ if (disk === null) return 'base-hash-target-missing';
502
+ return hashContent(disk) === entry.hash ? null : 'base-mismatch';
503
+ default:
504
+ return 'base-unknown';
431
505
  }
432
- atomicWrite(path, content);
433
- return true;
434
506
  }
435
507
 
436
508
  /**
@@ -444,8 +516,16 @@ function appendIfAbsent(path, entry, alreadyPresent) {
444
516
  if (existsSync(path)) {
445
517
  try {
446
518
  content = readFileSync(path, 'utf-8');
447
- } catch {
448
- content = '';
519
+ } catch (err) {
520
+ // ENOENT here is a narrow existsSync-then-readFileSync race (the file
521
+ // vanished between the two calls) — content='' is safe, we would create
522
+ // it fresh anyway. Anything else (EACCES/EISDIR/...) is a PERSISTENT
523
+ // read failure: retrying (the caller's lock-timeout conflict path) would
524
+ // never fix it, and swallowing it here would fall through to the
525
+ // atomicWrite below and silently replace the existing file's bytes with
526
+ // just this one entry — a data-loss overwrite. So rethrow and let the
527
+ // withFileLock/call-site catch hard-fail the close instead.
528
+ if (err?.code !== 'ENOENT') throw err;
449
529
  }
450
530
  }
451
531
  if (alreadyPresent(content)) return false;
@@ -510,6 +590,13 @@ function validatePayloadShape(payload) {
510
590
  if (payload.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(payload.date))) {
511
591
  errs.push('payload.date, when present, must be YYYY-MM-DD');
512
592
  }
593
+ if (
594
+ payload.sessionId !== undefined &&
595
+ payload.sessionId !== null &&
596
+ typeof payload.sessionId !== 'string'
597
+ ) {
598
+ errs.push('payload.sessionId, when present, must be a string');
599
+ }
513
600
  return errs;
514
601
  }
515
602
 
@@ -529,11 +616,14 @@ function runMarkSessionClosed(args) {
529
616
  console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
530
617
  process.exit(1);
531
618
  }
532
- // --project=<slug> on --mark is ATTRIBUTION ONLY (the marker's `project` field).
533
- // The gate stays GLOBAL never narrowed because a marker that narrowed its
534
- // gate could attest compact-ready while PreCompact re-checks all of today's
535
- // projects and stays red (the marker == compact-ready invariant,
536
- // codex design finding 1/2). Validate the attribution slug exists as a
619
+ // --project=<slug> on --mark names the project THIS session closed. It sets the
620
+ // marker's `project` field AND enters the close scope, so an incomplete close in a
621
+ // project this session did not touch is demoted to a notice instead of refusing the
622
+ // marker. It is NOT a gate narrow in the old sense: the marker records the same slug
623
+ // it scoped by, and PreCompact re-derives its own scope FROM that marker — so the
624
+ // two can never disagree, which is what the earlier "attribution only, gate stays
625
+ // global" rule was protecting (it assumed PreCompact stayed global; it no longer
626
+ // does). Everything else the gate checks stays global. Validate the slug exists as a
537
627
  // directory, exactly as --check does, but only when it is actually used (a
538
628
  // --log-only mark attributes to no project, so --project is moot there).
539
629
  if (args.project && !args.logOnly) requireProjectDir(args, args.project);
@@ -558,8 +648,12 @@ function runMarkSessionClosed(args) {
558
648
  // source for the user-close hard gate below.
559
649
  const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
560
650
  const gate = precompactGateStatus(args.hypoDir, {
651
+ ...(args.project && !args.logOnly ? { closeScope: [args.project] } : {}),
561
652
  ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
562
653
  ...(args.logOnly ? { logOnly: true } : {}),
654
+ // P2 (session-close attribution): the marker gate must refuse to attest compact-ready while the
655
+ // session's cwd project has an unstarted close. logOnly exempts it in-gate.
656
+ ...(args.sessionCwd ? { sessionCwd: args.sessionCwd } : {}),
563
657
  });
564
658
  const status = gate.close;
565
659
  if (!gate.ok) {
@@ -602,12 +696,43 @@ function runMarkSessionClosed(args) {
602
696
  console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
603
697
  process.exit(1);
604
698
  }
605
- // --project attributes the marker to that slug (gate stayed global); falls back
606
- // to the gate's resolved primary. log-only marks attribute to no project. Used
607
- // for the marker, the JSON result, and the success message so all three agree.
608
- const markerProject = !args.logOnly && args.project ? args.project : status.project;
699
+ // Marker attribution comes from EVIDENCE, never from the gate's global
700
+ // `primary` (which is recency-derived, hypo-shared.mjs). The marker is what
701
+ // PreCompact later re-derives its own scope from, so attributing it to a project
702
+ // this session did not close hands PreCompact a scope the marker never cleared.
703
+ // The evidence set is the close scope — explicit --project ∪ the transcript's
704
+ // touched close files ∪ any prior marker for this session — all of which
705
+ // resolveCloseScope already unioned into status.scope. The recency primary is
706
+ // deliberately excluded: an empty scope means this session has no proof it closed
707
+ // any project (evidence-based close attribution), so we FAIL CLOSED rather than misattribute to recency.
708
+ const closeScope = status.scope || [];
709
+ const markerProjects = [
710
+ ...new Set([...(!args.logOnly && args.project ? [args.project] : []), ...closeScope]),
711
+ ];
712
+ if (!args.logOnly && markerProjects.length === 0) {
713
+ const err =
714
+ 'cannot attribute this close to a project — no evidence (no --project, no transcript close-file edits, no prior marker). ' +
715
+ 'Pass --project=<slug> for the project this session closed, or --log-only for a non-project (tooling/wiki-only) session.';
716
+ console.log(
717
+ args.json
718
+ ? JSON.stringify(
719
+ {
720
+ ok: false,
721
+ session_id: args.sessionId,
722
+ skipReason: 'no-attribution-evidence',
723
+ error: err,
724
+ },
725
+ null,
726
+ 2,
727
+ )
728
+ : `✗ ${err}`,
729
+ );
730
+ process.exit(1);
731
+ }
732
+ const markerProject = !args.logOnly && args.project ? args.project : markerProjects[0];
609
733
  writeSessionClosedMarker(args.hypoDir, args.sessionId, {
610
734
  project: markerProject,
735
+ projects: args.logOnly ? [] : markerProjects,
611
736
  ...(args.logOnly ? { scope: 'log-only' } : {}),
612
737
  });
613
738
  // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
@@ -714,10 +839,76 @@ export function closeResultContradiction({ ok, markerWritten, markerSkipReason }
714
839
  return null;
715
840
  }
716
841
 
842
+ // What the model should do when the close is refused. Deliberately does NOT name
843
+ // a flag: the way out of this gate cannot be an argument the model can add, or
844
+ // the gate is decorative. The way out is the user, which is the one input the
845
+ // model does not author.
846
+ const CLOSE_REFUSAL_HELP = [
847
+ 'Nothing was written and nothing was committed.',
848
+ '',
849
+ 'Do NOT add a bypass flag, and do NOT write the close files directly with an editor',
850
+ 'or a shell — that is the same close without the check, and it is the thing this gate',
851
+ 'exists to stop.',
852
+ '',
853
+ 'If the user has not asked to close: do not close. Session-close is not a reward for',
854
+ 'finishing a task, and a long session is not a close signal. Keep working, or ask ONCE',
855
+ 'whether to wrap up, and take no for an answer.',
856
+ '',
857
+ 'If the user HAS asked: pass the current main-conversation --session-id (not a',
858
+ 'background-task or agent uuid from a /tmp path) and re-run this exact payload. The',
859
+ 'writes are idempotent.',
860
+ ].join('\n');
861
+
862
+ /**
863
+ * May this session apply a close at all?
864
+ *
865
+ * Authority comes from the transcript, because the transcript is the one input the
866
+ * model cannot author: the user's own words are in it, and nothing the model says
867
+ * counts (extractUserMessages drops injected, tool, and hook-feedback text).
868
+ *
869
+ * { ok: true }
870
+ * { ok: false, reason, error } reason: session-id-required | transcript-unresolved
871
+ * | no-user-close-signal
872
+ */
873
+ function verifyCloseAuthority(sessionId) {
874
+ if (!sessionId) {
875
+ return {
876
+ ok: false,
877
+ reason: 'session-id-required',
878
+ error:
879
+ 'session-close apply refused before any wiki write or commit: --session-id is required, ' +
880
+ "because the close signal is verified against that session's transcript. Omitting it does " +
881
+ 'not skip the check, it fails it.',
882
+ };
883
+ }
884
+ const transcript = resolveTranscriptBySessionId(sessionId);
885
+ if (!transcript) {
886
+ return {
887
+ ok: false,
888
+ reason: 'transcript-unresolved',
889
+ error:
890
+ `session-close apply refused before any wiki write or commit: no transcript resolves for ` +
891
+ `session ${sessionId}. Pass the MAIN conversation's session id — a uuid taken from a ` +
892
+ `background-task output path or an agent thread is not it, and --transcript-path is not ` +
893
+ `authority here.`,
894
+ };
895
+ }
896
+ if (!hasUserCloseSignal(transcript)) {
897
+ return {
898
+ ok: false,
899
+ reason: 'no-user-close-signal',
900
+ error:
901
+ "session-close apply refused before any wiki write or commit: this session's transcript " +
902
+ 'carries no user close signal. The user did not ask to close.',
903
+ };
904
+ }
905
+ return { ok: true };
906
+ }
907
+
717
908
  function applySessionClose(args) {
718
909
  // Option D: early-exit fires only when NO payload was supplied.
719
910
  // Rationale: payload presence is explicit close intent and must always run
720
- // the full apply path — the per-entry idempotency (writeIfChanged +
911
+ // the full apply path — the per-entry idempotency (overwrite's step-1 skip +
721
912
  // exact-entry append dedup) keeps re-apply cheap without short-circuiting,
722
913
  // and avoids silent-success when a same-day second close brings new bytes.
723
914
  // Payload-less invocation is treated as a cheap "already complete?" probe.
@@ -748,6 +939,51 @@ function applySessionClose(args) {
748
939
  // "payload is required" with the same error shape as before.
749
940
  }
750
941
 
942
+ // ── close authority: BEFORE any write ──────────────────────────────────────
943
+ // A payload is a claim that the user asked to close. Verify it here, ahead of
944
+ // readPayload and every byte that follows, because everything after this point
945
+ // is authoritative: session-state.md and the hot files are overwritten, the
946
+ // session-log and log.md are appended, and hypo-auto-commit lands the lot in git
947
+ // on the next Stop whether or not this command commits.
948
+ //
949
+ // The gate used to stand at the END, next to the marker (planMarkerDecision).
950
+ // It refused the MARKER on a close the user never asked for, and let the writes
951
+ // and the commit through. Worse, it was opt-in BY THE CALLER: with no
952
+ // --session-id there was no transcript to check, so the check simply did not
953
+ // run, and the apply reported ok:true with no skip reason at all. The model
954
+ // chose whether the model got checked. A model that has already decided to close
955
+ // does not reach for the flag that would stop it.
956
+ //
957
+ // So: no verifiable close signal, no close. Zero bytes, no commit, exit 1.
958
+ //
959
+ // What this does and does not promise. It promises that THIS entrypoint refuses
960
+ // a close with no recognized transcript evidence behind it. It does not promise
961
+ // the model cannot write these files at all: Write and Bash remain outside this
962
+ // gate, and the evidence predicate itself is coarse (any close signal anywhere in
963
+ // the transcript counts, so a stale one from earlier in a long session still
964
+ // passes, and it is the model that authors the AskUserQuestion option labels the
965
+ // user picks from). Narrowing that (current-turn binding, revocation, consent
966
+ // that the model did not word) is the follow-up this ADR names; it is not a
967
+ // reason to leave the default open in the meantime.
968
+ // Only a payload-bearing call can write. A payload-less one falls through to the
969
+ // "payload is required" error below without touching a byte, so gating it here
970
+ // would just replace one refusal with a less accurate one.
971
+ const closeAuth = args.payload ? verifyCloseAuthority(args.sessionId) : { ok: true };
972
+ if (!closeAuth.ok) {
973
+ const out = {
974
+ ok: false,
975
+ stage: 'no-user-close-signal',
976
+ reason: closeAuth.reason,
977
+ applied: [],
978
+ committed: false,
979
+ error: closeAuth.error,
980
+ };
981
+ console.log(
982
+ args.json ? JSON.stringify(out, null, 2) : `✗ ${closeAuth.error}\n\n${CLOSE_REFUSAL_HELP}`,
983
+ );
984
+ process.exit(1);
985
+ }
986
+
751
987
  let payload;
752
988
  try {
753
989
  payload = readPayload(args.payload);
@@ -768,6 +1004,43 @@ function applySessionClose(args) {
768
1004
  process.exit(1);
769
1005
  }
770
1006
 
1007
+ // Payload↔session binding (cross-session payload collision). The payload temp
1008
+ // file is now written to a session-scoped path (see commands/crystallize.md), so
1009
+ // two same-day sessions no longer share a file. This is the belt to that
1010
+ // suspenders: if the payload names
1011
+ // the session it was authored for, it must be THIS one — the --session-id whose
1012
+ // transcript already cleared close authority above. A mismatch means the file on
1013
+ // disk is not this session's close (a stray or hand-reused path handed us another
1014
+ // session's payload), and applying it would stamp that content with this session's
1015
+ // marker while the original session's record vanishes — the exact loss this
1016
+ // guard exists to prevent. Refuse before a byte is written.
1017
+ //
1018
+ // Absent field → fail open: older payloads predate this field, and Part 1's unique
1019
+ // path already prevents the collision. So the check only ever tightens; it never
1020
+ // rejects a close it would otherwise have allowed on a matching (or absent) id. It
1021
+ // is deliberately identity-based, not cwd-based: closing project B from a session
1022
+ // whose cwd is project A stays supported (payload.project is authoritative), so a
1023
+ // legitimately cross-project close is untouched.
1024
+ if (
1025
+ payload.sessionId !== undefined &&
1026
+ payload.sessionId !== null &&
1027
+ payload.sessionId !== args.sessionId
1028
+ ) {
1029
+ const msg =
1030
+ `payload.sessionId ${JSON.stringify(payload.sessionId)} does not match --session-id ` +
1031
+ `${JSON.stringify(args.sessionId)}: this payload was authored for a different session, so ` +
1032
+ `it is not this session's close. Refusing before any write (cross-session guard).`;
1033
+ const out = {
1034
+ ok: false,
1035
+ stage: 'session-id-mismatch',
1036
+ error: msg,
1037
+ applied: [],
1038
+ committed: false,
1039
+ };
1040
+ console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
1041
+ process.exit(1);
1042
+ }
1043
+
771
1044
  // Resolve project: payload.project is REQUIRED (B-3, close-gate-hardening). The
772
1045
  // old recency fallback (payload.project || probe.project) could, on a same-date
773
1046
  // root-hot.md tie, resolve a DIFFERENT project than the one the payload's files
@@ -883,6 +1156,10 @@ function applySessionClose(args) {
883
1156
  // Append targets (session-log, log.md) are NOT filtered: appending can't
884
1157
  // repair existing corruption, so a corrupt session-log must still block.
885
1158
  // Warns are informational (not gated) in either pass.
1159
+ //
1160
+ // The filter says "about to be replaced", and the observed-base guard can later
1161
+ // decline to replace one of these. Preflight runs before the guard, so it cannot
1162
+ // know. Harmless: post-apply lint re-scopes the same file and blocks on it there.
886
1163
  const overwriteTargets = new Set();
887
1164
  if (payload.sessionState) overwriteTargets.add(join('projects', project, 'session-state.md'));
888
1165
  if (payload.projectHot) overwriteTargets.add(join('projects', project, 'hot.md'));
@@ -952,11 +1229,63 @@ function applySessionClose(args) {
952
1229
 
953
1230
  const applied = [];
954
1231
  const skipped = [];
955
-
1232
+ // Overwrite targets this apply refused to write because the page moved under
1233
+ // it. T6 turns these into `.cache/proposals/` artifacts; here they are already
1234
+ // enough to withhold the bytes and fail the close.
1235
+ const conflicts = [];
1236
+
1237
+ /**
1238
+ * Replace a whole page, guarded by the base this session observed at start.
1239
+ *
1240
+ * The step order is load-bearing, not stylistic:
1241
+ *
1242
+ * 1. idempotent skip (disk already equals the payload)
1243
+ * 2. conflict (base unknown, or disk drifted away from base)
1244
+ * 3. direct write, then advance the base
1245
+ *
1246
+ * Step 1 must come first for two reasons. It keeps every existing
1247
+ * `--apply-session-close --session-id` test green (they read the payload
1248
+ * straight off disk, so they land here before any base lookup). And it breaks
1249
+ * the apply-then-reclose loop: once a human applies proposal P, disk == proposed
1250
+ * == payload.content, so the next close skips before it can re-raise a conflict.
1251
+ *
1252
+ * There is no caller here without a `--session-id`. verifyCloseAuthority refuses
1253
+ * that at the door, before a byte is written, so a session id is always present
1254
+ * by the time this runs and the base lookup always has something to look up.
1255
+ */
956
1256
  const overwrite = (key, relPath, field) => {
957
1257
  if (!field || typeof field.content !== 'string') return; // optional / absent
958
- const wrote = writeIfChanged(join(args.hypoDir, relPath), field.content);
959
- (wrote ? applied : skipped).push(`${key} (${relPath})`);
1258
+ const full = join(args.hypoDir, relPath);
1259
+ const disk = readTarget(full);
1260
+
1261
+ // (1) idempotent skip — preserves writeIfChanged's contract
1262
+ if (disk === field.content) {
1263
+ skipped.push(`${key} (${relPath})`);
1264
+ return;
1265
+ }
1266
+
1267
+ // (2) conflict, only where a session context makes a base observable
1268
+ if (args.sessionId) {
1269
+ const entry = readBaseEntry(args.hypoDir, args.sessionId, relPath);
1270
+ const reason = overwriteConflictReason(entry, disk);
1271
+ if (reason) {
1272
+ conflicts.push({
1273
+ key,
1274
+ target: relPath,
1275
+ reason,
1276
+ baseHash: entry.hash,
1277
+ currentHash: typeof disk === 'string' ? hashContent(disk) : null,
1278
+ proposedContent: field.content,
1279
+ });
1280
+ return; // target bytes untouched
1281
+ }
1282
+ }
1283
+
1284
+ // (3) write, then the content we just wrote IS this session's new base
1285
+ atomicWrite(full, field.content);
1286
+ if (args.sessionId)
1287
+ advanceBase(args.hypoDir, args.sessionId, relPath, hashContent(field.content));
1288
+ applied.push(`${key} (${relPath})`);
960
1289
  };
961
1290
 
962
1291
  overwrite('sessionState', join('projects', project, 'session-state.md'), payload.sessionState);
@@ -976,59 +1305,90 @@ function applySessionClose(args) {
976
1305
  const rel = join('projects', project, 'session-log', `${date}.md`);
977
1306
  const full = join(args.hypoDir, rel);
978
1307
  const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
979
- // Fallback-aware idempotency (hybrid cutover): during the month the
980
- // shard takes over, today's entry may already live in the legacy monthly
981
- // file from an earlier (pre-cutover) close. Treat presence in EITHER the
982
- // daily shard or the legacy monthly file as "already written" so a same-day
983
- // second close does not duplicate an identical entry across both files
984
- // and so an idempotent re-apply stays a true no-op (no shard is created).
985
- let alreadyThere = false;
986
- for (const cand of sessionLogReadCandidates(project, date)) {
987
- const cf = join(args.hypoDir, cand);
988
- if (!existsSync(cf)) continue;
989
- try {
990
- if (isPresent(readFileSync(cf, 'utf-8'))) {
991
- alreadyThere = true;
992
- break;
993
- }
994
- } catch {
995
- /* unreadable candidate fall through to the write path */
996
- }
997
- }
998
- if (alreadyThere) {
999
- skipped.push(`sessionLog (${rel})`);
1000
- } else if (!existsSync(full)) {
1001
- // A daily shard is a new file most days. Seed minimal valid frontmatter
1002
- // (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
1003
- // wiki page rather than a W1 "no frontmatter" warning, and write the header
1004
- // AND the first entry in ONE atomic write — never leave a header-only shard
1005
- // on disk, which freshness would skip (no dated heading) while derive could
1006
- // otherwise mistake it for the evidence file. The dated `## [date] ...`
1007
- // heading lives inside the entry, so freshness / derive / design-history
1008
- // are unchanged.
1009
- // Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
1010
- // `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
1011
- // docs/ARCHITECTURE.md). It is a CREATOR-only stamp only the session/
1012
- // machine that first seeds the daily shard is recorded; later same-day
1013
- // appends do not touch it. The per-session-accurate store is the LOCAL
1014
- // (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
1015
- // `session_id` is honest naming: the value is the Claude session UUID, and
1016
- // it is present only on the Stop-chain close path that passes --session-id.
1017
- const device = String(hostname() || 'unknown').replace(/[\r\n]/g, '');
1018
- const auditFm =
1019
- (args.sessionId ? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n` : '') +
1020
- `device: ${device}\n`;
1021
- const header =
1022
- `---\ntitle: Session Log ${date} (${project})\n` +
1023
- `type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
1024
- `# Session Log ${date} (${project})\n`;
1025
- const entry = payload.sessionLog.entry;
1026
- const body = entry.endsWith('\n') ? entry : `${entry}\n`;
1027
- atomicWrite(full, `${header}\n${body}`);
1028
- applied.push(`sessionLog (${rel})`);
1029
- } else {
1030
- const wrote = appendIfAbsent(full, payload.sessionLog.entry, isPresent);
1031
- (wrote ? applied : skipped).push(`sessionLog (${rel})`);
1308
+ // Serialize dedup + create/append on the daily shard so two concurrent
1309
+ // closes never lose an entry: the second closer takes the lock only after
1310
+ // the first committed, re-reads the shard under the lock, and appends onto
1311
+ // the committed bytes (temp+rename write-isolation is preserved a partial
1312
+ // write never tears the target). Create and append share ONE lock, so the
1313
+ // "seed a new shard" and "append to an existing shard" branches can't race
1314
+ // each other — only one closer is ever in the create path (closes the
1315
+ // wx-window a bare exclusive-create would leave open).
1316
+ try {
1317
+ const outcome = withFileLock(
1318
+ full,
1319
+ () => {
1320
+ // Fallback-aware idempotency (hybrid cutover): during the month the
1321
+ // shard takes over, today's entry may already live in the legacy monthly
1322
+ // file from an earlier (pre-cutover) close. Treat presence in EITHER the
1323
+ // daily shard or the legacy monthly file as "already written" so a same-day
1324
+ // second close does not duplicate an identical entry across both files —
1325
+ // and so an idempotent re-apply stays a true no-op (no shard is created).
1326
+ for (const cand of sessionLogReadCandidates(project, date)) {
1327
+ const cf = join(args.hypoDir, cand);
1328
+ if (!existsSync(cf)) continue;
1329
+ try {
1330
+ if (isPresent(readFileSync(cf, 'utf-8'))) return 'skipped';
1331
+ } catch {
1332
+ /* unreadable candidate fall through to the write path */
1333
+ }
1334
+ }
1335
+ if (!existsSync(full)) {
1336
+ // A daily shard is a new file most days. Seed minimal valid frontmatter
1337
+ // (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
1338
+ // wiki page rather than a W1 "no frontmatter" warning, and write the header
1339
+ // AND the first entry in ONE atomic write — never leave a header-only shard
1340
+ // on disk, which freshness would skip (no dated heading) while derive could
1341
+ // otherwise mistake it for the evidence file. The dated `## [date] ...`
1342
+ // heading lives inside the entry, so freshness / derive / design-history
1343
+ // are unchanged.
1344
+ // Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
1345
+ // `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
1346
+ // docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
1347
+ // machine that first seeds the daily shard is recorded; later same-day
1348
+ // appends do not touch it. The per-session-accurate store is the LOCAL
1349
+ // (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
1350
+ // `session_id` is honest naming: the value is the Claude session UUID, and
1351
+ // it is present only on the Stop-chain close path that passes --session-id.
1352
+ const device = currentDevice();
1353
+ const auditFm =
1354
+ (args.sessionId
1355
+ ? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n`
1356
+ : '') + `device: ${device}\n`;
1357
+ const header =
1358
+ `---\ntitle: Session Log ${date} (${project})\n` +
1359
+ `type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
1360
+ `# Session Log ${date} (${project})\n`;
1361
+ const entry = payload.sessionLog.entry;
1362
+ const body = entry.endsWith('\n') ? entry : `${entry}\n`;
1363
+ atomicWrite(full, `${header}\n${body}`);
1364
+ return 'created';
1365
+ }
1366
+ return appendIfAbsent(full, payload.sessionLog.entry, isPresent) ? 'appended' : 'skipped';
1367
+ },
1368
+ { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1369
+ );
1370
+ (outcome === 'skipped' ? skipped : applied).push(`sessionLog (${rel})`);
1371
+ } catch (err) {
1372
+ // Only a lock-TIMEOUT is withheld as a conflict. A real fn() write error
1373
+ // (disk-full, EACCES, mkdir failure) must NOT be masked as a proposal-
1374
+ // pending timeout — rethrow so it hard-fails like the overwrite path does.
1375
+ if (err?.code !== 'ELOCKTIMEOUT') throw err;
1376
+ // Lock-timeout: withhold rather than lose the entry. Recorded as a conflict
1377
+ // so the close goes proposal-pending (ok:false, no marker) and the next
1378
+ // close re-applies. `kind: 'append'` is what T6 branches on to SKIP parking
1379
+ // this: an append conflict never becomes a `.cache/proposals/` artifact —
1380
+ // the lock-timeout is transient and the next close self-heals by
1381
+ // re-appending, whereas a whole-file re-apply would drop this shard's other
1382
+ // entries. It still blocks the close; it just gets no artifact.
1383
+ conflicts.push({
1384
+ key: 'sessionLog',
1385
+ target: rel,
1386
+ reason: 'append-lock-timeout',
1387
+ kind: 'append',
1388
+ baseHash: null,
1389
+ currentHash: null,
1390
+ proposedContent: payload.sessionLog.entry,
1391
+ });
1032
1392
  }
1033
1393
  }
1034
1394
 
@@ -1046,31 +1406,126 @@ function applySessionClose(args) {
1046
1406
  // append onto a deliberately custom payload.log (codex pre-commit review). The
1047
1407
  // two payload paths are mutually exclusive: deriving on top of a present-but-
1048
1408
  // malformed payload.log would mask it and weaken the verifier's fail-loud.
1409
+ // log.md is shared across projects and also written by deriveRootLogEntries
1410
+ // (the Stop-hook backfill in hypo-shared.mjs). Both take the SAME lock on
1411
+ // log.md, so a concurrent close's append and this close's append serialize
1412
+ // instead of overwriting each other.
1413
+ const logFull = join(args.hypoDir, 'log.md');
1049
1414
  if (payload.log) {
1050
- const wrote = appendIfAbsent(
1051
- join(args.hypoDir, 'log.md'),
1052
- payload.log.entry,
1053
- entryAlreadyPresent(payload.log.entry),
1054
- );
1055
- (wrote ? applied : skipped).push('log (log.md)');
1415
+ try {
1416
+ const wrote = withFileLock(
1417
+ logFull,
1418
+ () => appendIfAbsent(logFull, payload.log.entry, entryAlreadyPresent(payload.log.entry)),
1419
+ { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1420
+ );
1421
+ (wrote ? applied : skipped).push('log (log.md)');
1422
+ } catch (err) {
1423
+ if (err?.code !== 'ELOCKTIMEOUT') throw err;
1424
+ // proposedContent is append-ready root-log bytes (the custom log line).
1425
+ conflicts.push({
1426
+ key: 'log',
1427
+ target: 'log.md',
1428
+ reason: 'append-lock-timeout',
1429
+ kind: 'append',
1430
+ baseHash: null,
1431
+ currentHash: null,
1432
+ proposedContent: payload.log.entry,
1433
+ });
1434
+ }
1056
1435
  } else {
1057
1436
  // matchAll (not exec) mirrors deriveRootLogEntries: a payload that carried
1058
1437
  // more than one dated heading derives one canonical line each, symmetric with
1059
1438
  // the global path. Exact-line dedup on the heading keeps a second apply (or a
1060
1439
  // titleless vs titled same-day pair) from duplicating.
1061
- const logFull = join(args.hypoDir, 'log.md');
1062
1440
  const headingRe = new RegExp(`^#{1,6} \\[${date}\\]\\s*(.*)$`, 'gm');
1063
- let wroteAny = false;
1064
- for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
1065
- const { heading, block } = rootLogEntry(project, date, m[1]);
1066
- const wrote = appendIfAbsent(logFull, block, (c) =>
1067
- (c || '').split(/\r?\n/).includes(heading),
1441
+ try {
1442
+ const wroteAny = withFileLock(
1443
+ logFull,
1444
+ () => {
1445
+ let w = false;
1446
+ for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
1447
+ const { heading, block } = rootLogEntry(project, date, m[1]);
1448
+ const wrote = appendIfAbsent(logFull, block, (c) =>
1449
+ (c || '').split(/\r?\n/).includes(heading),
1450
+ );
1451
+ w = w || wrote;
1452
+ }
1453
+ return w;
1454
+ },
1455
+ { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1068
1456
  );
1069
- wroteAny = wroteAny || wrote;
1457
+ (wroteAny ? applied : skipped).push('log (log.md, derived)');
1458
+ } catch (err) {
1459
+ if (err?.code !== 'ELOCKTIMEOUT') throw err;
1460
+ // `derived: true` discriminates this from the payload.log conflict above:
1461
+ // here proposedContent is the session-log entry to RE-DERIVE root-log lines
1462
+ // from (via rootLogEntry over its dated headings), NOT append-ready bytes.
1463
+ // Like every `kind: 'append'` conflict, this is NOT parked as an artifact —
1464
+ // it blocks the close and the next close re-derives + re-appends. The derived
1465
+ // shape only matters to the retry, never to T6's overwrite proposal store.
1466
+ conflicts.push({
1467
+ key: 'log',
1468
+ target: 'log.md',
1469
+ reason: 'append-lock-timeout',
1470
+ kind: 'append',
1471
+ derived: true,
1472
+ baseHash: null,
1473
+ currentHash: null,
1474
+ proposedContent: payload.sessionLog.entry,
1475
+ });
1070
1476
  }
1071
- (wroteAny ? applied : skipped).push('log (log.md, derived)');
1072
1477
  }
1073
1478
 
1479
+ // T6: park drifted OVERWRITE targets as `.cache/proposals/` artifacts.
1480
+ //
1481
+ // Runs regardless of --json AND regardless of args.sessionId: writing the
1482
+ // artifact is a SIDE EFFECT, not output, so it is conditioned on neither the
1483
+ // report format nor a session context. (In practice an overwrite conflict only
1484
+ // arises with a session id, so a session-less apply just finds no overwrite
1485
+ // conflicts to park — but the loop is unconditional to match that contract.)
1486
+ // Only overwrite conflicts (`kind !== 'append'`) become artifacts. An append
1487
+ // conflict is a transient lock-timeout the NEXT close self-heals by
1488
+ // re-appending; parking it as a whole-file artifact and later re-applying it
1489
+ // (T7 replaces the whole target) would drop every OTHER entry in that
1490
+ // append-only history file. Append conflicts still sit in `conflicts`, so the
1491
+ // close still goes proposal-pending — they just get no artifact and no
1492
+ // human-apply step.
1493
+ const proposals = [];
1494
+ const proposalStoreFailures = [];
1495
+ const device = currentDevice();
1496
+ for (const c of conflicts) {
1497
+ if (c.kind === 'append') continue; // append conflicts are never parked (see above)
1498
+ try {
1499
+ const saved = writeProposal(args.hypoDir, {
1500
+ target: c.target,
1501
+ baseHash: c.baseHash,
1502
+ currentAtProposalHash: c.currentHash,
1503
+ proposedContent: c.proposedContent, // internal (pre-drop) full page bytes
1504
+ sessionId: args.sessionId, // may be null; writeProposal coerces it
1505
+ device,
1506
+ });
1507
+ proposals.push({ id: saved.id, target: saved.target, path: saved.path });
1508
+ // Supersede-delete failure is NON-fatal: the new artifact IS parked, only
1509
+ // a stale sibling lingers (superseded next close). Surface it, don't fail.
1510
+ for (const w of saved.supersedeWarnings) {
1511
+ process.stderr.write(`\n⚠️ ${w} (stale artifact left behind, not fatal)\n`);
1512
+ }
1513
+ } catch (err) {
1514
+ // fail-loud: the target was withheld AND its bytes are now on neither disk
1515
+ // NOR a proposal artifact — a genuine data-loss risk. Never swallow this.
1516
+ const error = (err && err.message) || String(err);
1517
+ proposalStoreFailures.push({ target: c.target, key: c.key, error });
1518
+ process.stderr.write(
1519
+ `\n🛑 PROPOSAL STORE FAILED for ${c.key} (${c.target}): ${error}\n` +
1520
+ ` This close WITHHELD the target (it drifted from your observed base) but\n` +
1521
+ ` could NOT write the .cache/proposals/ artifact either. The payload bytes\n` +
1522
+ ` are on NEITHER disk NOR a proposal — re-run the close once the .cache/\n` +
1523
+ ` directory is writable so the withheld content is not lost.\n`,
1524
+ );
1525
+ }
1526
+ }
1527
+ const proposalStoreFailed = proposalStoreFailures.length > 0;
1528
+
1074
1529
  // Same-date-tie fix: verify against the SAME project this apply just wrote
1075
1530
  // (`project` = payload.project || probe.project, resolved at the top). Without
1076
1531
  // the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
@@ -1099,7 +1554,16 @@ function applySessionClose(args) {
1099
1554
  const m = unknownTagRe.exec(w.message || '');
1100
1555
  if (m && !checkForbidden(m[1])) pendingTags.push(m[1]);
1101
1556
  }
1102
- if (pendingTags.length > 0) appendPendingTags(args.hypoDir, pendingTags);
1557
+ // Withheld a target? Then register nothing. This close is going to be re-run
1558
+ // once a human resolves the conflict, and its `ok:false` skips the commit below
1559
+ // (`if (ok && args.sessionId)`) — so registering here would leave SCHEMA.md
1560
+ // mutated, uncommitted, and absent from `applied`, i.e. a silent side effect of
1561
+ // a close whose whole point was to write nothing. Registration is
1562
+ // eventually-consistent by design, so deferring it to the next close costs
1563
+ // nothing (codex W2 CONCERN).
1564
+ if (pendingTags.length > 0 && conflicts.length === 0) {
1565
+ appendPendingTags(args.hypoDir, pendingTags);
1566
+ }
1103
1567
 
1104
1568
  // Post-apply lint: payload may have introduced a malformed body or
1105
1569
  // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
@@ -1138,7 +1602,12 @@ function applySessionClose(args) {
1138
1602
  const postLintOk = !postApplyCrashed && postBlocking.length === 0;
1139
1603
  // `let` (not const): the close-result invariant self-check below may flip this
1140
1604
  // to false when the settled close result is internally contradictory.
1141
- let ok = verification.ok && postLintOk;
1605
+ //
1606
+ // A withheld conflict target must fail the close on its own, not merely via the
1607
+ // freshness gate. If the other session already touched that page TODAY, freshness
1608
+ // sees a fresh file and passes — and the close would report ok:true, write the
1609
+ // marker, and drop this session's payload silently. `conflicts` closes that hole.
1610
+ let ok = verification.ok && postLintOk && conflicts.length === 0;
1142
1611
 
1143
1612
  // Scope the non-blocking notice to the close-target project: debt under
1144
1613
  // projects/<project>/ stays listed; debt elsewhere folds to a count so the
@@ -1182,10 +1651,15 @@ function applySessionClose(args) {
1182
1651
  let gateOk = false;
1183
1652
  if (commitOutcome.committed) {
1184
1653
  closeTranscript = resolveTranscriptBySessionId(args.sessionId);
1185
- gateOk = precompactGateStatus(
1186
- args.hypoDir,
1187
- closeTranscript ? { transcriptPath: closeTranscript } : {},
1188
- ).ok;
1654
+ // closeScope: apply KNOWS which project it just closed, and it wrote
1655
+ // that project's files from inside this process — they never appear in the
1656
+ // transcript as Edit/Write, so `payload.project` is the only signal that puts
1657
+ // this close in scope. Without it, apply's own incomplete close could be demoted
1658
+ // to a foreign-debt notice and marked green.
1659
+ gateOk = precompactGateStatus(args.hypoDir, {
1660
+ closeScope: [project],
1661
+ ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
1662
+ }).ok;
1189
1663
  }
1190
1664
  const decision = planMarkerDecision({
1191
1665
  ok,
@@ -1200,7 +1674,9 @@ function applySessionClose(args) {
1200
1674
  });
1201
1675
  markerSkipReason = decision.skipReason;
1202
1676
  if (decision.write) {
1203
- writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
1677
+ // apply KNOWS its authoritative payload.project — stamp it as the v4
1678
+ // evidence set so PreCompact trusts this marker's scope directly (session-close attribution).
1679
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, { project, projects: [project] });
1204
1680
  // Codex CONCERN: the writer swallows IO errors (best-effort).
1205
1681
  // Verify the file actually landed — mirroring the standalone path — instead of
1206
1682
  // asserting markerWritten=true, so a .cache permission/disk problem surfaces
@@ -1212,13 +1688,21 @@ function applySessionClose(args) {
1212
1688
  }
1213
1689
  }
1214
1690
  }
1691
+ // A conflict outranks the downstream gates: verification and lint both describe
1692
+ // a tree this apply declined to finish writing, so naming them would point the
1693
+ // reader at the wrong repair. A proposal-STORE failure outranks even that: the
1694
+ // withheld bytes never reached an artifact, so it is the most urgent repair.
1215
1695
  let stage = ok
1216
1696
  ? null
1217
- : !verification.ok && !postLintOk
1218
- ? 'post-apply-verification+lint'
1219
- : !verification.ok
1220
- ? 'post-apply-verification'
1221
- : 'post-apply-lint';
1697
+ : proposalStoreFailed
1698
+ ? 'proposal-store-failed'
1699
+ : conflicts.length > 0
1700
+ ? 'proposal-pending'
1701
+ : !verification.ok && !postLintOk
1702
+ ? 'post-apply-verification+lint'
1703
+ : !verification.ok
1704
+ ? 'post-apply-verification'
1705
+ : 'post-apply-lint';
1222
1706
  // Runtime close-result invariant self-check. When a
1223
1707
  // marker-write path (args.sessionId present) settles into an internally
1224
1708
  // contradictory shape — ok:true with the marker silently withheld and no
@@ -1249,6 +1733,29 @@ function applySessionClose(args) {
1249
1733
  date,
1250
1734
  applied,
1251
1735
  skipped,
1736
+ // Targets withheld: an overwrite drifted from this session's observed base, or
1737
+ // an append could not take the file lock in time (`kind: 'append'`). Two
1738
+ // channels resolve these, and `proposals` vs `conflicts[].kind` are the sole
1739
+ // discriminators: an OVERWRITE conflict is parked as a `.cache/proposals/`
1740
+ // artifact (below) for a human to review and re-apply; an APPEND conflict gets
1741
+ // NO artifact and is re-tried automatically by the next close. `proposedContent`
1742
+ // is dropped from the reported shape either way (the artifact / the next close
1743
+ // holds the bytes; a whole page or an append entry does not belong in the JSON).
1744
+ conflicts: conflicts.map(({ proposedContent: _drop, ...rest }) => rest),
1745
+ // Parked overwrite proposals (id/target/path), one per drifted overwrite
1746
+ // target. Empty when only append conflicts (or none) occurred. The T7 CLI
1747
+ // lists and applies these; append conflicts never appear here.
1748
+ proposals,
1749
+ ...(proposalStoreFailed ? { proposalStoreFailures } : {}),
1750
+ // Partial close: some overwrite direct-writes (and/or appends) already landed
1751
+ // on disk while at least one conflict withheld the rest. Because the close is
1752
+ // ok:false, commitWikiChanges + the marker are skipped — so those written
1753
+ // files sit on disk UNCOMMITTED until the conflict is resolved and the close
1754
+ // re-runs. Named honestly: `appliedUncommitted` covers every write that landed
1755
+ // (overwrite or append), not overwrites alone.
1756
+ ...(applied.length > 0 && conflicts.length > 0
1757
+ ? { partialConflict: true, appliedUncommitted: [...applied] }
1758
+ : {}),
1252
1759
  verification,
1253
1760
  // Surface the marker outcome instead of skipping silently, so the
1254
1761
  // caller can tell "closed" from "applied but not marked".
@@ -1274,6 +1781,29 @@ function applySessionClose(args) {
1274
1781
  console.log(`Session-close apply (project: ${project}, date: ${date}):`);
1275
1782
  for (const a of applied) console.log(` ✓ wrote ${a}`);
1276
1783
  for (const s of skipped) console.log(` · skipped ${s} (already current)`);
1784
+ // Never let a withheld target read as a skip: `skipped` means "already current",
1785
+ // this means "your bytes are NOT on disk". Overwrite conflicts drifted from base;
1786
+ // an append conflict is a lock-timeout (someone else held the file's lock), which
1787
+ // is transient — the next close re-applies.
1788
+ for (const c of conflicts) {
1789
+ const why =
1790
+ c.kind === 'append'
1791
+ ? 'could not acquire the append lock in time; the next close re-applies'
1792
+ : 'the page changed since this session read it';
1793
+ console.log(` ⚠ WITHHELD ${c.key} (${c.target}) — ${c.reason}; ${why}`);
1794
+ }
1795
+ for (const p of proposals) {
1796
+ console.log(
1797
+ ` · parked proposal ${p.id} for ${p.target} (review with \`hypomnema proposal\`)`,
1798
+ );
1799
+ }
1800
+ if (applied.length > 0 && conflicts.length > 0) {
1801
+ console.log(
1802
+ '\n· partial close: the writes above ARE on disk but NOT committed — this close is\n' +
1803
+ ' ok:false, so no commit and no session-close marker run until the withheld\n' +
1804
+ ' target(s) are resolved and the close re-runs.',
1805
+ );
1806
+ }
1277
1807
  if (ok) {
1278
1808
  // When the marker was withheld, qualify the success line so a reader scanning
1279
1809
  // stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
@@ -1430,6 +1960,7 @@ function main() {
1430
1960
  } catch {
1431
1961
  continue;
1432
1962
  }
1963
+ if (!scopeVisible(readVisibilityScope(content), currentDevice())) continue;
1433
1964
  const fm = parseFrontmatter(content);
1434
1965
  if (!fm) continue;
1435
1966