hypomnema 1.8.0 → 1.8.2

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 (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.ko.md +8 -6
  5. package/README.md +8 -6
  6. package/commands/crystallize.md +32 -6
  7. package/commands/graph.md +7 -4
  8. package/commands/lint.md +8 -1
  9. package/commands/query.md +6 -4
  10. package/commands/resume.md +1 -0
  11. package/commands/verify.md +12 -1
  12. package/docs/ARCHITECTURE.md +26 -12
  13. package/docs/CONTRIBUTING.md +15 -6
  14. package/hooks/hypo-compact-guard.mjs +126 -23
  15. package/hooks/hypo-cwd-change.mjs +20 -19
  16. package/hooks/hypo-first-prompt.mjs +31 -16
  17. package/hooks/hypo-lookup.mjs +10 -5
  18. package/hooks/hypo-session-start.mjs +180 -2
  19. package/hooks/hypo-shared.mjs +410 -83
  20. package/hooks/hypo-web-fetch-ingest.mjs +9 -13
  21. package/package.json +2 -1
  22. package/scripts/doctor.mjs +32 -3
  23. package/scripts/graph.mjs +22 -2
  24. package/scripts/init.mjs +5 -1
  25. package/scripts/lib/crystallize-args.mjs +38 -2
  26. package/scripts/lib/crystallize-close-apply.mjs +745 -450
  27. package/scripts/lint.mjs +242 -39
  28. package/scripts/query.mjs +22 -2
  29. package/scripts/resume.mjs +177 -2
  30. package/scripts/upgrade.mjs +2 -2
  31. package/scripts/verify.mjs +22 -2
  32. package/templates/SCHEMA.md +23 -1
  33. package/templates/hypo-automation.md +4 -2
  34. package/templates/hypo-config.md +1 -1
  35. package/templates/hypo-guide.md +1 -1
  36. package/skills/crystallize/SKILL.md +0 -189
  37. package/skills/graph/SKILL.md +0 -58
  38. package/skills/ingest/SKILL.md +0 -107
  39. package/skills/lint/SKILL.md +0 -59
  40. package/skills/query/SKILL.md +0 -62
  41. package/skills/verify/SKILL.md +0 -96
@@ -472,10 +472,31 @@ export function runMarkSessionClosed(args) {
472
472
  process.exit(1);
473
473
  }
474
474
  const markerProject = !args.logOnly && args.project ? args.project : markerProjects[0];
475
+ // verified_scope (session-close-scope-boundary spec §3, revised 2026-09-07):
476
+ // records the set the gate ABOVE actually made a row for and evaluated —
477
+ // never `markerProjects` (evidence-based attribution, `projects` above).
478
+ // `closeScope: [args.project]` widens resolveCloseScope's mine/foreign
479
+ // partition (it feeds `opts.closeScope`, never `opts.projectOverride`), so
480
+ // it does NOT narrow `sessionCloseGlobalStatus`: the gate ran unnarrowed
481
+ // regardless of --project (resolveGateProjectOverride's own doc comment;
482
+ // hypo-shared.mjs's sessionCloseGlobalStatus/precompactGateStatus doc
483
+ // comments). `kind` is therefore always 'global' here. 'project' stays a
484
+ // shape normalizeVerifiedScope and doctor's reader accept — for a future
485
+ // writer that DOES pass opts.projectOverride, which none of the four
486
+ // marker-writing paths do today. The earlier premise here (an explicit
487
+ // --project earns 'project') was a doctor regression: a transcript-widened
488
+ // scope can attribute a project the gate never put a row for, and stamping
489
+ // markerProjects verbatim let that project's close artifacts pass doctor's
490
+ // correlation unchecked.
491
+ const evaluatedProjects = (status.projects || []).map((p) => p.project).filter(Boolean);
492
+ const verifiedScope = args.logOnly
493
+ ? { kind: 'log-only' }
494
+ : { kind: 'global', projects: evaluatedProjects };
475
495
  writeSessionClosedMarker(args.hypoDir, args.sessionId, {
476
496
  project: markerProject,
477
497
  projects: args.logOnly ? [] : markerProjects,
478
498
  ...(args.logOnly ? { scope: 'log-only' } : {}),
499
+ verifiedScope,
479
500
  });
480
501
  // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
481
502
  // the file actually landed before claiming success — otherwise CLI exits 0
@@ -609,8 +630,12 @@ const CLOSE_REFUSAL_HELP = [
609
630
  * counts (extractUserMessages drops injected, tool, and hook-feedback text).
610
631
  *
611
632
  * { ok: true }
612
- * { ok: false, reason, error } reason: session-id-required | transcript-unresolved
613
- * | no-user-close-signal
633
+ * { ok: false, reason, error, gateReason? } reason: session-id-required |
634
+ * transcript-unresolved | no-user-close-signal. `gateReason` is only present
635
+ * when `reason` is `no-user-close-signal`, and carries closeGateStatus's own
636
+ * reason string (no-open / transcript-rewrite-detected /
637
+ * no-new-open-since-resolution) for a caller that wants to tell those three
638
+ * apart without parsing `error`.
614
639
  */
615
640
  function verifyCloseAuthority(sessionId, hypoDir) {
616
641
  if (!sessionId) {
@@ -640,6 +665,13 @@ function verifyCloseAuthority(sessionId, hypoDir) {
640
665
  return {
641
666
  ok: false,
642
667
  reason: 'no-user-close-signal',
668
+ // The user-facing `reason` above stays the single collapsed string
669
+ // tests and downstream tooling already key on (see this function's own
670
+ // doc comment). `gateReason` carries closeGateStatus's actual reason
671
+ // (no-open / transcript-rewrite-detected / no-new-open-since-resolution)
672
+ // as a separate, machine-readable field, so a caller can tell the three
673
+ // apart without parsing the "Gate detail: ..." substring out of `error`.
674
+ gateReason: gateStatus.reason,
643
675
  error:
644
676
  "session-close apply refused before any wiki write or commit: this session's transcript " +
645
677
  'carries no user close signal. The user did not ask to close. ' +
@@ -713,69 +745,40 @@ export function ensureProjectIndex(hypoDir, project, relPath, today) {
713
745
  return relPath;
714
746
  }
715
747
 
716
- export function applySessionClose(args) {
717
- // Option D: early-exit fires only when NO payload was supplied.
718
- // Rationale: payload presence is explicit close intent and must always run
719
- // the full apply path — the per-entry idempotency (overwrite's step-1 skip +
720
- // exact-entry append dedup) keeps re-apply cheap without short-circuiting,
721
- // and avoids silent-success when a same-day second close brings new bytes.
722
- // Payload-less invocation is treated as a cheap "already complete?" probe.
723
- // --force opts out of that probe shortcut only — payload remains required
724
- // for any actual apply work (readPayload below surfaces "payload is
725
- // required" the same way it always has).
726
- if (!args.force && !args.payload) {
727
- // No-payload "already complete?" probe uses the
728
- // global invariant, not a recency pick.
729
- const probe = sessionCloseGlobalStatus(args.hypoDir);
730
- if (probe.ok) {
731
- const result = {
732
- ok: true,
733
- alreadyComplete: true,
734
- project: probe.project,
735
- date: probe.dates[0],
736
- message: '오늘 이미 close 완료로 보임 (probe 모드 — payload 미지정).',
737
- };
738
- if (args.json) {
739
- console.log(JSON.stringify(result, null, 2));
740
- } else {
741
- console.log(`✓ ${result.message}`);
742
- console.log(` project: ${result.project} / date: ${result.date}`);
743
- }
744
- process.exit(0);
745
- }
746
- // gate not ok → fall through to readPayload, which surfaces
747
- // "payload is required" with the same error shape as before.
748
- }
749
-
750
- // ── close authority: BEFORE any write ──────────────────────────────────────
751
- // A payload is a claim that the user asked to close. Verify it here, ahead of
752
- // readPayload and every byte that follows, because everything after this point
753
- // is authoritative: session-state.md and the hot files are overwritten, the
754
- // session-log and log.md are appended, and hypo-auto-commit lands the lot in git
755
- // on the next Stop whether or not this command commits.
756
- //
757
- // The gate used to stand at the END, next to the marker (planMarkerDecision).
758
- // It refused the MARKER on a close the user never asked for, and let the writes
759
- // and the commit through. Worse, it was opt-in BY THE CALLER: with no
760
- // --session-id there was no transcript to check, so the check simply did not
761
- // run, and the apply reported ok:true with no skip reason at all. The model
762
- // chose whether the model got checked. A model that has already decided to close
763
- // does not reach for the flag that would stop it.
764
- //
765
- // So: no verifiable close signal, no close. Zero bytes, no commit, exit 1.
766
- //
767
- // What this does and does not promise. It promises that THIS entrypoint refuses
768
- // a close with no recognized transcript evidence behind it. It does not promise
769
- // the model cannot write these files at all: Write and Bash remain outside this
770
- // gate, and the evidence predicate itself is coarse (any close signal anywhere in
771
- // the transcript counts, so a stale one from earlier in a long session still
772
- // passes, and it is the model that authors the AskUserQuestion option labels the
773
- // user picks from). Narrowing that (current-turn binding, revocation, consent
774
- // that the model did not word) is the follow-up this ADR names; it is not a
775
- // reason to leave the default open in the meantime.
776
- // Only a payload-bearing call can write. A payload-less one falls through to the
777
- // "payload is required" error below without touching a byte, so gating it here
778
- // would just replace one refusal with a less accurate one.
748
+ // ── close authority: BEFORE any write ──────────────────────────────────────
749
+ // A payload is a claim that the user asked to close. Verify it here, ahead of
750
+ // readPayload and every byte that follows, because everything after this point
751
+ // is authoritative: session-state.md and the hot files are overwritten, the
752
+ // session-log and log.md are appended, and hypo-auto-commit lands the lot in git
753
+ // on the next Stop whether or not this command commits.
754
+ //
755
+ // The gate used to stand at the END, next to the marker (planMarkerDecision).
756
+ // It refused the MARKER on a close the user never asked for, and let the writes
757
+ // and the commit through. Worse, it was opt-in BY THE CALLER: with no
758
+ // --session-id there was no transcript to check, so the check simply did not
759
+ // run, and the apply reported ok:true with no skip reason at all. The model
760
+ // chose whether the model got checked. A model that has already decided to close
761
+ // does not reach for the flag that would stop it.
762
+ //
763
+ // So: no verifiable close signal, no close. Zero bytes, no commit, exit 1.
764
+ //
765
+ // What this does and does not promise. It promises that THIS entrypoint refuses
766
+ // a close with no recognized transcript evidence behind it. It does not promise
767
+ // the model cannot write these files at all: Write and Bash remain outside this
768
+ // gate, and the evidence predicate itself is coarse (any close signal anywhere in
769
+ // the transcript counts, so a stale one from earlier in a long session still
770
+ // passes, and it is the model that authors the AskUserQuestion option labels the
771
+ // user picks from). Narrowing that (current-turn binding, revocation, consent
772
+ // that the model did not word) is the follow-up this ADR names; it is not a
773
+ // reason to leave the default open in the meantime.
774
+ // Only a payload-bearing call can write. A payload-less one falls through to the
775
+ // "payload is required" error below without touching a byte, so gating it here
776
+ // would just replace one refusal with a less accurate one.
777
+ //
778
+ // Refuses in place (console.log + process.exit(1)) instead of returning a
779
+ // verdict: process.exit never returns, so the caller's flow stops exactly where
780
+ // it stopped before this section was a function of its own.
781
+ function refuseUnlessCloseRequested(args) {
779
782
  const closeAuth = args.payload
780
783
  ? verifyCloseAuthority(args.sessionId, args.hypoDir)
781
784
  : { ok: true };
@@ -784,6 +787,11 @@ export function applySessionClose(args) {
784
787
  ok: false,
785
788
  stage: 'no-user-close-signal',
786
789
  reason: closeAuth.reason,
790
+ // Only set when `reason` is 'no-user-close-signal' (undefined otherwise,
791
+ // and JSON.stringify drops an undefined-valued key). Lets a caller tell
792
+ // apart the three closeGateStatus refusals collapsed into that one
793
+ // `reason` string, without parsing "Gate detail: ..." out of `error`.
794
+ gateReason: closeAuth.gateReason,
787
795
  applied: [],
788
796
  // `null`, not `false`: this refusal fires before the commit step is ever
789
797
  // reached (see the general result's own `committed` contract below).
@@ -796,7 +804,11 @@ export function applySessionClose(args) {
796
804
  );
797
805
  process.exit(1);
798
806
  }
807
+ }
799
808
 
809
+ // Read the payload, check its shape, and bind it to THIS session. Exits 1 on any
810
+ // of the three failures; returns the parsed payload otherwise.
811
+ function loadValidatedPayload(args) {
800
812
  let payload;
801
813
  try {
802
814
  payload = readPayload(args.payload);
@@ -856,21 +868,25 @@ export function applySessionClose(args) {
856
868
  process.exit(1);
857
869
  }
858
870
 
859
- // Resolve project: payload.project is REQUIRED (B-3, close-gate-hardening). The
860
- // old recency fallback (payload.project || probe.project) could, on a same-date
861
- // root-hot.md tie, resolve a DIFFERENT project than the one the payload's files
862
- // belong to — apply would then write the close into the wrong project (silent
863
- // data loss). Validate fail-fast, BEFORE the probe is consulted:
864
- // - missing → no target to write; abort rather than infer.
865
- // - invalid name → reject (non-string, wrong charset, or dot-only) BEFORE the
866
- // existsSync(join(...)) path build, so a `../`-style value
867
- // never reaches a path builder (traversal guard — order is
868
- // the guard). isValidProjectName is SHARED with createProject
869
- // so apply accepts exactly the namespace the repo can
870
- // scaffold (A-Za-z0-9._-, single segment) — no narrower.
871
- // - non-existent → projects/<slug>/ absent; abort rather than create.
872
- // A payload.project that merely DIFFERS from the inferred active project is NOT an
873
- // error — it is surfaced as a stderr note below and the close proceeds.
871
+ return payload;
872
+ }
873
+
874
+ // Resolve project: payload.project is REQUIRED (B-3, close-gate-hardening). The
875
+ // old recency fallback (payload.project || probe.project) could, on a same-date
876
+ // root-hot.md tie, resolve a DIFFERENT project than the one the payload's files
877
+ // belong to — apply would then write the close into the wrong project (silent
878
+ // data loss). Validate fail-fast, BEFORE the probe is consulted:
879
+ // - missing → no target to write; abort rather than infer.
880
+ // - invalid name → reject (non-string, wrong charset, or dot-only) BEFORE the
881
+ // existsSync(join(...)) path build, so a `../`-style value
882
+ // never reaches a path builder (traversal guard — order is
883
+ // the guard). isValidProjectName is SHARED with createProject
884
+ // so apply accepts exactly the namespace the repo can
885
+ // scaffold (A-Za-z0-9._-, single segment) — no narrower.
886
+ // - non-existent → projects/<slug>/ absent; abort rather than create.
887
+ // A payload.project that merely DIFFERS from the inferred active project is NOT an
888
+ // error — it is surfaced as a stderr note below and the close proceeds.
889
+ function resolveCloseProject(args, payload) {
874
890
  if (payload.project === undefined || payload.project === null) {
875
891
  const msg = 'payload.project is required (apply must not infer the close target project)';
876
892
  console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
@@ -911,15 +927,17 @@ export function applySessionClose(args) {
911
927
  `project "${probe.project}"; verifying and closing "${payload.project}".\n`,
912
928
  );
913
929
  }
914
- const date = payload.date || todayLocal();
930
+ return project;
931
+ }
915
932
 
916
- // Pre-apply freshness-contract gate: the post-apply verification holds
917
- // sessionCloseFileStatus's hasSessionLogHeading / hasLogEntry as the
918
- // definition of "closed today". Enforce that SAME contract on the payload
919
- // BEFORE writing a byte, so a heading the gate won't recognize is rejected
920
- // here as a format mismatch — not written and then misdiagnosed downstream as
921
- // "stale" (the "not updated" vs "format mismatch" conflation). All checks
922
- // exit 1 with stage='pre-apply-verification' and leave the tree untouched.
933
+ // Pre-apply freshness-contract gate: the post-apply verification holds
934
+ // sessionCloseFileStatus's hasSessionLogHeading / hasLogEntry as the
935
+ // definition of "closed today". Enforce that SAME contract on the payload
936
+ // BEFORE writing a byte, so a heading the gate won't recognize is rejected
937
+ // here as a format mismatch — not written and then misdiagnosed downstream as
938
+ // "stale" (the "not updated" vs "format mismatch" conflation). All checks
939
+ // exit 1 with stage='pre-apply-verification' and leave the tree untouched.
940
+ function assertPayloadFreshnessContract(args, payload, project, date) {
923
941
  const failPreApply = (msg) => {
924
942
  console.log(
925
943
  args.json
@@ -957,24 +975,30 @@ export function applySessionClose(args) {
957
975
  `close gate recognizes. Fix the entry heading, or omit payload.log to derive it.`,
958
976
  );
959
977
  }
978
+ }
960
979
 
961
- // Preflight: lint the wiki BEFORE writing any payload bytes. If lint
962
- // has blockers (errors) in files this apply WON'T overwrite, the wiki is in
963
- // a degraded state and apply would mask the root cause — abort fail-fast.
964
- //
965
- // Overwrite-target filter (codex P2 follow-up): errors in files we're about
966
- // to fully replace are IGNORED at preflight. Otherwise a bad payload
967
- // (post-apply-lint fail) would leave the broken file on disk and the very
968
- // next retry — even with a corrected payload — gets dead-locked here. The
969
- // post-apply lint is the authoritative check on payload content.
970
- //
971
- // Append targets (session-log, log.md) are NOT filtered: appending can't
972
- // repair existing corruption, so a corrupt session-log must still block.
973
- // Warns are informational (not gated) in either pass.
974
- //
975
- // The filter says "about to be replaced", and the observed-base guard can later
976
- // decline to replace one of these. Preflight runs before the guard, so it cannot
977
- // know. Harmless: post-apply lint re-scopes the same file and blocks on it there.
980
+ // Preflight: lint the wiki BEFORE writing any payload bytes. If lint
981
+ // has blockers (errors) in files this apply WON'T overwrite, the wiki is in
982
+ // a degraded state and apply would mask the root cause — abort fail-fast.
983
+ //
984
+ // Overwrite-target filter (codex P2 follow-up): errors in files we're about
985
+ // to fully replace are IGNORED at preflight. Otherwise a bad payload
986
+ // (post-apply-lint fail) would leave the broken file on disk and the very
987
+ // next retry — even with a corrected payload — gets dead-locked here. The
988
+ // post-apply lint is the authoritative check on payload content.
989
+ //
990
+ // Append targets (session-log, log.md) are NOT filtered: appending can't
991
+ // repair existing corruption, so a corrupt session-log must still block.
992
+ // Warns are informational (not gated) in either pass.
993
+ //
994
+ // The filter says "about to be replaced", and the observed-base guard can later
995
+ // decline to replace one of these. Preflight runs before the guard, so it cannot
996
+ // know. Harmless: post-apply lint re-scopes the same file and blocks on it there.
997
+ //
998
+ // Returns the payload scope and the A-1 index facts alongside the lint result:
999
+ // both are derived here (before any write) and consumed by the write and
1000
+ // post-apply phases.
1001
+ function runPreflight(args, payload, project, date) {
978
1002
  const overwriteTargets = new Set();
979
1003
  if (payload.sessionState) overwriteTargets.add(join('projects', project, 'session-state.md'));
980
1004
  if (payload.projectHot) overwriteTargets.add(join('projects', project, 'hot.md'));
@@ -1031,55 +1055,82 @@ export function applySessionClose(args) {
1031
1055
  const blockingErrors = preflightLint.errors.filter(
1032
1056
  (e) => payloadScope.has(e.file) && !overwriteTargets.has(e.file),
1033
1057
  );
1034
- if (blockingErrors.length > 0) {
1058
+ // W9 (invalid-YAML frontmatter) legacy-debt policy: the SAME append-target
1059
+ // rule as blockingErrors above, applied to W9 specifically. A pre-existing
1060
+ // broken frontmatter block on an append target (log.md, the session-log
1061
+ // shard) can never be healed by this close, because appendIfAbsent only adds
1062
+ // bytes below the block and never rewrites it. Leaving it unblocked would let
1063
+ // every future close keep appending onto (and reporting success over) a file
1064
+ // post-apply lint can never certify clean, so it is blocked HERE, before a
1065
+ // single byte is written, exactly like a pre-existing lint ERROR in an
1066
+ // append target already does two lines above. Overwrite targets are exempt
1067
+ // for the same reason blockingErrors exempts them: this close is about to
1068
+ // replace their bytes outright, so their PRE-existing frontmatter is moot
1069
+ // (the post-apply check below is what catches a payload that writes its own
1070
+ // broken YAML into one of those). Out-of-scope W9 debt elsewhere in the
1071
+ // vault (this repo's own maintainer vault carries one, under
1072
+ // pages/feedback/) is untouched by this filter and stays a plain warn.
1073
+ //
1074
+ // W9 carries no `id` in the default (non-strict) --json warns lint.mjs
1075
+ // returns, only W8 does (see lint.mjs's `toOut`), so it is matched by its
1076
+ // fixed message prefix instead, the same technique registerPendingTags
1077
+ // above already uses for W10.
1078
+ const blockingW9 = (preflightLint.warns || []).filter(
1079
+ (w) =>
1080
+ INVALID_YAML_WARN_RE.test(w.message || '') &&
1081
+ payloadScope.has(w.file) &&
1082
+ !overwriteTargets.has(w.file),
1083
+ );
1084
+ const allPreflightBlocking = [...blockingErrors, ...blockingW9];
1085
+ if (allPreflightBlocking.length > 0) {
1035
1086
  const out = {
1036
1087
  ok: false,
1037
1088
  stage: 'preflight-lint',
1038
1089
  error: 'lint preflight failed — apply aborted (no payload bytes written)',
1039
- lint: { ...summarizeLintForOutput(preflightLint), blockingErrors },
1090
+ lint: { ...summarizeLintForOutput(preflightLint), blockingErrors: allPreflightBlocking },
1040
1091
  };
1041
1092
  if (args.json) {
1042
1093
  console.log(JSON.stringify(out, null, 2));
1043
1094
  } else {
1044
1095
  console.log('✗ lint preflight failed — apply aborted (no payload bytes written):');
1045
- for (const e of blockingErrors) console.log(` ✗ ${e.file}: ${e.message}`);
1096
+ for (const e of allPreflightBlocking) console.log(` ✗ ${e.file}: ${e.message}`);
1046
1097
  console.log(' Fix the wiki (run `node scripts/lint.mjs`) and retry.');
1047
1098
  }
1048
1099
  process.exit(1);
1049
1100
  }
1050
1101
 
1051
- const applied = [];
1052
- const skipped = [];
1053
- // The ACTUAL vault-relative paths this apply wrote, kept separate
1054
- // from `applied` (whose entries are display strings like `key (relPath)`,
1055
- // not bare paths). This is the scope handed to commitWikiChanges below;
1056
- // never the broader `payloadScope` above, which also includes lint/evidence
1057
- // candidates this apply may not have written a byte to.
1058
- const appliedPaths = [];
1059
- // Overwrite targets this apply refused to write because the page moved under
1060
- // it. T6 turns these into `.cache/proposals/` artifacts; here they are already
1061
- // enough to withhold the bytes and fail the close.
1062
- const conflicts = [];
1102
+ return { preflightLint, payloadScope, indexRelPath, indexMissing };
1103
+ }
1104
+
1105
+ /**
1106
+ * Replace every whole-page overwrite target, then fill a missing project index.
1107
+ *
1108
+ * `acc` is the shared accumulator bag (`applied` / `skipped` / `appliedPaths` /
1109
+ * `conflicts`) the caller owns; every write phase pushes into the same arrays
1110
+ * rather than returning partial lists for the caller to merge, so the ordering
1111
+ * of the report lines is the call order, exactly as it was inline.
1112
+ *
1113
+ * Replace a whole page, guarded by the base this session observed at start.
1114
+ *
1115
+ * The step order inside `overwrite` is load-bearing, not stylistic:
1116
+ *
1117
+ * 1. idempotent skip (disk already equals the payload)
1118
+ * 2. conflict (base unknown, or disk drifted away from base)
1119
+ * 3. direct write, then advance the base
1120
+ *
1121
+ * Step 1 must come first for two reasons. It keeps every existing
1122
+ * `--apply-session-close --session-id` test green (they read the payload
1123
+ * straight off disk, so they land here before any base lookup). And it breaks
1124
+ * the apply-then-reclose loop: once a human applies proposal P, disk == proposed
1125
+ * == payload.content, so the next close skips before it can re-raise a conflict.
1126
+ *
1127
+ * There is no caller here without a `--session-id`. verifyCloseAuthority refuses
1128
+ * that at the door, before a byte is written, so a session id is always present
1129
+ * by the time this runs and the base lookup always has something to look up.
1130
+ */
1131
+ function applyOverwrites(args, payload, project, date, indexRelPath, indexMissing, acc) {
1132
+ const { applied, skipped, appliedPaths, conflicts } = acc;
1063
1133
 
1064
- /**
1065
- * Replace a whole page, guarded by the base this session observed at start.
1066
- *
1067
- * The step order is load-bearing, not stylistic:
1068
- *
1069
- * 1. idempotent skip (disk already equals the payload)
1070
- * 2. conflict (base unknown, or disk drifted away from base)
1071
- * 3. direct write, then advance the base
1072
- *
1073
- * Step 1 must come first for two reasons. It keeps every existing
1074
- * `--apply-session-close --session-id` test green (they read the payload
1075
- * straight off disk, so they land here before any base lookup). And it breaks
1076
- * the apply-then-reclose loop: once a human applies proposal P, disk == proposed
1077
- * == payload.content, so the next close skips before it can re-raise a conflict.
1078
- *
1079
- * There is no caller here without a `--session-id`. verifyCloseAuthority refuses
1080
- * that at the door, before a byte is written, so a session id is always present
1081
- * by the time this runs and the base lookup always has something to look up.
1082
- */
1083
1134
  const overwrite = (key, relPath, field) => {
1084
1135
  if (!field || typeof field.content !== 'string') return; // optional / absent
1085
1136
  const full = join(args.hypoDir, relPath);
@@ -1161,125 +1212,131 @@ export function applySessionClose(args) {
1161
1212
  appliedPaths.push(createdIndex);
1162
1213
  }
1163
1214
  }
1215
+ }
1164
1216
 
1165
- // Append idempotency: dedup by exact-entry presence, not by "any heading
1166
- // dated today". The freshness gate (sessionCloseFileStatus) is what answers
1167
- // "was this file touched today?"; that's a different concern and must not
1168
- // be reused for apply-time dedup, or a legitimate same-day second close gets
1169
- // silently dropped (Codex review of the apply path — Worker 1 finding 2).
1170
- const entryAlreadyPresent = (entry) => (content) =>
1171
- content.includes(entry.endsWith('\n') ? entry.replace(/\n+$/, '') : entry);
1172
-
1173
- {
1174
- const rel = join('projects', project, 'session-log', `${date}.md`);
1175
- const full = join(args.hypoDir, rel);
1176
- const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
1177
- // Serialize dedup + create/append on the daily shard so two concurrent
1178
- // closes never lose an entry: the second closer takes the lock only after
1179
- // the first committed, re-reads the shard under the lock, and appends onto
1180
- // the committed bytes (temp+rename write-isolation is preserved — a partial
1181
- // write never tears the target). Create and append share ONE lock, so the
1182
- // "seed a new shard" and "append to an existing shard" branches can't race
1183
- // each other — only one closer is ever in the create path (closes the
1184
- // wx-window a bare exclusive-create would leave open).
1185
- try {
1186
- const outcome = withFileLock(
1187
- full,
1188
- () => {
1189
- // Fallback-aware idempotency (hybrid cutover): during the month the
1190
- // shard takes over, today's entry may already live in the legacy monthly
1191
- // file from an earlier (pre-cutover) close. Treat presence in EITHER the
1192
- // daily shard or the legacy monthly file as "already written" so a same-day
1193
- // second close does not duplicate an identical entry across both files —
1194
- // and so an idempotent re-apply stays a true no-op (no shard is created).
1195
- for (const cand of sessionLogReadCandidates(project, date)) {
1196
- const cf = join(args.hypoDir, cand);
1197
- if (!existsSync(cf)) continue;
1198
- try {
1199
- if (isPresent(readFileSync(cf, 'utf-8'))) return 'skipped';
1200
- } catch {
1201
- /* unreadable candidate — fall through to the write path */
1202
- }
1203
- }
1204
- if (!existsSync(full)) {
1205
- // A daily shard is a new file most days. Seed minimal valid frontmatter
1206
- // (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
1207
- // wiki page rather than a W1 "no frontmatter" warning, and write the header
1208
- // AND the first entry in ONE atomic write — never leave a header-only shard
1209
- // on disk, which freshness would skip (no dated heading) while derive could
1210
- // otherwise mistake it for the evidence file. The dated `## [date] ...`
1211
- // heading lives inside the entry, so freshness / derive / design-history
1212
- // are unchanged.
1213
- // Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
1214
- // `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
1215
- // docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
1216
- // machine that first seeds the daily shard is recorded; later same-day
1217
- // appends do not touch it. The per-session-accurate store is the LOCAL
1218
- // (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
1219
- // `session_id` is honest naming: the value is the Claude session UUID, and
1220
- // it is present only on the Stop-chain close path that passes --session-id.
1221
- const device = currentDevice();
1222
- const auditFm =
1223
- (args.sessionId
1224
- ? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n`
1225
- : '') + `device: ${device}\n`;
1226
- const header =
1227
- `---\ntitle: Session Log ${date} (${project})\n` +
1228
- `type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
1229
- `# Session Log ${date} (${project})\n`;
1230
- const entry = payload.sessionLog.entry;
1231
- const body = entry.endsWith('\n') ? entry : `${entry}\n`;
1232
- atomicWrite(full, `${header}\n${body}`);
1233
- return 'created';
1217
+ // Append idempotency: dedup by exact-entry presence, not by "any heading
1218
+ // dated today". The freshness gate (sessionCloseFileStatus) is what answers
1219
+ // "was this file touched today?"; that's a different concern and must not
1220
+ // be reused for apply-time dedup, or a legitimate same-day second close gets
1221
+ // silently dropped (Codex review of the apply path — Worker 1 finding 2).
1222
+ const entryAlreadyPresent = (entry) => (content) =>
1223
+ content.includes(entry.endsWith('\n') ? entry.replace(/\n+$/, '') : entry);
1224
+
1225
+ // Append this close's entry to the project's daily session-log shard, pushing the
1226
+ // outcome into the shared `acc` bag.
1227
+ function appendSessionLogEntry(args, payload, project, date, acc) {
1228
+ const { applied, skipped, appliedPaths, conflicts } = acc;
1229
+ const rel = join('projects', project, 'session-log', `${date}.md`);
1230
+ const full = join(args.hypoDir, rel);
1231
+ const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
1232
+ // Serialize dedup + create/append on the daily shard so two concurrent
1233
+ // closes never lose an entry: the second closer takes the lock only after
1234
+ // the first committed, re-reads the shard under the lock, and appends onto
1235
+ // the committed bytes (temp+rename write-isolation is preserved — a partial
1236
+ // write never tears the target). Create and append share ONE lock, so the
1237
+ // "seed a new shard" and "append to an existing shard" branches can't race
1238
+ // each other — only one closer is ever in the create path (closes the
1239
+ // wx-window a bare exclusive-create would leave open).
1240
+ try {
1241
+ const outcome = withFileLock(
1242
+ full,
1243
+ () => {
1244
+ // Fallback-aware idempotency (hybrid cutover): during the month the
1245
+ // shard takes over, today's entry may already live in the legacy monthly
1246
+ // file from an earlier (pre-cutover) close. Treat presence in EITHER the
1247
+ // daily shard or the legacy monthly file as "already written" so a same-day
1248
+ // second close does not duplicate an identical entry across both files —
1249
+ // and so an idempotent re-apply stays a true no-op (no shard is created).
1250
+ for (const cand of sessionLogReadCandidates(project, date)) {
1251
+ const cf = join(args.hypoDir, cand);
1252
+ if (!existsSync(cf)) continue;
1253
+ try {
1254
+ if (isPresent(readFileSync(cf, 'utf-8'))) return 'skipped';
1255
+ } catch {
1256
+ /* unreadable candidate — fall through to the write path */
1234
1257
  }
1235
- return appendIfAbsent(full, payload.sessionLog.entry, isPresent) ? 'appended' : 'skipped';
1236
- },
1237
- { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1238
- );
1239
- (outcome === 'skipped' ? skipped : applied).push(`sessionLog (${rel})`);
1240
- if (outcome !== 'skipped') appliedPaths.push(rel);
1241
- } catch (err) {
1242
- // Only a lock-TIMEOUT is withheld as a conflict. A real fn() write error
1243
- // (disk-full, EACCES, mkdir failure) must NOT be masked as a proposal-
1244
- // pending timeout — rethrow so it hard-fails like the overwrite path does.
1245
- if (err?.code !== 'ELOCKTIMEOUT') throw err;
1246
- // Lock-timeout: withhold rather than lose the entry. Recorded as a conflict
1247
- // so the close goes proposal-pending (ok:false, no marker) and the next
1248
- // close re-applies. `kind: 'append'` is what T6 branches on to SKIP parking
1249
- // this: an append conflict never becomes a `.cache/proposals/` artifact —
1250
- // the lock-timeout is transient and the next close self-heals by
1251
- // re-appending, whereas a whole-file re-apply would drop this shard's other
1252
- // entries. It still blocks the close; it just gets no artifact.
1253
- conflicts.push({
1254
- key: 'sessionLog',
1255
- target: rel,
1256
- reason: 'append-lock-timeout',
1257
- kind: 'append',
1258
- baseHash: null,
1259
- currentHash: null,
1260
- proposedContent: payload.sessionLog.entry,
1261
- });
1262
- }
1258
+ }
1259
+ if (!existsSync(full)) {
1260
+ // A daily shard is a new file most days. Seed minimal valid frontmatter
1261
+ // (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
1262
+ // wiki page rather than a W1 "no frontmatter" warning, and write the header
1263
+ // AND the first entry in ONE atomic write — never leave a header-only shard
1264
+ // on disk, which freshness would skip (no dated heading) while derive could
1265
+ // otherwise mistake it for the evidence file. The dated `## [date] ...`
1266
+ // heading lives inside the entry, so freshness / derive / design-history
1267
+ // are unchanged.
1268
+ // Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
1269
+ // `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
1270
+ // docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
1271
+ // machine that first seeds the daily shard is recorded; later same-day
1272
+ // appends do not touch it. The per-session-accurate store is the LOCAL
1273
+ // (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
1274
+ // `session_id` is honest naming: the value is the Claude session UUID, and
1275
+ // it is present only on the Stop-chain close path that passes --session-id.
1276
+ const device = currentDevice();
1277
+ const auditFm =
1278
+ (args.sessionId
1279
+ ? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n`
1280
+ : '') + `device: ${device}\n`;
1281
+ const header =
1282
+ `---\ntitle: Session Log ${date} (${project})\n` +
1283
+ `type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
1284
+ `# Session Log ${date} (${project})\n`;
1285
+ const entry = payload.sessionLog.entry;
1286
+ const body = entry.endsWith('\n') ? entry : `${entry}\n`;
1287
+ atomicWrite(full, `${header}\n${body}`);
1288
+ return 'created';
1289
+ }
1290
+ return appendIfAbsent(full, payload.sessionLog.entry, isPresent) ? 'appended' : 'skipped';
1291
+ },
1292
+ { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1293
+ );
1294
+ (outcome === 'skipped' ? skipped : applied).push(`sessionLog (${rel})`);
1295
+ if (outcome !== 'skipped') appliedPaths.push(rel);
1296
+ } catch (err) {
1297
+ // Only a lock-TIMEOUT is withheld as a conflict. A real fn() write error
1298
+ // (disk-full, EACCES, mkdir failure) must NOT be masked as a proposal-
1299
+ // pending timeout — rethrow so it hard-fails like the overwrite path does.
1300
+ if (err?.code !== 'ELOCKTIMEOUT') throw err;
1301
+ // Lock-timeout: withhold rather than lose the entry. Recorded as a conflict
1302
+ // so the close goes proposal-pending (ok:false, no marker) and the next
1303
+ // close re-applies. `kind: 'append'` is what T6 branches on to SKIP parking
1304
+ // this: an append conflict never becomes a `.cache/proposals/` artifact —
1305
+ // the lock-timeout is transient and the next close self-heals by
1306
+ // re-appending, whereas a whole-file re-apply would drop this shard's other
1307
+ // entries. It still blocks the close; it just gets no artifact.
1308
+ conflicts.push({
1309
+ key: 'sessionLog',
1310
+ target: rel,
1311
+ reason: 'append-lock-timeout',
1312
+ kind: 'append',
1313
+ baseHash: null,
1314
+ currentHash: null,
1315
+ proposedContent: payload.sessionLog.entry,
1316
+ });
1263
1317
  }
1318
+ }
1264
1319
 
1265
- // log.md: `payload.log` is OPTIONAL (B-1). When the caller supplies it, keep
1266
- // the explicit appendIfAbsent path (backward-compat: a custom log line, with
1267
- // the same idempotent dedup). When it is ABSENT, the root log.md entry is a
1268
- // DERIVABLE artifact: reconstruct the canonical `## [date] session | <project>`
1269
- // line directly from THIS close's session-log heading (`payload.sessionLog`),
1270
- // not by re-reading the session-log files. Deriving from the payload is what
1271
- // makes the per-close entry exact: a same-day second close lands its distinct
1272
- // heading, and a hybrid daily/monthly session-log split can't hide it (apply
1273
- // never reads those files for this). The global scan-based deriveRootLogEntries
1274
- // (the Stop hook) still backfills OTHER projects; calling it here would either
1275
- // miss the current entry (single-candidate read) or, with a loosened guard,
1276
- // append onto a deliberately custom payload.log (codex pre-commit review). The
1277
- // two payload paths are mutually exclusive: deriving on top of a present-but-
1278
- // malformed payload.log would mask it and weaken the verifier's fail-loud.
1279
- // log.md is shared across projects and also written by deriveRootLogEntries
1280
- // (the Stop-hook backfill in hypo-shared.mjs). Both take the SAME lock on
1281
- // log.md, so a concurrent close's append and this close's append serialize
1282
- // instead of overwriting each other.
1320
+ // log.md: `payload.log` is OPTIONAL (B-1). When the caller supplies it, keep
1321
+ // the explicit appendIfAbsent path (backward-compat: a custom log line, with
1322
+ // the same idempotent dedup). When it is ABSENT, the root log.md entry is a
1323
+ // DERIVABLE artifact: reconstruct the canonical `## [date] session | <project>`
1324
+ // line directly from THIS close's session-log heading (`payload.sessionLog`),
1325
+ // not by re-reading the session-log files. Deriving from the payload is what
1326
+ // makes the per-close entry exact: a same-day second close lands its distinct
1327
+ // heading, and a hybrid daily/monthly session-log split can't hide it (apply
1328
+ // never reads those files for this). The global scan-based deriveRootLogEntries
1329
+ // (the Stop hook) still backfills OTHER projects; calling it here would either
1330
+ // miss the current entry (single-candidate read) or, with a loosened guard,
1331
+ // append onto a deliberately custom payload.log (codex pre-commit review). The
1332
+ // two payload paths are mutually exclusive: deriving on top of a present-but-
1333
+ // malformed payload.log would mask it and weaken the verifier's fail-loud.
1334
+ // log.md is shared across projects and also written by deriveRootLogEntries
1335
+ // (the Stop-hook backfill in hypo-shared.mjs). Both take the SAME lock on
1336
+ // log.md, so a concurrent close's append and this close's append serialize
1337
+ // instead of overwriting each other.
1338
+ function appendRootLogEntry(args, payload, project, date, acc) {
1339
+ const { applied, skipped, appliedPaths, conflicts } = acc;
1283
1340
  const logFull = join(args.hypoDir, 'log.md');
1284
1341
  if (payload.log) {
1285
1342
  try {
@@ -1347,21 +1404,23 @@ export function applySessionClose(args) {
1347
1404
  });
1348
1405
  }
1349
1406
  }
1407
+ }
1350
1408
 
1351
- // T6: park drifted OVERWRITE targets as `.cache/proposals/` artifacts.
1352
- //
1353
- // Runs regardless of --json AND regardless of args.sessionId: writing the
1354
- // artifact is a SIDE EFFECT, not output, so it is conditioned on neither the
1355
- // report format nor a session context. (In practice an overwrite conflict only
1356
- // arises with a session id, so a session-less apply just finds no overwrite
1357
- // conflicts to park — but the loop is unconditional to match that contract.)
1358
- // Only overwrite conflicts (`kind !== 'append'`) become artifacts. An append
1359
- // conflict is a transient lock-timeout the NEXT close self-heals by
1360
- // re-appending; parking it as a whole-file artifact and later re-applying it
1361
- // (T7 replaces the whole target) would drop every OTHER entry in that
1362
- // append-only history file. Append conflicts still sit in `conflicts`, so the
1363
- // close still goes proposal-pending — they just get no artifact and no
1364
- // human-apply step.
1409
+ // T6: park drifted OVERWRITE targets as `.cache/proposals/` artifacts.
1410
+ //
1411
+ // Runs regardless of --json AND regardless of args.sessionId: writing the
1412
+ // artifact is a SIDE EFFECT, not output, so it is conditioned on neither the
1413
+ // report format nor a session context. (In practice an overwrite conflict only
1414
+ // arises with a session id, so a session-less apply just finds no overwrite
1415
+ // conflicts to park — but the loop is unconditional to match that contract.)
1416
+ // Only overwrite conflicts (`kind !== 'append'`) become artifacts. An append
1417
+ // conflict is a transient lock-timeout the NEXT close self-heals by
1418
+ // re-appending; parking it as a whole-file artifact and later re-applying it
1419
+ // (T7 replaces the whole target) would drop every OTHER entry in that
1420
+ // append-only history file. Append conflicts still sit in `conflicts`, so the
1421
+ // close still goes proposal-pending — they just get no artifact and no
1422
+ // human-apply step.
1423
+ function parkOverwriteConflicts(args, conflicts) {
1365
1424
  const proposals = [];
1366
1425
  const proposalStoreFailures = [];
1367
1426
  const device = currentDevice();
@@ -1396,31 +1455,35 @@ export function applySessionClose(args) {
1396
1455
  );
1397
1456
  }
1398
1457
  }
1399
- const proposalStoreFailed = proposalStoreFailures.length > 0;
1458
+ return { proposals, proposalStoreFailures };
1459
+ }
1400
1460
 
1401
- // Same-date-tie fix: verify against the SAME project this apply just wrote
1402
- // (`project` = payload.project || probe.project, resolved at the top). Without
1403
- // the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
1404
- // on a same-date root-hot.md tie, can pick a different project — false-failing
1405
- // a completed close (the 2026-06-09 security-ops-kb incident).
1406
- const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
1461
+ // B-4 auto-register: lift unknown (non-forbidden) tags surfaced by the PREFLIGHT
1462
+ // lint into SCHEMA.md's `### Pending` section so the post-apply lint sees them as
1463
+ // known and the close never stalls on a vocabulary gap. The W10 id is hidden in
1464
+ // non-strict --json output (lint.mjs toOut), so the unknown-tag warns are matched
1465
+ // and the tag extracted from the message string itself — kept in lockstep with
1466
+ // lint.mjs's W10 emit (a copy-edit there breaks this; the close-path round-trip
1467
+ // test guards it). Forbidden patterns stay hard errors and are filtered out.
1468
+ // SCOPE (eventual consistency, intended): this registers PRE-EXISTING wiki debt
1469
+ // visible at preflight, NOT a novel tag this very close's payload introduces —
1470
+ // that one would surface only at post-apply and lands on the NEXT close. The
1471
+ // contract is "must not stall", which warns (not errors) already satisfy; the
1472
+ // registration just keeps the vocabulary catching up.
1473
+ // The capture is anchored on the FULL message suffix (not `[^"]+`) so a tag that
1474
+ // itself contains a `"` — non-forbidden, so reachable — is captured whole rather
1475
+ // than truncated at its first quote (codex stage-2 CONCERN).
1476
+ const unknownTagRe = /^Unknown tag: "(.+)" \(not in SCHEMA\.md Tag Vocabulary\)/;
1477
+
1478
+ // W9 (invalid-YAML frontmatter) is warn-severity in lint.mjs's default,
1479
+ // non-strict classification, and its `id` is stripped from the --json warns
1480
+ // this apply reads (only W8 survives toOut without --strict, see lint.mjs).
1481
+ // Matched by its fixed message prefix instead, same technique as
1482
+ // unknownTagRe above. Shared by runPreflight's append-target legacy-debt
1483
+ // check and runPostApplyLint's payload-scope promotion below.
1484
+ const INVALID_YAML_WARN_RE = /^Invalid YAML frontmatter: /;
1407
1485
 
1408
- // B-4 auto-register: lift unknown (non-forbidden) tags surfaced by the PREFLIGHT
1409
- // lint into SCHEMA.md's `### Pending` section so the post-apply lint sees them as
1410
- // known and the close never stalls on a vocabulary gap. The W10 id is hidden in
1411
- // non-strict --json output (lint.mjs toOut), so the unknown-tag warns are matched
1412
- // and the tag extracted from the message string itself — kept in lockstep with
1413
- // lint.mjs's W10 emit (a copy-edit there breaks this; the close-path round-trip
1414
- // test guards it). Forbidden patterns stay hard errors and are filtered out.
1415
- // SCOPE (eventual consistency, intended): this registers PRE-EXISTING wiki debt
1416
- // visible at preflight, NOT a novel tag this very close's payload introduces —
1417
- // that one would surface only at post-apply and lands on the NEXT close. The
1418
- // contract is "must not stall", which warns (not errors) already satisfy; the
1419
- // registration just keeps the vocabulary catching up.
1420
- // The capture is anchored on the FULL message suffix (not `[^"]+`) so a tag that
1421
- // itself contains a `"` — non-forbidden, so reachable — is captured whole rather
1422
- // than truncated at its first quote (codex stage-2 CONCERN).
1423
- const unknownTagRe = /^Unknown tag: "(.+)" \(not in SCHEMA\.md Tag Vocabulary\)/;
1486
+ function registerPendingTags(args, preflightLint, hasConflicts) {
1424
1487
  const pendingTags = [];
1425
1488
  for (const w of preflightLint.warns || []) {
1426
1489
  const m = unknownTagRe.exec(w.message || '');
@@ -1433,14 +1496,16 @@ export function applySessionClose(args) {
1433
1496
  // a close whose whole point was to write nothing. Registration is
1434
1497
  // eventually-consistent by design, so deferring it to the next close costs
1435
1498
  // nothing (codex W2 CONCERN).
1436
- if (pendingTags.length > 0 && conflicts.length === 0) {
1499
+ if (pendingTags.length > 0 && !hasConflicts) {
1437
1500
  appendPendingTags(args.hypoDir, pendingTags);
1438
1501
  }
1502
+ }
1439
1503
 
1440
- // Post-apply lint: payload may have introduced a malformed body or
1441
- // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
1442
- // broke" apart from "frontmatter stale". This runs even if the freshness gate
1443
- // also failed — both failure modes are useful to the caller.
1504
+ // Post-apply lint: payload may have introduced a malformed body or
1505
+ // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
1506
+ // broke" apart from "frontmatter stale". This runs even if the freshness gate
1507
+ // also failed — both failure modes are useful to the caller.
1508
+ function runPostApplyLint(args, payloadScope) {
1444
1509
  let postApplyLint;
1445
1510
  let postApplyCrashed = false;
1446
1511
  try {
@@ -1466,54 +1531,67 @@ export function applySessionClose(args) {
1466
1531
  postBlocking = postApplyLint.errors;
1467
1532
  postNotice = [];
1468
1533
  } else {
1469
- ({ blocking: postBlocking, notice: postNotice } = partitionLintScope(
1534
+ const { blocking: errBlocking, notice: errNotice } = partitionLintScope(
1470
1535
  postApplyLint.errors || [],
1471
1536
  payloadScope,
1472
- ));
1537
+ );
1538
+ // W9 promotion (codex pre-commit review): invalid-YAML frontmatter is
1539
+ // warn-severity, not error, in lint.mjs's default classification, so it
1540
+ // never reached `errors` above without an operator opting into --strict.
1541
+ // A wiki with no --lint-strict pre-commit hook installed could then reach
1542
+ // ok:true and exit 0 with corrupt frontmatter sitting in a file this very
1543
+ // close just wrote. Promote it here to a close-blocking finding, but ONLY
1544
+ // inside payloadScope: this partition is the single source of "this
1545
+ // close's own neighborhood" already used for errors above, so a W9 warn
1546
+ // anywhere else in the vault (this repo's own maintainer vault carries
1547
+ // one, under pages/feedback/) is discarded outright below and never
1548
+ // folded into `postNotice` either. That is untouched, not merely
1549
+ // non-blocking, matching runPreflight's identical scope contract for the
1550
+ // append-target legacy-debt case above. W1 (no-frontmatter) is
1551
+ // deliberately NOT promoted here: a legacy vault's frontmatter-less
1552
+ // log.md is the documented shape --strict itself exempts, and this close
1553
+ // path must keep accepting it.
1554
+ const w9Blocking = partitionLintScope(
1555
+ (postApplyLint.warns || []).filter((w) => INVALID_YAML_WARN_RE.test(w.message || '')),
1556
+ payloadScope,
1557
+ ).blocking;
1558
+ postBlocking = [...errBlocking, ...w9Blocking];
1559
+ postNotice = errNotice;
1473
1560
  }
1474
1561
  const postLintOk = !postApplyCrashed && postBlocking.length === 0;
1475
- // `let` (not const): the close-result invariant self-check below may flip this
1476
- // to false when the settled close result is internally contradictory.
1477
- //
1478
- // A withheld conflict target must fail the close on its own, not merely via the
1479
- // freshness gate. If the other session already touched that page TODAY, freshness
1480
- // sees a fresh file and passes — and the close would report ok:true, write the
1481
- // marker, and drop this session's payload silently. `conflicts` closes that hole.
1482
- let ok = verification.ok && postLintOk && conflicts.length === 0;
1483
-
1484
- // Scope the non-blocking notice to the close-target project: debt under
1485
- // projects/<project>/ stays listed; debt elsewhere folds to a count so the
1486
- // same untouched-file debt does not re-list its filenames on every close.
1487
- const closeScopeNotice = postNotice.filter((e) => isUnderProjectDirs(e.file, [project]));
1488
- const otherDebtCount = postNotice.length - closeScopeNotice.length;
1562
+ return { postApplyLint, postBlocking, postNotice, postLintOk };
1563
+ }
1489
1564
 
1490
- // Amendment 2026-05-19: auto-write the per-session
1491
- // closed marker on a verified close. Hook authority is read-only; this is
1492
- // one of the two writer paths (the other is --mark-session-closed standalone).
1493
- //
1494
- // The marker write is governed by the SAME gate as standalone
1495
- // --mark-session-closed and /compact (precompactGateStatus), NOT just apply's
1496
- // `ok` + git-clean. Apply's payload preflight/post-apply lint and `ok` still
1497
- // govern apply SUCCESS (exit code below), but the marker must additionally
1498
- // clear feedback projection / W8 design-history / hot.md structure, else this
1499
- // path could issue a marker the standalone path would refuse (the second
1500
- // divergence codex flagged).
1501
- //
1502
- // Apply just wrote the payload, so the tree is dirty by its OWN
1503
- // writes: the gate's `uncommitted` git blocker would always trip and the
1504
- // marker would be skipped, deferring the close to a manual --mark-session-closed
1505
- // ("done but still blocked" regression). Commit the payload HERE, via
1506
- // the SAME .hypoignore-aware helper the auto-commit Stop hook uses, so the gate sees
1507
- // a committed tree. Push stays deferred to the Stop hook; the resulting
1508
- // committed-but-unpushed state is a gate notice, not a blocker, so
1509
- // this still marks. A commit failure (not a repo / pre-commit reject / git error)
1510
- // skips the marker WITH a surfaced reason — today's behavior was also "no marker",
1511
- // but silently.
1565
+ // Amendment 2026-05-19: auto-write the per-session
1566
+ // closed marker on a verified close. Hook authority is read-only; this is
1567
+ // one of the two writer paths (the other is --mark-session-closed standalone).
1568
+ //
1569
+ // The marker write is governed by the SAME gate as standalone
1570
+ // --mark-session-closed and /compact (precompactGateStatus), NOT just apply's
1571
+ // `ok` + git-clean. Apply's payload preflight/post-apply lint and `ok` still
1572
+ // govern apply SUCCESS (exit code below), but the marker must additionally
1573
+ // clear feedback projection / W8 design-history / hot.md structure, else this
1574
+ // path could issue a marker the standalone path would refuse (the second
1575
+ // divergence codex flagged).
1576
+ //
1577
+ // Apply just wrote the payload, so the tree is dirty by its OWN
1578
+ // writes: the gate's `uncommitted` git blocker would always trip and the
1579
+ // marker would be skipped, deferring the close to a manual --mark-session-closed
1580
+ // ("done but still blocked" regression). Commit the payload HERE, via
1581
+ // the SAME .hypoignore-aware helper the auto-commit Stop hook uses, so the gate sees
1582
+ // a committed tree. Push stays deferred to the Stop hook; the resulting
1583
+ // committed-but-unpushed state is a gate notice, not a blocker, so
1584
+ // this still marks. A commit failure (not a repo / pre-commit reject / git error)
1585
+ // skips the marker WITH a surfaced reason — today's behavior was also "no marker",
1586
+ // but silently.
1587
+ //
1588
+ // Returns { markerWritten, markerSkipReason, commitOutcome }. `commitOutcome` is
1589
+ // reported by the result JSON: `null` when this apply never reached the commit
1590
+ // step at all (ok:false before the writes were even verified), distinct from a
1591
+ // commit that ran and reported `committed:false`.
1592
+ function runMarkerPhase(args, project, appliedPaths, ok) {
1512
1593
  let markerWritten = false;
1513
1594
  let markerSkipReason = null;
1514
- // Hoisted so the result JSON below can report it: `null` when this apply never
1515
- // reached the commit step at all (ok:false before the writes were even
1516
- // verified), distinct from a commit that ran and reported `committed:false`.
1517
1595
  let commitOutcome = null;
1518
1596
  if (ok && args.sessionId) {
1519
1597
  // Close-gate resolution: apply succeeding (`ok`) IS the resolution, not
@@ -1572,6 +1650,12 @@ export function applySessionClose(args) {
1572
1650
  }
1573
1651
  let closeTranscript = null;
1574
1652
  let gateOk = false;
1653
+ // verified_scope evidence (session-close-scope-boundary spec §3, revised
1654
+ // 2026-09-07): the set the gate below actually put a row for, filled in
1655
+ // once the gate below runs. Stays [] on any path that never reaches it
1656
+ // (uncommitted, no transcript) — normalizeVerifiedScope drops an empty
1657
+ // 'global' scope to "field absent" rather than persist a false claim.
1658
+ let gateEvaluatedProjects = [];
1575
1659
  if (commitOutcome.committed) {
1576
1660
  closeTranscript = resolveTranscriptBySessionId(args.sessionId);
1577
1661
  // closeScope: apply KNOWS which project it just closed, and it wrote
@@ -1592,11 +1676,19 @@ export function applySessionClose(args) {
1592
1676
  // `project` alone, going green on a foreign project's incomplete close
1593
1677
  // instead of demoting it to a notice.
1594
1678
  const autoMarkerOverride = resolveGateProjectOverride(args.hypoDir, { project });
1595
- gateOk = precompactGateStatus(args.hypoDir, {
1679
+ const gateStatus = precompactGateStatus(args.hypoDir, {
1596
1680
  closeScope: [project],
1597
1681
  ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
1598
1682
  ...(autoMarkerOverride ? { attributionScope: autoMarkerOverride } : {}),
1599
- }).ok;
1683
+ });
1684
+ gateOk = gateStatus.ok;
1685
+ // `closeScope` above widens the partition, it never narrows
1686
+ // sessionCloseGlobalStatus (only opts.projectOverride does, and this
1687
+ // call never sets it) — so gate.close.projects is the actual evaluated
1688
+ // set, not necessarily just `[project]`.
1689
+ gateEvaluatedProjects = (gateStatus.close.projects || [])
1690
+ .map((p) => p.project)
1691
+ .filter(Boolean);
1600
1692
  }
1601
1693
  const decision = planMarkerDecision({
1602
1694
  ok,
@@ -1623,7 +1715,17 @@ export function applySessionClose(args) {
1623
1715
  if (decision.write) {
1624
1716
  // apply KNOWS its authoritative payload.project — stamp it as the v4
1625
1717
  // evidence set so PreCompact trusts this marker's scope directly (session-close attribution).
1626
- writeSessionClosedMarker(args.hypoDir, args.sessionId, { project, projects: [project] });
1718
+ // verified_scope (revised 2026-09-07): `closeScope: [project]` above
1719
+ // widens resolveCloseScope's partition, it does not narrow
1720
+ // sessionCloseGlobalStatus — only opts.projectOverride does, and this
1721
+ // call never sets it. The gate ran unnarrowed, so `kind` is 'global',
1722
+ // with `projects` the set gate.close actually evaluated
1723
+ // (gateEvaluatedProjects), never `[project]` verbatim.
1724
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, {
1725
+ project,
1726
+ projects: [project],
1727
+ verifiedScope: { kind: 'global', projects: gateEvaluatedProjects },
1728
+ });
1627
1729
  // Codex CONCERN: the writer swallows IO errors (best-effort).
1628
1730
  // Verify the file actually landed — mirroring the standalone path — instead of
1629
1731
  // asserting markerWritten=true, so a .cache permission/disk problem surfaces
@@ -1635,11 +1737,15 @@ export function applySessionClose(args) {
1635
1737
  }
1636
1738
  }
1637
1739
  }
1638
- // A conflict outranks the downstream gates: verification and lint both describe
1639
- // a tree this apply declined to finish writing, so naming them would point the
1640
- // reader at the wrong repair. A proposal-STORE failure outranks even that: the
1641
- // withheld bytes never reached an artifact, so it is the most urgent repair.
1642
- let stage = ok
1740
+ return { markerWritten, markerSkipReason, commitOutcome };
1741
+ }
1742
+
1743
+ // A conflict outranks the downstream gates: verification and lint both describe
1744
+ // a tree this apply declined to finish writing, so naming them would point the
1745
+ // reader at the wrong repair. A proposal-STORE failure outranks even that: the
1746
+ // withheld bytes never reached an artifact, so it is the most urgent repair.
1747
+ function resolveCloseStage({ ok, proposalStoreFailed, conflicts, verification, postLintOk }) {
1748
+ return ok
1643
1749
  ? null
1644
1750
  : proposalStoreFailed
1645
1751
  ? 'proposal-store-failed'
@@ -1650,30 +1756,33 @@ export function applySessionClose(args) {
1650
1756
  : !verification.ok
1651
1757
  ? 'post-apply-verification'
1652
1758
  : 'post-apply-lint';
1653
- // Runtime close-result invariant self-check. When a
1654
- // marker-write path (args.sessionId present) settles into an internally
1655
- // contradictory shape — ok:true with the marker silently withheld and no
1656
- // reason, or a written marker that also carries a skip reason — flip ok:false
1657
- // and stage-tag it so the existing `process.exit(ok ? 0 : 1)` yields exit 1.
1658
- // That non-zero exit is the discriminator that separates a genuine
1659
- // contradiction (a code bug) from a legitimate withhold (exit 0, e.g.
1660
- // no-user-close-signal). apply is idempotent, so a non-zero re-run is safe.
1661
- // Unreachable today; this is a regression guard for future refactors.
1662
- if (args.sessionId) {
1663
- const contradiction = closeResultContradiction({ ok, markerWritten, markerSkipReason });
1664
- if (contradiction) {
1665
- ok = false;
1666
- stage = contradiction;
1667
- process.stderr.write(
1668
- `\n🛑 INTERNAL CONTRADICTION in session-close result: ${contradiction}\n` +
1669
- ` markerWritten=${markerWritten}, markerSkipReason=${JSON.stringify(markerSkipReason)}.\n` +
1670
- ` This is a close-pipeline bug, not a normal withhold. Exiting non-zero so it\n` +
1671
- ` cannot masquerade as a successful close. The applied payload files stand;\n` +
1672
- ` re-running apply is idempotent once the pipeline is fixed.\n`,
1673
- );
1674
- }
1675
- }
1676
- const result = {
1759
+ }
1760
+
1761
+ // The stdout JSON contract of a payload-bearing apply. Takes one bag because it
1762
+ // genuinely consumes the whole settled close state; every field below is read
1763
+ // straight off it.
1764
+ function buildCloseResult({
1765
+ ok,
1766
+ stage,
1767
+ project,
1768
+ date,
1769
+ applied,
1770
+ skipped,
1771
+ commitOutcome,
1772
+ conflicts,
1773
+ proposals,
1774
+ proposalStoreFailed,
1775
+ proposalStoreFailures,
1776
+ verification,
1777
+ sessionId,
1778
+ markerWritten,
1779
+ markerSkipReason,
1780
+ preflightLint,
1781
+ postApplyLint,
1782
+ closeScopeNotice,
1783
+ otherDebtCount,
1784
+ }) {
1785
+ return {
1677
1786
  ok,
1678
1787
  stage,
1679
1788
  project,
@@ -1719,7 +1828,7 @@ export function applySessionClose(args) {
1719
1828
  verification,
1720
1829
  // Surface the marker outcome instead of skipping silently, so the
1721
1830
  // caller can tell "closed" from "applied but not marked".
1722
- ...(args.sessionId ? { markerWritten, markerSkipReason } : {}),
1831
+ ...(sessionId ? { markerWritten, markerSkipReason } : {}),
1723
1832
  lint: {
1724
1833
  preflight: summarizeLintForOutput(preflightLint),
1725
1834
  postApply: summarizeLintForOutput(postApplyLint),
@@ -1734,97 +1843,283 @@ export function applySessionClose(args) {
1734
1843
  notices: [...new Set(closeScopeNotice.map((e) => e.file))],
1735
1844
  otherDebtCount,
1736
1845
  };
1846
+ }
1737
1847
 
1738
- if (args.json) {
1739
- console.log(JSON.stringify(result, null, 2));
1740
- } else {
1741
- console.log(`Session-close apply (project: ${project}, date: ${date}):`);
1742
- for (const a of applied) console.log(` ✓ wrote ${a}`);
1743
- for (const s of skipped) console.log(` · skipped ${s} (already current)`);
1744
- // Never let a withheld target read as a skip: `skipped` means "already current",
1745
- // this means "your bytes are NOT on disk". Overwrite conflicts drifted from base;
1746
- // an append conflict is a lock-timeout (someone else held the file's lock), which
1747
- // is transient — the next close re-applies.
1748
- for (const c of conflicts) {
1749
- const why =
1750
- c.kind === 'append'
1751
- ? 'could not acquire the append lock in time; the next close re-applies'
1752
- : 'the page changed since this session read it';
1753
- console.log(` ⚠ WITHHELD ${c.key} (${c.target}) — ${c.reason}; ${why}`);
1754
- }
1755
- for (const p of proposals) {
1848
+ // The human-readable (non --json) rendering of the same settled state.
1849
+ function printCloseReport({
1850
+ project,
1851
+ date,
1852
+ applied,
1853
+ skipped,
1854
+ conflicts,
1855
+ proposals,
1856
+ ok,
1857
+ markerWritten,
1858
+ markerSkipReason,
1859
+ verification,
1860
+ postLintOk,
1861
+ postBlocking,
1862
+ closeScopeNotice,
1863
+ otherDebtCount,
1864
+ }) {
1865
+ console.log(`Session-close apply (project: ${project}, date: ${date}):`);
1866
+ for (const a of applied) console.log(` ✓ wrote ${a}`);
1867
+ for (const s of skipped) console.log(` · skipped ${s} (already current)`);
1868
+ // Never let a withheld target read as a skip: `skipped` means "already current",
1869
+ // this means "your bytes are NOT on disk". Overwrite conflicts drifted from base;
1870
+ // an append conflict is a lock-timeout (someone else held the file's lock), which
1871
+ // is transient — the next close re-applies.
1872
+ for (const c of conflicts) {
1873
+ const why =
1874
+ c.kind === 'append'
1875
+ ? 'could not acquire the append lock in time; the next close re-applies'
1876
+ : 'the page changed since this session read it';
1877
+ console.log(` ⚠ WITHHELD ${c.key} (${c.target}) — ${c.reason}; ${why}`);
1878
+ }
1879
+ for (const p of proposals) {
1880
+ console.log(` · parked proposal ${p.id} for ${p.target} (review with \`hypomnema proposal\`)`);
1881
+ }
1882
+ if (applied.length > 0 && conflicts.length > 0) {
1883
+ console.log(
1884
+ '\n· partial close: the writes above ARE on disk but NOT committed — this close is\n' +
1885
+ ' ok:false, so no commit and no session-close marker run until the withheld\n' +
1886
+ ' target(s) are resolved and the close re-runs.',
1887
+ );
1888
+ }
1889
+ if (ok) {
1890
+ // When the marker was withheld, qualify the success line so a reader scanning
1891
+ // stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
1892
+ // is non-null exactly when args.sessionId is set and the marker did not land.
1893
+ if (markerSkipReason) {
1756
1894
  console.log(
1757
- ` · parked proposal ${p.id} for ${p.target} (review with \`hypomnema proposal\`)`,
1895
+ '\n✓ session-close files verified (all 5 mandatory files fresh, lint clean).' +
1896
+ '\n session NOT fully closed: the Stop-chain marker was not written (see warning below).',
1758
1897
  );
1898
+ } else {
1899
+ console.log('\n✓ session-close verified — all 5 mandatory files fresh, lint clean.');
1759
1900
  }
1760
- if (applied.length > 0 && conflicts.length > 0) {
1761
- console.log(
1762
- '\n· partial close: the writes above ARE on disk but NOT committed — this close is\n' +
1763
- ' ok:false, so no commit and no session-close marker run until the withheld\n' +
1764
- ' target(s) are resolved and the close re-runs.',
1765
- );
1901
+ }
1902
+ // When ok:true but the session-close marker was NOT written, the Stop-chain
1903
+ // still sees an open session and will re-prompt at the next Stop. Surface this
1904
+ // loudly so neither the human nor a skill-following model reads "ok:true" as
1905
+ // "session fully closed". Gate on `!markerWritten` too so this "marker NOT
1906
+ // written" line cannot fire on the contradiction-B path (a written marker that
1907
+ // also carried a skip reason) — there the invariant's own 🛑 line already
1908
+ // explains the failure, and this message would contradict markerWritten:true.
1909
+ if (markerSkipReason && !markerWritten) {
1910
+ process.stderr.write(
1911
+ `\n⚠️ session-close marker NOT written (reason: ${markerSkipReason})\n` +
1912
+ ` The 5 mandatory files were applied and verified (ok:true), but the\n` +
1913
+ ` per-session Stop-chain marker was withheld. The session is NOT fully\n` +
1914
+ ` closed: the Stop hook will re-prompt until the marker is present.\n` +
1915
+ ` To fix: re-run with the correct main-conversation --session-id (NOT\n` +
1916
+ ` a background task or Agent UUID from a /tmp/... path).\n` +
1917
+ ` Example: crystallize.mjs --apply-session-close --payload=<path>\n` +
1918
+ ` --session-id=<main-conversation-id> --hypo-dir=<path>\n`,
1919
+ );
1920
+ }
1921
+ if (!ok) {
1922
+ if (!verification.ok) {
1923
+ const bad = [
1924
+ ...verification.missing.map((f) => `${f} (missing)`),
1925
+ ...verification.stale.map((f) => `${f} (stale)`),
1926
+ ].join(', ');
1927
+ console.log(`\n✗ session-close still incomplete after apply: ${bad}`);
1928
+ console.log(' Fix the payload (likely an `updated:` field) and retry.');
1766
1929
  }
1767
- if (ok) {
1768
- // When the marker was withheld, qualify the success line so a reader scanning
1769
- // stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
1770
- // is non-null exactly when args.sessionId is set and the marker did not land.
1771
- if (markerSkipReason) {
1772
- console.log(
1773
- '\n✓ session-close files verified (all 5 mandatory files fresh, lint clean).' +
1774
- '\n session NOT fully closed: the Stop-chain marker was not written (see warning below).',
1775
- );
1930
+ if (!postLintOk) {
1931
+ console.log('\n✗ post-apply lint failed:');
1932
+ for (const e of postBlocking) console.log(` ✗ ${e.file}: ${e.message}`);
1933
+ console.log(' Payload introduced a lint blocker — fix the payload content and retry.');
1934
+ }
1935
+ }
1936
+ if (closeScopeNotice.length > 0) {
1937
+ console.log(
1938
+ `\n· ${closeScopeNotice.length} pre-existing lint issue(s) in untouched files (not blocking): ${[
1939
+ ...new Set(closeScopeNotice.map((e) => e.file)),
1940
+ ]
1941
+ .slice(0, 5)
1942
+ .join(', ')}${closeScopeNotice.length > 5 ? ', …' : ''}`,
1943
+ );
1944
+ }
1945
+ if (otherDebtCount > 0) {
1946
+ console.log(
1947
+ `\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.`,
1948
+ );
1949
+ }
1950
+ }
1951
+
1952
+ export function applySessionClose(args) {
1953
+ // Option D: early-exit fires only when NO payload was supplied.
1954
+ // Rationale: payload presence is explicit close intent and must always run
1955
+ // the full apply path — the per-entry idempotency (overwrite's step-1 skip +
1956
+ // exact-entry append dedup) keeps re-apply cheap without short-circuiting,
1957
+ // and avoids silent-success when a same-day second close brings new bytes.
1958
+ // Payload-less invocation is treated as a cheap "already complete?" probe.
1959
+ // --force opts out of that probe shortcut only — payload remains required
1960
+ // for any actual apply work (readPayload below surfaces "payload is
1961
+ // required" the same way it always has).
1962
+ if (!args.force && !args.payload) {
1963
+ // No-payload "already complete?" probe uses the
1964
+ // global invariant, not a recency pick.
1965
+ const probe = sessionCloseGlobalStatus(args.hypoDir);
1966
+ if (probe.ok) {
1967
+ const result = {
1968
+ ok: true,
1969
+ alreadyComplete: true,
1970
+ project: probe.project,
1971
+ date: probe.dates[0],
1972
+ message: '오늘 이미 close 완료로 보임 (probe 모드 — payload 미지정).',
1973
+ };
1974
+ if (args.json) {
1975
+ console.log(JSON.stringify(result, null, 2));
1776
1976
  } else {
1777
- console.log('\n✓ session-close verified — all 5 mandatory files fresh, lint clean.');
1977
+ console.log(`✓ ${result.message}`);
1978
+ console.log(` project: ${result.project} / date: ${result.date}`);
1778
1979
  }
1980
+ process.exit(0);
1779
1981
  }
1780
- // When ok:true but the session-close marker was NOT written, the Stop-chain
1781
- // still sees an open session and will re-prompt at the next Stop. Surface this
1782
- // loudly so neither the human nor a skill-following model reads "ok:true" as
1783
- // "session fully closed". Gate on `!markerWritten` too so this "marker NOT
1784
- // written" line cannot fire on the contradiction-B path (a written marker that
1785
- // also carried a skip reason) — there the invariant's own 🛑 line already
1786
- // explains the failure, and this message would contradict markerWritten:true.
1787
- if (markerSkipReason && !markerWritten) {
1982
+ // gate not ok → fall through to readPayload, which surfaces
1983
+ // "payload is required" with the same error shape as before.
1984
+ }
1985
+
1986
+ refuseUnlessCloseRequested(args);
1987
+ const payload = loadValidatedPayload(args);
1988
+ const project = resolveCloseProject(args, payload);
1989
+ const date = payload.date || todayLocal();
1990
+ assertPayloadFreshnessContract(args, payload, project, date);
1991
+ const { preflightLint, payloadScope, indexRelPath, indexMissing } = runPreflight(
1992
+ args,
1993
+ payload,
1994
+ project,
1995
+ date,
1996
+ );
1997
+
1998
+ const applied = [];
1999
+ const skipped = [];
2000
+ // The ACTUAL vault-relative paths this apply wrote, kept separate
2001
+ // from `applied` (whose entries are display strings like `key (relPath)`,
2002
+ // not bare paths). This is the scope handed to commitWikiChanges below;
2003
+ // never the broader `payloadScope` above, which also includes lint/evidence
2004
+ // candidates this apply may not have written a byte to.
2005
+ const appliedPaths = [];
2006
+ // Overwrite targets this apply refused to write because the page moved under
2007
+ // it. T6 turns these into `.cache/proposals/` artifacts; here they are already
2008
+ // enough to withhold the bytes and fail the close.
2009
+ const conflicts = [];
2010
+ // One bag for the four accumulators, passed to every write phase below. They
2011
+ // push into it in call order; nothing is merged back afterwards, so the
2012
+ // report lines keep the exact order the inline version produced.
2013
+ const acc = { applied, skipped, appliedPaths, conflicts };
2014
+
2015
+ applyOverwrites(args, payload, project, date, indexRelPath, indexMissing, acc);
2016
+ appendSessionLogEntry(args, payload, project, date, acc);
2017
+ appendRootLogEntry(args, payload, project, date, acc);
2018
+
2019
+ const { proposals, proposalStoreFailures } = parkOverwriteConflicts(args, conflicts);
2020
+ const proposalStoreFailed = proposalStoreFailures.length > 0;
2021
+
2022
+ // Same-date-tie fix: verify against the SAME project this apply just wrote
2023
+ // (`project` = payload.project || probe.project, resolved at the top). Without
2024
+ // the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
2025
+ // on a same-date root-hot.md tie, can pick a different project — false-failing
2026
+ // a completed close (the 2026-06-09 security-ops-kb incident).
2027
+ const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
2028
+
2029
+ registerPendingTags(args, preflightLint, conflicts.length > 0);
2030
+
2031
+ const { postApplyLint, postBlocking, postNotice, postLintOk } = runPostApplyLint(
2032
+ args,
2033
+ payloadScope,
2034
+ );
2035
+
2036
+ // `let` (not const): the close-result invariant self-check below may flip this
2037
+ // to false when the settled close result is internally contradictory.
2038
+ //
2039
+ // A withheld conflict target must fail the close on its own, not merely via the
2040
+ // freshness gate. If the other session already touched that page TODAY, freshness
2041
+ // sees a fresh file and passes — and the close would report ok:true, write the
2042
+ // marker, and drop this session's payload silently. `conflicts` closes that hole.
2043
+ let ok = verification.ok && postLintOk && conflicts.length === 0;
2044
+
2045
+ // Scope the non-blocking notice to the close-target project: debt under
2046
+ // projects/<project>/ stays listed; debt elsewhere folds to a count so the
2047
+ // same untouched-file debt does not re-list its filenames on every close.
2048
+ const closeScopeNotice = postNotice.filter((e) => isUnderProjectDirs(e.file, [project]));
2049
+ const otherDebtCount = postNotice.length - closeScopeNotice.length;
2050
+
2051
+ const { markerWritten, markerSkipReason, commitOutcome } = runMarkerPhase(
2052
+ args,
2053
+ project,
2054
+ appliedPaths,
2055
+ ok,
2056
+ );
2057
+
2058
+ let stage = resolveCloseStage({ ok, proposalStoreFailed, conflicts, verification, postLintOk });
2059
+ // Runtime close-result invariant self-check. When a
2060
+ // marker-write path (args.sessionId present) settles into an internally
2061
+ // contradictory shape — ok:true with the marker silently withheld and no
2062
+ // reason, or a written marker that also carries a skip reason — flip ok:false
2063
+ // and stage-tag it so the existing `process.exit(ok ? 0 : 1)` yields exit 1.
2064
+ // That non-zero exit is the discriminator that separates a genuine
2065
+ // contradiction (a code bug) from a legitimate withhold (exit 0, e.g.
2066
+ // no-user-close-signal). apply is idempotent, so a non-zero re-run is safe.
2067
+ // Unreachable today; this is a regression guard for future refactors.
2068
+ if (args.sessionId) {
2069
+ const contradiction = closeResultContradiction({ ok, markerWritten, markerSkipReason });
2070
+ if (contradiction) {
2071
+ ok = false;
2072
+ stage = contradiction;
1788
2073
  process.stderr.write(
1789
- `\n⚠️ session-close marker NOT written (reason: ${markerSkipReason})\n` +
1790
- ` The 5 mandatory files were applied and verified (ok:true), but the\n` +
1791
- ` per-session Stop-chain marker was withheld. The session is NOT fully\n` +
1792
- ` closed: the Stop hook will re-prompt until the marker is present.\n` +
1793
- ` To fix: re-run with the correct main-conversation --session-id (NOT\n` +
1794
- ` a background task or Agent UUID from a /tmp/... path).\n` +
1795
- ` Example: crystallize.mjs --apply-session-close --payload=<path>\n` +
1796
- ` --session-id=<main-conversation-id> --hypo-dir=<path>\n`,
1797
- );
1798
- }
1799
- if (!ok) {
1800
- if (!verification.ok) {
1801
- const bad = [
1802
- ...verification.missing.map((f) => `${f} (missing)`),
1803
- ...verification.stale.map((f) => `${f} (stale)`),
1804
- ].join(', ');
1805
- console.log(`\n✗ session-close still incomplete after apply: ${bad}`);
1806
- console.log(' Fix the payload (likely an `updated:` field) and retry.');
1807
- }
1808
- if (!postLintOk) {
1809
- console.log('\n✗ post-apply lint failed:');
1810
- for (const e of postBlocking) console.log(` ✗ ${e.file}: ${e.message}`);
1811
- console.log(' Payload introduced a lint blocker — fix the payload content and retry.');
1812
- }
1813
- }
1814
- if (closeScopeNotice.length > 0) {
1815
- console.log(
1816
- `\n· ${closeScopeNotice.length} pre-existing lint issue(s) in untouched files (not blocking): ${[
1817
- ...new Set(closeScopeNotice.map((e) => e.file)),
1818
- ]
1819
- .slice(0, 5)
1820
- .join(', ')}${closeScopeNotice.length > 5 ? ', …' : ''}`,
1821
- );
1822
- }
1823
- if (otherDebtCount > 0) {
1824
- console.log(
1825
- `\n· +${otherDebtCount} pre-existing lint issue(s) elsewhere in the vault (other projects / shared pages, not blocking) — run \`node scripts/lint.mjs\` for the full list.`,
2074
+ `\n🛑 INTERNAL CONTRADICTION in session-close result: ${contradiction}\n` +
2075
+ ` markerWritten=${markerWritten}, markerSkipReason=${JSON.stringify(markerSkipReason)}.\n` +
2076
+ ` This is a close-pipeline bug, not a normal withhold. Exiting non-zero so it\n` +
2077
+ ` cannot masquerade as a successful close. The applied payload files stand;\n` +
2078
+ ` re-running apply is idempotent once the pipeline is fixed.\n`,
1826
2079
  );
1827
2080
  }
1828
2081
  }
2082
+ const result = buildCloseResult({
2083
+ ok,
2084
+ stage,
2085
+ project,
2086
+ date,
2087
+ applied,
2088
+ skipped,
2089
+ commitOutcome,
2090
+ conflicts,
2091
+ proposals,
2092
+ proposalStoreFailed,
2093
+ proposalStoreFailures,
2094
+ verification,
2095
+ sessionId: args.sessionId,
2096
+ markerWritten,
2097
+ markerSkipReason,
2098
+ preflightLint,
2099
+ postApplyLint,
2100
+ closeScopeNotice,
2101
+ otherDebtCount,
2102
+ });
2103
+
2104
+ if (args.json) {
2105
+ console.log(JSON.stringify(result, null, 2));
2106
+ } else {
2107
+ printCloseReport({
2108
+ project,
2109
+ date,
2110
+ applied,
2111
+ skipped,
2112
+ conflicts,
2113
+ proposals,
2114
+ ok,
2115
+ markerWritten,
2116
+ markerSkipReason,
2117
+ verification,
2118
+ postLintOk,
2119
+ postBlocking,
2120
+ closeScopeNotice,
2121
+ otherDebtCount,
2122
+ });
2123
+ }
1829
2124
  process.exit(ok ? 0 : 1);
1830
2125
  }