cadet-agent 0.20.2 → 0.24.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 +46 -6
- package/package.json +35 -35
- package/src/cli.mjs +373 -33
- package/src/harness/archive.mjs +242 -0
- package/src/harness/budget.mjs +298 -0
- package/src/harness/context.mjs +229 -0
- package/src/harness/hook.mjs +147 -0
- package/src/harness/index.mjs +60 -0
- package/src/harness/ledger.mjs +309 -0
- package/src/harness/policy.mjs +359 -0
- package/src/harness/redaction.mjs +133 -0
- package/src/harness/routing.mjs +153 -0
- package/src/harness/state.mjs +646 -0
- package/src/harness/util.mjs +149 -0
- package/src/harness/verification.mjs +490 -0
- package/src/install.mjs +92 -154
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cadet-Agent state validation, legal transitions, and evidence-backed gates.
|
|
3
|
+
*
|
|
4
|
+
* Phase names, gate names, and the transition table are frozen compatibility
|
|
5
|
+
* invariants (docs/core/HarnessContract.md §1). This module validates state v2,
|
|
6
|
+
* migrates v1 → v2 atomically, rejects unsupported gate claims, and enforces
|
|
7
|
+
* evidence freshness before any phase transition.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, renameSync, copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { join, dirname } from 'node:path';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import {
|
|
14
|
+
PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES,
|
|
15
|
+
} from './policy.mjs';
|
|
16
|
+
import { hashTree, hashFile, hashCriteria, timestamp, isUuid } from './util.mjs';
|
|
17
|
+
|
|
18
|
+
export { PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES };
|
|
19
|
+
|
|
20
|
+
export const STATE_VERSION = 2;
|
|
21
|
+
|
|
22
|
+
class StateError extends Error {
|
|
23
|
+
constructor(message, detail = {}) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'StateError';
|
|
26
|
+
Object.assign(this, detail);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPlainObject(v) {
|
|
31
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Validation ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate a state document. Returns `{ valid, errors, warnings }` — never throws
|
|
38
|
+
* for user input so the CLI can report every problem at once.
|
|
39
|
+
*
|
|
40
|
+
* When `context.rootDir` is supplied, a claimed-true gate's supporting evidence is
|
|
41
|
+
* also checked for work-item binding and input-tree freshness, so a stale or
|
|
42
|
+
* foreign evidence record cannot make `state validate` report a false valid.
|
|
43
|
+
*
|
|
44
|
+
* Without `rootDir`, freshness cannot be checked; a warning is emitted so a
|
|
45
|
+
* structural-only validation is never mistaken for a full gate-safety check.
|
|
46
|
+
* Callers that validate state on disk should pass `rootDir`.
|
|
47
|
+
*/
|
|
48
|
+
export function validateState(state, context = {}) {
|
|
49
|
+
const errors = [];
|
|
50
|
+
const warnings = [];
|
|
51
|
+
const rootDir = context.rootDir || null;
|
|
52
|
+
const hasTrueGate = isPlainObject(state) && isPlainObject(state.gates)
|
|
53
|
+
&& Object.values(state.gates).some((v) => v === true);
|
|
54
|
+
if (!rootDir && hasTrueGate && context.structuralOnly !== true) {
|
|
55
|
+
warnings.push({
|
|
56
|
+
path: 'gateEvidence',
|
|
57
|
+
message: 'gate freshness was not verified: no rootDir was supplied, so stale or foreign '
|
|
58
|
+
+ 'evidence cannot be detected. Pass { rootDir } to run the full check.',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!isPlainObject(state)) {
|
|
63
|
+
return { valid: false, errors: [{ path: '$', message: 'state must be a JSON object' }], warnings };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const version = state.version ?? state.stateVersion;
|
|
67
|
+
if (version !== 1 && version !== 2) {
|
|
68
|
+
errors.push({ path: 'version', message: `unsupported state version ${JSON.stringify(version)} (expected 1 or 2)` });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!isPlainObject(state.session)) {
|
|
72
|
+
errors.push({ path: 'session', message: 'session must be an object' });
|
|
73
|
+
} else {
|
|
74
|
+
const s = state.session;
|
|
75
|
+
for (const required of ['workflowPath', 'currentPhase', 'trackingMode']) {
|
|
76
|
+
if (s[required] === undefined || s[required] === null) {
|
|
77
|
+
errors.push({ path: `session.${required}`, message: `${required} is required` });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (s.currentPhase !== undefined && !PHASES.includes(s.currentPhase)) {
|
|
81
|
+
errors.push({ path: 'session.currentPhase', message: `unknown phase "${s.currentPhase}"` });
|
|
82
|
+
}
|
|
83
|
+
if (s.workflowPath !== undefined && !['large', 'small', 'no_test_required'].includes(s.workflowPath)) {
|
|
84
|
+
errors.push({ path: 'session.workflowPath', message: `unknown workflowPath "${s.workflowPath}"` });
|
|
85
|
+
}
|
|
86
|
+
if (s.trackingMode !== undefined && !['markdown', 'github'].includes(s.trackingMode)) {
|
|
87
|
+
errors.push({ path: 'session.trackingMode', message: `unknown trackingMode "${s.trackingMode}"` });
|
|
88
|
+
}
|
|
89
|
+
if (s.learnerTier !== undefined && !['beginner', 'intermediate', 'advanced', 'guided'].includes(s.learnerTier)) {
|
|
90
|
+
errors.push({ path: 'session.learnerTier', message: `unknown learnerTier "${s.learnerTier}"` });
|
|
91
|
+
}
|
|
92
|
+
if (s.operatingMode !== undefined && !['instruction-first', 'implementation-first', 'hybrid'].includes(s.operatingMode)) {
|
|
93
|
+
errors.push({ path: 'session.operatingMode', message: `unknown operatingMode "${s.operatingMode}"` });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (state.epics !== undefined && !isPlainObject(state.epics)) {
|
|
98
|
+
errors.push({ path: 'epics', message: 'epics must be an object' });
|
|
99
|
+
}
|
|
100
|
+
if (state.gates !== undefined && !isPlainObject(state.gates)) {
|
|
101
|
+
errors.push({ path: 'gates', message: 'gates must be an object' });
|
|
102
|
+
}
|
|
103
|
+
if (state.gates && isPlainObject(state.gates)) {
|
|
104
|
+
for (const key of Object.keys(state.gates)) {
|
|
105
|
+
if (!GATES.includes(key)) {
|
|
106
|
+
warnings.push({ path: `gates.${key}`, message: `unknown gate "${key}"` });
|
|
107
|
+
} else if (typeof state.gates[key] !== 'boolean') {
|
|
108
|
+
errors.push({ path: `gates.${key}`, message: `gate "${key}" must be a boolean` });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (version === 2) {
|
|
114
|
+
if (state.gateEvidence !== undefined && !Array.isArray(state.gateEvidence)) {
|
|
115
|
+
errors.push({ path: 'gateEvidence', message: 'gateEvidence must be an array' });
|
|
116
|
+
}
|
|
117
|
+
if (Array.isArray(state.gateEvidence)) {
|
|
118
|
+
state.gateEvidence.forEach((ev, i) => {
|
|
119
|
+
for (const e of validateEvidenceShape(ev)) errors.push({ path: `gateEvidence[${i}].${e.path}`, message: e.message });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// A claimed-true gate must be backed by evidence. This is rejected at
|
|
123
|
+
// validation time (not only at transition time) so `state validate` cannot
|
|
124
|
+
// report an unsupported gate as valid. When a rootDir is available, the
|
|
125
|
+
// supporting record must also belong to the active work item and have a
|
|
126
|
+
// fresh input tree hash.
|
|
127
|
+
if (isPlainObject(state.gates)) {
|
|
128
|
+
const activeWorkItem = isPlainObject(state.activeWorkItem) ? state.activeWorkItem : null;
|
|
129
|
+
const workItemId = activeWorkItem
|
|
130
|
+
? `${activeWorkItem.epicId || 'none'}::${activeWorkItem.storyId || 'none'}`
|
|
131
|
+
: null;
|
|
132
|
+
for (const gate of GATES) {
|
|
133
|
+
if (state.gates[gate] !== true) continue;
|
|
134
|
+
const evidence = latestEvidenceForGate(state, gate);
|
|
135
|
+
if (!evidence || (evidence.status !== 'passed' && evidence.status !== 'manual-confirmation')) {
|
|
136
|
+
errors.push({
|
|
137
|
+
path: `gates.${gate}`,
|
|
138
|
+
message: `gate "${gate}" is true but has no supporting evidence record (status "passed" or "manual-confirmation")`,
|
|
139
|
+
});
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (workItemId && evidence.workItemId && evidence.workItemId !== workItemId) {
|
|
143
|
+
errors.push({
|
|
144
|
+
path: `gates.${gate}`,
|
|
145
|
+
message: `gate "${gate}" is backed by evidence for work item "${evidence.workItemId}", not the active work item "${workItemId}"`,
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (rootDir) {
|
|
150
|
+
const relevant = Array.isArray(evidence.relevantFiles) ? evidence.relevantFiles : [];
|
|
151
|
+
const currentHash = computeInputTreeHash(rootDir, relevant);
|
|
152
|
+
if (evidence.inputTreeHash && evidence.inputTreeHash !== currentHash) {
|
|
153
|
+
errors.push({
|
|
154
|
+
path: `gates.${gate}`,
|
|
155
|
+
message: `gate "${gate}" is backed by stale evidence: the input tree hash no longer matches the current files`,
|
|
156
|
+
});
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (evidence.expiresAt && Date.parse(evidence.expiresAt) <= Date.now()) {
|
|
161
|
+
errors.push({
|
|
162
|
+
path: `gates.${gate}`,
|
|
163
|
+
message: `gate "${gate}" is backed by expired evidence (expired ${evidence.expiresAt})`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (state.activeRunId !== undefined && state.activeRunId !== null && !isUuid(state.activeRunId)) {
|
|
169
|
+
errors.push({ path: 'activeRunId', message: 'activeRunId must be a UUIDv4 or null' });
|
|
170
|
+
}
|
|
171
|
+
if (state.activeWorkItem !== undefined && state.activeWorkItem !== null) {
|
|
172
|
+
if (!isPlainObject(state.activeWorkItem)) {
|
|
173
|
+
errors.push({ path: 'activeWorkItem', message: 'activeWorkItem must be an object or null' });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (state.lastTransition !== undefined && state.lastTransition !== null) {
|
|
177
|
+
const lt = state.lastTransition;
|
|
178
|
+
if (!isPlainObject(lt)) {
|
|
179
|
+
errors.push({ path: 'lastTransition', message: 'lastTransition must be an object or null' });
|
|
180
|
+
} else {
|
|
181
|
+
if (lt.from !== undefined && !PHASES.includes(lt.from)) errors.push({ path: 'lastTransition.from', message: `unknown phase "${lt.from}"` });
|
|
182
|
+
if (lt.to !== undefined && !PHASES.includes(lt.to)) errors.push({ path: 'lastTransition.to', message: `unknown phase "${lt.to}"` });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} else if (state.gateEvidence !== undefined) {
|
|
186
|
+
warnings.push({ path: 'gateEvidence', message: 'gateEvidence on a v1 state is ignored until migration' });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validateEvidenceShape(ev) {
|
|
193
|
+
const errors = [];
|
|
194
|
+
if (!isPlainObject(ev)) return [{ path: '', message: 'evidence must be an object' }];
|
|
195
|
+
// Required, non-null fields (contract §2).
|
|
196
|
+
const required = ['evidenceId', 'workItemId', 'phase', 'gate', 'status', 'inputTreeHash', 'criteriaHash', 'relevantFiles', 'createdAt'];
|
|
197
|
+
for (const field of required) {
|
|
198
|
+
if (ev[field] === undefined || ev[field] === null) {
|
|
199
|
+
errors.push({ path: field, message: `${field} is required` });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Required keys that may be explicitly null (e.g. manual-confirmation has no command).
|
|
203
|
+
for (const field of ['command', 'result']) {
|
|
204
|
+
if (!(field in ev)) errors.push({ path: field, message: `${field} is required (may be null)` });
|
|
205
|
+
}
|
|
206
|
+
// A freshness bound is mandatory: either an expiry or an explicit policy.
|
|
207
|
+
if (ev.expiresAt === undefined && ev.freshnessPolicy === undefined) {
|
|
208
|
+
errors.push({ path: 'expiresAt', message: 'evidence must declare expiresAt or freshnessPolicy' });
|
|
209
|
+
}
|
|
210
|
+
if (ev.evidenceId !== undefined && !isUuid(ev.evidenceId)) errors.push({ path: 'evidenceId', message: 'evidenceId must be a UUIDv4' });
|
|
211
|
+
if (ev.phase !== undefined && !PHASES.includes(ev.phase)) errors.push({ path: 'phase', message: `unknown phase "${ev.phase}"` });
|
|
212
|
+
if (ev.gate !== undefined && !GATES.includes(ev.gate)) errors.push({ path: 'gate', message: `unknown gate "${ev.gate}"` });
|
|
213
|
+
if (ev.status !== undefined && !EVIDENCE_STATUSES.includes(ev.status)) errors.push({ path: 'status', message: `unknown evidence status "${ev.status}"` });
|
|
214
|
+
if (ev.relevantFiles !== undefined && !Array.isArray(ev.relevantFiles)) errors.push({ path: 'relevantFiles', message: 'relevantFiles must be an array' });
|
|
215
|
+
if (ev.inputTreeHash !== undefined && !/^[0-9a-f]{64}$/.test(String(ev.inputTreeHash))) {
|
|
216
|
+
errors.push({ path: 'inputTreeHash', message: 'inputTreeHash must be a SHA-256 hex digest' });
|
|
217
|
+
}
|
|
218
|
+
if (ev.criteriaHash !== undefined && ev.criteriaHash !== null && !/^[0-9a-f]{64}$/.test(String(ev.criteriaHash))) {
|
|
219
|
+
errors.push({ path: 'criteriaHash', message: 'criteriaHash must be a SHA-256 hex digest' });
|
|
220
|
+
}
|
|
221
|
+
if (ev.command !== undefined && ev.command !== null && typeof ev.command !== 'string') {
|
|
222
|
+
errors.push({ path: 'command', message: 'command must be a string or null' });
|
|
223
|
+
}
|
|
224
|
+
if (ev.result !== undefined && ev.result !== null && typeof ev.result !== 'string') {
|
|
225
|
+
errors.push({ path: 'result', message: 'result must be a string or null' });
|
|
226
|
+
}
|
|
227
|
+
if (ev.createdAt !== undefined && ev.createdAt !== null && Number.isNaN(Date.parse(ev.createdAt))) {
|
|
228
|
+
errors.push({ path: 'createdAt', message: 'createdAt must be an ISO-8601 date-time' });
|
|
229
|
+
}
|
|
230
|
+
if (ev.expiresAt !== undefined && ev.expiresAt !== null && Number.isNaN(Date.parse(ev.expiresAt))) {
|
|
231
|
+
errors.push({ path: 'expiresAt', message: 'expiresAt must be an ISO-8601 date-time or null' });
|
|
232
|
+
}
|
|
233
|
+
if (ev.freshnessPolicy !== undefined && ev.freshnessPolicy !== null) {
|
|
234
|
+
if (!isPlainObject(ev.freshnessPolicy)) {
|
|
235
|
+
errors.push({ path: 'freshnessPolicy', message: 'freshnessPolicy must be an object or null' });
|
|
236
|
+
} else if (!['story', 'phase', 'run', 'manual'].includes(ev.freshnessPolicy.scope)) {
|
|
237
|
+
errors.push({ path: 'freshnessPolicy.scope', message: 'freshnessPolicy.scope must be story|phase|run|manual' });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return errors;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Migration ───────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Migrate a v1 state document to v2 in memory. Unknown top-level fields are
|
|
247
|
+
* preserved. Does not touch the filesystem.
|
|
248
|
+
*/
|
|
249
|
+
export function migrateStateV1toV2(v1) {
|
|
250
|
+
if (!isPlainObject(v1)) throw new StateError('cannot migrate a non-object state');
|
|
251
|
+
if (v1.version === 2 || v1.stateVersion === 2) {
|
|
252
|
+
return { state: { ...v1, version: 2, stateVersion: 2, gateEvidence: v1.gateEvidence || [] }, changed: false };
|
|
253
|
+
}
|
|
254
|
+
const gates = isPlainObject(v1.gates) ? { ...v1.gates } : {};
|
|
255
|
+
for (const gate of GATES) {
|
|
256
|
+
if (typeof gates[gate] !== 'boolean') gates[gate] = false;
|
|
257
|
+
}
|
|
258
|
+
// Preserve any unknown top-level fields that are safe to keep.
|
|
259
|
+
const preserved = {};
|
|
260
|
+
for (const [key, value] of Object.entries(v1)) {
|
|
261
|
+
if (!['version', 'session', 'epics', 'gates', 'spikes', 'changeHistory'].includes(key)) {
|
|
262
|
+
preserved[key] = value;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const v2 = {
|
|
266
|
+
...preserved,
|
|
267
|
+
version: 2,
|
|
268
|
+
stateVersion: 2,
|
|
269
|
+
session: { ...v1.session },
|
|
270
|
+
epics: v1.epics || {},
|
|
271
|
+
gates,
|
|
272
|
+
gateEvidence: [],
|
|
273
|
+
activeRunId: null,
|
|
274
|
+
activeWorkItem: activeWorkItemFromState(v1),
|
|
275
|
+
lastTransition: null,
|
|
276
|
+
spikes: v1.spikes || {},
|
|
277
|
+
changeHistory: Array.isArray(v1.changeHistory) ? [...v1.changeHistory] : [],
|
|
278
|
+
};
|
|
279
|
+
return { state: v2, changed: true };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function activeWorkItemFromState(state) {
|
|
283
|
+
const epics = state.epics;
|
|
284
|
+
if (!isPlainObject(epics)) return null;
|
|
285
|
+
for (const [epicId, epic] of Object.entries(epics)) {
|
|
286
|
+
if (!isPlainObject(epic) || !isPlainObject(epic.stories)) continue;
|
|
287
|
+
for (const [storyId, status] of Object.entries(epic.stories)) {
|
|
288
|
+
if (status === 'in-progress') return { epicId, storyId };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Migrate a state file on disk atomically: write a temporary file, optionally
|
|
296
|
+
* back up the original, then rename into place. A failed migration leaves the
|
|
297
|
+
* original untouched.
|
|
298
|
+
*/
|
|
299
|
+
export function migrateStateFile(statePath, { backup = true } = {}) {
|
|
300
|
+
if (!existsSync(statePath)) {
|
|
301
|
+
throw new StateError(`state file not found: ${statePath}`);
|
|
302
|
+
}
|
|
303
|
+
let raw;
|
|
304
|
+
try {
|
|
305
|
+
raw = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
306
|
+
} catch (err) {
|
|
307
|
+
throw new StateError(`cannot migrate malformed state: ${err.message}`);
|
|
308
|
+
}
|
|
309
|
+
const { state, changed } = migrateStateV1toV2(raw);
|
|
310
|
+
if (!changed) return { migrated: false, statePath, state };
|
|
311
|
+
|
|
312
|
+
const dir = dirname(statePath);
|
|
313
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'cadet-state-'));
|
|
314
|
+
const tmpPath = join(tmpDir, 'state.json');
|
|
315
|
+
try {
|
|
316
|
+
writeFileSync(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
317
|
+
if (backup) {
|
|
318
|
+
copyFileSync(statePath, `${statePath}.v1.bak`);
|
|
319
|
+
}
|
|
320
|
+
// A failed rename leaves the original in place; validate before committing.
|
|
321
|
+
// This is a structural-only check: a freshly migrated state has no on-disk
|
|
322
|
+
// evidence to bind, so freshness is intentionally not evaluated here.
|
|
323
|
+
const check = validateState(state, { structuralOnly: true });
|
|
324
|
+
if (!check.valid) {
|
|
325
|
+
throw new StateError(`migrated state failed validation: ${check.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
|
|
326
|
+
}
|
|
327
|
+
renameSync(tmpPath, statePath);
|
|
328
|
+
} finally {
|
|
329
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
330
|
+
}
|
|
331
|
+
return { migrated: true, statePath, state };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Evidence ────────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/** Build an evidence record. `id` defaults to a fresh UUIDv4. */
|
|
337
|
+
export function createEvidence({
|
|
338
|
+
evidenceId,
|
|
339
|
+
workItemId,
|
|
340
|
+
acceptanceCriterionId = null,
|
|
341
|
+
phase,
|
|
342
|
+
gate,
|
|
343
|
+
status,
|
|
344
|
+
command = null,
|
|
345
|
+
result = null,
|
|
346
|
+
exitCode = null,
|
|
347
|
+
artifactPath = null,
|
|
348
|
+
artifactHash = null,
|
|
349
|
+
inputTreeHash,
|
|
350
|
+
criteriaHash = null,
|
|
351
|
+
relevantFiles = [],
|
|
352
|
+
toolVersion = null,
|
|
353
|
+
createdAt = new Date(),
|
|
354
|
+
expiresAt = null,
|
|
355
|
+
freshnessPolicy = null,
|
|
356
|
+
source = 'automated',
|
|
357
|
+
id,
|
|
358
|
+
}) {
|
|
359
|
+
return {
|
|
360
|
+
evidenceId: evidenceId || id || undefined,
|
|
361
|
+
workItemId,
|
|
362
|
+
acceptanceCriterionId,
|
|
363
|
+
phase,
|
|
364
|
+
gate,
|
|
365
|
+
status,
|
|
366
|
+
command,
|
|
367
|
+
result,
|
|
368
|
+
exitCode,
|
|
369
|
+
artifactPath,
|
|
370
|
+
artifactHash,
|
|
371
|
+
inputTreeHash,
|
|
372
|
+
criteriaHash: criteriaHash || hashCriteria([]),
|
|
373
|
+
relevantFiles,
|
|
374
|
+
toolVersion,
|
|
375
|
+
createdAt: timestamp(createdAt),
|
|
376
|
+
expiresAt: expiresAt ? timestamp(expiresAt) : null,
|
|
377
|
+
freshnessPolicy,
|
|
378
|
+
supersededBy: null,
|
|
379
|
+
source,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Build the input tree hash for a set of relevant files resolved against a root.
|
|
385
|
+
* Missing files are recorded as `missing` so their absence is detectable.
|
|
386
|
+
*/
|
|
387
|
+
export function computeInputTreeHash(rootDir, relativePaths) {
|
|
388
|
+
const pairs = relativePaths.map((p) => ({ path: p, hash: hashFile(join(rootDir, p)) }));
|
|
389
|
+
return hashTree(pairs);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** A work item identifier string used to bind evidence to the active story. */
|
|
393
|
+
export function workItemIdOf(state) {
|
|
394
|
+
const item = state?.activeWorkItem;
|
|
395
|
+
if (!item) return 'unscoped';
|
|
396
|
+
return `${item.epicId || 'none'}::${item.storyId || 'none'}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Is an evidence record usable for a gate at this point in time?
|
|
401
|
+
* Returns `{ fresh, reasons }`.
|
|
402
|
+
*/
|
|
403
|
+
export function evidenceFreshness(evidence, context) {
|
|
404
|
+
const reasons = [];
|
|
405
|
+
const {
|
|
406
|
+
now = new Date(),
|
|
407
|
+
workItemId = null,
|
|
408
|
+
phase = null,
|
|
409
|
+
inputTreeHash = null,
|
|
410
|
+
criteriaHash = null,
|
|
411
|
+
} = context || {};
|
|
412
|
+
|
|
413
|
+
if (evidence.status === 'superseded') reasons.push('evidence was superseded');
|
|
414
|
+
if (evidence.status !== 'passed' && evidence.status !== 'manual-confirmation') {
|
|
415
|
+
if (evidence.status !== 'superseded') reasons.push(`evidence status is "${evidence.status}", not passing`);
|
|
416
|
+
}
|
|
417
|
+
if (workItemId && evidence.workItemId !== workItemId) {
|
|
418
|
+
reasons.push(`evidence belongs to work item "${evidence.workItemId}", not "${workItemId}"`);
|
|
419
|
+
}
|
|
420
|
+
if (inputTreeHash && evidence.inputTreeHash !== inputTreeHash) {
|
|
421
|
+
reasons.push('input tree hash changed since the evidence was recorded');
|
|
422
|
+
}
|
|
423
|
+
if (criteriaHash && evidence.criteriaHash && evidence.criteriaHash !== criteriaHash) {
|
|
424
|
+
reasons.push('acceptance criteria changed since the evidence was recorded');
|
|
425
|
+
}
|
|
426
|
+
if (evidence.expiresAt && new Date(evidence.expiresAt).getTime() <= now.getTime()) {
|
|
427
|
+
reasons.push('evidence expired');
|
|
428
|
+
}
|
|
429
|
+
if (phase && evidence.phase !== phase) {
|
|
430
|
+
const allowed = evidence.freshnessPolicy?.allowPhases || [];
|
|
431
|
+
if (!allowed.includes(phase)) {
|
|
432
|
+
reasons.push(`evidence was recorded for phase "${evidence.phase}", not "${phase}"`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return { fresh: reasons.length === 0, reasons };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The most recent evidence for a gate, or null. */
|
|
439
|
+
export function latestEvidenceForGate(state, gate) {
|
|
440
|
+
const list = Array.isArray(state?.gateEvidence) ? state.gateEvidence : [];
|
|
441
|
+
const matching = list.filter((e) => e.gate === gate);
|
|
442
|
+
if (matching.length === 0) return null;
|
|
443
|
+
return matching.reduce((a, b) => (new Date(a.createdAt) >= new Date(b.createdAt) ? a : b));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Active gate exceptions keyed by gate, honoring scope and expiry. */
|
|
447
|
+
export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
|
|
448
|
+
const history = Array.isArray(state?.changeHistory) ? state.changeHistory : [];
|
|
449
|
+
const active = {};
|
|
450
|
+
for (const entry of history) {
|
|
451
|
+
if (entry?.type !== 'gate-exception') continue;
|
|
452
|
+
if (workItemId && entry.scope && !String(entry.scope).includes(workItemId)) continue;
|
|
453
|
+
if (entry.expiresAt && new Date(entry.expiresAt).getTime() <= now.getTime()) continue;
|
|
454
|
+
if (entry.gate) active[entry.gate] = entry;
|
|
455
|
+
}
|
|
456
|
+
return active;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Transitions ─────────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
/** Required gates for a transition target, or null when the target is not gated. */
|
|
462
|
+
export function requiredGates(toPhase) {
|
|
463
|
+
for (const [from, spec] of Object.entries(TRANSITIONS)) {
|
|
464
|
+
if (spec.to === toPhase) return { from, gates: spec.gates };
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Evaluate whether a transition is legal and evidence-backed.
|
|
471
|
+
* Returns a machine-readable result: `{ allowed, toPhase, missingGates, staleEvidence, errors }`.
|
|
472
|
+
*/
|
|
473
|
+
export function evaluateTransition(state, toPhase, context = {}) {
|
|
474
|
+
const errors = [];
|
|
475
|
+
const missingGates = [];
|
|
476
|
+
const staleEvidence = [];
|
|
477
|
+
const fromPhase = state?.session?.currentPhase;
|
|
478
|
+
const workItemId = context.workItemId || workItemIdOf(state);
|
|
479
|
+
const now = context.now || new Date();
|
|
480
|
+
|
|
481
|
+
if (!PHASES.includes(toPhase)) {
|
|
482
|
+
errors.push(`unknown target phase "${toPhase}"`);
|
|
483
|
+
return { allowed: false, fromPhase, toPhase, missingGates, staleEvidence, errors };
|
|
484
|
+
}
|
|
485
|
+
if (toPhase === fromPhase) {
|
|
486
|
+
errors.push(`already in phase "${toPhase}"`);
|
|
487
|
+
return { allowed: false, fromPhase, toPhase, missingGates, staleEvidence, errors };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const spec = requiredGates(toPhase);
|
|
491
|
+
if (!spec) {
|
|
492
|
+
// Ungated transitions (e.g. context-resolution → requirements) are legal.
|
|
493
|
+
return { allowed: true, fromPhase, toPhase, missingGates, staleEvidence, errors };
|
|
494
|
+
}
|
|
495
|
+
if (spec.from !== fromPhase) {
|
|
496
|
+
errors.push(`illegal transition "${fromPhase}" → "${toPhase}" (expected from "${spec.from}")`);
|
|
497
|
+
return { allowed: false, fromPhase, toPhase, missingGates: [...spec.gates], staleEvidence, errors };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const exceptions = activeExceptions(state, { workItemId, now });
|
|
501
|
+
const gates = isPlainObject(state.gates) ? state.gates : {};
|
|
502
|
+
// Freshness is enforced by default: unless the caller explicitly supplies a
|
|
503
|
+
// hash, compute the current input-tree hash from each evidence record's own
|
|
504
|
+
// relevant files. This prevents a caller from silently accepting stale evidence.
|
|
505
|
+
const rootDir = context.rootDir || process.cwd();
|
|
506
|
+
const computeTreeHash = context.inputTreeHash === undefined && context.computeFreshness !== false;
|
|
507
|
+
|
|
508
|
+
for (const gate of spec.gates) {
|
|
509
|
+
if (gates[gate] !== true) {
|
|
510
|
+
// A gate exception may substitute for a gate that is not claimed true.
|
|
511
|
+
if (exceptions[gate]) continue;
|
|
512
|
+
missingGates.push(gate);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
// Gate is claimed true — require fresh, non-superseded evidence.
|
|
516
|
+
const evidence = latestEvidenceForGate(state, gate);
|
|
517
|
+
if (!evidence) {
|
|
518
|
+
if (exceptions[gate]) continue;
|
|
519
|
+
missingGates.push(gate);
|
|
520
|
+
staleEvidence.push({ gate, reason: 'no evidence record for a claimed-true gate' });
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
let inputTreeHash = context.inputTreeHash !== undefined ? context.inputTreeHash : null;
|
|
524
|
+
if (computeTreeHash) {
|
|
525
|
+
const relevant = Array.isArray(evidence.relevantFiles) ? evidence.relevantFiles : [];
|
|
526
|
+
// An evidence record with no relevant files cannot prove freshness of any
|
|
527
|
+
// file; the tree hash still captures "nothing relevant changed".
|
|
528
|
+
inputTreeHash = computeInputTreeHash(rootDir, relevant);
|
|
529
|
+
}
|
|
530
|
+
const critHash = context.criteriaHash !== undefined ? context.criteriaHash : null;
|
|
531
|
+
const { fresh, reasons } = evidenceFreshness(evidence, {
|
|
532
|
+
now,
|
|
533
|
+
workItemId,
|
|
534
|
+
phase: fromPhase,
|
|
535
|
+
inputTreeHash,
|
|
536
|
+
criteriaHash: critHash,
|
|
537
|
+
});
|
|
538
|
+
if (!fresh && !exceptions[gate]) {
|
|
539
|
+
missingGates.push(gate);
|
|
540
|
+
staleEvidence.push({ gate, evidenceId: evidence.evidenceId, reasons });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
allowed: missingGates.length === 0 && errors.length === 0,
|
|
546
|
+
fromPhase,
|
|
547
|
+
toPhase,
|
|
548
|
+
missingGates,
|
|
549
|
+
staleEvidence,
|
|
550
|
+
errors,
|
|
551
|
+
exceptions: Object.keys(exceptions),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Apply a legal transition to a state object (returns a new object).
|
|
557
|
+
* Resets the target transition's gates is NOT done here — gates reset when a new
|
|
558
|
+
* work item starts (see `resetGatesForNewWorkItem`).
|
|
559
|
+
*/
|
|
560
|
+
export function applyTransition(state, toPhase, { evidenceIds = [], at = new Date(), inputTreeHash = undefined, criteriaHash = undefined, rootDir = undefined } = {}) {
|
|
561
|
+
const context = { now: at };
|
|
562
|
+
if (inputTreeHash !== undefined) context.inputTreeHash = inputTreeHash;
|
|
563
|
+
if (criteriaHash !== undefined) context.criteriaHash = criteriaHash;
|
|
564
|
+
if (rootDir !== undefined) context.rootDir = rootDir;
|
|
565
|
+
const evaluation = evaluateTransition(state, toPhase, context);
|
|
566
|
+
if (!evaluation.allowed) {
|
|
567
|
+
throw new StateError(
|
|
568
|
+
`illegal transition to "${toPhase}": ${[...evaluation.errors, ...evaluation.missingGates.map((g) => `missing gate ${g}`)].join('; ')}`,
|
|
569
|
+
{ evaluation }
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
...state,
|
|
574
|
+
version: STATE_VERSION,
|
|
575
|
+
stateVersion: STATE_VERSION,
|
|
576
|
+
session: { ...state.session, currentPhase: toPhase },
|
|
577
|
+
lastTransition: {
|
|
578
|
+
from: state.session.currentPhase,
|
|
579
|
+
to: toPhase,
|
|
580
|
+
at: timestamp(at),
|
|
581
|
+
evidenceIds,
|
|
582
|
+
},
|
|
583
|
+
changeHistory: [
|
|
584
|
+
...(Array.isArray(state.changeHistory) ? state.changeHistory : []),
|
|
585
|
+
{ date: timestamp(at), change: `Phase transition ${state.session.currentPhase} → ${toPhase}`, phase: toPhase },
|
|
586
|
+
],
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Reset all gates to false for a new work item (atomic in the returned copy). */
|
|
591
|
+
export function resetGatesForNewWorkItem(state, { epicId = null, storyId = null, at = new Date() } = {}) {
|
|
592
|
+
const gates = {};
|
|
593
|
+
for (const gate of GATES) gates[gate] = false;
|
|
594
|
+
return {
|
|
595
|
+
...state,
|
|
596
|
+
gates,
|
|
597
|
+
gateEvidence: [],
|
|
598
|
+
activeWorkItem: { epicId, storyId },
|
|
599
|
+
changeHistory: [
|
|
600
|
+
...(Array.isArray(state.changeHistory) ? state.changeHistory : []),
|
|
601
|
+
{ date: timestamp(at), change: `Gate reset for new work item ${epicId || 'none'}::${storyId || 'none'}`, phase: state.session?.currentPhase },
|
|
602
|
+
],
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ── File helpers ────────────────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
export function statePathFor(targetDir) {
|
|
609
|
+
return join(targetDir, '.cadet', 'state.json');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function readState(targetDir) {
|
|
613
|
+
const path = statePathFor(targetDir);
|
|
614
|
+
if (!existsSync(path)) return { exists: false, path, state: null };
|
|
615
|
+
try {
|
|
616
|
+
return { exists: true, path, state: JSON.parse(readFileSync(path, 'utf-8')) };
|
|
617
|
+
} catch (err) {
|
|
618
|
+
throw new StateError(`failed to parse ${path}: ${err.message}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Atomically write a JSON document: serialize to a sibling temp file, then
|
|
624
|
+
* rename into place. A process interruption cannot leave a truncated target.
|
|
625
|
+
*/
|
|
626
|
+
export function writeJsonAtomic(path, value) {
|
|
627
|
+
const payload = JSON.stringify(value, null, 2) + '\n';
|
|
628
|
+
// Verify the payload is valid JSON before it can replace the target.
|
|
629
|
+
JSON.parse(payload);
|
|
630
|
+
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
631
|
+
try {
|
|
632
|
+
writeFileSync(tmpPath, payload, 'utf-8');
|
|
633
|
+
renameSync(tmpPath, path);
|
|
634
|
+
} catch (err) {
|
|
635
|
+
try { rmSync(tmpPath, { force: true }); } catch { /* best effort */ }
|
|
636
|
+
throw new StateError(`failed to write ${path}: ${err.message}`);
|
|
637
|
+
}
|
|
638
|
+
return path;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Atomically write state.json. */
|
|
642
|
+
export function writeState(targetDir, state) {
|
|
643
|
+
return writeJsonAtomic(statePathFor(targetDir), state);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export { StateError };
|