cadet-agent 0.48.0 → 0.49.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.
- package/package.json +1 -1
- package/src/cli.mjs +69 -4
- package/src/harness/commands.mjs +9 -0
- package/src/harness/index.mjs +1 -1
- package/src/harness/state.mjs +126 -11
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
collectDeclaredTestNames, reconcileTestNames,
|
|
18
18
|
resolveCommand, describeCommand, describeAllCommands, checkUnattendedRequirements, COMMANDS,
|
|
19
19
|
STATE_VERSION, sealedEvidence, recordEvidence, appendEvidence, sealWorkItem, toStateV4,
|
|
20
|
-
isHistoryExternal, HISTORY_ENTRIES_KEPT,
|
|
20
|
+
isHistoryExternal, HISTORY_ENTRIES_KEPT, resetGatesForNewWorkItem,
|
|
21
21
|
} from './harness/index.mjs';
|
|
22
22
|
|
|
23
23
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -50,6 +50,7 @@ function showHelp() {
|
|
|
50
50
|
cadet-agent state migrate Atomically migrate state to the current version (backup on write)
|
|
51
51
|
cadet-agent state migrate --to 4 Compact: archive closed work items' evidence, build the index
|
|
52
52
|
cadet-agent state compact --keep <bound> Move closed work items' evidence into .cadet/archive/
|
|
53
|
+
cadet-agent state begin --epic <id> --story <file> Start a work item: reset gates, archive the previous item's evidence
|
|
53
54
|
cadet-agent state seal Write the active work item's evidence as commit trailers
|
|
54
55
|
cadet-agent state transition --to <phase> Enforce the transition matrix + evidence
|
|
55
56
|
cadet-agent state transition --to <phase> --dry-run Check only; writes nothing
|
|
@@ -90,6 +91,8 @@ function showHelp() {
|
|
|
90
91
|
--agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
|
|
91
92
|
--older-than-ms Age bound, in ms, for records cleanup may delete (harness cleanup; required)
|
|
92
93
|
--keep always|active|<work-item ids> for what stays in state.json (state compact; required)
|
|
94
|
+
--retain-all state compact: keep every record of the kept work items; only cross-work-item records leave
|
|
95
|
+
--epic Epic id of the work item being started (state begin)
|
|
93
96
|
--commit-msg Path to write the prepared commit message to (state seal)
|
|
94
97
|
--verify-sealed Also verify evidence sealed in commit trailers (state validate)
|
|
95
98
|
--dry-run Report what a mutating command would do and write nothing (all mutating commands)
|
|
@@ -170,6 +173,12 @@ function parseArgs(argv) {
|
|
|
170
173
|
// working-tree scan (which could bind evidence to Cadet's own files).
|
|
171
174
|
case '--files': opts.filesGiven = true; opts.files = value(a).split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
172
175
|
case '--story': opts.story = value(a); break;
|
|
176
|
+
// state begin: the epic the new work item belongs to.
|
|
177
|
+
case '--epic': opts.epicId = value(a); break;
|
|
178
|
+
// state compact: keep every record inline instead of applying the
|
|
179
|
+
// within-work-item retention rule. Made explicit at the call site, because
|
|
180
|
+
// otherwise a reader cannot tell a retained document from an unbounded one.
|
|
181
|
+
case '--retain-all': opts.retainAll = true; break;
|
|
173
182
|
case '--report': opts.report = value(a); break;
|
|
174
183
|
// AR-1: the revision a gate record attests, so a gate-related fix claim
|
|
175
184
|
// can be traced to the commit that contains it.
|
|
@@ -531,7 +540,7 @@ async function cmdState(opts) {
|
|
|
531
540
|
fail(opts, `state.json is v${state.version ?? state.stateVersion}; compaction requires v${STATE_VERSION}. Run "cadet-agent state migrate --to ${STATE_VERSION}" first.`, () => 1, { ok: false, code: 'compact-requires-v4' });
|
|
532
541
|
}
|
|
533
542
|
const keep = parseKeepBound(opts.keep);
|
|
534
|
-
const { state: next, archived, archivedHistory } = toStateV4(state, { keep });
|
|
543
|
+
const { state: next, archived, archivedHistory } = toStateV4(state, { keep, retainAll: opts.retainAll === true });
|
|
535
544
|
const written = appendEvidenceArchive(opts.targetDir, archived);
|
|
536
545
|
const history = appendHistoryArchive(opts.targetDir, archivedHistory);
|
|
537
546
|
const changed = archived.length > 0 || archivedHistory.length > 0;
|
|
@@ -539,7 +548,7 @@ async function cmdState(opts) {
|
|
|
539
548
|
emit(
|
|
540
549
|
opts,
|
|
541
550
|
changed
|
|
542
|
-
? `✅ Compacted state.json: archived ${archived.length} evidence record(s), ${history.appended} change-log entr(ies); kept ${next.gateEvidence.length} record(s) and ${(next.changeHistory || []).length} entr(ies) inline
|
|
551
|
+
? `✅ Compacted state.json: archived ${archived.length} evidence record(s), ${history.appended} change-log entr(ies); kept ${next.gateEvidence.length} record(s) and ${(next.changeHistory || []).length} entr(ies) inline.${opts.retainAll ? '\n --retain-all: every kept work item\'s records stay inline, so only cross-work-item records left.' : ''}\n Archive: ${join(opts.targetDir, '.cadet', 'archive')}`
|
|
543
552
|
: '✅ Nothing to compact: every evidence record and change-log entry is already kept inline.',
|
|
544
553
|
{
|
|
545
554
|
ok: true,
|
|
@@ -555,6 +564,62 @@ async function cmdState(opts) {
|
|
|
555
564
|
return;
|
|
556
565
|
}
|
|
557
566
|
|
|
567
|
+
if (sub === 'begin') {
|
|
568
|
+
// The story boundary, as a command.
|
|
569
|
+
//
|
|
570
|
+
// It existed only as a sentence in `skills/Resume.md` — "set `activeWorkItem`,
|
|
571
|
+
// reset gates" — which an agent carried out by editing state.json by hand, and
|
|
572
|
+
// the sentence never mentions evidence. So the previous work item's records
|
|
573
|
+
// stayed inline for ever. On the audited repository that was 63 records and
|
|
574
|
+
// ~3,000 lines, none of which any gate could read: `evidenceFreshness` rejects
|
|
575
|
+
// a record whose `workItemId` is not the active one. `resetGatesForNewWorkItem`
|
|
576
|
+
// already did the job correctly — cleared the evidence, folded it into the
|
|
577
|
+
// coverage index first, dropped expired exceptions, wrote one boundary line —
|
|
578
|
+
// and had no caller. This is the door.
|
|
579
|
+
if (!opts.epicId) fail(opts, 'state begin requires --epic <epicId>');
|
|
580
|
+
if (!opts.story) fail(opts, 'state begin requires --story <storyFile>');
|
|
581
|
+
const { exists, state } = readState(opts.targetDir);
|
|
582
|
+
if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before starting a work item.', () => 2);
|
|
583
|
+
|
|
584
|
+
const fromId = state.activeWorkItem ? workItemIdOf(state) : null;
|
|
585
|
+
const toId = `${opts.epicId}::${opts.story}`;
|
|
586
|
+
if (fromId === toId) {
|
|
587
|
+
fail(opts, `the active work item is already "${toId}"; nothing to begin.`, () => 1, { ok: false, code: 'already-active', workItemId: toId });
|
|
588
|
+
}
|
|
589
|
+
// `closed` is terminal: Resume says new work starts from a fresh session
|
|
590
|
+
// (context-resolution) rather than by beginning a work item inside a plan that
|
|
591
|
+
// is already finished.
|
|
592
|
+
if (state.session?.currentPhase === 'closed') {
|
|
593
|
+
fail(opts, 'the session is closed — start new work from a fresh session (context-resolution) rather than beginning a work item inside a closed plan.', () => 1, { ok: false, code: 'session-closed' });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Nothing leaves state.json without being written down first (contract v5 §1).
|
|
597
|
+
// The reset folds the outgoing records into the coverage index, which is a
|
|
598
|
+
// *summary* — so the records themselves are archived here, exactly as `compact`
|
|
599
|
+
// archives a closed work item's, and before the document is written.
|
|
600
|
+
const outgoing = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
|
|
601
|
+
const written = appendEvidenceArchive(opts.targetDir, outgoing);
|
|
602
|
+
const next = resetGatesForNewWorkItem(state, { epicId: opts.epicId, storyId: opts.story });
|
|
603
|
+
writeState(opts.targetDir, next);
|
|
604
|
+
emit(
|
|
605
|
+
opts,
|
|
606
|
+
`✅ Began ${toId}.\n Gates reset; ${outgoing.length} evidence record(s) archived (${written.appended} appended, ${written.skipped} already archived).`
|
|
607
|
+
+ (fromId ? `\n Previous work item: ${fromId}` : '')
|
|
608
|
+
+ `\n Coverage rows: ${Object.keys(next.evidenceCoverage || {}).length}`,
|
|
609
|
+
{
|
|
610
|
+
ok: true,
|
|
611
|
+
from: fromId,
|
|
612
|
+
to: toId,
|
|
613
|
+
gatesReset: true,
|
|
614
|
+
archived: written.appended,
|
|
615
|
+
alreadyArchived: written.skipped,
|
|
616
|
+
coverageRows: Object.keys(next.evidenceCoverage || {}).length,
|
|
617
|
+
archivePaths: written.files,
|
|
618
|
+
},
|
|
619
|
+
);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
558
623
|
if (sub === 'seal') {
|
|
559
624
|
const { exists, state } = readState(opts.targetDir);
|
|
560
625
|
if (!exists) fail(opts, 'No .cadet/state.json found.', () => 2);
|
|
@@ -656,7 +721,7 @@ async function cmdState(opts) {
|
|
|
656
721
|
return;
|
|
657
722
|
}
|
|
658
723
|
|
|
659
|
-
fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use validate|migrate|compact|seal|transition.`);
|
|
724
|
+
fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use validate|migrate|compact|begin|seal|transition.`);
|
|
660
725
|
}
|
|
661
726
|
|
|
662
727
|
/**
|
package/src/harness/commands.mjs
CHANGED
|
@@ -72,6 +72,15 @@ export const COMMANDS = {
|
|
|
72
72
|
unattended: false,
|
|
73
73
|
requiresForUnattended: ['--keep'],
|
|
74
74
|
},
|
|
75
|
+
'state begin': {
|
|
76
|
+
mutates: true,
|
|
77
|
+
summary: 'Start a work item: reset gates, archive the previous item\'s evidence, fold it into the coverage index.',
|
|
78
|
+
writes: ['.cadet/state.json', '.cadet/archive/**'],
|
|
79
|
+
// The bound is already content-bearing: `--epic` and `--story` name the work
|
|
80
|
+
// item being started, so an unattended caller cannot begin one without saying
|
|
81
|
+
// which. That is why this needs no separate confirmation flag.
|
|
82
|
+
unattended: true,
|
|
83
|
+
},
|
|
75
84
|
'state transition': {
|
|
76
85
|
mutates: true,
|
|
77
86
|
summary: 'Enforce the transition matrix and evidence; applies the transition.',
|
package/src/harness/index.mjs
CHANGED
|
@@ -34,7 +34,7 @@ export {
|
|
|
34
34
|
STATE_VERSION, READABLE_STATE_VERSIONS, HISTORY_EXTERNAL_SINCE, isHistoryExternal,
|
|
35
35
|
validateState, migrateStateV1toV2, migrateStateDocument, migrateStateFile, parseTargetVersion,
|
|
36
36
|
toStateV4, splitEvidence, buildEvidenceCoverage, mergeEvidenceCoverage, sealWorkItem, recordEvidence, appendEvidence,
|
|
37
|
-
HISTORY_ENTRIES_KEPT, compactHistory,
|
|
37
|
+
HISTORY_ENTRIES_KEPT, compactHistory, DEFAULT_MAX_LIVE_EVIDENCE, retainLiveRecords,
|
|
38
38
|
createEvidence, computeInputTreeHash, workItemIdOf, evidenceFreshness,
|
|
39
39
|
latestEvidenceForGate, activeExceptions, requiredGates, isUngatedForwardEdge, evaluateTransition, resolveStrict,
|
|
40
40
|
applyTransition, resetGatesForNewWorkItem, statePathFor, readState, writeState, writeJsonAtomic, StateError,
|
package/src/harness/state.mjs
CHANGED
|
@@ -150,6 +150,50 @@ export function validateState(state, context = {}) {
|
|
|
150
150
|
for (const e of validateEvidenceShape(ev, strict)) errors.push({ path: `gateEvidence[${i}].${e.path}`, message: e.message });
|
|
151
151
|
});
|
|
152
152
|
}
|
|
153
|
+
// Contract v5 C14 says a v4 document's `gateEvidence` holds the ACTIVE work
|
|
154
|
+
// item's records — and in practice it did not. The story boundary is a
|
|
155
|
+
// hand-edit (`skills/Resume.md`: "set `activeWorkItem`, reset gates"), that
|
|
156
|
+
// sentence says nothing about evidence, and `resetGatesForNewWorkItem` — the
|
|
157
|
+
// function that clears it correctly — has no caller. Nothing surfaced the
|
|
158
|
+
// result: the gate checks below only ever ask whether a claimed-true gate's
|
|
159
|
+
// OWN record is bound to the active item, never whether foreign records are
|
|
160
|
+
// sitting in the array. On the audited repository that was 63 records and
|
|
161
|
+
// ~3,000 lines of dead weight inside an 8,000-line document.
|
|
162
|
+
//
|
|
163
|
+
// Warnings, not errors. A foreign record is unreadable by every gate check
|
|
164
|
+
// (`evidenceFreshness` rejects the work-item mismatch), so this is hygiene
|
|
165
|
+
// rather than a safety violation; and an error would invalidate every document
|
|
166
|
+
// that predates the check, on upgrade, for a condition its reader cannot
|
|
167
|
+
// repair in place. Only v4 documents are scoped this way — a v1-v3 document
|
|
168
|
+
// keeps every record inline by design.
|
|
169
|
+
if (version === STATE_VERSION && Array.isArray(state.gateEvidence)) {
|
|
170
|
+
const active = isPlainObject(state.activeWorkItem) ? state.activeWorkItem : null;
|
|
171
|
+
const activeId = active ? `${active.epicId || 'none'}::${active.storyId || 'none'}` : null;
|
|
172
|
+
const foreign = new Map();
|
|
173
|
+
for (const record of state.gateEvidence) {
|
|
174
|
+
const id = record?.workItemId;
|
|
175
|
+
if (!id || id === activeId) continue;
|
|
176
|
+
foreign.set(id, (foreign.get(id) || 0) + 1);
|
|
177
|
+
}
|
|
178
|
+
if (foreign.size > 0) {
|
|
179
|
+
const total = [...foreign.values()].reduce((a, b) => a + b, 0);
|
|
180
|
+
const named = [...foreign.entries()].map(([id, n]) => `${id} (${n})`).join(', ');
|
|
181
|
+
warnings.push({
|
|
182
|
+
path: 'gateEvidence',
|
|
183
|
+
message: `gateEvidence holds ${total} record(s) for work item(s) that are not the active one: ${named}. `
|
|
184
|
+
+ 'They cannot satisfy any gate and only grow the document. Run "cadet-agent state compact --keep active" to move them to .cadet/archive/.',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const maxLive = context.maxLiveEvidence ?? DEFAULT_MAX_LIVE_EVIDENCE;
|
|
188
|
+
if (Number.isFinite(maxLive) && state.gateEvidence.length > maxLive) {
|
|
189
|
+
warnings.push({
|
|
190
|
+
path: 'gateEvidence',
|
|
191
|
+
message: `gateEvidence holds ${state.gateEvidence.length} records inline (limit ${maxLive}). `
|
|
192
|
+
+ 'Most of a record is its `relevantFiles` list, so re-running a gate inflates the document rather than the story. '
|
|
193
|
+
+ 'Run "cadet-agent state compact --keep active", which keeps the newest record per gate plus the red-before-green records and archives the rest.',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
153
197
|
// Gate exceptions are categorised under strict closure (contract v3 §4).
|
|
154
198
|
//
|
|
155
199
|
// v4 gives them their own field, because they are *live state* — scoped to a
|
|
@@ -688,16 +732,21 @@ export function compactHistory(entries, { keepRecent = HISTORY_ENTRIES_KEPT } =
|
|
|
688
732
|
}
|
|
689
733
|
|
|
690
734
|
/**
|
|
691
|
-
* Reshape a document into v4 (contract v5): keep
|
|
735
|
+
* Reshape a document into v4 (contract v5): keep the active work item's live
|
|
692
736
|
* evidence inline, move the rest to `archived` for the caller to persist, promote
|
|
693
737
|
* gate exceptions into their own field, bound the change log, and install the
|
|
694
738
|
* coverage index.
|
|
695
739
|
*
|
|
740
|
+
* Two independent bounds decide what stays inline — `keep` selects the work items
|
|
741
|
+
* and `retainAll` controls the within-item retention described on
|
|
742
|
+
* `retainLiveRecords`. Passing `retainAll: true` keeps an item's whole record set,
|
|
743
|
+
* which is the pre-retention behaviour.
|
|
744
|
+
*
|
|
696
745
|
* Pure — it returns the records to archive rather than writing them, so the
|
|
697
746
|
* caller owns the archive location and this stays testable without a filesystem.
|
|
698
747
|
*/
|
|
699
|
-
export function toStateV4(state, { keep = 'active', keepHistory = HISTORY_ENTRIES_KEPT } = {}) {
|
|
700
|
-
const { live, archived, coverage } = splitEvidence(state, { keep });
|
|
748
|
+
export function toStateV4(state, { keep = 'active', keepHistory = HISTORY_ENTRIES_KEPT, retainAll = false } = {}) {
|
|
749
|
+
const { live, archived, coverage } = splitEvidence(state, { keep, retainAll });
|
|
701
750
|
|
|
702
751
|
const promoted = [];
|
|
703
752
|
const remainingHistory = [];
|
|
@@ -1527,10 +1576,11 @@ export function mergeEvidenceCoverage(existing, records) {
|
|
|
1527
1576
|
* merely conservative — it is provably safe for any document that was valid before
|
|
1528
1577
|
* compaction: `validateState` already rejects a claimed-true gate whose supporting
|
|
1529
1578
|
* record belongs to a *different* work item, so every gate a valid document
|
|
1530
|
-
* depends on is already backed by exactly the records this keeps.
|
|
1531
|
-
*
|
|
1532
|
-
*
|
|
1533
|
-
*
|
|
1579
|
+
* depends on is already backed by exactly the records this keeps.
|
|
1580
|
+
*
|
|
1581
|
+
* This selector answers *which work item*. It deliberately does not answer *which
|
|
1582
|
+
* of that item's records* — see `retainLiveRecords` for that second, independent
|
|
1583
|
+
* bound, and for why "newest passing record per gate" would be wrong on its own.
|
|
1534
1584
|
*/
|
|
1535
1585
|
function keepSelector(keep, state) {
|
|
1536
1586
|
if (keep === 'always') return () => true;
|
|
@@ -1549,23 +1599,88 @@ function keepSelector(keep, state) {
|
|
|
1549
1599
|
throw new StateError(`unknown keep selector ${JSON.stringify(keep)}; expected "always", "active", or a list of work item ids`);
|
|
1550
1600
|
}
|
|
1551
1601
|
|
|
1602
|
+
/**
|
|
1603
|
+
* How many evidence records may stay inline for one work item before
|
|
1604
|
+
* `state validate` warns.
|
|
1605
|
+
*
|
|
1606
|
+
* A record is ~24 fields plus one line per path in `relevantFiles`, and a long
|
|
1607
|
+
* story re-records the same gate many times, so the array grows with *re-runs*
|
|
1608
|
+
* rather than with the size of the story. Measured on the audited repository: 26
|
|
1609
|
+
* `testsPassed` records for a single story, of which one was live.
|
|
1610
|
+
*/
|
|
1611
|
+
export const DEFAULT_MAX_LIVE_EVIDENCE = 60;
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* Which of one work item's records stay inline.
|
|
1615
|
+
*
|
|
1616
|
+
* `keepSelector` answers *which work item*; this answers *which of its records*,
|
|
1617
|
+
* and the two bounds are independent. Without this second one, a single long
|
|
1618
|
+
* story's re-run history stays for ever — the story boundary never fires inside a
|
|
1619
|
+
* story, so nothing bounds it.
|
|
1620
|
+
*
|
|
1621
|
+
* The retained set is exactly what the machinery can still read:
|
|
1622
|
+
*
|
|
1623
|
+
* - the **newest record per gate**, because `latestEvidenceForGate` reads
|
|
1624
|
+
* precisely that and a newer record shadows every older one for its gate;
|
|
1625
|
+
* - every **`passed` / `manual-confirmation`** record, because a claimed-true
|
|
1626
|
+
* gate must be backed by one, and "newest per gate" alone would drop a live
|
|
1627
|
+
* record that a later-appended but *older-stamped* record shadows (a
|
|
1628
|
+
* `manual-confirmation` may carry its own `at`);
|
|
1629
|
+
* - every **`failed`** record, because red-before-green reads the prior red for
|
|
1630
|
+
* the same work item and gate (`runVerificationLoop`'s `priorEvidence`).
|
|
1631
|
+
*
|
|
1632
|
+
* Everything else — `superseded`, and `blocked` that is not the newest for its
|
|
1633
|
+
* gate — is history. It leaves the document exactly as a closed work item's
|
|
1634
|
+
* records do: same archive, same ordering guarantee, same coverage rebuild.
|
|
1635
|
+
*/
|
|
1636
|
+
export function retainLiveRecords(records, { retainAll = false } = {}) {
|
|
1637
|
+
const list = Array.isArray(records) ? records : [];
|
|
1638
|
+
if (retainAll) return new Set(list);
|
|
1639
|
+
|
|
1640
|
+
const at = (record) => {
|
|
1641
|
+
const parsed = Date.parse(record?.createdAt);
|
|
1642
|
+
return Number.isFinite(parsed) ? parsed : -Infinity;
|
|
1643
|
+
};
|
|
1644
|
+
|
|
1645
|
+
const newestByGate = new Map();
|
|
1646
|
+
for (const record of list) {
|
|
1647
|
+
if (!record || typeof record !== 'object' || !record.gate) continue;
|
|
1648
|
+
const current = newestByGate.get(record.gate);
|
|
1649
|
+
if (!current || at(record) >= at(current)) newestByGate.set(record.gate, record);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
const retained = new Set(newestByGate.values());
|
|
1653
|
+
for (const record of list) {
|
|
1654
|
+
if (!record || typeof record !== 'object') continue;
|
|
1655
|
+
if (record.status === 'passed' || record.status === 'manual-confirmation' || record.status === 'failed') {
|
|
1656
|
+
retained.add(record);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
return retained;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1552
1662
|
/**
|
|
1553
1663
|
* Split a document's evidence into the part that stays live and the part that
|
|
1554
1664
|
* becomes history (contract v5).
|
|
1555
1665
|
*
|
|
1556
|
-
*
|
|
1557
|
-
* `keepSelector` for why that boundary is
|
|
1666
|
+
* Two independent bounds decide "live": the keep selector (which work items),
|
|
1667
|
+
* defaulting to the active one — see `keepSelector` for why that boundary is safe
|
|
1668
|
+
* — and `retainLiveRecords` (which of that item's records), which is what bounds
|
|
1669
|
+
* a single long story.
|
|
1558
1670
|
*
|
|
1559
1671
|
* Returns `{ live, archived, coverage }`. Pure: no I/O, so the caller decides
|
|
1560
1672
|
* where the archive is written.
|
|
1561
1673
|
*/
|
|
1562
|
-
export function splitEvidence(state, { keep = 'active' } = {}) {
|
|
1674
|
+
export function splitEvidence(state, { keep = 'active', retainAll = false } = {}) {
|
|
1563
1675
|
const records = Array.isArray(state?.gateEvidence) ? state.gateEvidence : [];
|
|
1564
1676
|
const keepRecord = keepSelector(keep, state);
|
|
1677
|
+
const inScope = records.filter((record) => keepRecord(record));
|
|
1678
|
+
const retained = retainLiveRecords(inScope, { retainAll });
|
|
1565
1679
|
const live = [];
|
|
1566
1680
|
const archived = [];
|
|
1567
1681
|
for (const record of records) {
|
|
1568
|
-
|
|
1682
|
+
// Original order is preserved on both sides so the archive stays readable.
|
|
1683
|
+
if (inScope.includes(record) && retained.has(record)) live.push(record);
|
|
1569
1684
|
else archived.push(record);
|
|
1570
1685
|
}
|
|
1571
1686
|
const prior = isPlainObject(state?.evidenceCoverage) ? state.evidenceCoverage : {};
|