cadet-agent 0.47.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cadet-agent",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  validateState, migrateStateFile, readState, writeState, evaluateTransition, applyTransition,
7
7
  workItemIdOf, loadPolicy, RunLedger, loadRun, listRuns, cleanupRuns, buildReport, formatReport,
8
8
  runVerificationLoop, commandForGate, detectCapabilities, runsDir, gitChangedFiles, PolicyError, StateError,
9
- detectRepoRole, describeRepoRole, GATES, manualConfirmation,
9
+ detectRepoRole, describeRepoRole, GATES, PHASES, manualConfirmation,
10
10
  gitChangeSet, DEFAULT_REPORT_DIR,
11
11
  reconcileArtifacts, PLANS_DEFAULT_DIR,
12
12
  parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
@@ -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
@@ -74,6 +75,7 @@ function showHelp() {
74
75
  --command Command override (harness verify)
75
76
  --files Comma-separated relevant files to bind evidence to (harness verify|confirm)
76
77
  --commit Revision the gate attests, as a hex SHA (harness verify|confirm)
78
+ --expect-phase Refuse to record a gate unless the current phase matches (harness verify|confirm|verify-acs|verify-reachability)
77
79
  --reason Why automation was unavailable (harness confirm)
78
80
  --expires-at ISO-8601 expiry bounding the confirmation (harness confirm)
79
81
  --environment key=value,... describing what was verified (harness confirm)
@@ -89,6 +91,8 @@ function showHelp() {
89
91
  --agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
90
92
  --older-than-ms Age bound, in ms, for records cleanup may delete (harness cleanup; required)
91
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)
92
96
  --commit-msg Path to write the prepared commit message to (state seal)
93
97
  --verify-sealed Also verify evidence sealed in commit trailers (state validate)
94
98
  --dry-run Report what a mutating command would do and write nothing (all mutating commands)
@@ -154,6 +158,9 @@ function parseArgs(argv) {
154
158
  case '--command': opts.command = value(a); break;
155
159
  case '--work-item': opts.workItemId = value(a); break;
156
160
  case '--phase': opts.phase = value(a); break;
161
+ // --expect-phase: a guard against recording a gate into a phase the caller
162
+ // did not intend. See assertExpectedPhase.
163
+ case '--expect-phase': opts.expectPhase = value(a); break;
157
164
  case '--run': opts.runId = value(a); break;
158
165
  case '--type': opts.type = value(a); break;
159
166
  case '--reason': opts.reason = value(a); break;
@@ -166,6 +173,12 @@ function parseArgs(argv) {
166
173
  // working-tree scan (which could bind evidence to Cadet's own files).
167
174
  case '--files': opts.filesGiven = true; opts.files = value(a).split(',').map((s) => s.trim()).filter(Boolean); break;
168
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;
169
182
  case '--report': opts.report = value(a); break;
170
183
  // AR-1: the revision a gate record attests, so a gate-related fix claim
171
184
  // can be traced to the commit that contains it.
@@ -236,6 +249,40 @@ function fail(opts, message, code = json => json.exitCode || 1, json = {}) {
236
249
  process.exit(exitCode);
237
250
  }
238
251
 
252
+ /**
253
+ * `--expect-phase <phase>` — refuse to record gate evidence into a phase the
254
+ * caller did not intend.
255
+ *
256
+ * The failure this closes is a caller error, not a framework one: `state
257
+ * transition` already reports `allowed: false` and exits 1, but an agent that
258
+ * chains commands with `;` and filters the output reads the *next* command's
259
+ * success as the transition's, and goes on to record the following gates into the
260
+ * phase it never left. The verdict was correct and ignored; the record was then
261
+ * written anyway. This is a check because the mistake recurred after being
262
+ * documented, and a check is what the framework's own doctrine asks for at that
263
+ * point.
264
+ *
265
+ * The flag is opt-in and cheap: omitting it changes nothing. A mismatch is
266
+ * refused before any write, so a stray `--expect-phase` cannot corrupt state —
267
+ * it can only stop the command.
268
+ */
269
+ function assertExpectedPhase(opts, state) {
270
+ if (!opts.expectPhase) return;
271
+ if (!PHASES.includes(opts.expectPhase)) {
272
+ fail(opts, `--expect-phase "${opts.expectPhase}" is not a known phase. Valid phases: ${PHASES.join(', ')}.`, () => 1, { ok: false, code: 'unknown-phase', expectedPhase: opts.expectPhase });
273
+ }
274
+ const actual = state?.session?.currentPhase ?? null;
275
+ if (actual === opts.expectPhase) return;
276
+ fail(
277
+ opts,
278
+ `--expect-phase ${opts.expectPhase}, but the current phase is "${actual ?? '(none)'}". `
279
+ + 'Refusing to record evidence for a phase the caller did not intend — re-read .cadet/state.json '
280
+ + '(or run `state transition --dry-run`) and retry once the phase is what you expected.',
281
+ () => 1,
282
+ { ok: false, code: 'phase-mismatch', expectedPhase: opts.expectPhase, actualPhase: actual },
283
+ );
284
+ }
285
+
239
286
  // ── evidence archive (contract v5) ──────────────────────────────────────────
240
287
 
241
288
  /**
@@ -493,7 +540,7 @@ async function cmdState(opts) {
493
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' });
494
541
  }
495
542
  const keep = parseKeepBound(opts.keep);
496
- const { state: next, archived, archivedHistory } = toStateV4(state, { keep });
543
+ const { state: next, archived, archivedHistory } = toStateV4(state, { keep, retainAll: opts.retainAll === true });
497
544
  const written = appendEvidenceArchive(opts.targetDir, archived);
498
545
  const history = appendHistoryArchive(opts.targetDir, archivedHistory);
499
546
  const changed = archived.length > 0 || archivedHistory.length > 0;
@@ -501,7 +548,7 @@ async function cmdState(opts) {
501
548
  emit(
502
549
  opts,
503
550
  changed
504
- ? `✅ 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.\n Archive: ${join(opts.targetDir, '.cadet', 'archive')}`
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')}`
505
552
  : '✅ Nothing to compact: every evidence record and change-log entry is already kept inline.',
506
553
  {
507
554
  ok: true,
@@ -517,6 +564,62 @@ async function cmdState(opts) {
517
564
  return;
518
565
  }
519
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
+
520
623
  if (sub === 'seal') {
521
624
  const { exists, state } = readState(opts.targetDir);
522
625
  if (!exists) fail(opts, 'No .cadet/state.json found.', () => 2);
@@ -618,7 +721,7 @@ async function cmdState(opts) {
618
721
  return;
619
722
  }
620
723
 
621
- 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.`);
622
725
  }
623
726
 
624
727
  /**
@@ -672,6 +775,7 @@ async function cmdHarness(opts) {
672
775
 
673
776
  const { exists, state } = readState(opts.targetDir);
674
777
  if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before recording confirmation.', () => 2);
778
+ assertExpectedPhase(opts, state);
675
779
 
676
780
  const strict = policy.strictClosure?.enabled === true ? policy.strictClosure : null;
677
781
  const mc = strict?.manualConfirmation || null;
@@ -829,6 +933,7 @@ async function cmdHarness(opts) {
829
933
  const gate = opts.gate;
830
934
  if (!gate) fail(opts, 'harness verify requires --gate <gate>');
831
935
  const { state } = readState(opts.targetDir);
936
+ assertExpectedPhase(opts, state);
832
937
  const caps = detectCapabilities({ targetDir: opts.targetDir });
833
938
  const descriptor = opts.command
834
939
  ? { command: opts.command, tool: 'custom', automated: true }
@@ -973,6 +1078,7 @@ async function cmdHarness(opts) {
973
1078
  // never written cannot be asserted into coverage.
974
1079
  if (!opts.story) fail(opts, 'harness verify-acs requires --story <path>');
975
1080
  const { exists, state } = readState(opts.targetDir);
1081
+ assertExpectedPhase(opts, state);
976
1082
  const strict = policy.strictClosure?.enabled === true;
977
1083
  const workItemId = state ? workItemIdOf(state) : 'unscoped';
978
1084
  const phase = state?.session?.currentPhase || 'implementation';
@@ -1082,6 +1188,26 @@ async function cmdHarness(opts) {
1082
1188
  const at = new Date();
1083
1189
  const criteriaStrings = coverage.ac.flatMap((a) => [a.id, ...a.declared]);
1084
1190
  const nowIso = at.toISOString();
1191
+ // The story is the only INPUT to the AC claim: it carries the declared
1192
+ // AC→test mapping, and `criteriaHash` binds those names (C12), so editing the
1193
+ // mapping invalidates the record.
1194
+ //
1195
+ // The test report is an OUTPUT of the run that satisfied `testsPassed`, not an
1196
+ // input, and binding it was a defect: a repository whose test script rewrites a
1197
+ // fixed report path (e.g. `test-results-junit.xml`) staled this record the
1198
+ // moment it re-ran the tests, because the file the record had just read changed
1199
+ // underneath it. This is the same class Harness §5 already excludes
1200
+ // (`.cadet/state.json`, `.cadet/runs/**`) — "binding evidence to either would
1201
+ // make a gate stale the instant it was written" — so a generated report gets
1202
+ // the same treatment and is kept as `artifactPath` for audit, where nothing
1203
+ // re-hashes it.
1204
+ //
1205
+ // The story path is made repo-relative for the same reason verify-reachability
1206
+ // does it: an absolute path never resolves under the root when freshness is
1207
+ // re-derived at transition time, so both hashes would be computed over a
1208
+ // missing file and match — a binding that is silently inert.
1209
+ const storyPath = resolve(opts.targetDir, opts.story);
1210
+ const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
1085
1211
  const evidence = createEvidence({
1086
1212
  evidenceId: newId(),
1087
1213
  workItemId,
@@ -1092,9 +1218,11 @@ async function cmdHarness(opts) {
1092
1218
  command: `harness verify-acs --story ${opts.story}`,
1093
1219
  result: `AC coverage verified: ${coverage.ac.length} criteria, inventory ${coverage.inventorySize} (${inventory.format})`,
1094
1220
  exitCode: 0,
1095
- inputTreeHash: computeInputTreeHash(opts.targetDir, [opts.story, ...(reportPath ? [reportPath] : [])]),
1221
+ // Audit pointer only. Not a relevant file: see above.
1222
+ artifactPath: reportPath ? reportPath.replace(/\\/g, '/') : null,
1223
+ inputTreeHash: computeInputTreeHash(opts.targetDir, [storyRel]),
1096
1224
  criteriaHash: hashCriteria(criteriaStrings),
1097
- relevantFiles: [opts.story, ...(reportPath ? [reportPath] : [])].map((f) => f.replace(/\\/g, '/')),
1225
+ relevantFiles: [storyRel],
1098
1226
  createdAt: at,
1099
1227
  expiresAt: null,
1100
1228
  // Schema + validator require an object carrying a `scope`, not a bare
@@ -1162,6 +1290,7 @@ async function cmdHarness(opts) {
1162
1290
  const storyPath = resolve(opts.targetDir, opts.story);
1163
1291
  const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
1164
1292
  const { exists, state } = readState(opts.targetDir);
1293
+ assertExpectedPhase(opts, state);
1165
1294
  const enabled = policy.reachability?.enabled === true;
1166
1295
  const probeCommand = policy.reachability?.command || null;
1167
1296
  const workItemId = state ? workItemIdOf(state) : 'unscoped';
@@ -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.',
@@ -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,
@@ -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 only the active work item's
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 = [];
@@ -1177,8 +1226,17 @@ export function isUngatedForwardEdge(fromPhase, toPhase) {
1177
1226
  * must have been created at or after that instant. Without it, "fresh" would mean
1178
1227
  * only "not yet expired", which lets a long phase carry evidence that predates
1179
1228
  * the work it is meant to attest.
1229
+ *
1230
+ * `phaseScoped` is false for the strict-closure `revalidate` set. A revalidated
1231
+ * gate asks "is this still true *now*?" — answered by the input-tree hash, the
1232
+ * criteria hash, and the expiry — not "was it recorded in the phase I am leaving?".
1233
+ * Enforcing the phase stamp on a revalidated gate made the answer "no" for every
1234
+ * record written in an earlier phase, so the whole suite had to be re-recorded in
1235
+ * `review` and again in `validation` on a tree that had not changed by a byte.
1236
+ * Primary gates keep the phase scope: a record still has to be written in the
1237
+ * phase it belongs to.
1180
1238
  */
1181
- function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase, rootDir, computeTreeHash, inputTreeHash, critHash, recencyFloor = null }) {
1239
+ function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase, rootDir, computeTreeHash, inputTreeHash, critHash, recencyFloor = null, phaseScoped = true }) {
1182
1240
  const missingGates = [];
1183
1241
  const staleEvidence = [];
1184
1242
 
@@ -1204,7 +1262,7 @@ function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase,
1204
1262
  const { fresh, reasons } = evidenceFreshness(evidence, {
1205
1263
  now,
1206
1264
  workItemId,
1207
- phase: fromPhase,
1265
+ phase: phaseScoped ? fromPhase : null,
1208
1266
  inputTreeHash: currentTreeHash,
1209
1267
  criteriaHash: critHash,
1210
1268
  });
@@ -1344,6 +1402,14 @@ export function evaluateTransition(state, toPhase, context = {}) {
1344
1402
  }
1345
1403
 
1346
1404
  // Strict closure: re-derive the earlier gates at this transition.
1405
+ //
1406
+ // A revalidated gate is checked WITHOUT the phase scope (`phaseScoped: false`).
1407
+ // Its question is "is this still true now?", which the input-tree hash, the
1408
+ // criteria hash, and the expiry answer; the phase stamp answers only "which
1409
+ // phase wrote it down", which is exactly the fact revalidation is not doubting.
1410
+ // With the phase scope on, every record written in an earlier phase was rejected
1411
+ // as stale, so an unchanged tree still forced the whole suite to be re-recorded
1412
+ // in `review` and again in `validation`. See checkGate.
1347
1413
  const strict = resolveStrict(context);
1348
1414
  const revalidated = [];
1349
1415
  if (strict && strict.revalidateOnClosure !== false) {
@@ -1353,7 +1419,7 @@ export function evaluateTransition(state, toPhase, context = {}) {
1353
1419
  for (const gate of spec.revalidate) {
1354
1420
  if (spec.gates.includes(gate)) continue; // already checked as a primary gate
1355
1421
  revalidated.push(gate);
1356
- const r = checkGate({ ...shared, gate, recencyFloor });
1422
+ const r = checkGate({ ...shared, gate, recencyFloor, phaseScoped: false });
1357
1423
  missingGates.push(...r.missingGates);
1358
1424
  staleEvidence.push(...r.staleEvidence);
1359
1425
  }
@@ -1510,10 +1576,11 @@ export function mergeEvidenceCoverage(existing, records) {
1510
1576
  * merely conservative — it is provably safe for any document that was valid before
1511
1577
  * compaction: `validateState` already rejects a claimed-true gate whose supporting
1512
1578
  * record belongs to a *different* work item, so every gate a valid document
1513
- * depends on is already backed by exactly the records this keeps. A stricter
1514
- * selector (say, "newest passing record per gate") would be smaller and would
1515
- * silently break the red-before-green rule, which needs the prior failing record
1516
- * for the same work item and gate to still exist.
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.
1517
1584
  */
1518
1585
  function keepSelector(keep, state) {
1519
1586
  if (keep === 'always') return () => true;
@@ -1532,23 +1599,88 @@ function keepSelector(keep, state) {
1532
1599
  throw new StateError(`unknown keep selector ${JSON.stringify(keep)}; expected "always", "active", or a list of work item ids`);
1533
1600
  }
1534
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
+
1535
1662
  /**
1536
1663
  * Split a document's evidence into the part that stays live and the part that
1537
1664
  * becomes history (contract v5).
1538
1665
  *
1539
- * "Live" is defined by a keep selector, defaulting to the active work item — see
1540
- * `keepSelector` for why that boundary is the safe one.
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.
1541
1670
  *
1542
1671
  * Returns `{ live, archived, coverage }`. Pure: no I/O, so the caller decides
1543
1672
  * where the archive is written.
1544
1673
  */
1545
- export function splitEvidence(state, { keep = 'active' } = {}) {
1674
+ export function splitEvidence(state, { keep = 'active', retainAll = false } = {}) {
1546
1675
  const records = Array.isArray(state?.gateEvidence) ? state.gateEvidence : [];
1547
1676
  const keepRecord = keepSelector(keep, state);
1677
+ const inScope = records.filter((record) => keepRecord(record));
1678
+ const retained = retainLiveRecords(inScope, { retainAll });
1548
1679
  const live = [];
1549
1680
  const archived = [];
1550
1681
  for (const record of records) {
1551
- if (keepRecord(record)) live.push(record);
1682
+ // Original order is preserved on both sides so the archive stays readable.
1683
+ if (inScope.includes(record) && retained.has(record)) live.push(record);
1552
1684
  else archived.push(record);
1553
1685
  }
1554
1686
  const prior = isPlainObject(state?.evidenceCoverage) ? state.evidenceCoverage : {};