cadet-agent 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,14 +15,35 @@ import {
15
15
  EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS, EXCEPTION_REQUIRES_REVIEW_NOTE,
16
16
  } from './policy.mjs';
17
17
  import { hashTree, hashFile, hashCriteria, timestamp, isUuid } from './util.mjs';
18
+ import { encodeEvidenceTrailers } from './gitmemo.mjs';
18
19
 
19
20
  export { PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES };
20
21
  export { EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS };
21
22
 
22
- export const STATE_VERSION = 3;
23
+ export const STATE_VERSION = 4;
23
24
 
24
- /** Highest state version this module can read. v1/v2 remain readable. */
25
- export const READABLE_STATE_VERSIONS = Object.freeze([1, 2, 3]);
25
+ /** Highest state version this module can read. v1/v2/v3 remain readable. */
26
+ export const READABLE_STATE_VERSIONS = Object.freeze([1, 2, 3, 4]);
27
+
28
+ /**
29
+ * Version at which evidence history stopped living in `state.json` (contract v5).
30
+ *
31
+ * A v4 document keeps only the active work item's evidence inline: gate
32
+ * exceptions live in their own field, `changeHistory` is no longer appended to,
33
+ * and everything historical moves to commit trailers plus `.cadet/archive/`.
34
+ *
35
+ * The test is `>=` rather than `===` on purpose. A future version bump inherits
36
+ * "history is external" instead of silently reverting to the unbounded growth
37
+ * this version exists to remove — the failure mode being avoided is a version
38
+ * check that quietly stops applying.
39
+ */
40
+ export const HISTORY_EXTERNAL_SINCE = 4;
41
+
42
+ /** Is this document a version that keeps history out of `state.json`? */
43
+ export function isHistoryExternal(state) {
44
+ const version = state?.version ?? state?.stateVersion;
45
+ return typeof version === 'number' && version >= HISTORY_EXTERNAL_SINCE;
46
+ }
26
47
 
27
48
  class StateError extends Error {
28
49
  constructor(message, detail = {}) {
@@ -71,7 +92,7 @@ export function validateState(state, context = {}) {
71
92
 
72
93
  const version = state.version ?? state.stateVersion;
73
94
  if (!READABLE_STATE_VERSIONS.includes(version)) {
74
- errors.push({ path: 'version', message: `unsupported state version ${JSON.stringify(version)} (expected 1, 2 or 3)` });
95
+ errors.push({ path: 'version', message: `unsupported state version ${JSON.stringify(version)} (expected 1, 2, 3 or 4)` });
75
96
  }
76
97
 
77
98
  if (!isPlainObject(state.session)) {
@@ -116,7 +137,7 @@ export function validateState(state, context = {}) {
116
137
  }
117
138
  }
118
139
 
119
- if (version === 2 || version === 3) {
140
+ if (version >= 2 && version <= STATE_VERSION) {
120
141
  if (state.gateEvidence !== undefined && !Array.isArray(state.gateEvidence)) {
121
142
  errors.push({ path: 'gateEvidence', message: 'gateEvidence must be an array' });
122
143
  }
@@ -126,14 +147,58 @@ export function validateState(state, context = {}) {
126
147
  });
127
148
  }
128
149
  // Gate exceptions are categorised under strict closure (contract v3 §4).
129
- if (strict && Array.isArray(state.changeHistory)) {
150
+ //
151
+ // v4 gives them their own field, because they are *live state* — scoped to a
152
+ // work item and bounded by `expiresAt`, and read by `activeExceptions` — and
153
+ // filing live state in a history array is how the array grew without bound.
154
+ // v1-v3 documents keep them in `changeHistory`, so both sources are read and
155
+ // each is reported under the path it actually lives at.
156
+ if (Array.isArray(state.changeHistory)) {
130
157
  state.changeHistory.forEach((entry, i) => {
131
158
  if (entry?.type !== 'gate-exception') return;
159
+ if (!strict) return;
132
160
  for (const e of validateGateException(entry, strict)) {
133
161
  errors.push({ path: `changeHistory[${i}].${e.path}`, message: e.message });
134
162
  }
135
163
  });
136
164
  }
165
+ if (state.gateExceptions !== undefined && !Array.isArray(state.gateExceptions)) {
166
+ errors.push({ path: 'gateExceptions', message: 'gateExceptions must be an array' });
167
+ }
168
+ if (Array.isArray(state.gateExceptions)) {
169
+ state.gateExceptions.forEach((entry, i) => {
170
+ if (!isPlainObject(entry)) {
171
+ errors.push({ path: `gateExceptions[${i}]`, message: 'gate exception must be an object' });
172
+ return;
173
+ }
174
+ if (!strict) return;
175
+ for (const e of validateGateException(entry, strict)) {
176
+ errors.push({ path: `gateExceptions[${i}].${e.path}`, message: e.message });
177
+ }
178
+ });
179
+ }
180
+ // The coverage index (contract v5). It is what keeps the "a done story owns
181
+ // evidence" check answerable once the records themselves have moved to
182
+ // commits and `.cadet/archive/`, so a malformed index is an error: an index
183
+ // that silently reads as empty would report every completed story as
184
+ // unevidenced, and an index that reads as complete would hide real gaps.
185
+ if (state.evidenceCoverage !== undefined && !isPlainObject(state.evidenceCoverage)) {
186
+ errors.push({ path: 'evidenceCoverage', message: 'evidenceCoverage must be an object keyed by work item id' });
187
+ }
188
+ if (isPlainObject(state.evidenceCoverage)) {
189
+ for (const [workItemId, row] of Object.entries(state.evidenceCoverage)) {
190
+ if (!isPlainObject(row)) {
191
+ errors.push({ path: `evidenceCoverage.${workItemId}`, message: 'coverage row must be an object' });
192
+ continue;
193
+ }
194
+ if (!Number.isInteger(row.recordCount) || row.recordCount < 0) {
195
+ errors.push({ path: `evidenceCoverage.${workItemId}.recordCount`, message: 'recordCount must be a non-negative integer' });
196
+ }
197
+ if (row.gates !== undefined && !Array.isArray(row.gates)) {
198
+ errors.push({ path: `evidenceCoverage.${workItemId}.gates`, message: 'gates must be an array' });
199
+ }
200
+ }
201
+ }
137
202
  // A claimed-true gate must be backed by evidence. This is rejected at
138
203
  // validation time (not only at transition time) so `state validate` cannot
139
204
  // report an unsupported gate as valid. When a rootDir is available, the
@@ -240,6 +305,17 @@ export function validateState(state, context = {}) {
240
305
  .map((e) => (isPlainObject(e) ? e.workItemId : null))
241
306
  .filter((id) => typeof id === 'string' && id.length > 0),
242
307
  );
308
+ // A v4 document archives a closed work item's records, so the inline array
309
+ // is no longer the only evidence of coverage. The index is what remains,
310
+ // and reading it here is what stops compaction from looking like loss:
311
+ // without this, compacting a repository would report every already-done
312
+ // story as unevidenced, and the fix for unbounded growth would be a false
313
+ // accusation of missing evidence.
314
+ if (isPlainObject(state.evidenceCoverage)) {
315
+ for (const id of Object.keys(state.evidenceCoverage)) {
316
+ if (typeof id === 'string' && id.length > 0) evidenced.add(id);
317
+ }
318
+ }
243
319
  // A scoped exception is the FIRST-CLASS escape for a permanent historical
244
320
  // gap. `activeExceptions` is keyed on the ACTIVE work item, which is the
245
321
  // current story — the wrong scope here, where we walk every completed story
@@ -247,13 +323,15 @@ export function validateState(state, context = {}) {
247
323
  // Only a valid, categorised exception counts: an unknown category is not a
248
324
  // loophole, it is a typo, and `validateGateException` rejects it separately.
249
325
  const excepted = new Set();
250
- if (Array.isArray(state.changeHistory)) {
251
- for (const entry of state.changeHistory) {
252
- if (!isPlainObject(entry) || entry.type !== 'gate-exception') continue;
253
- if (!EXCEPTION_CATEGORIES.includes(entry.category)) continue;
254
- const scope = Array.isArray(entry.scope) ? entry.scope : (entry.scope ? [entry.scope] : []);
255
- for (const s of scope) excepted.add(String(s));
256
- }
326
+ const exceptionSources = [
327
+ ...(Array.isArray(state.changeHistory) ? state.changeHistory : []),
328
+ ...(Array.isArray(state.gateExceptions) ? state.gateExceptions : []),
329
+ ];
330
+ for (const entry of exceptionSources) {
331
+ if (!isPlainObject(entry) || entry.type !== 'gate-exception') continue;
332
+ if (!EXCEPTION_CATEGORIES.includes(entry.category)) continue;
333
+ const scope = Array.isArray(entry.scope) ? entry.scope : (entry.scope ? [entry.scope] : []);
334
+ for (const s of scope) excepted.add(String(s));
257
335
  }
258
336
  for (const [epicId, epic] of Object.entries(state.epics)) {
259
337
  if (!isPlainObject(epic) || !isPlainObject(epic.stories)) continue;
@@ -524,16 +602,20 @@ function validateGateException(entry, strict) {
524
602
  // ── Migration ───────────────────────────────────────────────────────────────
525
603
 
526
604
  /**
527
- * Migrate a v1 state document to v2 in memory. Unknown top-level fields are
528
- * preserved. Does not touch the filesystem.
605
+ * Migrate a v1 state document to the current version in memory. Unknown
606
+ * top-level fields are preserved. Does not touch the filesystem.
607
+ *
608
+ * A v2/v3/v4 document is returned *unchanged*. That is deliberate and is pinned
609
+ * by tests: a read must not silently rewrite a document to a new version, because
610
+ * the version stamp decides which semantics apply and a bump would otherwise
611
+ * change how existing evidence is judged. Moving a v2/v3 document to v4 is an
612
+ * explicit act — `state migrate --to 4` — not a side effect of reading it.
529
613
  */
530
614
  export function migrateStateV1toV2(v1) {
531
615
  if (!isPlainObject(v1)) throw new StateError('cannot migrate a non-object state');
532
- if (v1.version === 2 || v1.stateVersion === 2) {
533
- return { state: { ...v1, version: 2, stateVersion: 2, gateEvidence: v1.gateEvidence || [] }, changed: false };
534
- }
535
- if (v1.version === 3 || v1.stateVersion === 3) {
536
- return { state: { ...v1, version: 3, stateVersion: 3, gateEvidence: v1.gateEvidence || [] }, changed: false };
616
+ const declared = v1.version ?? v1.stateVersion;
617
+ if (typeof declared === 'number' && declared >= 2 && READABLE_STATE_VERSIONS.includes(declared)) {
618
+ return { state: { ...v1, gateEvidence: v1.gateEvidence || [] }, changed: false };
537
619
  }
538
620
  const gates = isPlainObject(v1.gates) ? { ...v1.gates } : {};
539
621
  for (const gate of GATES) {
@@ -546,9 +628,12 @@ export function migrateStateV1toV2(v1) {
546
628
  preserved[key] = value;
547
629
  }
548
630
  }
549
- // v1 migrates straight to the current version (v3). The intermediate v2
550
- // shape is identical for these fields; only the version stamp differs, so a
551
- // single-step migration avoids a transient on-disk v2 document.
631
+ // v1 migrates straight to the current version. The intermediate shapes are
632
+ // identical for these fields; only the version stamp differs, so a single-step
633
+ // migration avoids a transient on-disk document at a version nobody asked for.
634
+ // `toStateV4` then applies the current shape, which for a v1 document means
635
+ // promoting any gate exceptions out of `changeHistory` (v1 had no evidence
636
+ // array to compact) and installing an empty coverage index.
552
637
  const migrated = {
553
638
  ...preserved,
554
639
  version: STATE_VERSION,
@@ -563,27 +648,146 @@ export function migrateStateV1toV2(v1) {
563
648
  spikes: v1.spikes || {},
564
649
  changeHistory: Array.isArray(v1.changeHistory) ? [...v1.changeHistory] : [],
565
650
  };
566
- return { state: migrated, changed: true };
651
+ const { state } = toStateV4(migrated);
652
+ return { state, changed: true };
567
653
  }
568
654
 
569
- function activeWorkItemFromState(state) {
570
- const epics = state.epics;
571
- if (!isPlainObject(epics)) return null;
572
- for (const [epicId, epic] of Object.entries(epics)) {
573
- if (!isPlainObject(epic) || !isPlainObject(epic.stories)) continue;
574
- for (const [storyId, status] of Object.entries(epic.stories)) {
575
- if (status === 'in-progress') return { epicId, storyId };
576
- }
655
+ /**
656
+ * How many `changeHistory` entries stay inline after compaction.
657
+ *
658
+ * `changeHistory` is *not* retired in v4, and this constant is why. Eight skills
659
+ * instruct the agent to record an artifact path there ("record the requirements
660
+ * document path in `changeHistory`"), and `Resume` cross-checks its last entry
661
+ * against commit history. Removing the field would make those instructions wrong
662
+ * and take away a facility with no replacement. What was actually unbounded was
663
+ * not the field — it was its *contents*: on the audited repository 65% of the log
664
+ * was 116 handoff entries averaging 1.9 KB of prose each, every one of them
665
+ * duplicating a file already written to `.cadet/handoffs/`, plus 162 one-line
666
+ * transition records already covered by `lastTransition`.
667
+ */
668
+ export const HISTORY_ENTRIES_KEPT = 25;
669
+
670
+ /**
671
+ * Split a change log into the tail that stays inline and the overflow to archive.
672
+ *
673
+ * Keeps the most recent entries, because that is what `Resume` reads and what a
674
+ * handoff cross-checks against. The overflow is returned rather than discarded so
675
+ * the caller can persist it: an audit trail may move, but it must not evaporate.
676
+ */
677
+ export function compactHistory(entries, { keepRecent = HISTORY_ENTRIES_KEPT } = {}) {
678
+ const list = Array.isArray(entries) ? entries : [];
679
+ if (list.length <= keepRecent) return { kept: list, archived: [] };
680
+ return {
681
+ kept: list.slice(list.length - keepRecent),
682
+ archived: list.slice(0, list.length - keepRecent),
683
+ };
684
+ }
685
+
686
+ /**
687
+ * Reshape a document into v4 (contract v5): keep only the active work item's
688
+ * evidence inline, move the rest to `archived` for the caller to persist, promote
689
+ * gate exceptions into their own field, bound the change log, and install the
690
+ * coverage index.
691
+ *
692
+ * Pure — it returns the records to archive rather than writing them, so the
693
+ * caller owns the archive location and this stays testable without a filesystem.
694
+ */
695
+ export function toStateV4(state, { keep = 'active', keepHistory = HISTORY_ENTRIES_KEPT } = {}) {
696
+ const { live, archived, coverage } = splitEvidence(state, { keep });
697
+
698
+ const promoted = [];
699
+ const remainingHistory = [];
700
+ for (const entry of Array.isArray(state.changeHistory) ? state.changeHistory : []) {
701
+ // Exceptions are live state, so they are kept — a legacy one still in
702
+ // changeHistory is promoted rather than dropped, since dropping it would
703
+ // silently withdraw an exception that a gate currently depends on.
704
+ if (entry?.type === 'gate-exception') promoted.push({ ...entry });
705
+ else remainingHistory.push(entry);
577
706
  }
578
- return null;
707
+ const { kept: history, archived: archivedHistory } = compactHistory(remainingHistory, { keepRecent: keepHistory });
708
+
709
+ const next = {
710
+ ...state,
711
+ version: STATE_VERSION,
712
+ stateVersion: STATE_VERSION,
713
+ gateEvidence: live,
714
+ evidenceCoverage: coverage,
715
+ gateExceptions: [
716
+ ...promoted,
717
+ ...(Array.isArray(state.gateExceptions) ? state.gateExceptions : []),
718
+ ],
719
+ changeHistory: history,
720
+ };
721
+
722
+ return {
723
+ state: next,
724
+ archived,
725
+ archivedHistory,
726
+ live: live.length,
727
+ promoted: promoted.length,
728
+ droppedHistory: archivedHistory.length,
729
+ };
730
+ }
731
+
732
+ /**
733
+ * Parse a `--to` value: accepts `4`, `"4"`, and `"v4"`. Returns `null` when
734
+ * absent, and `NaN` when present but unusable so the caller can reject loudly
735
+ * instead of silently migrating to a default.
736
+ */
737
+ export function parseTargetVersion(to) {
738
+ if (to === null || to === undefined || to === '') return null;
739
+ const text = String(to).trim().replace(/^v/i, '');
740
+ if (!/^\d+$/.test(text)) return NaN;
741
+ const n = Number(text);
742
+ return READABLE_STATE_VERSIONS.includes(n) ? n : NaN;
743
+ }
744
+
745
+ /**
746
+ * Apply a migration to a document in memory, at the requested target.
747
+ *
748
+ * The rule is "never downgrade, never guess": a v1 document always migrates
749
+ * forward (that is the documented v1 path), a document only changes when the
750
+ * caller explicitly asked for a higher version, and a request at or below the
751
+ * current version is a no-op rather than an error, so a retried migration is safe.
752
+ */
753
+ export function migrateStateDocument(raw, { to = null, keep = 'active', keepHistory = HISTORY_ENTRIES_KEPT } = {}) {
754
+ const target = parseTargetVersion(to);
755
+ if (Number.isNaN(target)) {
756
+ throw new StateError(`invalid --to version ${JSON.stringify(to)}; expected one of ${READABLE_STATE_VERSIONS.join(', ')}`);
757
+ }
758
+ const from = raw?.version ?? raw?.stateVersion;
759
+ if (from === 1) {
760
+ const { state, changed } = migrateStateV1toV2(raw);
761
+ return { state, changed, archived: [], archivedHistory: [], promoted: 0, droppedHistory: 0 };
762
+ }
763
+ if (typeof from !== 'number' || !READABLE_STATE_VERSIONS.includes(from)) {
764
+ throw new StateError(`cannot migrate unsupported state version ${JSON.stringify(from)}`);
765
+ }
766
+ if (target !== null && from < target) {
767
+ const r = toStateV4(raw, { keep, keepHistory });
768
+ return {
769
+ state: r.state,
770
+ changed: true,
771
+ archived: r.archived,
772
+ archivedHistory: r.archivedHistory,
773
+ promoted: r.promoted,
774
+ droppedHistory: r.droppedHistory,
775
+ };
776
+ }
777
+ return { state: raw, changed: false, archived: [], archivedHistory: [], promoted: 0, droppedHistory: 0 };
579
778
  }
580
779
 
581
780
  /**
582
781
  * Migrate a state file on disk atomically: write a temporary file, optionally
583
782
  * back up the original, then rename into place. A failed migration leaves the
584
783
  * original untouched.
784
+ *
785
+ * `archived` records are returned rather than written here: the caller decides
786
+ * where the append-only archive lives, and keeping this function's failure
787
+ * contract simple ("nothing is written") is what the `atomicFailure` guarantee in
788
+ * the command registry depends on.
585
789
  */
586
- export function migrateStateFile(statePath, { backup = true } = {}) {
790
+ export function migrateStateFile(statePath, { backup = true, to = null, keep = 'active', keepHistory = HISTORY_ENTRIES_KEPT, beforeWrite = null } = {}) {
587
791
  if (!existsSync(statePath)) {
588
792
  throw new StateError(`state file not found: ${statePath}`);
589
793
  }
@@ -593,9 +797,11 @@ export function migrateStateFile(statePath, { backup = true } = {}) {
593
797
  } catch (err) {
594
798
  throw new StateError(`cannot migrate malformed state: ${err.message}`);
595
799
  }
596
- const { state, changed } = migrateStateV1toV2(raw);
597
- if (!changed) return { migrated: false, statePath, state };
800
+ const { state, changed, archived, archivedHistory, promoted, droppedHistory } = migrateStateDocument(raw, { to, keep, keepHistory });
801
+ if (!changed) return { migrated: false, statePath, state, archived: [], archivedHistory: [], promoted: 0, droppedHistory: 0 };
598
802
 
803
+ const from = raw.version ?? raw.stateVersion;
804
+ const backupPath = `${statePath}.v${from}.bak`;
599
805
  const dir = dirname(statePath);
600
806
  const tmpDir = mkdtempSync(join(tmpdir(), 'cadet-state-'));
601
807
  const tmpPath = join(tmpDir, 'state.json');
@@ -613,15 +819,59 @@ export function migrateStateFile(statePath, { backup = true } = {}) {
613
819
  if (!check.valid) {
614
820
  throw new StateError(`migrated state failed validation: ${check.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
615
821
  }
822
+ // Persist the archive BEFORE the document that no longer references it.
823
+ //
824
+ // This ordering is the whole safety argument for compaction. The records
825
+ // filtered out of `gateEvidence` exist nowhere else, so writing the slimmer
826
+ // document first and the archive second would lose them outright if the
827
+ // process died in between. Written first, a crash leaves records present in
828
+ // *both* places — recoverable and detectable, which is the direction a
829
+ // failure should point. A throw here happens before the backup is copied and
830
+ // before the rename, so `atomicFailure` still holds: the tree is untouched.
831
+ if (beforeWrite) beforeWrite({ archived, archivedHistory, state, from });
616
832
  if (backup) {
617
- copyFileSync(statePath, `${statePath}.v1.bak`);
833
+ copyFileSync(statePath, backupPath);
618
834
  }
619
835
  // A failed rename leaves the original in place.
620
836
  renameSync(tmpPath, statePath);
621
837
  } finally {
622
838
  rmSync(tmpDir, { recursive: true, force: true });
623
839
  }
624
- return { migrated: true, statePath, state };
840
+ return { migrated: true, statePath, state, archived, archivedHistory, promoted, droppedHistory, backupPath };
841
+ }
842
+
843
+ /**
844
+ * The trailer lines that seal a work item's evidence into a commit, plus the
845
+ * records they carry (contract v5).
846
+ *
847
+ * Selection is by work item, never by status: `sealWorkItem` is the counterpart of
848
+ * `splitEvidence` and must produce exactly the records that compaction left
849
+ * inline, or the archive and the commit would disagree about what was sealed.
850
+ */
851
+ export function sealWorkItem(state, { workItemId = null, maxBytes = undefined } = {}) {
852
+ const id = workItemId || (state?.activeWorkItem ? workItemIdOf(state) : null);
853
+ const records = (Array.isArray(state?.gateEvidence) ? state.gateEvidence : [])
854
+ .filter((r) => !id || r?.workItemId === id);
855
+ const lines = [];
856
+ const partial = [];
857
+ for (const record of records) {
858
+ const encoded = encodeEvidenceTrailers(record, maxBytes === undefined ? {} : { maxBytes });
859
+ lines.push(...encoded.lines, '');
860
+ if (encoded.partial) partial.push(record.evidenceId || '(no id)');
861
+ }
862
+ return { workItemId: id, records, lines, partial };
863
+ }
864
+
865
+ function activeWorkItemFromState(state) {
866
+ const epics = state.epics;
867
+ if (!isPlainObject(epics)) return null;
868
+ for (const [epicId, epic] of Object.entries(epics)) {
869
+ if (!isPlainObject(epic) || !isPlainObject(epic.stories)) continue;
870
+ for (const [storyId, status] of Object.entries(epic.stories)) {
871
+ if (status === 'in-progress') return { epicId, storyId };
872
+ }
873
+ }
874
+ return null;
625
875
  }
626
876
 
627
877
  // ── Evidence ────────────────────────────────────────────────────────────────
@@ -768,12 +1018,68 @@ export function latestEvidenceForGate(state, gate) {
768
1018
  return matching.reduce((a, b) => (new Date(a.createdAt) >= new Date(b.createdAt) ? a : b));
769
1019
  }
770
1020
 
771
- /** Active gate exceptions keyed by gate, honoring scope and expiry. */
1021
+ /**
1022
+ * Append an evidence record without touching gates or superseding anything.
1023
+ *
1024
+ * The primitive every evidence writer shares. It exists so the coverage index has
1025
+ * exactly one place to be maintained: an index that only some append paths updated
1026
+ * would report coverage for whichever gates happened to travel through the
1027
+ * maintained path, which is worse than no index because it looks authoritative.
1028
+ */
1029
+ export function appendEvidence(state, evidence) {
1030
+ const next = {
1031
+ ...state,
1032
+ gateEvidence: [...(Array.isArray(state?.gateEvidence) ? state.gateEvidence : []), evidence],
1033
+ };
1034
+ if (isHistoryExternal(state)) {
1035
+ next.evidenceCoverage = mergeEvidenceCoverage(
1036
+ isPlainObject(state?.evidenceCoverage) ? state.evidenceCoverage : {},
1037
+ [evidence],
1038
+ );
1039
+ }
1040
+ return next;
1041
+ }
1042
+
1043
+ /**
1044
+ * Append an evidence record to a document and flip its gate.
1045
+ *
1046
+ * Supersedes prior passing evidence for the same gate rather than overwriting it,
1047
+ * because evidence is immutable: a correction is a new record that names the one it
1048
+ * replaces. Built on `appendEvidence` so the index and the array stay in step.
1049
+ *
1050
+ * Centralised so `harness confirm`, `harness verify`, `harness verify-acs` and the
1051
+ * tests cannot drift.
1052
+ */
1053
+ export function recordEvidence(state, evidence) {
1054
+ const gate = evidence?.gate;
1055
+ const superseding = { ...state };
1056
+ if (gate) {
1057
+ superseding.gateEvidence = (Array.isArray(state?.gateEvidence) ? state.gateEvidence : [])
1058
+ .map((e) => (e?.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')
1059
+ ? { ...e, status: 'superseded', supersededBy: evidence.evidenceId }
1060
+ : e));
1061
+ }
1062
+ const next = appendEvidence(superseding, evidence);
1063
+ if (gate) next.gates = { ...(state.gates || {}), [gate]: true };
1064
+ return next;
1065
+ }
1066
+
1067
+ /**
1068
+ * Active gate exceptions keyed by gate, honoring scope and expiry.
1069
+ *
1070
+ * Reads both homes for an exception: `changeHistory` (v1-v3, where a `type`
1071
+ * discriminator picks it out of the log) and `gateExceptions` (v4, a dedicated
1072
+ * field where the discriminator would be redundant). Later entries win, so a
1073
+ * v4 document that still carries legacy entries behaves as it did before.
1074
+ */
772
1075
  export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
773
- const history = Array.isArray(state?.changeHistory) ? state.changeHistory : [];
1076
+ const candidates = [
1077
+ ...(Array.isArray(state?.changeHistory) ? state.changeHistory.filter((e) => e?.type === 'gate-exception') : []),
1078
+ ...(Array.isArray(state?.gateExceptions) ? state.gateExceptions : []),
1079
+ ];
774
1080
  const active = {};
775
- for (const entry of history) {
776
- if (entry?.type !== 'gate-exception') continue;
1081
+ for (const entry of candidates) {
1082
+ if (!isPlainObject(entry)) continue;
777
1083
  if (workItemId && entry.scope && !String(entry.scope).includes(workItemId)) continue;
778
1084
  if (entry.expiresAt && new Date(entry.expiresAt).getTime() <= now.getTime()) continue;
779
1085
  if (entry.gate) active[entry.gate] = entry;
@@ -1004,7 +1310,7 @@ export function applyTransition(state, toPhase, { evidenceIds = [], at = new Dat
1004
1310
  { evaluation }
1005
1311
  );
1006
1312
  }
1007
- return {
1313
+ const next = {
1008
1314
  ...state,
1009
1315
  version: STATE_VERSION,
1010
1316
  stateVersion: STATE_VERSION,
@@ -1015,27 +1321,190 @@ export function applyTransition(state, toPhase, { evidenceIds = [], at = new Dat
1015
1321
  at: timestamp(at),
1016
1322
  evidenceIds,
1017
1323
  },
1018
- changeHistory: [
1324
+ };
1325
+ // v1-v3 documents record each transition as a prose line in `changeHistory`,
1326
+ // which is how those versions were specified. A v4 document does not: the
1327
+ // transition is already in `lastTransition`, and the commit that seals the work
1328
+ // item carries the rest. Appending a line per transition was one of the two
1329
+ // growth paths this version exists to close, along with the evidence array.
1330
+ if (!isHistoryExternal(state)) {
1331
+ next.changeHistory = [
1019
1332
  ...(Array.isArray(state.changeHistory) ? state.changeHistory : []),
1020
1333
  { date: timestamp(at), change: `Phase transition ${state.session.currentPhase} → ${toPhase}`, phase: toPhase },
1021
- ],
1022
- };
1334
+ ];
1335
+ }
1336
+ return next;
1337
+ }
1338
+
1339
+ /**
1340
+ * Recompute coverage rows from a record set.
1341
+ *
1342
+ * Rows for work items present in `records` are *replaced*, rows for items absent
1343
+ * are preserved. That split matters: compaction is by work item, so an item is
1344
+ * either archived (absent here, preserved from the prior index) or inline
1345
+ * (present here, authoritative) — never both. Recomputing rather than adding is
1346
+ * what makes this idempotent, so running it twice over the same document does not
1347
+ * report a story as doubly covered.
1348
+ */
1349
+ export function buildEvidenceCoverage(records, existing = {}) {
1350
+ const coverage = { ...existing };
1351
+ const fresh = new Map();
1352
+ for (const record of Array.isArray(records) ? records : []) {
1353
+ const id = record?.workItemId;
1354
+ if (typeof id !== 'string' || id.length === 0) continue;
1355
+ if (!fresh.has(id)) {
1356
+ fresh.set(id, { workItemId: id, recordCount: 0, gates: [], firstAt: null, lastAt: null, sealedCommit: null });
1357
+ }
1358
+ const row = fresh.get(id);
1359
+ row.recordCount += 1;
1360
+ if (record.gate && !row.gates.includes(record.gate)) row.gates.push(record.gate);
1361
+ const at = record.createdAt ? Date.parse(record.createdAt) : NaN;
1362
+ if (Number.isFinite(at)) {
1363
+ if (!row.firstAt || at < Date.parse(row.firstAt)) row.firstAt = record.createdAt;
1364
+ if (!row.lastAt || at >= Date.parse(row.lastAt)) row.lastAt = record.createdAt;
1365
+ }
1366
+ }
1367
+ for (const [id, row] of fresh) {
1368
+ coverage[id] = {
1369
+ ...row,
1370
+ gates: row.gates.slice().sort(),
1371
+ // A seal recorded earlier survives a recompute; it is a fact about git
1372
+ // history, not something derivable from the records in hand.
1373
+ sealedCommit: coverage[id]?.sealedCommit ?? null,
1374
+ };
1375
+ }
1376
+ return coverage;
1377
+ }
1378
+
1379
+ /**
1380
+ * Fold newly-recorded evidence into an existing index.
1381
+ *
1382
+ * Incremental by design, and deliberately distinct from `buildEvidenceCoverage`:
1383
+ * the records here are additions the index has never seen, so their counts must
1384
+ * be added to what is already known, not substituted for it. Conflating the two
1385
+ * operations is how a rebuilt index silently doubles every count.
1386
+ */
1387
+ export function mergeEvidenceCoverage(existing, records) {
1388
+ const coverage = { ...(existing || {}) };
1389
+ for (const record of Array.isArray(records) ? records : []) {
1390
+ if (!record || typeof record !== 'object') continue;
1391
+ const id = record.workItemId;
1392
+ if (typeof id !== 'string' || id.length === 0) continue;
1393
+ const row = coverage[id] || { workItemId: id, recordCount: 0, gates: [], firstAt: null, lastAt: null, sealedCommit: null };
1394
+ row.recordCount = (row.recordCount || 0) + 1;
1395
+ const gates = Array.isArray(row.gates) ? row.gates : [];
1396
+ if (record.gate && !gates.includes(record.gate)) gates.push(record.gate);
1397
+ row.gates = gates.slice().sort();
1398
+ const at = record.createdAt ? Date.parse(record.createdAt) : NaN;
1399
+ if (Number.isFinite(at)) {
1400
+ if (!row.firstAt || at < Date.parse(row.firstAt)) row.firstAt = record.createdAt;
1401
+ if (!row.lastAt || at >= Date.parse(row.lastAt)) row.lastAt = record.createdAt;
1402
+ }
1403
+ coverage[id] = row;
1404
+ }
1405
+ return coverage;
1406
+ }
1407
+
1408
+ /**
1409
+ * Resolve a `keep` selector into a predicate over an evidence record.
1410
+ *
1411
+ * `active` (the default) keeps the active work item's records. That choice is not
1412
+ * merely conservative — it is provably safe for any document that was valid before
1413
+ * compaction: `validateState` already rejects a claimed-true gate whose supporting
1414
+ * record belongs to a *different* work item, so every gate a valid document
1415
+ * depends on is already backed by exactly the records this keeps. A stricter
1416
+ * selector (say, "newest passing record per gate") would be smaller and would
1417
+ * silently break the red-before-green rule, which needs the prior failing record
1418
+ * for the same work item and gate to still exist.
1419
+ */
1420
+ function keepSelector(keep, state) {
1421
+ if (keep === 'always') return () => true;
1422
+ if (Array.isArray(keep)) {
1423
+ const wanted = new Set(keep.map((id) => String(id)));
1424
+ return (record) => wanted.has(String(record?.workItemId));
1425
+ }
1426
+ if (keep === null || keep === undefined || keep === 'active') {
1427
+ const activeId = state?.activeWorkItem ? workItemIdOf(state) : null;
1428
+ // With no active work item there is nothing to scope to. Keeping everything
1429
+ // would silently defeat the purpose, so nothing is kept — and a document in
1430
+ // that state with a claimed-true gate was already invalid, because the gate
1431
+ // check needs an active work item to bind against.
1432
+ return (record) => Boolean(activeId) && record?.workItemId === activeId;
1433
+ }
1434
+ throw new StateError(`unknown keep selector ${JSON.stringify(keep)}; expected "always", "active", or a list of work item ids`);
1435
+ }
1436
+
1437
+ /**
1438
+ * Split a document's evidence into the part that stays live and the part that
1439
+ * becomes history (contract v5).
1440
+ *
1441
+ * "Live" is defined by a keep selector, defaulting to the active work item — see
1442
+ * `keepSelector` for why that boundary is the safe one.
1443
+ *
1444
+ * Returns `{ live, archived, coverage }`. Pure: no I/O, so the caller decides
1445
+ * where the archive is written.
1446
+ */
1447
+ export function splitEvidence(state, { keep = 'active' } = {}) {
1448
+ const records = Array.isArray(state?.gateEvidence) ? state.gateEvidence : [];
1449
+ const keepRecord = keepSelector(keep, state);
1450
+ const live = [];
1451
+ const archived = [];
1452
+ for (const record of records) {
1453
+ if (keepRecord(record)) live.push(record);
1454
+ else archived.push(record);
1455
+ }
1456
+ const prior = isPlainObject(state?.evidenceCoverage) ? state.evidenceCoverage : {};
1457
+ const coverage = buildEvidenceCoverage(records, prior);
1458
+ return { live, archived, coverage };
1023
1459
  }
1024
1460
 
1025
1461
  /** Reset all gates to false for a new work item (atomic in the returned copy). */
1026
1462
  export function resetGatesForNewWorkItem(state, { epicId = null, storyId = null, at = new Date() } = {}) {
1027
1463
  const gates = {};
1028
1464
  for (const gate of GATES) gates[gate] = false;
1029
- return {
1465
+ const base = {
1030
1466
  ...state,
1031
1467
  gates,
1468
+ // The previous work item's evidence never belongs to the new one, so it is
1469
+ // cleared here in every version. What differs is whether anything is kept
1470
+ // behind to remember that it existed.
1032
1471
  gateEvidence: [],
1033
1472
  activeWorkItem: { epicId, storyId },
1034
- changeHistory: [
1035
- ...(Array.isArray(state.changeHistory) ? state.changeHistory : []),
1036
- { date: timestamp(at), change: `Gate reset for new work item ${epicId || 'none'}::${storyId || 'none'}`, phase: state.session?.currentPhase },
1037
- ],
1038
1473
  };
1474
+
1475
+ if (isHistoryExternal(state)) {
1476
+ // Fold the cleared records into the coverage index before they go.
1477
+ //
1478
+ // This is the whole point. `validateState` rejects a `done` story with no
1479
+ // evidence — and the reason that check exists is that it was once possible to
1480
+ // clear a story's gate state and have validation report the document clean,
1481
+ // with the gap invisible. Clearing an array that nothing summarised is exactly
1482
+ // how that happened, so the summary is written at the moment of clearing.
1483
+ const { coverage } = splitEvidence(state);
1484
+ base.evidenceCoverage = coverage;
1485
+ // An expired exception is inert by definition — `activeExceptions` already
1486
+ // ignores it — so dropping it cannot change a verdict. Keeping it would add a
1487
+ // row per exception forever to the document whose entire purpose is to stay
1488
+ // small.
1489
+ base.gateExceptions = (Array.isArray(state.gateExceptions) ? state.gateExceptions : [])
1490
+ .filter((entry) => !entry?.expiresAt || Date.parse(entry.expiresAt) > at.getTime());
1491
+ // A reset is still worth one line: `lastTransition` does not record it, and
1492
+ // `Resume` reads the log's tail. It is bounded by the number of stories and
1493
+ // carries no prose, so it costs nothing — the unbounded growth this version
1494
+ // removes came from per-transition records and pasted handoff summaries, not
1495
+ // from a one-line story boundary.
1496
+ base.changeHistory = [
1497
+ ...(Array.isArray(state.changeHistory) ? state.changeHistory : []),
1498
+ { date: timestamp(at), change: `Gates reset for new work item ${epicId || 'none'}::${storyId || 'none'}`, phase: state.session?.currentPhase },
1499
+ ];
1500
+ return base;
1501
+ }
1502
+
1503
+ base.changeHistory = [
1504
+ ...(Array.isArray(state.changeHistory) ? state.changeHistory : []),
1505
+ { date: timestamp(at), change: `Gate reset for new work item ${epicId || 'none'}::${storyId || 'none'}`, phase: state.session?.currentPhase },
1506
+ ];
1507
+ return base;
1039
1508
  }
1040
1509
 
1041
1510
  // ── File helpers ────────────────────────────────────────────────────────────