dsh-math-modeling-agent 0.4.1 → 0.5.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 +247 -187
- package/package.json +34 -34
- package/skills/math-modeling-agent/SKILL.md +70 -69
- package/skills/math-modeling-agent/references/claims-evidence.md +53 -41
- package/skills/math-modeling-agent/references/interaction-protocol.md +165 -163
- package/skills/math-modeling-agent/references/report-contract.md +130 -122
- package/skills/math-modeling-agent/references/run-directory.md +65 -62
- package/skills/math-modeling-agent/references/verification-recipes.md +43 -0
- package/skills/math-modeling-agent/schemas/attempt.schema.json +89 -70
- package/skills/math-modeling-agent/schemas/evidence.schema.json +110 -0
- package/skills/math-modeling-agent/schemas/failure.schema.json +30 -0
- package/skills/math-modeling-agent/schemas/ledger.schema.json +88 -68
- package/skills/math-modeling-agent/schemas/run.schema.json +126 -102
- package/skills/math-modeling-agent/schemas/verification.schema.json +60 -0
- package/skills/math-modeling-agent/scripts/correction-lineage.mjs +78 -0
- package/skills/math-modeling-agent/scripts/evidence-store.mjs +349 -0
- package/skills/math-modeling-agent/scripts/failure-insights.mjs +79 -0
- package/skills/math-modeling-agent/scripts/input-snapshot.mjs +98 -0
- package/skills/math-modeling-agent/scripts/ledger-mutation.mjs +82 -0
- package/skills/math-modeling-agent/scripts/migration-v3.mjs +31 -0
- package/skills/math-modeling-agent/scripts/output-integrity.mjs +82 -0
- package/skills/math-modeling-agent/scripts/paper-evidence.mjs +45 -0
- package/skills/math-modeling-agent/scripts/report-contract.mjs +112 -0
- package/skills/math-modeling-agent/scripts/run-state.mjs +829 -707
- package/skills/math-modeling-agent/scripts/verification-recipes.mjs +52 -0
- package/skills/math-modeling-agent/scripts/verification-runner.mjs +8 -0
- package/skills/math-modeling-audit/SKILL.md +41 -41
- package/skills/math-modeling-audit/references/mcm-icm-final-judge.md +335 -331
- package/skills/math-modeling-audit/scripts/mcm-score.mjs +293 -218
- package/skills/math-modeling-audit/scripts/paper-final-review.mjs +40 -0
- package/skills/math-modeling-audit/scripts/project-initial-review.mjs +41 -0
|
@@ -1,707 +1,829 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { access, appendFile, mkdir, open, readFile, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { dirname, join, resolve } from 'node:path'
|
|
4
|
-
import { fileURLToPath } from 'node:url'
|
|
5
|
-
import { isDeepStrictEqual } from 'node:util'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export const
|
|
10
|
-
export const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (
|
|
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
|
-
return
|
|
207
|
-
|
|
208
|
-
await
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
await
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
await
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
await
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
function
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
function
|
|
282
|
-
function
|
|
283
|
-
|
|
284
|
-
function
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
addDiagnostic(diagnostics,
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
addDiagnostic(diagnostics, typeof run.
|
|
296
|
-
addDiagnostic(diagnostics,
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
addDiagnostic(diagnostics,
|
|
301
|
-
|
|
302
|
-
addDiagnostic(diagnostics,
|
|
303
|
-
addDiagnostic(diagnostics, typeof
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
addDiagnostic(diagnostics, SUPPORTED_SCHEMA_VERSIONS.includes(
|
|
318
|
-
addDiagnostic(diagnostics,
|
|
319
|
-
addDiagnostic(diagnostics,
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
addDiagnostic(diagnostics,
|
|
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
|
-
addDiagnostic(diagnostics, event
|
|
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
|
-
if (
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if (
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
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
|
-
if (
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
if (!
|
|
538
|
-
if (
|
|
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
|
-
}
|
|
607
|
-
|
|
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
|
-
if (
|
|
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
|
-
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { access, appendFile, mkdir, open, readFile, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
6
|
+
|
|
7
|
+
import { validateCoverage } from './report-contract.mjs'
|
|
8
|
+
|
|
9
|
+
export const SCHEMA_VERSION = 3
|
|
10
|
+
export const SUPPORTED_SCHEMA_VERSIONS = [1, 2, 3]
|
|
11
|
+
export const INTERACTION_DECISIONS = Object.freeze({ D1: 'routing', D2: 'assumptions', D3: 'direction', D4: 'verdict' })
|
|
12
|
+
export const MODE_DEFAULTS = Object.freeze({
|
|
13
|
+
fast: Object.freeze({ attempts: 2, researchQueries: 0, computeSeconds: 60 }),
|
|
14
|
+
standard: Object.freeze({ attempts: 12, researchQueries: 12, computeSeconds: 1800 }),
|
|
15
|
+
'high-assurance': Object.freeze({ attempts: 24, researchQueries: 30, computeSeconds: 7200 }),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const NON_FINAL_STATES = ['TRIAGE', 'SCOPE_FROZEN', 'INPUT_PROFILED', 'CLAIMS_REGISTERED', 'CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'EVALUATE', 'CORRECTION_REQUIRED', 'REVISE', 'RESEARCH', 'FORK']
|
|
19
|
+
const FINAL_STATES = ['SOLVED', 'PARTIAL', 'CONDITIONAL', 'INCONCLUSIVE', 'REFUTED', 'INFEASIBLE', 'UNIDENTIFIABLE', 'BLOCKED', 'CANCELLED']
|
|
20
|
+
const STATUSES = [...NON_FINAL_STATES, ...FINAL_STATES]
|
|
21
|
+
const STOP_TRANSITIONS = ['BLOCKED', 'CANCELLED']
|
|
22
|
+
const TRANSITIONS = Object.freeze({
|
|
23
|
+
TRIAGE: ['RESEARCH', 'SCOPE_FROZEN', ...STOP_TRANSITIONS],
|
|
24
|
+
RESEARCH: ['SCOPE_FROZEN', 'CANDIDATES_READY', ...STOP_TRANSITIONS],
|
|
25
|
+
SCOPE_FROZEN: ['INPUT_PROFILED', ...STOP_TRANSITIONS],
|
|
26
|
+
INPUT_PROFILED: ['CLAIMS_REGISTERED', ...STOP_TRANSITIONS],
|
|
27
|
+
CLAIMS_REGISTERED: ['RESEARCH', 'CANDIDATES_READY', ...STOP_TRANSITIONS],
|
|
28
|
+
CANDIDATES_READY: ['ATTEMPT', ...STOP_TRANSITIONS],
|
|
29
|
+
ATTEMPT: ['EXECUTE', ...STOP_TRANSITIONS],
|
|
30
|
+
EXECUTE: ['VERIFY', ...STOP_TRANSITIONS],
|
|
31
|
+
VERIFY: ['EVALUATE', 'REVISE', 'RESEARCH', 'FORK', ...FINAL_STATES],
|
|
32
|
+
EVALUATE: ['CORRECTION_REQUIRED', 'REVISE', 'CONDITIONAL', ...STOP_TRANSITIONS],
|
|
33
|
+
CORRECTION_REQUIRED: ['REVISE', ...STOP_TRANSITIONS],
|
|
34
|
+
REVISE: ['ATTEMPT', ...STOP_TRANSITIONS],
|
|
35
|
+
FORK: ['ATTEMPT', ...STOP_TRANSITIONS],
|
|
36
|
+
})
|
|
37
|
+
const TASK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
38
|
+
const RUN_KEYS_V2 = ['schemaVersion', 'taskId', 'mode', 'status', 'eventSequence', 'currentAttempt', 'bestCandidateId', 'budget', 'createdAt', 'updatedAt']
|
|
39
|
+
const RUN_KEYS = ['schemaVersion', 'taskId', 'mode', 'status', 'eventSequence', 'currentAttempt', 'bestCandidateId', 'budget', 'createdAt', 'updatedAt', 'lineageId', 'parentRunId', 'revision', 'inputSnapshotHash', 'evidenceGraphHash']
|
|
40
|
+
const IMMUTABLE_RUN_KEYS = ['schemaVersion', 'taskId', 'mode', 'budget', 'createdAt', 'lineageId', 'parentRunId', 'revision']
|
|
41
|
+
const LEDGER_KEYS_V2 = ['schemaVersion', 'taskId', 'scope', 'assumptions', 'claims', 'obligations', 'subproblems', 'candidates', 'issues']
|
|
42
|
+
const LEDGER_KEYS = [...LEDGER_KEYS_V2, 'requirements', 'evidence', 'verifications', 'failures']
|
|
43
|
+
const RUN_FILE = 'run.json'
|
|
44
|
+
const LEDGER_FILE = 'ledger.json'
|
|
45
|
+
const EVENTS_FILE = 'events.jsonl'
|
|
46
|
+
const MUTATION_LOCK_FILE = '.run-state.lock'
|
|
47
|
+
const RECLAIM_GUARD_FILE = '.run-state.reclaim'
|
|
48
|
+
|
|
49
|
+
function clone(value) { return JSON.parse(JSON.stringify(value)) }
|
|
50
|
+
function now() { return new Date().toISOString() }
|
|
51
|
+
function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
|
|
52
|
+
function exactKeys(value, keys) { return isObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)) }
|
|
53
|
+
function nonnegativeInteger(value) { return Number.isInteger(value) && value >= 0 }
|
|
54
|
+
function validateBudgetShape(budget, diagnostics, prefix = 'budget') {
|
|
55
|
+
addDiagnostic(diagnostics, isObject(budget), `${prefix} must be an object`)
|
|
56
|
+
if (!isObject(budget)) return
|
|
57
|
+
addDiagnostic(diagnostics, exactKeys(budget, ['attempts', 'researchQueries', 'computeSeconds']), `${prefix} has invalid fields`)
|
|
58
|
+
addDiagnostic(diagnostics, Number.isFinite(budget.attempts) && Number.isInteger(budget.attempts) && budget.attempts >= 1, `${prefix}.attempts must be a finite integer >= 1`)
|
|
59
|
+
addDiagnostic(diagnostics, Number.isFinite(budget.researchQueries) && nonnegativeInteger(budget.researchQueries), `${prefix}.researchQueries must be a finite nonnegative integer`)
|
|
60
|
+
addDiagnostic(diagnostics, Number.isFinite(budget.computeSeconds) && nonnegativeInteger(budget.computeSeconds), `${prefix}.computeSeconds must be a finite nonnegative integer`)
|
|
61
|
+
}
|
|
62
|
+
async function readJson(path) { return JSON.parse(await readFile(path, 'utf8')) }
|
|
63
|
+
const RENAME_DEFAULTS = Object.freeze({ attempts: 5, retryDelayMs: 5 })
|
|
64
|
+
const WINDOWS_SHARING_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
65
|
+
async function renameWithRetry(source, target, runtime = {}) {
|
|
66
|
+
const options = { ...RENAME_DEFAULTS, ...(runtime.rename ?? {}) }
|
|
67
|
+
const renameFile = runtime.fs?.rename ?? rename
|
|
68
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
69
|
+
try { return await renameFile(source, target) }
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (!WINDOWS_SHARING_ERRORS.has(error.code) || attempt >= options.attempts) {
|
|
72
|
+
if (WINDOWS_SHARING_ERRORS.has(error.code)) error.message = `atomic rename failed after ${attempt} attempts: ${error.message}`
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
75
|
+
await sleep(options.retryDelayMs * attempt)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function unlinkWithRetry(path, unlinkFile = unlink, options = RENAME_DEFAULTS) {
|
|
80
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
81
|
+
try { await unlinkFile(path); return true }
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error.code === 'ENOENT') return false
|
|
84
|
+
if (!WINDOWS_SHARING_ERRORS.has(error.code) || attempt >= options.attempts) throw error
|
|
85
|
+
await sleep(options.retryDelayMs * attempt)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function atomicWrite(path, value, runtime = {}) {
|
|
90
|
+
await mkdir(dirname(path), { recursive: true })
|
|
91
|
+
const temporary = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
92
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
93
|
+
try { await renameWithRetry(temporary, path, runtime) }
|
|
94
|
+
finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error }) }
|
|
95
|
+
}
|
|
96
|
+
function paths(root) { return { run: join(root, RUN_FILE), ledger: join(root, LEDGER_FILE), events: join(root, EVENTS_FILE) } }
|
|
97
|
+
const LOCK_DEFAULTS = Object.freeze({ timeoutMs: 2000, retryDelayMs: 10, staleMs: 30_000 })
|
|
98
|
+
function sleep(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)) }
|
|
99
|
+
function processIsAlive(pid) {
|
|
100
|
+
try { process.kill(pid, 0); return true }
|
|
101
|
+
catch (error) { return error.code === 'EPERM' }
|
|
102
|
+
}
|
|
103
|
+
function lockBusyError(operation) {
|
|
104
|
+
const error = new Error(`LOCK_BUSY: timed out waiting for run-state lock during ${operation}`)
|
|
105
|
+
error.code = 'LOCK_BUSY'
|
|
106
|
+
return error
|
|
107
|
+
}
|
|
108
|
+
async function readLockMetadata(lockPath) {
|
|
109
|
+
try { return JSON.parse(await readFile(lockPath, 'utf8')) } catch { return null }
|
|
110
|
+
}
|
|
111
|
+
async function validCompleteRunExists(root) {
|
|
112
|
+
try {
|
|
113
|
+
const p = paths(root)
|
|
114
|
+
validateAuthoritativeState(await readJson(p.run), await readJson(p.ledger), await readEvents(p.events))
|
|
115
|
+
return true
|
|
116
|
+
} catch { return false }
|
|
117
|
+
}
|
|
118
|
+
async function cleanupDeadInitAttempt(root, metadata) {
|
|
119
|
+
if (metadata?.operation !== 'init' || await validCompleteRunExists(root)) return
|
|
120
|
+
const owned = Array.isArray(metadata.ownedArtifacts) ? metadata.ownedArtifacts : []
|
|
121
|
+
for (const file of owned) {
|
|
122
|
+
if ([RUN_FILE, LEDGER_FILE, EVENTS_FILE].includes(file)) await unlink(join(root, file)).catch(error => { if (error.code !== 'ENOENT') throw error })
|
|
123
|
+
}
|
|
124
|
+
if (typeof metadata.stagingDir === 'string' && metadata.stagingDir.startsWith('.run-state-init-')) {
|
|
125
|
+
const stagingRoot = join(root, metadata.stagingDir)
|
|
126
|
+
for (const file of [RUN_FILE, LEDGER_FILE, EVENTS_FILE]) await unlink(join(stagingRoot, file)).catch(error => { if (error.code !== 'ENOENT') throw error })
|
|
127
|
+
await rmdir(stagingRoot).catch(error => { if (!['ENOENT', 'ENOTEMPTY'].includes(error.code)) throw error })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function reclaimGuardIsBusy(path, runtime = {}) {
|
|
131
|
+
const accessFile = runtime.fs?.access ?? access
|
|
132
|
+
try { await accessFile(path); return true }
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error.code === 'ENOENT') return false
|
|
135
|
+
if (WINDOWS_SHARING_ERRORS.has(error.code)) return true
|
|
136
|
+
throw error
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function sameLockGeneration(observedMetadata, observedStat, currentMetadata, currentStat) {
|
|
140
|
+
if (observedMetadata === null || currentMetadata === null) {
|
|
141
|
+
return observedMetadata === null && currentMetadata === null
|
|
142
|
+
&& observedStat.dev === currentStat.dev
|
|
143
|
+
&& observedStat.ino === currentStat.ino
|
|
144
|
+
&& observedStat.size === currentStat.size
|
|
145
|
+
&& observedStat.mtimeMs === currentStat.mtimeMs
|
|
146
|
+
}
|
|
147
|
+
return typeof observedMetadata.ownerId === 'string'
|
|
148
|
+
&& currentMetadata.ownerId === observedMetadata.ownerId
|
|
149
|
+
&& observedStat.dev === currentStat.dev
|
|
150
|
+
&& observedStat.ino === currentStat.ino
|
|
151
|
+
&& observedStat.size === currentStat.size
|
|
152
|
+
&& observedStat.mtimeMs === currentStat.mtimeMs
|
|
153
|
+
}
|
|
154
|
+
function demonstrablyDeadAndStale(metadata, lockStat, lockOptions) {
|
|
155
|
+
const acquiredAt = Date.parse(metadata?.acquiredAt)
|
|
156
|
+
const staleSince = Number.isFinite(acquiredAt) ? acquiredAt : lockStat.mtimeMs
|
|
157
|
+
return typeof metadata?.ownerId === 'string'
|
|
158
|
+
&& Number.isInteger(metadata.pid)
|
|
159
|
+
&& Date.now() - staleSince >= lockOptions.staleMs
|
|
160
|
+
&& !lockOptions.isProcessAlive(metadata.pid)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isReclaimableLock(metadata, lockStat, lockOptions) {
|
|
164
|
+
if (metadata === null) return Date.now() - lockStat.mtimeMs >= lockOptions.staleMs
|
|
165
|
+
return demonstrablyDeadAndStale(metadata, lockStat, lockOptions)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function reclaimStaleGuard(guardPath, lockOptions, runtime = {}) {
|
|
169
|
+
const statFile = runtime.fs?.stat ?? stat
|
|
170
|
+
const unlinkFile = runtime.fs?.unlink ?? unlink
|
|
171
|
+
try {
|
|
172
|
+
const guardStat = await statFile(guardPath)
|
|
173
|
+
if (Date.now() - guardStat.mtimeMs < lockOptions.staleMs) return false
|
|
174
|
+
const guardMetadata = await readLockMetadata(guardPath)
|
|
175
|
+
if (guardMetadata !== null && typeof guardMetadata.ownerId !== 'string') return false
|
|
176
|
+
await unlinkWithRetry(guardPath, unlinkFile)
|
|
177
|
+
return true
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error.code === 'ENOENT') return true
|
|
180
|
+
throw error
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function reclaimDeadStaleLock(root, lockPath, guardPath, lockOptions) {
|
|
184
|
+
let observedMetadata, observedStat
|
|
185
|
+
try { [observedMetadata, observedStat] = await Promise.all([readLockMetadata(lockPath), stat(lockPath)]) }
|
|
186
|
+
catch (error) { if (error.code === 'ENOENT') return true; throw error }
|
|
187
|
+
if (!isReclaimableLock(observedMetadata, observedStat, lockOptions)) return false
|
|
188
|
+
const guardOwnerId = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
189
|
+
let guard
|
|
190
|
+
try {
|
|
191
|
+
guard = await open(guardPath, 'wx')
|
|
192
|
+
await guard.writeFile(JSON.stringify({ ownerId: guardOwnerId }), 'utf8')
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (guard) {
|
|
195
|
+
await guard.close()
|
|
196
|
+
const currentGuard = await readLockMetadata(guardPath)
|
|
197
|
+
if (currentGuard?.ownerId === guardOwnerId) await unlinkWithRetry(guardPath)
|
|
198
|
+
}
|
|
199
|
+
if (['EEXIST', 'EPERM', 'EBUSY', 'EACCES'].includes(error.code)) return false
|
|
200
|
+
throw error
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
let currentMetadata, currentStat
|
|
204
|
+
try { [currentMetadata, currentStat] = await Promise.all([readLockMetadata(lockPath), stat(lockPath)]) }
|
|
205
|
+
catch (error) { if (error.code === 'ENOENT') return true; throw error }
|
|
206
|
+
if (!sameLockGeneration(observedMetadata, observedStat, currentMetadata, currentStat)) return false
|
|
207
|
+
if (!isReclaimableLock(currentMetadata, currentStat, lockOptions)) return false
|
|
208
|
+
await cleanupDeadInitAttempt(root, currentMetadata)
|
|
209
|
+
const finalMetadata = await readLockMetadata(lockPath)
|
|
210
|
+
if (finalMetadata?.ownerId !== observedMetadata?.ownerId) return false
|
|
211
|
+
await unlinkWithRetry(lockPath)
|
|
212
|
+
return true
|
|
213
|
+
} finally {
|
|
214
|
+
await guard.close()
|
|
215
|
+
const currentGuard = await readLockMetadata(guardPath)
|
|
216
|
+
if (currentGuard?.ownerId === guardOwnerId) await unlinkWithRetry(guardPath)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function withLock(root, file, operationName, operation, runtime = {}) {
|
|
220
|
+
await mkdir(root, { recursive: true })
|
|
221
|
+
const lockPath = join(root, file)
|
|
222
|
+
const guardPath = join(root, RECLAIM_GUARD_FILE)
|
|
223
|
+
const lockOptions = { ...LOCK_DEFAULTS, ...(runtime.lock ?? {}), isProcessAlive: runtime.lock?.isProcessAlive ?? processIsAlive }
|
|
224
|
+
const deadline = Date.now() + lockOptions.timeoutMs
|
|
225
|
+
const ownerId = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
226
|
+
let lock
|
|
227
|
+
let metadata = { pid: process.pid, acquiredAt: now(), operation: operationName, ownerId }
|
|
228
|
+
while (!lock) {
|
|
229
|
+
if (await reclaimGuardIsBusy(guardPath, runtime)) {
|
|
230
|
+
if (await reclaimStaleGuard(guardPath, lockOptions, runtime)) {
|
|
231
|
+
if (Date.now() >= deadline) throw lockBusyError(operationName)
|
|
232
|
+
await sleep(lockOptions.retryDelayMs)
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
if (Date.now() >= deadline) throw lockBusyError(operationName)
|
|
236
|
+
await sleep(lockOptions.retryDelayMs)
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const candidate = await open(lockPath, 'wx')
|
|
241
|
+
if (await reclaimGuardIsBusy(guardPath, runtime)) {
|
|
242
|
+
await candidate.close()
|
|
243
|
+
await unlinkWithRetry(lockPath)
|
|
244
|
+
if (await reclaimStaleGuard(guardPath, lockOptions, runtime)) {
|
|
245
|
+
if (Date.now() >= deadline) throw lockBusyError(operationName)
|
|
246
|
+
await sleep(lockOptions.retryDelayMs)
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
if (Date.now() >= deadline) throw lockBusyError(operationName)
|
|
250
|
+
await sleep(lockOptions.retryDelayMs)
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
lock = candidate
|
|
254
|
+
await lock.writeFile(JSON.stringify(metadata), 'utf8')
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (!['EEXIST', 'EPERM', 'EBUSY', 'EACCES'].includes(error.code)) throw error
|
|
257
|
+
if (error.code === 'EEXIST' && await reclaimDeadStaleLock(root, lockPath, guardPath, lockOptions)) continue
|
|
258
|
+
if (Date.now() >= deadline) throw lockBusyError(operationName)
|
|
259
|
+
await sleep(lockOptions.retryDelayMs)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const updateMetadata = async patch => {
|
|
263
|
+
metadata = { ...metadata, ...patch }
|
|
264
|
+
await writeFile(lockPath, JSON.stringify(metadata), 'utf8')
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
await runtime.hooks?.afterLockAcquired?.({ operation: operationName, ownerId })
|
|
268
|
+
return await operation({ ownerId, updateMetadata })
|
|
269
|
+
} finally {
|
|
270
|
+
await lock.close()
|
|
271
|
+
const current = await readLockMetadata(lockPath)
|
|
272
|
+
if (current?.ownerId === ownerId) await unlinkWithRetry(lockPath)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function assertRunFilesDoNotExist(p) {
|
|
276
|
+
for (const path of [p.run, p.ledger, p.events]) {
|
|
277
|
+
try { await access(path); throw new Error('contracted run file already exists') }
|
|
278
|
+
catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function assertTaskId(taskId) { if (!TASK_ID.test(taskId ?? '')) throw new Error('taskId must be kebab-case') }
|
|
282
|
+
function assertMode(mode) { if (!MODE_DEFAULTS[mode]) throw new Error(`unsupported mode: ${mode}`) }
|
|
283
|
+
function linkedIds(input) { return [...(input.evidenceIds ?? []), ...(input.issueIds ?? [])] }
|
|
284
|
+
function isAllowedTransition(from, to) { return (TRANSITIONS[from] ?? []).includes(to) }
|
|
285
|
+
function illegalTransitionMessage(from, to) { return `illegal transition ${from} -> ${to}` }
|
|
286
|
+
function nextAttempt(currentAttempt, to) { return currentAttempt + (to === 'ATTEMPT' ? 1 : 0) }
|
|
287
|
+
function addDiagnostic(diagnostics, condition, message) { if (!condition) diagnostics.push(message) }
|
|
288
|
+
function deeplyEqual(left, right) { return isDeepStrictEqual(left, right) }
|
|
289
|
+
|
|
290
|
+
function validateRunShape(run, diagnostics, prefix = 'run') {
|
|
291
|
+
const keys = run?.schemaVersion >= 3 ? RUN_KEYS : RUN_KEYS_V2
|
|
292
|
+
addDiagnostic(diagnostics, exactKeys(run, keys), `${prefix} must contain exactly the contracted fields`)
|
|
293
|
+
if (!isObject(run)) return
|
|
294
|
+
addDiagnostic(diagnostics, SUPPORTED_SCHEMA_VERSIONS.includes(run.schemaVersion), `${prefix}.schemaVersion must be one of ${SUPPORTED_SCHEMA_VERSIONS.join(',')}`)
|
|
295
|
+
addDiagnostic(diagnostics, typeof run.taskId === 'string' && TASK_ID.test(run.taskId), `${prefix}.taskId must be kebab-case`)
|
|
296
|
+
addDiagnostic(diagnostics, Object.hasOwn(MODE_DEFAULTS, run.mode), `${prefix}.mode is invalid`)
|
|
297
|
+
addDiagnostic(diagnostics, STATUSES.includes(run.status), `${prefix}.status is invalid`)
|
|
298
|
+
addDiagnostic(diagnostics, nonnegativeInteger(run.eventSequence), `${prefix}.eventSequence must be a nonnegative integer`)
|
|
299
|
+
addDiagnostic(diagnostics, nonnegativeInteger(run.currentAttempt), `${prefix}.currentAttempt must be a nonnegative integer`)
|
|
300
|
+
addDiagnostic(diagnostics, run.bestCandidateId === null || typeof run.bestCandidateId === 'string', `${prefix}.bestCandidateId must be a string or null`)
|
|
301
|
+
validateBudgetShape(run.budget, diagnostics, `${prefix}.budget`)
|
|
302
|
+
addDiagnostic(diagnostics, typeof run.createdAt === 'string' && run.createdAt.length > 0, `${prefix}.createdAt must be a string`)
|
|
303
|
+
addDiagnostic(diagnostics, typeof run.updatedAt === 'string' && run.updatedAt.length > 0, `${prefix}.updatedAt must be a string`)
|
|
304
|
+
if (run.schemaVersion >= 3) {
|
|
305
|
+
addDiagnostic(diagnostics, typeof run.lineageId === 'string' && run.lineageId.length > 0, `${prefix}.lineageId must be a nonempty string`)
|
|
306
|
+
addDiagnostic(diagnostics, run.parentRunId === null || typeof run.parentRunId === 'string', `${prefix}.parentRunId must be a string or null`)
|
|
307
|
+
addDiagnostic(diagnostics, nonnegativeInteger(run.revision), `${prefix}.revision must be a nonnegative integer`)
|
|
308
|
+
addDiagnostic(diagnostics, run.inputSnapshotHash === null || typeof run.inputSnapshotHash === 'string', `${prefix}.inputSnapshotHash must be a string or null`)
|
|
309
|
+
addDiagnostic(diagnostics, run.evidenceGraphHash === null || typeof run.evidenceGraphHash === 'string', `${prefix}.evidenceGraphHash must be a string or null`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function validateLedgerShape(ledger, diagnostics) {
|
|
314
|
+
const keys = ledger?.schemaVersion >= 3 ? LEDGER_KEYS : LEDGER_KEYS_V2
|
|
315
|
+
addDiagnostic(diagnostics, exactKeys(ledger, keys), 'ledger must contain exactly the contracted fields')
|
|
316
|
+
if (!isObject(ledger)) return
|
|
317
|
+
addDiagnostic(diagnostics, SUPPORTED_SCHEMA_VERSIONS.includes(ledger.schemaVersion), `ledger.schemaVersion must be one of ${SUPPORTED_SCHEMA_VERSIONS.join(',')}`)
|
|
318
|
+
addDiagnostic(diagnostics, typeof ledger.taskId === 'string' && TASK_ID.test(ledger.taskId), 'ledger.taskId must be kebab-case')
|
|
319
|
+
addDiagnostic(diagnostics, isObject(ledger.scope), 'ledger.scope must be an object')
|
|
320
|
+
const arrays = ['assumptions', 'claims', 'obligations', 'subproblems', 'candidates', 'issues']
|
|
321
|
+
if (ledger.schemaVersion >= 3) arrays.push('requirements', 'evidence', 'verifications', 'failures')
|
|
322
|
+
for (const key of arrays) addDiagnostic(diagnostics, Array.isArray(ledger[key]), `ledger.${key} must be an array`)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function validateEventShape(event, diagnostics, index) {
|
|
326
|
+
const prefix = `event[${index}]`
|
|
327
|
+
addDiagnostic(diagnostics, isObject(event), `${prefix} must be an object`)
|
|
328
|
+
if (!isObject(event)) return
|
|
329
|
+
const expectedType = index === 0 ? 'RUN_INITIALIZED' : 'STATUS_TRANSITION'
|
|
330
|
+
const expectedKeys = expectedType === 'RUN_INITIALIZED'
|
|
331
|
+
? ['schemaVersion', 'sequence', 'type', 'taskId', 'timestamp', 'snapshot']
|
|
332
|
+
: ['schemaVersion', 'sequence', 'type', 'taskId', 'from', 'to', 'reason', 'evidenceIds', 'issueIds', 'timestamp', 'snapshot']
|
|
333
|
+
addDiagnostic(diagnostics, exactKeys(event, expectedKeys), `${prefix} must contain exactly the contracted fields`)
|
|
334
|
+
addDiagnostic(diagnostics, SUPPORTED_SCHEMA_VERSIONS.includes(event.schemaVersion), `${prefix}.schemaVersion must be one of ${SUPPORTED_SCHEMA_VERSIONS.join(',')}`)
|
|
335
|
+
addDiagnostic(diagnostics, event.sequence === index, `${prefix}.sequence must equal ${index}`)
|
|
336
|
+
addDiagnostic(diagnostics, typeof event.taskId === 'string' && TASK_ID.test(event.taskId), `${prefix}.taskId must be kebab-case`)
|
|
337
|
+
addDiagnostic(diagnostics, event.type === expectedType, `${prefix}.type must be ${expectedType}`)
|
|
338
|
+
addDiagnostic(diagnostics, typeof event.timestamp === 'string' && event.timestamp.length > 0, `${prefix}.timestamp must be a string`)
|
|
339
|
+
addDiagnostic(diagnostics, isObject(event.snapshot), `${prefix}.snapshot must be an object`)
|
|
340
|
+
if (isObject(event.snapshot)) {
|
|
341
|
+
validateRunShape(event.snapshot, diagnostics, `${prefix}.snapshot`)
|
|
342
|
+
addDiagnostic(diagnostics, event.snapshot.taskId === event.taskId, `${prefix} snapshot task mismatch`)
|
|
343
|
+
addDiagnostic(diagnostics, event.snapshot.eventSequence === event.sequence, `${prefix} snapshot sequence mismatch`)
|
|
344
|
+
if (expectedType === 'RUN_INITIALIZED') {
|
|
345
|
+
addDiagnostic(diagnostics, event.snapshot.status === 'TRIAGE', `${prefix}.snapshot.status must be TRIAGE`)
|
|
346
|
+
addDiagnostic(diagnostics, event.snapshot.eventSequence === 0, `${prefix}.snapshot.eventSequence must equal 0`)
|
|
347
|
+
addDiagnostic(diagnostics, event.snapshot.currentAttempt === 0, `${prefix}.snapshot.currentAttempt must equal 0`)
|
|
348
|
+
addDiagnostic(diagnostics, event.snapshot.bestCandidateId === null, `${prefix}.snapshot.bestCandidateId must be null`)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (expectedType === 'STATUS_TRANSITION') {
|
|
352
|
+
addDiagnostic(diagnostics, STATUSES.includes(event.from) && STATUSES.includes(event.to), `${prefix} transition states are invalid`)
|
|
353
|
+
addDiagnostic(diagnostics, typeof event.reason === 'string' && event.reason.trim().length > 0, `${prefix}.reason must be a nonempty string`)
|
|
354
|
+
const evidenceIdsValid = Array.isArray(event.evidenceIds) && event.evidenceIds.every(id => typeof id === 'string')
|
|
355
|
+
const issueIdsValid = Array.isArray(event.issueIds) && event.issueIds.every(id => typeof id === 'string')
|
|
356
|
+
addDiagnostic(diagnostics, evidenceIdsValid, `${prefix}.evidenceIds must be a string array`)
|
|
357
|
+
addDiagnostic(diagnostics, issueIdsValid, `${prefix}.issueIds must be a string array`)
|
|
358
|
+
addDiagnostic(diagnostics, evidenceIdsValid && issueIdsValid && linkedIds(event).length > 0, `${prefix} requires at least one evidence or issue ID`)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function validateJournal(events, diagnostics) {
|
|
363
|
+
if (events.length === 0) { diagnostics.push('event journal is empty'); return }
|
|
364
|
+
const taskId = events[0]?.taskId
|
|
365
|
+
const initializationSnapshot = events[0]?.snapshot
|
|
366
|
+
events.forEach((event, index) => {
|
|
367
|
+
validateEventShape(event, diagnostics, index)
|
|
368
|
+
addDiagnostic(diagnostics, event?.sequence === index, 'non-monotonic events')
|
|
369
|
+
addDiagnostic(diagnostics, event?.taskId === taskId, `event[${index}] task mismatch`)
|
|
370
|
+
if (index > 0) {
|
|
371
|
+
for (const key of IMMUTABLE_RUN_KEYS) {
|
|
372
|
+
addDiagnostic(diagnostics, deeplyEqual(event?.snapshot?.[key], initializationSnapshot?.[key]), `event[${index}] snapshot immutable field ${key} mismatch`)
|
|
373
|
+
}
|
|
374
|
+
const previousSnapshot = events[index - 1]?.snapshot
|
|
375
|
+
const previousStatus = previousSnapshot?.status
|
|
376
|
+
addDiagnostic(diagnostics, event?.from === previousStatus, `event[${index}] transition from does not match previous snapshot`)
|
|
377
|
+
addDiagnostic(diagnostics, event?.to === event?.snapshot?.status, `event[${index}] transition to does not match snapshot`)
|
|
378
|
+
addDiagnostic(diagnostics, isAllowedTransition(event?.from, event?.to), illegalTransitionMessage(event?.from, event?.to))
|
|
379
|
+
addDiagnostic(diagnostics, !FINAL_STATES.includes(previousStatus), `terminal state ${previousStatus} cannot have later events`)
|
|
380
|
+
const expectedAttempt = nextAttempt(previousSnapshot?.currentAttempt, event?.to)
|
|
381
|
+
addDiagnostic(diagnostics, event?.snapshot?.currentAttempt === expectedAttempt, `event[${index}] currentAttempt delta is invalid`)
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function initRun(root, { taskId, mode = 'standard', budget, budgets, contract }, runtime = {}) {
|
|
387
|
+
assertTaskId(taskId); assertMode(mode)
|
|
388
|
+
const effectiveBudget = { ...MODE_DEFAULTS[mode], ...(budget ?? budgets ?? {}) }
|
|
389
|
+
const budgetDiagnostics = []
|
|
390
|
+
validateBudgetShape(effectiveBudget, budgetDiagnostics)
|
|
391
|
+
if (budgetDiagnostics.length > 0) throw new Error(budgetDiagnostics.join('; '))
|
|
392
|
+
return withLock(root, MUTATION_LOCK_FILE, 'init', async ({ ownerId, updateMetadata }) => {
|
|
393
|
+
const timestamp = now()
|
|
394
|
+
const run = {
|
|
395
|
+
schemaVersion: SCHEMA_VERSION, taskId, mode, status: 'TRIAGE', eventSequence: 0, currentAttempt: 0,
|
|
396
|
+
bestCandidateId: null, budget: effectiveBudget, createdAt: timestamp, updatedAt: timestamp,
|
|
397
|
+
lineageId: taskId, parentRunId: null, revision: 0, inputSnapshotHash: null, evidenceGraphHash: null,
|
|
398
|
+
}
|
|
399
|
+
const ledger = {
|
|
400
|
+
schemaVersion: SCHEMA_VERSION, taskId,
|
|
401
|
+
scope: { independentAuditPassed: false, interactions: [], decisionStack: [], robustnessExempt: false, ...(contract === false ? { contractExempt: true } : {}) },
|
|
402
|
+
assumptions: [], claims: [], obligations: [], subproblems: [], candidates: [], issues: [],
|
|
403
|
+
requirements: [], evidence: [], verifications: [], failures: [],
|
|
404
|
+
}
|
|
405
|
+
const event = { schemaVersion: SCHEMA_VERSION, sequence: 0, type: 'RUN_INITIALIZED', taskId, timestamp, snapshot: clone(run) }
|
|
406
|
+
const p = paths(root)
|
|
407
|
+
await assertRunFilesDoNotExist(p)
|
|
408
|
+
const stagingName = `.run-state-init-${ownerId}`
|
|
409
|
+
const stagingRoot = join(root, stagingName)
|
|
410
|
+
const staged = paths(stagingRoot)
|
|
411
|
+
const published = []
|
|
412
|
+
await mkdir(stagingRoot)
|
|
413
|
+
await updateMetadata({ stagingDir: stagingName, ownedArtifacts: [LEDGER_FILE, EVENTS_FILE, RUN_FILE] })
|
|
414
|
+
try {
|
|
415
|
+
await writeFile(staged.ledger, `${JSON.stringify(ledger, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' })
|
|
416
|
+
await writeFile(staged.events, `${JSON.stringify(event)}\n`, { encoding: 'utf8', flag: 'wx' })
|
|
417
|
+
await writeFile(staged.run, `${JSON.stringify(run, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' })
|
|
418
|
+
for (const [source, target] of [[staged.ledger, p.ledger], [staged.events, p.events], [staged.run, p.run]]) {
|
|
419
|
+
await renameWithRetry(source, target, runtime)
|
|
420
|
+
published.push(target)
|
|
421
|
+
await runtime.hooks?.afterPublish?.({ file: target, count: published.length })
|
|
422
|
+
}
|
|
423
|
+
return clone(run)
|
|
424
|
+
} catch (error) {
|
|
425
|
+
for (const target of published.reverse()) await unlink(target).catch(cleanupError => { if (cleanupError.code !== 'ENOENT') throw cleanupError })
|
|
426
|
+
throw error
|
|
427
|
+
} finally {
|
|
428
|
+
for (const path of [staged.run, staged.events, staged.ledger]) await unlink(path).catch(error => { if (error.code !== 'ENOENT') throw error })
|
|
429
|
+
await rmdir(stagingRoot).catch(error => { if (!['ENOENT', 'ENOTEMPTY'].includes(error.code)) throw error })
|
|
430
|
+
}
|
|
431
|
+
}, runtime)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function graphItem(graph, collection, id, label) {
|
|
435
|
+
const items = Array.isArray(graph?.[collection]) ? graph[collection] : []
|
|
436
|
+
const item = items.find(value => value?.id === id)
|
|
437
|
+
if (!item) throw new Error('unknown ' + label + ' id: ' + id)
|
|
438
|
+
return item
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function linkedVerifications(graph, obligation) {
|
|
442
|
+
const ids = Array.isArray(obligation.verificationIds) ? obligation.verificationIds : []
|
|
443
|
+
const values = Array.isArray(graph?.verifications) ? graph.verifications : []
|
|
444
|
+
return ids.map(id => values.find(value => value?.id === id)).filter(Boolean)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function aggregateObligation(graph, id) {
|
|
448
|
+
const obligation = graphItem(graph, 'obligations', id, 'obligation')
|
|
449
|
+
if (obligation.status === 'WAIVED') return 'WAIVED'
|
|
450
|
+
if (obligation.stale === true || obligation.status === 'STALE') return 'STALE'
|
|
451
|
+
if (obligation.status === 'BLOCKED') return 'BLOCKED'
|
|
452
|
+
const verifications = linkedVerifications(graph, obligation)
|
|
453
|
+
if (verifications.some(value => value.verdict === 'FAIL')) return 'BLOCKED'
|
|
454
|
+
const required = Number.isInteger(obligation.minimumEvidenceCount) && obligation.minimumEvidenceCount > 0
|
|
455
|
+
? obligation.minimumEvidenceCount
|
|
456
|
+
: 1
|
|
457
|
+
const independentKeys = new Set()
|
|
458
|
+
for (const verification of verifications) {
|
|
459
|
+
if (verification.verdict === 'PASS' && typeof verification.independenceKey === 'string' && verification.independenceKey.trim() !== '') independentKeys.add(verification.independenceKey)
|
|
460
|
+
}
|
|
461
|
+
if (independentKeys.size >= required) return 'SATISFIED'
|
|
462
|
+
return 'OPEN'
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function aggregateClaim(graph, id) {
|
|
466
|
+
const claim = graphItem(graph, 'claims', id, 'claim')
|
|
467
|
+
if (claim.stale === true || claim.verdict === 'STALE') return 'STALE'
|
|
468
|
+
if (claim.verdict === 'REFUTED') return 'REFUTED'
|
|
469
|
+
const obligationIds = Array.isArray(claim.obligationIds) ? claim.obligationIds : []
|
|
470
|
+
if (obligationIds.length === 0) return claim.verdict === 'SUPPORTED' ? 'SUPPORTED' : 'INCONCLUSIVE'
|
|
471
|
+
const statuses = obligationIds.map(obligationId => aggregateObligation(graph, obligationId))
|
|
472
|
+
if (statuses.some(status => status === 'BLOCKED')) return 'INCONCLUSIVE'
|
|
473
|
+
if (statuses.some(status => status === 'STALE')) return 'STALE'
|
|
474
|
+
if (statuses.some(status => status === 'WAIVED')) return 'CONDITIONAL'
|
|
475
|
+
return statuses.every(status => status === 'SATISFIED') ? 'SUPPORTED' : 'INCONCLUSIVE'
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function classifyGate(graph, targetState) {
|
|
479
|
+
const blockers = []
|
|
480
|
+
const conditionals = []
|
|
481
|
+
for (const obligation of Array.isArray(graph?.obligations) ? graph.obligations : []) {
|
|
482
|
+
if (obligation.required === false) continue
|
|
483
|
+
const status = aggregateObligation(graph, obligation.id)
|
|
484
|
+
if (status === 'BLOCKED' || status === 'STALE') blockers.push({ id: obligation.id, status, reason: 'required obligation is not valid' })
|
|
485
|
+
else if (status === 'WAIVED') conditionals.push({ id: obligation.id, status, reason: 'user waiver' })
|
|
486
|
+
else if (status !== 'SATISFIED') {
|
|
487
|
+
if (targetState === 'CONDITIONAL') conditionals.push({ id: obligation.id, status, reason: 'required evidence is incomplete' })
|
|
488
|
+
else blockers.push({ id: obligation.id, status, reason: 'required obligation is not satisfied' })
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
for (const claim of Array.isArray(graph?.claims) ? graph.claims : []) {
|
|
492
|
+
const verdict = aggregateClaim(graph, claim.id)
|
|
493
|
+
if (verdict === 'REFUTED' || verdict === 'STALE') blockers.push({ id: claim.id, verdict, reason: 'claim is refuted or stale' })
|
|
494
|
+
else if (verdict === 'CONDITIONAL') conditionals.push({ id: claim.id, verdict, reason: 'claim scope is conditional' })
|
|
495
|
+
else if (verdict === 'INCONCLUSIVE') {
|
|
496
|
+
if (targetState === 'CONDITIONAL') conditionals.push({ id: claim.id, verdict, reason: 'claim evidence is incomplete' })
|
|
497
|
+
else blockers.push({ id: claim.id, verdict, reason: 'claim is not supported' })
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return { allowed: blockers.length === 0 && (targetState !== 'SOLVED' || conditionals.length === 0), blockers, conditionals, requiredNextState: blockers.length > 0 ? 'REVISE' : (conditionals.length > 0 ? 'CONDITIONAL' : targetState) }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** The v3 interaction contract: decision records, layered verdicts, and the robustness gate. */
|
|
504
|
+
function hasDecisionRecord(scope, decisionPoint, { allowAuto = true } = {}) {
|
|
505
|
+
return Array.isArray(scope?.interactions) && scope.interactions.some(entry =>
|
|
506
|
+
entry && (entry.decisionPoint === decisionPoint || (allowAuto && entry.auto === true)))
|
|
507
|
+
}
|
|
508
|
+
/** D4 verdict records carry the attempt number they adjudicate: {decisionPoint:'D4', attempt: 2, ...} */
|
|
509
|
+
function hasD4ForAttempt(scope, attempt) {
|
|
510
|
+
return Array.isArray(scope?.interactions) && scope.interactions.some(entry =>
|
|
511
|
+
entry && (entry.auto === true || (entry.decisionPoint === 'D4' && entry.attempt === attempt)))
|
|
512
|
+
}
|
|
513
|
+
function decisionStackIds(scope) {
|
|
514
|
+
return Array.isArray(scope?.decisionStack)
|
|
515
|
+
? scope.decisionStack.filter(entry => entry && typeof entry.id === 'string').map(entry => entry.id)
|
|
516
|
+
: []
|
|
517
|
+
}
|
|
518
|
+
/** D2 records carry the subproblem id(s) they cover: {decisionPoint:'D2', scope:['SP1'], ...} */
|
|
519
|
+
function hasSubproblemD2(scope, subproblem) {
|
|
520
|
+
if (!subproblem || !Array.isArray(scope?.interactions)) return true
|
|
521
|
+
return scope.interactions.some(entry => entry && entry.decisionPoint === 'D2' &&
|
|
522
|
+
(Array.isArray(entry.scope) ? entry.scope.includes(subproblem) : entry.subproblem === subproblem))
|
|
523
|
+
}
|
|
524
|
+
function interactionContractViolations(run, ledger, to, reason, subproblem) {
|
|
525
|
+
if (ledger.schemaVersion !== SCHEMA_VERSION) return []
|
|
526
|
+
const scope = ledger.scope ?? {}
|
|
527
|
+
if (scope.contractExempt === true) return []
|
|
528
|
+
const violations = []
|
|
529
|
+
const transitional = to !== undefined
|
|
530
|
+
const earlyStop = ['BLOCKED', 'CANCELLED'].includes(run.status)
|
|
531
|
+
const statusOrder = ['TRIAGE', 'RESEARCH', 'SCOPE_FROZEN', 'INPUT_PROFILED', 'CLAIMS_REGISTERED', 'CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'EVALUATE', 'CORRECTION_REQUIRED', 'REVISE', 'FORK']
|
|
532
|
+
const reached = (target) => transitional
|
|
533
|
+
? to === target
|
|
534
|
+
: (statusOrder.indexOf(target) <= statusOrder.indexOf(run.status) && !earlyStop)
|
|
535
|
+
if (reached('SCOPE_FROZEN')) {
|
|
536
|
+
if (!hasDecisionRecord(scope, 'D0')) violations.push('D0 restatement interaction record missing (ledger.scope.interactions)')
|
|
537
|
+
if (!hasDecisionRecord(scope, 'D1')) violations.push('D1 routing interaction record missing (ledger.scope.interactions)')
|
|
538
|
+
if (!hasDecisionRecord(scope, 'D-R', { allowAuto: false })) violations.push('D-R literature-research interaction record missing (literature survey precedes restatement; auto-authorization is not accepted)')
|
|
539
|
+
}
|
|
540
|
+
if (reached('CLAIMS_REGISTERED') && !hasDecisionRecord(scope, 'D2-G')) violations.push('D2-G global-assumptions interaction record missing (global assumptions must be confirmed before claims registration)')
|
|
541
|
+
const candidatesGate = reached('CANDIDATES_READY')
|
|
542
|
+
if (candidatesGate && !hasDecisionRecord(scope, 'D-R', { allowAuto: false })) violations.push('D-R literature-research interaction record missing (backstop check)')
|
|
543
|
+
const afterCandidates = ['CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'REVISE', 'FORK', ...FINAL_STATES]
|
|
544
|
+
const enteringAttemptFromCandidates = transitional ? (to === 'ATTEMPT' && run.status === 'CANDIDATES_READY') : (run.currentAttempt >= 1)
|
|
545
|
+
if (enteringAttemptFromCandidates) {
|
|
546
|
+
if (transitional && !subproblem) violations.push('ATTEMPT from CANDIDATES_READY requires --subproblem <id>')
|
|
547
|
+
if (!hasDecisionRecord(scope, 'D3')) violations.push('D3 direction-selection interaction record missing')
|
|
548
|
+
if (transitional && subproblem && !hasSubproblemD2(scope, subproblem)) violations.push(`D2 assumption interaction record missing for subproblem ${subproblem} (run the gate with --subproblem <id>)`)
|
|
549
|
+
}
|
|
550
|
+
const enteringTerminalFromVerify = transitional
|
|
551
|
+
? (FINAL_STATES.includes(to) && run.status === 'VERIFY')
|
|
552
|
+
: (FINAL_STATES.includes(run.status) && !['BLOCKED', 'CANCELLED'].includes(run.status))
|
|
553
|
+
if (enteringTerminalFromVerify && !hasD4ForAttempt(scope, run.currentAttempt)) violations.push(`D4 verdict interaction record for attempt ${run.currentAttempt} missing`)
|
|
554
|
+
if (transitional && (to === 'REVISE' || (to === 'RESEARCH' && run.status === 'VERIFY'))) {
|
|
555
|
+
const ids = decisionStackIds(scope)
|
|
556
|
+
if (ids.length > 0 && !ids.some(id => typeof reason === 'string' && reason.includes(id))) {
|
|
557
|
+
violations.push(`REVISE/RESEARCH reason must reference a decisionStack entry id (available: ${ids.join(', ')})`)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const terminalAndExempt = transitional
|
|
561
|
+
? (FINAL_STATES.includes(to) && !['BLOCKED', 'CANCELLED'].includes(to))
|
|
562
|
+
: (FINAL_STATES.includes(run.status) && !['BLOCKED', 'CANCELLED'].includes(run.status))
|
|
563
|
+
if (terminalAndExempt) {
|
|
564
|
+
if (to === 'SOLVED' && scope.robustnessExempt !== true) {
|
|
565
|
+
const robos = Array.isArray(ledger.obligations) ? ledger.obligations.filter(o => o && o.kind === 'robustness' && o.required !== false) : []
|
|
566
|
+
const allSatisfied = robos.length > 0 && robos.every(o => o.status === 'SATISFIED' || (ledger.schemaVersion < 3 && o.status === 'PASS'))
|
|
567
|
+
if (!allSatisfied) violations.push(`terminal state requires ALL ${robos.length} required robustness obligations to be SATISFIED (or scope.robustnessExempt: true)`)
|
|
568
|
+
}
|
|
569
|
+
const subs = Array.isArray(ledger.subproblems) ? ledger.subproblems : []
|
|
570
|
+
if (subs.length > 0) {
|
|
571
|
+
const done = (sp) => sp.status === 'DONE' || sp.status === 'CLOSED'
|
|
572
|
+
const unfinished = subs.filter(sp => !done(sp))
|
|
573
|
+
if (unfinished.length > 0) violations.push(`terminal state requires every subproblem DONE (open: ${unfinished.map(sp => sp.id).join(', ')})`)
|
|
574
|
+
const depGaps = subs.filter(sp => done(sp) && Array.isArray(sp.dependencies) && sp.dependencies.some(dep => {
|
|
575
|
+
const d = subs.find(x => x.id === dep); return d === undefined || !done(d)
|
|
576
|
+
}))
|
|
577
|
+
if (depGaps.length > 0) violations.push(`subproblem dependencies incomplete: ${depGaps.map(sp => sp.id).join(', ')}`)
|
|
578
|
+
}
|
|
579
|
+
if (scope.cleanupPassed !== true) violations.push('terminal state requires scope.cleanupPassed: true (run the cleanup checklist in run-directory.md)')
|
|
580
|
+
}
|
|
581
|
+
return violations
|
|
582
|
+
}
|
|
583
|
+
function enforceInteractionContract(run, ledger, to, reason) {
|
|
584
|
+
const violations = interactionContractViolations(run, ledger, to, reason)
|
|
585
|
+
if (violations.length > 0) throw new Error(`interaction contract violated: ${violations.join('; ')}`)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function enforceSolvedGate(run, ledger) {
|
|
589
|
+
if (ledger.claims.length < 1) throw new Error('SOLVED requires at least one claim')
|
|
590
|
+
const graphClaims = ledger.schemaVersion >= 3 && ledger.claims.some(item => Object.hasOwn(item, 'verdict') || Object.hasOwn(item, 'obligationIds'))
|
|
591
|
+
if (graphClaims) {
|
|
592
|
+
const gate = classifyGate(ledger, 'SOLVED')
|
|
593
|
+
if (gate.blockers.length > 0) throw new Error('required obligations remain open: ' + gate.blockers.map(item => item.id).join(', '))
|
|
594
|
+
if (gate.conditionals.length > 0) throw new Error('SOLVED cannot accept conditional claims: ' + gate.conditionals.map(item => item.id).join(', '))
|
|
595
|
+
} else if (ledger.obligations.some(item => item.required !== false && item.status !== 'PASS' && item.status !== 'SATISFIED')) {
|
|
596
|
+
throw new Error('required obligations remain open')
|
|
597
|
+
}
|
|
598
|
+
if (ledger.issues.some(item => item.severity === 'critical' && item.status !== 'CLOSED')) throw new Error('critical issues remain open')
|
|
599
|
+
if (run.mode === 'high-assurance' && ledger.scope.independentAuditPassed !== true) throw new Error('independent audit must pass')
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function validateTransitionInput(input) {
|
|
603
|
+
if (typeof input?.reason !== 'string' || input.reason.trim().length === 0) throw new Error('transition reason must be a nonempty string')
|
|
604
|
+
for (const key of ['evidenceIds', 'issueIds']) {
|
|
605
|
+
if (input[key] !== undefined && (!Array.isArray(input[key]) || input[key].some(id => typeof id !== 'string'))) throw new Error(`${key} must be an array of strings`)
|
|
606
|
+
}
|
|
607
|
+
if (linkedIds(input).length === 0) throw new Error('transition requires at least one evidence or issue ID')
|
|
608
|
+
if (input.patch !== undefined && (!isObject(input.patch) || Object.keys(input.patch).some(key => key !== 'bestCandidateId'))) throw new Error('patch may only contain bestCandidateId')
|
|
609
|
+
if (input.patch && (!Object.hasOwn(input.patch, 'bestCandidateId') || (input.patch.bestCandidateId !== null && typeof input.patch.bestCandidateId !== 'string'))) throw new Error('bestCandidateId must be a string or null')
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function validateAuthoritativeState(run, ledger, events) {
|
|
613
|
+
const diagnostics = []
|
|
614
|
+
validateRunShape(run, diagnostics)
|
|
615
|
+
validateLedgerShape(ledger, diagnostics)
|
|
616
|
+
validateJournal(events, diagnostics)
|
|
617
|
+
if (run.taskId !== ledger.taskId || events.some(event => event.taskId !== run.taskId)) diagnostics.push('task mismatch')
|
|
618
|
+
const last = events.at(-1)
|
|
619
|
+
if (!last || run.eventSequence !== last.sequence) diagnostics.push('state/journal sequence mismatch')
|
|
620
|
+
if (!last || !deeplyEqual(run, last.snapshot)) diagnostics.push('state/journal snapshot mismatch')
|
|
621
|
+
if (diagnostics.length > 0) throw new Error(`invalid run state: ${diagnostics.join('; ')}`)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export async function transitionRun(root, input, runtime = {}) {
|
|
625
|
+
validateTransitionInput(input)
|
|
626
|
+
return withLock(root, MUTATION_LOCK_FILE, 'transition', async () => {
|
|
627
|
+
const p = paths(root)
|
|
628
|
+
const run = await readJson(p.run)
|
|
629
|
+
const ledger = await readJson(p.ledger)
|
|
630
|
+
const events = await readEvents(p.events)
|
|
631
|
+
validateAuthoritativeState(run, ledger, events)
|
|
632
|
+
if (!isAllowedTransition(run.status, input.to)) throw new Error(illegalTransitionMessage(run.status, input.to))
|
|
633
|
+
if (input.to === 'SOLVED') enforceSolvedGate(run, ledger)
|
|
634
|
+
const timestamp = now()
|
|
635
|
+
const bestCandidateId = input.patch && Object.hasOwn(input.patch, 'bestCandidateId') ? input.patch.bestCandidateId : run.bestCandidateId
|
|
636
|
+
const next = { ...run, status: input.to, currentAttempt: nextAttempt(run.currentAttempt, input.to), eventSequence: run.eventSequence + 1, bestCandidateId, updatedAt: timestamp }
|
|
637
|
+
const event = {
|
|
638
|
+
schemaVersion: SCHEMA_VERSION, sequence: next.eventSequence, type: 'STATUS_TRANSITION', taskId: run.taskId,
|
|
639
|
+
from: run.status, to: input.to, reason: input.reason, evidenceIds: input.evidenceIds ?? [], issueIds: input.issueIds ?? [], timestamp, snapshot: clone(next),
|
|
640
|
+
}
|
|
641
|
+
const prospectiveEvents = [...events, event]
|
|
642
|
+
validateAuthoritativeState(next, ledger, prospectiveEvents)
|
|
643
|
+
await appendFile(p.events, `${JSON.stringify(event)}\n`, 'utf8')
|
|
644
|
+
await atomicWrite(p.run, next, runtime)
|
|
645
|
+
return clone(next)
|
|
646
|
+
}, runtime)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function readEvents(path) {
|
|
650
|
+
const text = await readFile(path, 'utf8')
|
|
651
|
+
if (!text.trim()) return []
|
|
652
|
+
return text.trimEnd().split(/\r?\n/).map((line, index) => {
|
|
653
|
+
try { return JSON.parse(line) } catch { throw new Error(`invalid event JSON at line ${index + 1}`) }
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function validateRunUnlocked(root, expectedTaskId) {
|
|
658
|
+
const diagnostics = []
|
|
659
|
+
const warnings = []
|
|
660
|
+
let run, ledger, events
|
|
661
|
+
const p = paths(root)
|
|
662
|
+
try { run = await readJson(p.run) } catch (error) { diagnostics.push(`run unreadable: ${error.message}`) }
|
|
663
|
+
try { ledger = await readJson(p.ledger) } catch (error) { diagnostics.push(`ledger unreadable: ${error.message}`) }
|
|
664
|
+
try { events = await readEvents(p.events) } catch (error) { diagnostics.push(error.message) }
|
|
665
|
+
if (!run || !ledger || !events) return { valid: false, diagnostics, warnings }
|
|
666
|
+
validateRunShape(run, diagnostics)
|
|
667
|
+
validateLedgerShape(ledger, diagnostics)
|
|
668
|
+
validateJournal(events, diagnostics)
|
|
669
|
+
if (run.taskId !== ledger.taskId || events.some(event => event.taskId !== run.taskId) || (expectedTaskId && expectedTaskId !== run.taskId)) diagnostics.push('task mismatch')
|
|
670
|
+
const last = events.at(-1)
|
|
671
|
+
if (last && run.eventSequence !== last.sequence) diagnostics.push('state/journal sequence mismatch')
|
|
672
|
+
if (last && JSON.stringify(run) !== JSON.stringify(last.snapshot)) diagnostics.push('state/journal snapshot mismatch')
|
|
673
|
+
if (ledger.schemaVersion !== SCHEMA_VERSION) warnings.push(`legacy run (ledger schema v${ledger.schemaVersion}): interaction contract not enforced`)
|
|
674
|
+
else {
|
|
675
|
+
for (const violation of interactionContractViolations(run, ledger, undefined, undefined)) {
|
|
676
|
+
diagnostics.push(`interaction contract: ${violation}`)
|
|
677
|
+
}
|
|
678
|
+
if (run.currentAttempt >= 1) {
|
|
679
|
+
const reportPath = join(root, 'attempts', String(run.currentAttempt), 'report.md')
|
|
680
|
+
try {
|
|
681
|
+
const reportText = await readFile(reportPath, 'utf8')
|
|
682
|
+
for (const section of ['问题重述', '问题分析', '模型假设', '模型建立与求解', '验证', '鲁棒性', '评价与改进', '参考文献']) {
|
|
683
|
+
if (!reportText.includes(section)) warnings.push(`attempt ${run.currentAttempt} report.md missing runlog digest section: ${section}`)
|
|
684
|
+
}
|
|
685
|
+
} catch {
|
|
686
|
+
warnings.push(`attempt ${run.currentAttempt} report.md unreadable (runlog digest required)`)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
for (const assumption of Array.isArray(ledger.assumptions) ? ledger.assumptions : []) {
|
|
690
|
+
const revised = assumption && (assumption.status === 'revised' || (Array.isArray(assumption.revisionHistory) && assumption.revisionHistory.length > 0))
|
|
691
|
+
if (!revised) continue
|
|
692
|
+
for (const claim of Array.isArray(ledger.claims) ? ledger.claims : []) {
|
|
693
|
+
const depends = claim && Array.isArray(claim.assumptions) && claim.assumptions.includes(assumption.id)
|
|
694
|
+
if (depends && claim.status === 'VERIFIED' && (!Array.isArray(claim.evidenceIds) || claim.evidenceIds.length === 0)) {
|
|
695
|
+
warnings.push(`claim ${claim.id} depends on revised assumption ${assumption.id} but has no post-revision evidence`)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return { valid: diagnostics.length === 0, diagnostics, warnings, run: clone(run) }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export async function validateRun(root, expectedTaskId, runtime = {}) {
|
|
704
|
+
return withLock(root, MUTATION_LOCK_FILE, 'validate', () => validateRunUnlocked(root, expectedTaskId), runtime)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function coverageGate(root, ledger, to) {
|
|
708
|
+
const gatedStates = new Set(['EVALUATE', 'SOLVED', 'CONDITIONAL'])
|
|
709
|
+
if (!gatedStates.has(to) || ledger.schemaVersion < 3 || !Array.isArray(ledger.requirements) || ledger.requirements.length === 0) return { blockers: [], conditionals: [], violations: [] }
|
|
710
|
+
const path = join(root, 'answer-coverage.json')
|
|
711
|
+
let raw
|
|
712
|
+
try { raw = JSON.parse(await readFile(path, 'utf8')) } catch (error) {
|
|
713
|
+
const message = error.code === 'ENOENT' ? 'answer-coverage.json missing' : 'answer-coverage.json invalid: ' + error.message
|
|
714
|
+
return { blockers: [{ id: 'coverage', reason: message }], conditionals: [], violations: [message] }
|
|
715
|
+
}
|
|
716
|
+
const entries = Array.isArray(raw) ? raw : raw.entries
|
|
717
|
+
const result = validateCoverage(entries)
|
|
718
|
+
const blockers = result.blockers.map(id => ({ id, reason: 'requirement coverage is ' + id }))
|
|
719
|
+
const conditionals = result.conditionals.map(id => ({ id, reason: 'partial requirement coverage is ' + id }))
|
|
720
|
+
if (to === 'SOLVED' && conditionals.length > 0) {
|
|
721
|
+
blockers.push(...conditionals)
|
|
722
|
+
conditionals.length = 0
|
|
723
|
+
}
|
|
724
|
+
return { blockers, conditionals, violations: [...blockers, ...conditionals].map(item => item.reason) }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Check the v2 interaction contract for a PROSPECTIVE transition without
|
|
729
|
+
* mutating anything. The agent workflow must call this before `transition`;
|
|
730
|
+
* the CLI `transition` command enforces it internally and has NO escape hatch.
|
|
731
|
+
*/
|
|
732
|
+
export async function gateTransition(root, { to, reason, subproblem }, runtime = {}) {
|
|
733
|
+
return withLock(root, MUTATION_LOCK_FILE, 'gate', async () => {
|
|
734
|
+
const p = paths(root)
|
|
735
|
+
const run = await readJson(p.run)
|
|
736
|
+
const ledger = await readJson(p.ledger)
|
|
737
|
+
const violations = interactionContractViolations(run, ledger, to, reason, subproblem)
|
|
738
|
+
const coverage = await coverageGate(root, ledger, to)
|
|
739
|
+
violations.push(...coverage.violations)
|
|
740
|
+
const blockers = [
|
|
741
|
+
...violations.map(reason => ({ id: 'interaction', reason })),
|
|
742
|
+
...coverage.blockers,
|
|
743
|
+
]
|
|
744
|
+
const conditionals = [...coverage.conditionals]
|
|
745
|
+
const exempt = ledger.scope?.contractExempt === true
|
|
746
|
+
if (to === 'CANDIDATES_READY' && !exempt) {
|
|
747
|
+
const sourcesPath = join(root, 'research', 'sources.jsonl')
|
|
748
|
+
try {
|
|
749
|
+
if (!(await readFile(sourcesPath, 'utf8')).trim()) violations.push('research/sources.jsonl is empty (pre-modeling literature survey artifacts are mandatory)')
|
|
750
|
+
} catch {
|
|
751
|
+
violations.push('research/sources.jsonl missing (pre-modeling literature survey artifacts are mandatory)')
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (FINAL_STATES.includes(to) && !['BLOCKED', 'CANCELLED'].includes(to) && !exempt) {
|
|
755
|
+
const reportPath = join(root, 'attempts', String(run.currentAttempt), 'report.md')
|
|
756
|
+
try {
|
|
757
|
+
await readFile(reportPath, 'utf8')
|
|
758
|
+
} catch {
|
|
759
|
+
violations.push(`attempts/${run.currentAttempt}/report.md missing (write the runlog report before a terminal transition)`)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return { allowed: blockers.length === 0 && conditionals.length === 0, violations, blockers, conditionals, requiredNextState: blockers.length > 0 ? 'REVISE' : (conditionals.length > 0 ? 'CONDITIONAL' : to), status: run.status }
|
|
763
|
+
}, runtime)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export async function recoverRun(root, runtime = {}) {
|
|
767
|
+
return withLock(root, MUTATION_LOCK_FILE, 'recover', async () => {
|
|
768
|
+
const p = paths(root)
|
|
769
|
+
const events = await readEvents(p.events)
|
|
770
|
+
if (events.length === 0) throw new Error('event journal is empty')
|
|
771
|
+
const last = events.at(-1)
|
|
772
|
+
const diagnostics = []
|
|
773
|
+
validateJournal(events, diagnostics)
|
|
774
|
+
if (diagnostics.length > 0) throw new Error(`malformed journal: ${diagnostics.join('; ')}`)
|
|
775
|
+
const solvedEvent = events.find(event => event.to === 'SOLVED')
|
|
776
|
+
if (solvedEvent) {
|
|
777
|
+
const ledger = await readJson(p.ledger)
|
|
778
|
+
enforceSolvedGate(solvedEvent.snapshot, ledger)
|
|
779
|
+
}
|
|
780
|
+
await runtime.hooks?.afterJournalValidated?.()
|
|
781
|
+
const snapshot = clone(last.snapshot)
|
|
782
|
+
await atomicWrite(p.run, snapshot, runtime)
|
|
783
|
+
return snapshot
|
|
784
|
+
}, runtime)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function statusRunUnlocked(root) { return readJson(paths(root).run) }
|
|
788
|
+
export async function statusRun(root, runtime = {}) {
|
|
789
|
+
return withLock(root, MUTATION_LOCK_FILE, 'status', () => statusRunUnlocked(root), runtime)
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function parseOptions(args) {
|
|
793
|
+
const options = { _: [] }
|
|
794
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
795
|
+
const token = args[index]
|
|
796
|
+
if (!token.startsWith('--')) options._.push(token)
|
|
797
|
+
else { const key = token.slice(2); const value = args[index + 1]; options[key] = value; index += 1 }
|
|
798
|
+
}
|
|
799
|
+
return options
|
|
800
|
+
}
|
|
801
|
+
async function cli(argv) {
|
|
802
|
+
const [command, rootArg = '.', ...rest] = argv
|
|
803
|
+
const root = resolve(rootArg)
|
|
804
|
+
const options = parseOptions(rest)
|
|
805
|
+
if (command === 'init') return initRun(root, { taskId: options['task-id'], mode: options.mode ?? 'standard' })
|
|
806
|
+
if (command === 'gate') {
|
|
807
|
+
const result = await gateTransition(root, { to: options.to, reason: options.reason, subproblem: options.subproblem })
|
|
808
|
+
if (!result.allowed) process.exitCode = 1
|
|
809
|
+
return result
|
|
810
|
+
}
|
|
811
|
+
if (command === 'transition') {
|
|
812
|
+
const gate = await gateTransition(root, { to: options.to, reason: options.reason, subproblem: options.subproblem })
|
|
813
|
+
if (!gate.allowed) {
|
|
814
|
+
process.exitCode = 1
|
|
815
|
+
return { error: `interaction contract violated: ${gate.violations.join('; ')}`, gate }
|
|
816
|
+
}
|
|
817
|
+
return transitionRun(root, { to: options.to, reason: options.reason, evidenceIds: options.evidence ? options.evidence.split(',').filter(Boolean) : [], issueIds: options.issues ? options.issues.split(',').filter(Boolean) : [], patch: options.candidate ? { bestCandidateId: options.candidate } : undefined })
|
|
818
|
+
}
|
|
819
|
+
if (command === 'validate') {
|
|
820
|
+
const result = await validateRun(root, options['task-id'])
|
|
821
|
+
if (!result.valid) process.exitCode = 1
|
|
822
|
+
return result
|
|
823
|
+
}
|
|
824
|
+
if (command === 'recover') return recoverRun(root)
|
|
825
|
+
if (command === 'status') return statusRun(root)
|
|
826
|
+
throw new Error('usage: run-state.mjs <init|transition|gate|validate|recover|status> <run-directory> [options]')
|
|
827
|
+
}
|
|
828
|
+
const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
829
|
+
if (invoked) cli(process.argv.slice(2)).then(result => process.stdout.write(`${JSON.stringify(result)}\n`)).catch(error => { process.stdout.write(`${JSON.stringify({ error: error.message })}\n`); process.exitCode = 1 })
|