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