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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.ko.md +8 -6
- package/README.md +8 -6
- package/commands/crystallize.md +32 -6
- package/commands/graph.md +7 -4
- package/commands/lint.md +8 -1
- package/commands/query.md +6 -4
- package/commands/resume.md +1 -0
- package/commands/verify.md +12 -1
- package/docs/ARCHITECTURE.md +26 -12
- package/docs/CONTRIBUTING.md +15 -6
- package/hooks/hypo-compact-guard.mjs +126 -23
- package/hooks/hypo-cwd-change.mjs +20 -19
- package/hooks/hypo-first-prompt.mjs +31 -16
- package/hooks/hypo-lookup.mjs +10 -5
- package/hooks/hypo-session-start.mjs +180 -2
- package/hooks/hypo-shared.mjs +410 -83
- package/hooks/hypo-web-fetch-ingest.mjs +9 -13
- package/package.json +2 -1
- package/scripts/doctor.mjs +32 -3
- package/scripts/graph.mjs +22 -2
- package/scripts/init.mjs +5 -1
- package/scripts/lib/crystallize-args.mjs +38 -2
- package/scripts/lib/crystallize-close-apply.mjs +745 -450
- package/scripts/lint.mjs +242 -39
- package/scripts/query.mjs +22 -2
- package/scripts/resume.mjs +177 -2
- package/scripts/upgrade.mjs +2 -2
- package/scripts/verify.mjs +22 -2
- package/templates/SCHEMA.md +23 -1
- package/templates/hypo-automation.md +4 -2
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +1 -1
- package/skills/crystallize/SKILL.md +0 -189
- package/skills/graph/SKILL.md +0 -58
- package/skills/ingest/SKILL.md +0 -107
- package/skills/lint/SKILL.md +0 -59
- package/skills/query/SKILL.md +0 -62
- 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 |
|
|
613
|
-
*
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
-
|
|
930
|
+
return project;
|
|
931
|
+
}
|
|
915
932
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
}
|
|
1204
|
-
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
1458
|
+
return { proposals, proposalStoreFailures };
|
|
1459
|
+
}
|
|
1400
1460
|
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
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 &&
|
|
1499
|
+
if (pendingTags.length > 0 && !hasConflicts) {
|
|
1437
1500
|
appendPendingTags(args.hypoDir, pendingTags);
|
|
1438
1501
|
}
|
|
1502
|
+
}
|
|
1439
1503
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1476
|
-
|
|
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
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
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
|
-
|
|
1679
|
+
const gateStatus = precompactGateStatus(args.hypoDir, {
|
|
1596
1680
|
closeScope: [project],
|
|
1597
1681
|
...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
|
|
1598
1682
|
...(autoMarkerOverride ? { attributionScope: autoMarkerOverride } : {}),
|
|
1599
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
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 (
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
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(
|
|
1977
|
+
console.log(`✓ ${result.message}`);
|
|
1978
|
+
console.log(` project: ${result.project} / date: ${result.date}`);
|
|
1778
1979
|
}
|
|
1980
|
+
process.exit(0);
|
|
1779
1981
|
}
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
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
|
|
1790
|
-
`
|
|
1791
|
-
`
|
|
1792
|
-
`
|
|
1793
|
-
`
|
|
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
|
}
|