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