cadet-agent 0.41.0 → 0.43.1

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