cadet-agent 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +37 -37
- package/src/cli.mjs +336 -7
- package/src/harness/index.mjs +10 -3
- package/src/harness/policy.mjs +560 -359
- package/src/harness/state.mjs +919 -661
- package/src/harness/verification.mjs +523 -490
- package/src/harness/verify-acs.mjs +291 -0
package/src/harness/state.mjs
CHANGED
|
@@ -1,661 +1,919 @@
|
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
if (s.
|
|
87
|
-
errors.push({ path: 'session.
|
|
88
|
-
}
|
|
89
|
-
if (s.
|
|
90
|
-
errors.push({ path: 'session.
|
|
91
|
-
}
|
|
92
|
-
if (s.
|
|
93
|
-
errors.push({ path: 'session.
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (state.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (ev
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
errors.push({ path:
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (!
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
export function
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
export function
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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, DEFAULT_STRICT_CLOSURE,
|
|
15
|
+
EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS, EXCEPTION_REQUIRES_REVIEW_NOTE,
|
|
16
|
+
} from './policy.mjs';
|
|
17
|
+
import { hashTree, hashFile, hashCriteria, timestamp, isUuid } from './util.mjs';
|
|
18
|
+
|
|
19
|
+
export { PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES };
|
|
20
|
+
export { EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS };
|
|
21
|
+
|
|
22
|
+
export const STATE_VERSION = 3;
|
|
23
|
+
|
|
24
|
+
/** Highest state version this module can read. v1/v2 remain readable. */
|
|
25
|
+
export const READABLE_STATE_VERSIONS = Object.freeze([1, 2, 3]);
|
|
26
|
+
|
|
27
|
+
class StateError extends Error {
|
|
28
|
+
constructor(message, detail = {}) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'StateError';
|
|
31
|
+
Object.assign(this, detail);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPlainObject(v) {
|
|
36
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Validation ──────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate a state document. Returns `{ valid, errors, warnings }` — never throws
|
|
43
|
+
* for user input so the CLI can report every problem at once.
|
|
44
|
+
*
|
|
45
|
+
* When `context.rootDir` is supplied, a claimed-true gate's supporting evidence is
|
|
46
|
+
* also checked for work-item binding and input-tree freshness, so a stale or
|
|
47
|
+
* foreign evidence record cannot make `state validate` report a false valid.
|
|
48
|
+
*
|
|
49
|
+
* Without `rootDir`, freshness cannot be checked; a warning is emitted so a
|
|
50
|
+
* structural-only validation is never mistaken for a full gate-safety check.
|
|
51
|
+
* Callers that validate state on disk should pass `rootDir`.
|
|
52
|
+
*/
|
|
53
|
+
export function validateState(state, context = {}) {
|
|
54
|
+
const errors = [];
|
|
55
|
+
const warnings = [];
|
|
56
|
+
const rootDir = context.rootDir || null;
|
|
57
|
+
const strict = resolveStrict(context);
|
|
58
|
+
const hasTrueGate = isPlainObject(state) && isPlainObject(state.gates)
|
|
59
|
+
&& Object.values(state.gates).some((v) => v === true);
|
|
60
|
+
if (!rootDir && hasTrueGate && context.structuralOnly !== true) {
|
|
61
|
+
warnings.push({
|
|
62
|
+
path: 'gateEvidence',
|
|
63
|
+
message: 'gate freshness was not verified: no rootDir was supplied, so stale or foreign '
|
|
64
|
+
+ 'evidence cannot be detected. Pass { rootDir } to run the full check.',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!isPlainObject(state)) {
|
|
69
|
+
return { valid: false, errors: [{ path: '$', message: 'state must be a JSON object' }], warnings };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const version = state.version ?? state.stateVersion;
|
|
73
|
+
if (!READABLE_STATE_VERSIONS.includes(version)) {
|
|
74
|
+
errors.push({ path: 'version', message: `unsupported state version ${JSON.stringify(version)} (expected 1, 2 or 3)` });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!isPlainObject(state.session)) {
|
|
78
|
+
errors.push({ path: 'session', message: 'session must be an object' });
|
|
79
|
+
} else {
|
|
80
|
+
const s = state.session;
|
|
81
|
+
for (const required of ['workflowPath', 'currentPhase', 'trackingMode']) {
|
|
82
|
+
if (s[required] === undefined || s[required] === null) {
|
|
83
|
+
errors.push({ path: `session.${required}`, message: `${required} is required` });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (s.currentPhase !== undefined && !PHASES.includes(s.currentPhase)) {
|
|
87
|
+
errors.push({ path: 'session.currentPhase', message: `unknown phase "${s.currentPhase}"` });
|
|
88
|
+
}
|
|
89
|
+
if (s.workflowPath !== undefined && !['large', 'small', 'no_test_required'].includes(s.workflowPath)) {
|
|
90
|
+
errors.push({ path: 'session.workflowPath', message: `unknown workflowPath "${s.workflowPath}"` });
|
|
91
|
+
}
|
|
92
|
+
if (s.trackingMode !== undefined && !['markdown', 'github'].includes(s.trackingMode)) {
|
|
93
|
+
errors.push({ path: 'session.trackingMode', message: `unknown trackingMode "${s.trackingMode}"` });
|
|
94
|
+
}
|
|
95
|
+
if (s.learnerTier !== undefined && !['beginner', 'intermediate', 'advanced', 'guided'].includes(s.learnerTier)) {
|
|
96
|
+
errors.push({ path: 'session.learnerTier', message: `unknown learnerTier "${s.learnerTier}"` });
|
|
97
|
+
}
|
|
98
|
+
if (s.operatingMode !== undefined && !['instruction-first', 'implementation-first', 'hybrid'].includes(s.operatingMode)) {
|
|
99
|
+
errors.push({ path: 'session.operatingMode', message: `unknown operatingMode "${s.operatingMode}"` });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (state.epics !== undefined && !isPlainObject(state.epics)) {
|
|
104
|
+
errors.push({ path: 'epics', message: 'epics must be an object' });
|
|
105
|
+
}
|
|
106
|
+
if (state.gates !== undefined && !isPlainObject(state.gates)) {
|
|
107
|
+
errors.push({ path: 'gates', message: 'gates must be an object' });
|
|
108
|
+
}
|
|
109
|
+
if (state.gates && isPlainObject(state.gates)) {
|
|
110
|
+
for (const key of Object.keys(state.gates)) {
|
|
111
|
+
if (!GATES.includes(key)) {
|
|
112
|
+
warnings.push({ path: `gates.${key}`, message: `unknown gate "${key}"` });
|
|
113
|
+
} else if (typeof state.gates[key] !== 'boolean') {
|
|
114
|
+
errors.push({ path: `gates.${key}`, message: `gate "${key}" must be a boolean` });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (version === 2 || version === 3) {
|
|
120
|
+
if (state.gateEvidence !== undefined && !Array.isArray(state.gateEvidence)) {
|
|
121
|
+
errors.push({ path: 'gateEvidence', message: 'gateEvidence must be an array' });
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(state.gateEvidence)) {
|
|
124
|
+
state.gateEvidence.forEach((ev, i) => {
|
|
125
|
+
for (const e of validateEvidenceShape(ev, strict)) errors.push({ path: `gateEvidence[${i}].${e.path}`, message: e.message });
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// Gate exceptions are categorised under strict closure (contract v3 §4).
|
|
129
|
+
if (strict && Array.isArray(state.changeHistory)) {
|
|
130
|
+
state.changeHistory.forEach((entry, i) => {
|
|
131
|
+
if (entry?.type !== 'gate-exception') return;
|
|
132
|
+
for (const e of validateGateException(entry, strict)) {
|
|
133
|
+
errors.push({ path: `changeHistory[${i}].${e.path}`, message: e.message });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// A claimed-true gate must be backed by evidence. This is rejected at
|
|
138
|
+
// validation time (not only at transition time) so `state validate` cannot
|
|
139
|
+
// report an unsupported gate as valid. When a rootDir is available, the
|
|
140
|
+
// supporting record must also belong to the active work item and have a
|
|
141
|
+
// fresh input tree hash.
|
|
142
|
+
if (isPlainObject(state.gates)) {
|
|
143
|
+
const activeWorkItem = isPlainObject(state.activeWorkItem) ? state.activeWorkItem : null;
|
|
144
|
+
const workItemId = activeWorkItem
|
|
145
|
+
? `${activeWorkItem.epicId || 'none'}::${activeWorkItem.storyId || 'none'}`
|
|
146
|
+
: null;
|
|
147
|
+
|
|
148
|
+
// Gate exceptions are honoured here for the same reasons `evaluateTransition`
|
|
149
|
+
// honours them. Before this, a scoped exception could make a transition legal
|
|
150
|
+
// while `state validate` still reported the identical document as invalid, so
|
|
151
|
+
// the two official commands contradicted each other and a reader could not tell
|
|
152
|
+
// "correctly excepted" from "evidence broken". Exceptions are keyed on the
|
|
153
|
+
// ACTIVE work item, so they cannot excuse a different story's gates.
|
|
154
|
+
const exceptions = activeExceptions(state, { workItemId: workItemId || undefined });
|
|
155
|
+
|
|
156
|
+
for (const gate of GATES) {
|
|
157
|
+
if (state.gates[gate] !== true) continue;
|
|
158
|
+
|
|
159
|
+
// An excepted gate is intentionally not held to freshness or work-item
|
|
160
|
+
// ownership: that is precisely what the exception is for. It still must have
|
|
161
|
+
// been claimed true, which the loop condition above already guarantees.
|
|
162
|
+
if (exceptions[gate]) continue;
|
|
163
|
+
|
|
164
|
+
const evidence = latestEvidenceForGate(state, gate);
|
|
165
|
+
if (!evidence || (evidence.status !== 'passed' && evidence.status !== 'manual-confirmation')) {
|
|
166
|
+
errors.push({
|
|
167
|
+
path: `gates.${gate}`,
|
|
168
|
+
message: `gate "${gate}" is true but has no supporting evidence record (status "passed" or "manual-confirmation")`,
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (workItemId && evidence.workItemId && evidence.workItemId !== workItemId) {
|
|
173
|
+
errors.push({
|
|
174
|
+
path: `gates.${gate}`,
|
|
175
|
+
message: `gate "${gate}" is backed by evidence for work item "${evidence.workItemId}", not the active work item "${workItemId}"`,
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (rootDir) {
|
|
180
|
+
const relevant = Array.isArray(evidence.relevantFiles) ? evidence.relevantFiles : [];
|
|
181
|
+
const currentHash = computeInputTreeHash(rootDir, relevant);
|
|
182
|
+
if (evidence.inputTreeHash && evidence.inputTreeHash !== currentHash) {
|
|
183
|
+
errors.push({
|
|
184
|
+
path: `gates.${gate}`,
|
|
185
|
+
message: `gate "${gate}" is backed by stale evidence: the input tree hash no longer matches the current files`,
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (evidence.expiresAt && Date.parse(evidence.expiresAt) <= Date.now()) {
|
|
191
|
+
errors.push({
|
|
192
|
+
path: `gates.${gate}`,
|
|
193
|
+
message: `gate "${gate}" is backed by expired evidence (expired ${evidence.expiresAt})`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (state.activeRunId !== undefined && state.activeRunId !== null && !isUuid(state.activeRunId)) {
|
|
199
|
+
errors.push({ path: 'activeRunId', message: 'activeRunId must be a UUIDv4 or null' });
|
|
200
|
+
}
|
|
201
|
+
if (state.activeWorkItem !== undefined && state.activeWorkItem !== null) {
|
|
202
|
+
if (!isPlainObject(state.activeWorkItem)) {
|
|
203
|
+
errors.push({ path: 'activeWorkItem', message: 'activeWorkItem must be an object or null' });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (state.lastTransition !== undefined && state.lastTransition !== null) {
|
|
207
|
+
const lt = state.lastTransition;
|
|
208
|
+
if (!isPlainObject(lt)) {
|
|
209
|
+
errors.push({ path: 'lastTransition', message: 'lastTransition must be an object or null' });
|
|
210
|
+
} else {
|
|
211
|
+
if (lt.from !== undefined && !PHASES.includes(lt.from)) errors.push({ path: 'lastTransition.from', message: `unknown phase "${lt.from}"` });
|
|
212
|
+
if (lt.to !== undefined && !PHASES.includes(lt.to)) errors.push({ path: 'lastTransition.to', message: `unknown phase "${lt.to}"` });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} else if (state.gateEvidence !== undefined) {
|
|
216
|
+
warnings.push({ path: 'gateEvidence', message: 'gateEvidence on a v1 state is ignored until migration' });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Validate the shape of one evidence record.
|
|
224
|
+
*
|
|
225
|
+
* `strict` (contract v3 §3) is the resolved `strictClosure` policy, or null when
|
|
226
|
+
* strict closure is off. When provided, a `manual-confirmation` record must carry
|
|
227
|
+
* machine-checkable `reason`, `environment`, `scope`, and a real `expiresAt` —
|
|
228
|
+
* because in v2 "declared the key" was accepted as "declared a bound", which let
|
|
229
|
+
* an unbounded record satisfy a gate.
|
|
230
|
+
*/
|
|
231
|
+
function validateEvidenceShape(ev, strict = null) {
|
|
232
|
+
const errors = [];
|
|
233
|
+
if (!isPlainObject(ev)) return [{ path: '', message: 'evidence must be an object' }];
|
|
234
|
+
// Required, non-null fields (contract §2).
|
|
235
|
+
const required = ['evidenceId', 'workItemId', 'phase', 'gate', 'status', 'inputTreeHash', 'criteriaHash', 'relevantFiles', 'createdAt'];
|
|
236
|
+
for (const field of required) {
|
|
237
|
+
if (ev[field] === undefined || ev[field] === null) {
|
|
238
|
+
errors.push({ path: field, message: `${field} is required` });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Required keys that may be explicitly null (e.g. manual-confirmation has no command).
|
|
242
|
+
for (const field of ['command', 'result']) {
|
|
243
|
+
if (!(field in ev)) errors.push({ path: field, message: `${field} is required (may be null)` });
|
|
244
|
+
}
|
|
245
|
+
// A freshness bound is mandatory: either an expiry or an explicit policy.
|
|
246
|
+
// v3 (strict only, manual-confirmation only): a *non-null* bound.
|
|
247
|
+
// An automated `passed` record is bound to files by `inputTreeHash`, so a
|
|
248
|
+
// null expiry does not mean "never stale" for it. A manual-confirmation has
|
|
249
|
+
// no such binding — its only freshness control is the expiry — so there,
|
|
250
|
+
// `expiresAt: null` + `freshnessPolicy: null` is a real hole.
|
|
251
|
+
const hasExpiry = ev.expiresAt !== undefined && ev.expiresAt !== null;
|
|
252
|
+
const hasPolicy = ev.freshnessPolicy !== undefined && ev.freshnessPolicy !== null;
|
|
253
|
+
if (ev.expiresAt === undefined && ev.freshnessPolicy === undefined) {
|
|
254
|
+
errors.push({ path: 'expiresAt', message: 'evidence must declare expiresAt or freshnessPolicy' });
|
|
255
|
+
} else if (strict && ev.status === 'manual-confirmation' && !hasExpiry && !hasPolicy) {
|
|
256
|
+
errors.push({
|
|
257
|
+
path: 'expiresAt',
|
|
258
|
+
message: 'strictClosure requires a usable freshness bound: expiresAt and freshnessPolicy are both null, so the record never expires',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (ev.evidenceId !== undefined && !isUuid(ev.evidenceId)) errors.push({ path: 'evidenceId', message: 'evidenceId must be a UUIDv4' });
|
|
262
|
+
if (ev.phase !== undefined && !PHASES.includes(ev.phase)) errors.push({ path: 'phase', message: `unknown phase "${ev.phase}"` });
|
|
263
|
+
if (ev.gate !== undefined && !GATES.includes(ev.gate)) errors.push({ path: 'gate', message: `unknown gate "${ev.gate}"` });
|
|
264
|
+
if (ev.status !== undefined && !EVIDENCE_STATUSES.includes(ev.status)) errors.push({ path: 'status', message: `unknown evidence status "${ev.status}"` });
|
|
265
|
+
if (ev.relevantFiles !== undefined && !Array.isArray(ev.relevantFiles)) errors.push({ path: 'relevantFiles', message: 'relevantFiles must be an array' });
|
|
266
|
+
if (ev.inputTreeHash !== undefined && !/^[0-9a-f]{64}$/.test(String(ev.inputTreeHash))) {
|
|
267
|
+
errors.push({ path: 'inputTreeHash', message: 'inputTreeHash must be a SHA-256 hex digest' });
|
|
268
|
+
}
|
|
269
|
+
if (ev.criteriaHash !== undefined && ev.criteriaHash !== null && !/^[0-9a-f]{64}$/.test(String(ev.criteriaHash))) {
|
|
270
|
+
errors.push({ path: 'criteriaHash', message: 'criteriaHash must be a SHA-256 hex digest' });
|
|
271
|
+
}
|
|
272
|
+
if (ev.command !== undefined && ev.command !== null && typeof ev.command !== 'string') {
|
|
273
|
+
errors.push({ path: 'command', message: 'command must be a string or null' });
|
|
274
|
+
}
|
|
275
|
+
if (ev.result !== undefined && ev.result !== null && typeof ev.result !== 'string') {
|
|
276
|
+
errors.push({ path: 'result', message: 'result must be a string or null' });
|
|
277
|
+
}
|
|
278
|
+
if (ev.createdAt !== undefined && ev.createdAt !== null && Number.isNaN(Date.parse(ev.createdAt))) {
|
|
279
|
+
errors.push({ path: 'createdAt', message: 'createdAt must be an ISO-8601 date-time' });
|
|
280
|
+
}
|
|
281
|
+
if (ev.expiresAt !== undefined && ev.expiresAt !== null && Number.isNaN(Date.parse(ev.expiresAt))) {
|
|
282
|
+
errors.push({ path: 'expiresAt', message: 'expiresAt must be an ISO-8601 date-time or null' });
|
|
283
|
+
}
|
|
284
|
+
if (ev.freshnessPolicy !== undefined && ev.freshnessPolicy !== null) {
|
|
285
|
+
if (!isPlainObject(ev.freshnessPolicy)) {
|
|
286
|
+
errors.push({ path: 'freshnessPolicy', message: 'freshnessPolicy must be an object or null' });
|
|
287
|
+
} else if (!['story', 'phase', 'run', 'manual'].includes(ev.freshnessPolicy.scope)) {
|
|
288
|
+
errors.push({ path: 'freshnessPolicy.scope', message: 'freshnessPolicy.scope must be story|phase|run|manual' });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (strict && ev.status === 'manual-confirmation') {
|
|
293
|
+
errors.push(...validateManualConfirmation(ev, strict));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return errors;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Strict-closure constraints on a manual-confirmation record (contract v3 §3).
|
|
301
|
+
* Reports *every* problem at once so a caller fixes the record in one pass.
|
|
302
|
+
*/
|
|
303
|
+
function validateManualConfirmation(ev, strict) {
|
|
304
|
+
const errors = [];
|
|
305
|
+
const mc = strict.manualConfirmation || DEFAULT_STRICT_CLOSURE.manualConfirmation;
|
|
306
|
+
|
|
307
|
+
if (mc.requireReason !== false && (typeof ev.reason !== 'string' || ev.reason.trim() === '')) {
|
|
308
|
+
errors.push({ path: 'reason', message: 'strictClosure requires a non-empty "reason" explaining why automation was unavailable' });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (mc.requireExpiresAt !== false) {
|
|
312
|
+
if (typeof ev.expiresAt !== 'string' || Number.isNaN(Date.parse(ev.expiresAt))) {
|
|
313
|
+
errors.push({ path: 'expiresAt', message: 'strictClosure requires a concrete "expiresAt" (a null validity bound is not accepted)' });
|
|
314
|
+
} else if (mc.maxValidityMs !== null && mc.maxValidityMs !== undefined && ev.createdAt) {
|
|
315
|
+
// A record whose createdAt lies in the future can shift both timestamps
|
|
316
|
+
// forward and stay "valid" indefinitely: the window would look legal while
|
|
317
|
+
// the assertion never expires. Reject future-dated records outright, with a
|
|
318
|
+
// small tolerance for clock skew between the writer and the validator.
|
|
319
|
+
const createdMs = Date.parse(ev.createdAt);
|
|
320
|
+
if (Number.isFinite(createdMs)) {
|
|
321
|
+
const skew = mc.clockSkewToleranceMs ?? DEFAULT_STRICT_CLOSURE.manualConfirmation.clockSkewToleranceMs;
|
|
322
|
+
if (createdMs > Date.now() + skew) {
|
|
323
|
+
errors.push({
|
|
324
|
+
path: 'createdAt',
|
|
325
|
+
message: `strictClosure rejects a future-dated "createdAt" (${ev.createdAt}); a record cannot be created in the future`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const window = Date.parse(ev.expiresAt) - Date.parse(ev.createdAt);
|
|
330
|
+
if (Number.isFinite(window) && window > mc.maxValidityMs) {
|
|
331
|
+
errors.push({
|
|
332
|
+
path: 'expiresAt',
|
|
333
|
+
message: `strictClosure manual-confirmation validity (${window}ms) exceeds maxValidityMs (${mc.maxValidityMs}ms)`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
// The window must also be measured against the present, so that a record
|
|
337
|
+
// cannot be given an arbitrarily distant expiry by post-dating createdAt.
|
|
338
|
+
const remaining = Date.parse(ev.expiresAt) - Date.now();
|
|
339
|
+
if (Number.isFinite(remaining) && remaining > mc.maxValidityMs) {
|
|
340
|
+
errors.push({
|
|
341
|
+
path: 'expiresAt',
|
|
342
|
+
message: `strictClosure manual-confirmation expiry is ${remaining}ms from now, beyond maxValidityMs (${mc.maxValidityMs}ms)`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (mc.requireEnvironment !== false) {
|
|
349
|
+
const env = ev.environment;
|
|
350
|
+
if (!isPlainObject(env)) {
|
|
351
|
+
errors.push({ path: 'environment', message: 'strictClosure requires an "environment" object describing what was verified' });
|
|
352
|
+
} else if (!env.projectPath && !env.tool) {
|
|
353
|
+
errors.push({ path: 'environment', message: 'strictClosure requires "environment.projectPath" or "environment.tool"' });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (mc.requireScope !== false) {
|
|
358
|
+
const scope = ev.scope;
|
|
359
|
+
if (!Array.isArray(scope) || scope.length === 0) {
|
|
360
|
+
errors.push({ path: 'scope', message: 'strictClosure requires a non-empty "scope" array naming what the confirmation covers' });
|
|
361
|
+
} else if (!scope.every((s) => typeof s === 'string' && s.trim() !== '')) {
|
|
362
|
+
// The schema declares items as strings; code and schema must agree, or a
|
|
363
|
+
// record validates here and then fails schema validation downstream.
|
|
364
|
+
errors.push({ path: 'scope', message: 'strictClosure requires every "scope" entry to be a non-empty string' });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (Array.isArray(strict.disallowManualFor) && strict.disallowManualFor.includes(ev.gate)) {
|
|
369
|
+
errors.push({
|
|
370
|
+
path: 'status',
|
|
371
|
+
message: `manual-confirmation is not permitted for gate "${ev.gate}" under strictClosure.disallowManualFor; record automated evidence instead`,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return errors;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Resume the resolved strict-closure policy from whatever the caller supplied.
|
|
380
|
+
* Accepts a full resolved policy, a bare `strictClosure` block, or null.
|
|
381
|
+
* Returns null when strict closure is not active, so callers can branch cheaply
|
|
382
|
+
* and cannot accidentally apply half-strict behaviour.
|
|
383
|
+
*/
|
|
384
|
+
export function resolveStrict(context) {
|
|
385
|
+
if (!context) return null;
|
|
386
|
+
// Accept the shapes a caller may reasonably pass, so a resolved policy and a
|
|
387
|
+
// bare strictClosure block are interchangeable:
|
|
388
|
+
// { strictClosure: { enabled, ... } } — a resolved policy
|
|
389
|
+
// { enabled, manualConfirmation, ... } — a bare strictClosure block
|
|
390
|
+
// { strictClosure: { strictClosure: {...} } } — a resolved policy nested as a block
|
|
391
|
+
const nested = context.strictClosure;
|
|
392
|
+
const block = (nested && nested.strictClosure && nested.strictClosure.enabled !== undefined)
|
|
393
|
+
? nested.strictClosure
|
|
394
|
+
: nested;
|
|
395
|
+
if (!block || block.enabled !== true) return null;
|
|
396
|
+
return {
|
|
397
|
+
...DEFAULT_STRICT_CLOSURE,
|
|
398
|
+
...block,
|
|
399
|
+
manualConfirmation: { ...DEFAULT_STRICT_CLOSURE.manualConfirmation, ...(block.manualConfirmation || {}) },
|
|
400
|
+
disallowManualFor: block.disallowManualFor || [...DEFAULT_STRICT_CLOSURE.disallowManualFor],
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Strict-closure constraints on a `gate-exception` entry (contract v3 §4).
|
|
406
|
+
*
|
|
407
|
+
* The category is what makes an exception's expiry policy and review burden
|
|
408
|
+
* derivable instead of arbitrary. An unknown category is rejected with the valid
|
|
409
|
+
* set named, mirroring how unknown budget keys are handled: a typo must not
|
|
410
|
+
* produce an exception that silently escapes its rules.
|
|
411
|
+
*/
|
|
412
|
+
function validateGateException(entry, strict) {
|
|
413
|
+
const errors = [];
|
|
414
|
+
if (!entry.gate || !GATES.includes(entry.gate)) {
|
|
415
|
+
errors.push({ path: 'gate', message: `gate-exception has unknown gate "${entry.gate}"` });
|
|
416
|
+
}
|
|
417
|
+
const category = entry.category;
|
|
418
|
+
if (!category) {
|
|
419
|
+
errors.push({ path: 'category', message: `strictClosure requires a "category" on gate-exception. Valid categories: ${EXCEPTION_CATEGORIES.join(', ')}` });
|
|
420
|
+
return errors;
|
|
421
|
+
}
|
|
422
|
+
if (!EXCEPTION_CATEGORIES.includes(category)) {
|
|
423
|
+
errors.push({ path: 'category', message: `unknown exception category "${category}". Valid categories: ${EXCEPTION_CATEGORIES.join(', ')}` });
|
|
424
|
+
return errors;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (EXCEPTION_REQUIRES_REVIEW_NOTE.includes(category)) {
|
|
428
|
+
if (typeof entry.closureReviewNote !== 'string' || entry.closureReviewNote.trim() === '') {
|
|
429
|
+
errors.push({ path: 'closureReviewNote', message: `exception category "${category}" requires a "closureReviewNote" recording who accepted it and what would change that judgement` });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Category-derived expiry: a category default can be shortened freely, but
|
|
434
|
+
// extending it is a deliberate act that must be explained.
|
|
435
|
+
const defaultDays = EXCEPTION_EXPIRY_DAYS[category];
|
|
436
|
+
if (defaultDays !== null && defaultDays !== undefined) {
|
|
437
|
+
if (entry.expiresAt === undefined || entry.expiresAt === null) {
|
|
438
|
+
errors.push({ path: 'expiresAt', message: `exception category "${category}" requires an "expiresAt" (default window is ${defaultDays} day(s))` });
|
|
439
|
+
} else {
|
|
440
|
+
const maxMs = defaultDays * 24 * 60 * 60 * 1000;
|
|
441
|
+
const created = entry.createdAt ? Date.parse(entry.createdAt) : Date.now();
|
|
442
|
+
const expiry = Date.parse(entry.expiresAt);
|
|
443
|
+
if (Number.isFinite(expiry) && Number.isFinite(created) && expiry - created > maxMs) {
|
|
444
|
+
if (typeof entry.expiryExtendedReason !== 'string' || entry.expiryExtendedReason.trim() === '') {
|
|
445
|
+
errors.push({
|
|
446
|
+
path: 'expiryExtendedReason',
|
|
447
|
+
message: `exception category "${category}" expires beyond its ${defaultDays}-day default; state an "expiryExtendedReason" to extend it`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return errors;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── Migration ───────────────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Migrate a v1 state document to v2 in memory. Unknown top-level fields are
|
|
460
|
+
* preserved. Does not touch the filesystem.
|
|
461
|
+
*/
|
|
462
|
+
export function migrateStateV1toV2(v1) {
|
|
463
|
+
if (!isPlainObject(v1)) throw new StateError('cannot migrate a non-object state');
|
|
464
|
+
if (v1.version === 2 || v1.stateVersion === 2) {
|
|
465
|
+
return { state: { ...v1, version: 2, stateVersion: 2, gateEvidence: v1.gateEvidence || [] }, changed: false };
|
|
466
|
+
}
|
|
467
|
+
if (v1.version === 3 || v1.stateVersion === 3) {
|
|
468
|
+
return { state: { ...v1, version: 3, stateVersion: 3, gateEvidence: v1.gateEvidence || [] }, changed: false };
|
|
469
|
+
}
|
|
470
|
+
const gates = isPlainObject(v1.gates) ? { ...v1.gates } : {};
|
|
471
|
+
for (const gate of GATES) {
|
|
472
|
+
if (typeof gates[gate] !== 'boolean') gates[gate] = false;
|
|
473
|
+
}
|
|
474
|
+
// Preserve any unknown top-level fields that are safe to keep.
|
|
475
|
+
const preserved = {};
|
|
476
|
+
for (const [key, value] of Object.entries(v1)) {
|
|
477
|
+
if (!['version', 'session', 'epics', 'gates', 'spikes', 'changeHistory'].includes(key)) {
|
|
478
|
+
preserved[key] = value;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// v1 migrates straight to the current version (v3). The intermediate v2
|
|
482
|
+
// shape is identical for these fields; only the version stamp differs, so a
|
|
483
|
+
// single-step migration avoids a transient on-disk v2 document.
|
|
484
|
+
const migrated = {
|
|
485
|
+
...preserved,
|
|
486
|
+
version: STATE_VERSION,
|
|
487
|
+
stateVersion: STATE_VERSION,
|
|
488
|
+
session: { ...v1.session },
|
|
489
|
+
epics: v1.epics || {},
|
|
490
|
+
gates,
|
|
491
|
+
gateEvidence: [],
|
|
492
|
+
activeRunId: null,
|
|
493
|
+
activeWorkItem: activeWorkItemFromState(v1),
|
|
494
|
+
lastTransition: null,
|
|
495
|
+
spikes: v1.spikes || {},
|
|
496
|
+
changeHistory: Array.isArray(v1.changeHistory) ? [...v1.changeHistory] : [],
|
|
497
|
+
};
|
|
498
|
+
return { state: migrated, changed: true };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function activeWorkItemFromState(state) {
|
|
502
|
+
const epics = state.epics;
|
|
503
|
+
if (!isPlainObject(epics)) return null;
|
|
504
|
+
for (const [epicId, epic] of Object.entries(epics)) {
|
|
505
|
+
if (!isPlainObject(epic) || !isPlainObject(epic.stories)) continue;
|
|
506
|
+
for (const [storyId, status] of Object.entries(epic.stories)) {
|
|
507
|
+
if (status === 'in-progress') return { epicId, storyId };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Migrate a state file on disk atomically: write a temporary file, optionally
|
|
515
|
+
* back up the original, then rename into place. A failed migration leaves the
|
|
516
|
+
* original untouched.
|
|
517
|
+
*/
|
|
518
|
+
export function migrateStateFile(statePath, { backup = true } = {}) {
|
|
519
|
+
if (!existsSync(statePath)) {
|
|
520
|
+
throw new StateError(`state file not found: ${statePath}`);
|
|
521
|
+
}
|
|
522
|
+
let raw;
|
|
523
|
+
try {
|
|
524
|
+
raw = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
525
|
+
} catch (err) {
|
|
526
|
+
throw new StateError(`cannot migrate malformed state: ${err.message}`);
|
|
527
|
+
}
|
|
528
|
+
const { state, changed } = migrateStateV1toV2(raw);
|
|
529
|
+
if (!changed) return { migrated: false, statePath, state };
|
|
530
|
+
|
|
531
|
+
const dir = dirname(statePath);
|
|
532
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'cadet-state-'));
|
|
533
|
+
const tmpPath = join(tmpDir, 'state.json');
|
|
534
|
+
try {
|
|
535
|
+
writeFileSync(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
536
|
+
if (backup) {
|
|
537
|
+
copyFileSync(statePath, `${statePath}.v1.bak`);
|
|
538
|
+
}
|
|
539
|
+
// A failed rename leaves the original in place; validate before committing.
|
|
540
|
+
// This is a structural-only check: a freshly migrated state has no on-disk
|
|
541
|
+
// evidence to bind, so freshness is intentionally not evaluated here.
|
|
542
|
+
const check = validateState(state, { structuralOnly: true });
|
|
543
|
+
if (!check.valid) {
|
|
544
|
+
throw new StateError(`migrated state failed validation: ${check.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
|
|
545
|
+
}
|
|
546
|
+
renameSync(tmpPath, statePath);
|
|
547
|
+
} finally {
|
|
548
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
549
|
+
}
|
|
550
|
+
return { migrated: true, statePath, state };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ── Evidence ────────────────────────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
/** Build an evidence record. `id` defaults to a fresh UUIDv4. */
|
|
556
|
+
export function createEvidence({
|
|
557
|
+
evidenceId,
|
|
558
|
+
workItemId,
|
|
559
|
+
acceptanceCriterionId = null,
|
|
560
|
+
phase,
|
|
561
|
+
gate,
|
|
562
|
+
status,
|
|
563
|
+
command = null,
|
|
564
|
+
result = null,
|
|
565
|
+
exitCode = null,
|
|
566
|
+
artifactPath = null,
|
|
567
|
+
artifactHash = null,
|
|
568
|
+
inputTreeHash,
|
|
569
|
+
criteriaHash = null,
|
|
570
|
+
relevantFiles = [],
|
|
571
|
+
toolVersion = null,
|
|
572
|
+
createdAt = new Date(),
|
|
573
|
+
expiresAt = null,
|
|
574
|
+
freshnessPolicy = null,
|
|
575
|
+
source = 'automated',
|
|
576
|
+
id,
|
|
577
|
+
}) {
|
|
578
|
+
return {
|
|
579
|
+
evidenceId: evidenceId || id || undefined,
|
|
580
|
+
workItemId,
|
|
581
|
+
acceptanceCriterionId,
|
|
582
|
+
phase,
|
|
583
|
+
gate,
|
|
584
|
+
status,
|
|
585
|
+
command,
|
|
586
|
+
result,
|
|
587
|
+
exitCode,
|
|
588
|
+
artifactPath,
|
|
589
|
+
artifactHash,
|
|
590
|
+
inputTreeHash,
|
|
591
|
+
criteriaHash: criteriaHash || hashCriteria([]),
|
|
592
|
+
relevantFiles,
|
|
593
|
+
toolVersion,
|
|
594
|
+
createdAt: timestamp(createdAt),
|
|
595
|
+
expiresAt: expiresAt ? timestamp(expiresAt) : null,
|
|
596
|
+
freshnessPolicy,
|
|
597
|
+
supersededBy: null,
|
|
598
|
+
source,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Build the input tree hash for a set of relevant files resolved against a root.
|
|
604
|
+
* Missing files are recorded as `missing` so their absence is detectable.
|
|
605
|
+
*/
|
|
606
|
+
export function computeInputTreeHash(rootDir, relativePaths) {
|
|
607
|
+
const pairs = relativePaths.map((p) => ({ path: p, hash: hashFile(join(rootDir, p)) }));
|
|
608
|
+
return hashTree(pairs);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** A work item identifier string used to bind evidence to the active story. */
|
|
612
|
+
export function workItemIdOf(state) {
|
|
613
|
+
const item = state?.activeWorkItem;
|
|
614
|
+
if (!item) return 'unscoped';
|
|
615
|
+
return `${item.epicId || 'none'}::${item.storyId || 'none'}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Is an evidence record usable for a gate at this point in time?
|
|
620
|
+
* Returns `{ fresh, reasons }`.
|
|
621
|
+
*/
|
|
622
|
+
export function evidenceFreshness(evidence, context) {
|
|
623
|
+
const reasons = [];
|
|
624
|
+
const {
|
|
625
|
+
now = new Date(),
|
|
626
|
+
workItemId = null,
|
|
627
|
+
phase = null,
|
|
628
|
+
inputTreeHash = null,
|
|
629
|
+
criteriaHash = null,
|
|
630
|
+
} = context || {};
|
|
631
|
+
|
|
632
|
+
if (evidence.status === 'superseded') reasons.push('evidence was superseded');
|
|
633
|
+
if (evidence.status !== 'passed' && evidence.status !== 'manual-confirmation') {
|
|
634
|
+
if (evidence.status !== 'superseded') reasons.push(`evidence status is "${evidence.status}", not passing`);
|
|
635
|
+
}
|
|
636
|
+
if (workItemId && evidence.workItemId !== workItemId) {
|
|
637
|
+
reasons.push(`evidence belongs to work item "${evidence.workItemId}", not "${workItemId}"`);
|
|
638
|
+
}
|
|
639
|
+
if (inputTreeHash && evidence.inputTreeHash !== inputTreeHash) {
|
|
640
|
+
reasons.push('input tree hash changed since the evidence was recorded');
|
|
641
|
+
}
|
|
642
|
+
if (criteriaHash && evidence.criteriaHash && evidence.criteriaHash !== criteriaHash) {
|
|
643
|
+
reasons.push('acceptance criteria changed since the evidence was recorded');
|
|
644
|
+
}
|
|
645
|
+
if (evidence.expiresAt && new Date(evidence.expiresAt).getTime() <= now.getTime()) {
|
|
646
|
+
reasons.push('evidence expired');
|
|
647
|
+
}
|
|
648
|
+
if (phase && evidence.phase !== phase) {
|
|
649
|
+
const allowed = evidence.freshnessPolicy?.allowPhases || [];
|
|
650
|
+
if (!allowed.includes(phase)) {
|
|
651
|
+
reasons.push(`evidence was recorded for phase "${evidence.phase}", not "${phase}"`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return { fresh: reasons.length === 0, reasons };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** The most recent evidence for a gate, or null. */
|
|
658
|
+
export function latestEvidenceForGate(state, gate) {
|
|
659
|
+
const list = Array.isArray(state?.gateEvidence) ? state.gateEvidence : [];
|
|
660
|
+
const matching = list.filter((e) => e.gate === gate);
|
|
661
|
+
if (matching.length === 0) return null;
|
|
662
|
+
return matching.reduce((a, b) => (new Date(a.createdAt) >= new Date(b.createdAt) ? a : b));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Active gate exceptions keyed by gate, honoring scope and expiry. */
|
|
666
|
+
export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
|
|
667
|
+
const history = Array.isArray(state?.changeHistory) ? state.changeHistory : [];
|
|
668
|
+
const active = {};
|
|
669
|
+
for (const entry of history) {
|
|
670
|
+
if (entry?.type !== 'gate-exception') continue;
|
|
671
|
+
if (workItemId && entry.scope && !String(entry.scope).includes(workItemId)) continue;
|
|
672
|
+
if (entry.expiresAt && new Date(entry.expiresAt).getTime() <= now.getTime()) continue;
|
|
673
|
+
if (entry.gate) active[entry.gate] = entry;
|
|
674
|
+
}
|
|
675
|
+
return active;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ── Transitions ─────────────────────────────────────────────────────────────
|
|
679
|
+
|
|
680
|
+
/** Required gates for a transition target, or null when the target is not gated. */
|
|
681
|
+
export function requiredGates(toPhase) {
|
|
682
|
+
for (const [from, spec] of Object.entries(TRANSITIONS)) {
|
|
683
|
+
if (spec.to === toPhase) return { from, gates: spec.gates, revalidate: spec.revalidate || [] };
|
|
684
|
+
}
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Check one gate for a transition. Shared by the primary `gates` set and the
|
|
690
|
+
* strict-closure `revalidate` set so the two can never drift apart.
|
|
691
|
+
*
|
|
692
|
+
* `recencyFloor` implements `requireFreshRevalidation`: when supplied, evidence
|
|
693
|
+
* must have been created at or after that instant. Without it, "fresh" would mean
|
|
694
|
+
* only "not yet expired", which lets a long phase carry evidence that predates
|
|
695
|
+
* the work it is meant to attest.
|
|
696
|
+
*/
|
|
697
|
+
function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase, rootDir, computeTreeHash, inputTreeHash, critHash, recencyFloor = null }) {
|
|
698
|
+
const missingGates = [];
|
|
699
|
+
const staleEvidence = [];
|
|
700
|
+
|
|
701
|
+
if (gates[gate] !== true) {
|
|
702
|
+
if (exceptions[gate]) return { missingGates, staleEvidence };
|
|
703
|
+
missingGates.push(gate);
|
|
704
|
+
return { missingGates, staleEvidence };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const evidence = latestEvidenceForGate(state, gate);
|
|
708
|
+
if (!evidence) {
|
|
709
|
+
if (exceptions[gate]) return { missingGates, staleEvidence };
|
|
710
|
+
missingGates.push(gate);
|
|
711
|
+
staleEvidence.push({ gate, reason: 'no evidence record for a claimed-true gate' });
|
|
712
|
+
return { missingGates, staleEvidence };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
let currentTreeHash = inputTreeHash;
|
|
716
|
+
if (computeTreeHash) {
|
|
717
|
+
const relevant = Array.isArray(evidence.relevantFiles) ? evidence.relevantFiles : [];
|
|
718
|
+
currentTreeHash = computeInputTreeHash(rootDir, relevant);
|
|
719
|
+
}
|
|
720
|
+
const { fresh, reasons } = evidenceFreshness(evidence, {
|
|
721
|
+
now,
|
|
722
|
+
workItemId,
|
|
723
|
+
phase: fromPhase,
|
|
724
|
+
inputTreeHash: currentTreeHash,
|
|
725
|
+
criteriaHash: critHash,
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
const allReasons = [...reasons];
|
|
729
|
+
let stillFresh = fresh;
|
|
730
|
+
if (recencyFloor && evidence.createdAt) {
|
|
731
|
+
const created = Date.parse(evidence.createdAt);
|
|
732
|
+
if (Number.isFinite(created) && created < recencyFloor.getTime()) {
|
|
733
|
+
stillFresh = false;
|
|
734
|
+
allReasons.push('evidence predates the last transition, so it does not confirm the gate is still true now');
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (!stillFresh && !exceptions[gate]) {
|
|
739
|
+
missingGates.push(gate);
|
|
740
|
+
staleEvidence.push({ gate, evidenceId: evidence.evidenceId, reasons: allReasons });
|
|
741
|
+
}
|
|
742
|
+
return { missingGates, staleEvidence };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Evaluate whether a transition is legal and evidence-backed.
|
|
747
|
+
* Returns a machine-readable result:
|
|
748
|
+
* `{ allowed, toPhase, missingGates, staleEvidence, errors, revalidated }`.
|
|
749
|
+
*
|
|
750
|
+
* Strict closure (contract v3 §1) additionally re-checks the `revalidate` set for
|
|
751
|
+
* the target transition when `context.strictClosure.enabled` is true. With the
|
|
752
|
+
* flag off, only `gates` is checked and behaviour matches v2 exactly.
|
|
753
|
+
*/
|
|
754
|
+
export function evaluateTransition(state, toPhase, context = {}) {
|
|
755
|
+
const errors = [];
|
|
756
|
+
const missingGates = [];
|
|
757
|
+
const staleEvidence = [];
|
|
758
|
+
const fromPhase = state?.session?.currentPhase;
|
|
759
|
+
const workItemId = context.workItemId || workItemIdOf(state);
|
|
760
|
+
const now = context.now || new Date();
|
|
761
|
+
|
|
762
|
+
if (!PHASES.includes(toPhase)) {
|
|
763
|
+
errors.push(`unknown target phase "${toPhase}"`);
|
|
764
|
+
return { allowed: false, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
|
|
765
|
+
}
|
|
766
|
+
if (toPhase === fromPhase) {
|
|
767
|
+
errors.push(`already in phase "${toPhase}"`);
|
|
768
|
+
return { allowed: false, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const spec = requiredGates(toPhase);
|
|
772
|
+
if (!spec) {
|
|
773
|
+
// Ungated transitions (e.g. context-resolution → requirements) are legal.
|
|
774
|
+
return { allowed: true, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
|
|
775
|
+
}
|
|
776
|
+
if (spec.from !== fromPhase) {
|
|
777
|
+
errors.push(`illegal transition "${fromPhase}" → "${toPhase}" (expected from "${spec.from}")`);
|
|
778
|
+
return { allowed: false, fromPhase, toPhase, missingGates: [...spec.gates], staleEvidence, errors, revalidated: [] };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const exceptions = activeExceptions(state, { workItemId, now });
|
|
782
|
+
const gates = isPlainObject(state.gates) ? state.gates : {};
|
|
783
|
+
// Freshness is enforced by default: unless the caller explicitly supplies a
|
|
784
|
+
// hash, compute the current input-tree hash from each evidence record's own
|
|
785
|
+
// relevant files. This prevents a caller from silently accepting stale evidence.
|
|
786
|
+
const rootDir = context.rootDir || process.cwd();
|
|
787
|
+
const computeTreeHash = context.inputTreeHash === undefined && context.computeFreshness !== false;
|
|
788
|
+
const inputTreeHash = context.inputTreeHash !== undefined ? context.inputTreeHash : null;
|
|
789
|
+
const critHash = context.criteriaHash !== undefined ? context.criteriaHash : null;
|
|
790
|
+
|
|
791
|
+
const shared = { state, gates, exceptions, now, workItemId, fromPhase, rootDir, computeTreeHash, inputTreeHash, critHash };
|
|
792
|
+
|
|
793
|
+
for (const gate of spec.gates) {
|
|
794
|
+
const r = checkGate({ ...shared, gate });
|
|
795
|
+
missingGates.push(...r.missingGates);
|
|
796
|
+
staleEvidence.push(...r.staleEvidence);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Strict closure: re-derive the earlier gates at this transition.
|
|
800
|
+
const strict = resolveStrict(context);
|
|
801
|
+
const revalidated = [];
|
|
802
|
+
if (strict && strict.revalidateOnClosure !== false) {
|
|
803
|
+
const recencyFloor = strict.requireFreshRevalidation !== false && state?.lastTransition?.at
|
|
804
|
+
? new Date(state.lastTransition.at)
|
|
805
|
+
: null;
|
|
806
|
+
for (const gate of spec.revalidate) {
|
|
807
|
+
if (spec.gates.includes(gate)) continue; // already checked as a primary gate
|
|
808
|
+
revalidated.push(gate);
|
|
809
|
+
const r = checkGate({ ...shared, gate, recencyFloor });
|
|
810
|
+
missingGates.push(...r.missingGates);
|
|
811
|
+
staleEvidence.push(...r.staleEvidence);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return {
|
|
816
|
+
allowed: missingGates.length === 0 && errors.length === 0,
|
|
817
|
+
fromPhase,
|
|
818
|
+
toPhase,
|
|
819
|
+
missingGates,
|
|
820
|
+
staleEvidence,
|
|
821
|
+
errors,
|
|
822
|
+
revalidated,
|
|
823
|
+
exceptions: Object.keys(exceptions),
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Apply a legal transition to a state object (returns a new object).
|
|
829
|
+
* Resets the target transition's gates is NOT done here — gates reset when a new
|
|
830
|
+
* work item starts (see `resetGatesForNewWorkItem`).
|
|
831
|
+
*/
|
|
832
|
+
export function applyTransition(state, toPhase, { evidenceIds = [], at = new Date(), inputTreeHash = undefined, criteriaHash = undefined, rootDir = undefined, strictClosure = undefined } = {}) {
|
|
833
|
+
const context = { now: at };
|
|
834
|
+
if (inputTreeHash !== undefined) context.inputTreeHash = inputTreeHash;
|
|
835
|
+
if (criteriaHash !== undefined) context.criteriaHash = criteriaHash;
|
|
836
|
+
if (rootDir !== undefined) context.rootDir = rootDir;
|
|
837
|
+
if (strictClosure !== undefined) context.strictClosure = strictClosure;
|
|
838
|
+
const evaluation = evaluateTransition(state, toPhase, context);
|
|
839
|
+
if (!evaluation.allowed) {
|
|
840
|
+
throw new StateError(
|
|
841
|
+
`illegal transition to "${toPhase}": ${[...evaluation.errors, ...evaluation.missingGates.map((g) => `missing gate ${g}`)].join('; ')}`,
|
|
842
|
+
{ evaluation }
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
...state,
|
|
847
|
+
version: STATE_VERSION,
|
|
848
|
+
stateVersion: STATE_VERSION,
|
|
849
|
+
session: { ...state.session, currentPhase: toPhase },
|
|
850
|
+
lastTransition: {
|
|
851
|
+
from: state.session.currentPhase,
|
|
852
|
+
to: toPhase,
|
|
853
|
+
at: timestamp(at),
|
|
854
|
+
evidenceIds,
|
|
855
|
+
},
|
|
856
|
+
changeHistory: [
|
|
857
|
+
...(Array.isArray(state.changeHistory) ? state.changeHistory : []),
|
|
858
|
+
{ date: timestamp(at), change: `Phase transition ${state.session.currentPhase} → ${toPhase}`, phase: toPhase },
|
|
859
|
+
],
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Reset all gates to false for a new work item (atomic in the returned copy). */
|
|
864
|
+
export function resetGatesForNewWorkItem(state, { epicId = null, storyId = null, at = new Date() } = {}) {
|
|
865
|
+
const gates = {};
|
|
866
|
+
for (const gate of GATES) gates[gate] = false;
|
|
867
|
+
return {
|
|
868
|
+
...state,
|
|
869
|
+
gates,
|
|
870
|
+
gateEvidence: [],
|
|
871
|
+
activeWorkItem: { epicId, storyId },
|
|
872
|
+
changeHistory: [
|
|
873
|
+
...(Array.isArray(state.changeHistory) ? state.changeHistory : []),
|
|
874
|
+
{ date: timestamp(at), change: `Gate reset for new work item ${epicId || 'none'}::${storyId || 'none'}`, phase: state.session?.currentPhase },
|
|
875
|
+
],
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// ── File helpers ────────────────────────────────────────────────────────────
|
|
880
|
+
|
|
881
|
+
export function statePathFor(targetDir) {
|
|
882
|
+
return join(targetDir, '.cadet', 'state.json');
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export function readState(targetDir) {
|
|
886
|
+
const path = statePathFor(targetDir);
|
|
887
|
+
if (!existsSync(path)) return { exists: false, path, state: null };
|
|
888
|
+
try {
|
|
889
|
+
return { exists: true, path, state: JSON.parse(readFileSync(path, 'utf-8')) };
|
|
890
|
+
} catch (err) {
|
|
891
|
+
throw new StateError(`failed to parse ${path}: ${err.message}`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Atomically write a JSON document: serialize to a sibling temp file, then
|
|
897
|
+
* rename into place. A process interruption cannot leave a truncated target.
|
|
898
|
+
*/
|
|
899
|
+
export function writeJsonAtomic(path, value) {
|
|
900
|
+
const payload = JSON.stringify(value, null, 2) + '\n';
|
|
901
|
+
// Verify the payload is valid JSON before it can replace the target.
|
|
902
|
+
JSON.parse(payload);
|
|
903
|
+
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
904
|
+
try {
|
|
905
|
+
writeFileSync(tmpPath, payload, 'utf-8');
|
|
906
|
+
renameSync(tmpPath, path);
|
|
907
|
+
} catch (err) {
|
|
908
|
+
try { rmSync(tmpPath, { force: true }); } catch { /* best effort */ }
|
|
909
|
+
throw new StateError(`failed to write ${path}: ${err.message}`);
|
|
910
|
+
}
|
|
911
|
+
return path;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Atomically write state.json. */
|
|
915
|
+
export function writeState(targetDir, state) {
|
|
916
|
+
return writeJsonAtomic(statePathFor(targetDir), state);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
export { StateError };
|