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 +1 -1
- package/src/cli.mjs +136 -7
- package/src/harness/commands.mjs +9 -0
- package/src/harness/index.mjs +1 -1
- package/src/harness/state.mjs +146 -14
package/package.json
CHANGED
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
|
|
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
|
-
|
|
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: [
|
|
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';
|
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 = [];
|
|
@@ -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.
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
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
|
-
*
|
|
1540
|
-
* `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.
|
|
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
|
-
|
|
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 : {};
|