devlyn-cli 2.8.1 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -1
- package/CLAUDE.md +9 -5
- package/README.md +31 -2
- package/benchmark/auto-resolve/fixtures/test-repo/README.md +8 -23
- package/benchmark/auto-resolve/fixtures/test-repo/bin/cli.js +2 -2
- package/benchmark/auto-resolve/fixtures/test-repo/package-lock.json +2 -2
- package/benchmark/auto-resolve/fixtures/test-repo/package.json +2 -2
- package/benchmark/auto-resolve/fixtures/test-repo/playwright.config.js +3 -3
- package/benchmark/auto-resolve/fixtures/test-repo/server/index.js +2 -2
- package/benchmark/auto-resolve/fixtures/test-repo/web/index.html +3 -3
- package/config/skills/_shared/adapters/claude.md +6 -3
- package/config/skills/_shared/archive_run.py +25 -1
- package/config/skills/_shared/codex-config.md +1 -0
- package/config/skills/_shared/finish-gate.py +517 -0
- package/config/skills/_shared/run-bounded.py +44 -0
- package/config/skills/_shared/spec-verify-check.py +716 -93
- package/config/skills/_shared/state-phase-write.py +113 -15
- package/config/skills/_shared/verify-merge-findings.py +197 -1
- package/config/skills/devlyn:resolve/SKILL.md +37 -20
- package/config/skills/devlyn:resolve/references/free-form-mode.md +7 -7
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/probe-derive.md +14 -3
- package/config/skills/devlyn:resolve/references/phases/verify.md +19 -4
- package/config/skills/devlyn:resolve/references/state-schema.md +16 -9
- package/package.json +5 -2
- package/scripts/lint-skills.sh +1 -1
|
@@ -5,13 +5,12 @@ Usage:
|
|
|
5
5
|
python3 state-phase-write.py --devlyn-dir .devlyn --phase implement spawn \
|
|
6
6
|
--round 1 --triggered-by verify [--pre-sha <sha>] [--engine claude] [--model <id>]
|
|
7
7
|
python3 state-phase-write.py --devlyn-dir .devlyn --phase implement complete \
|
|
8
|
-
--verdict PASS [--findings-file <path>] [--log-file <path>] [--engine claude] [--model <id>]
|
|
8
|
+
--verdict PASS [--post-sha <sha>] [--findings-file <path>] [--log-file <path>] [--engine claude] [--model <id>]
|
|
9
9
|
|
|
10
10
|
references/state-schema.md#write-protocol is the contract this implements.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
round, producing an internally inconsistent phase timeline. `spawn` always
|
|
11
|
+
A prior hand-edited fix-loop respawn left `started_at` at its original round's
|
|
12
|
+
value while `completed_at`/`duration_ms`/`round`/`triggered_by` advanced to the
|
|
13
|
+
new round, producing an internally inconsistent phase timeline. `spawn` always
|
|
15
14
|
resets `started_at` fresh and nulls the completion fields; `complete` derives
|
|
16
15
|
`duration_ms` from the phase's own recorded `started_at`, so the two can never
|
|
17
16
|
drift apart again.
|
|
@@ -95,6 +94,24 @@ def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
|
|
|
95
94
|
(devlyn / "verify-merge.summary.json").unlink(missing_ok=True)
|
|
96
95
|
|
|
97
96
|
|
|
97
|
+
def append_round_history(entry: dict) -> None:
|
|
98
|
+
if entry.get("started_at") is None:
|
|
99
|
+
return
|
|
100
|
+
history = entry.get("rounds_history")
|
|
101
|
+
if not isinstance(history, list):
|
|
102
|
+
history = []
|
|
103
|
+
history.append({
|
|
104
|
+
"round": entry.get("round"),
|
|
105
|
+
"started_at": entry.get("started_at"),
|
|
106
|
+
"completed_at": entry.get("completed_at"),
|
|
107
|
+
"duration_ms": entry.get("duration_ms"),
|
|
108
|
+
"verdict": entry.get("verdict"),
|
|
109
|
+
"triggered_by": entry.get("triggered_by"),
|
|
110
|
+
"engine": entry.get("engine"),
|
|
111
|
+
})
|
|
112
|
+
entry["rounds_history"] = history
|
|
113
|
+
|
|
114
|
+
|
|
98
115
|
def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
99
116
|
pre_sha: str | None, engine: str | None, model: str | None) -> None:
|
|
100
117
|
# Merge, don't replace: a phase-gated large run's `exec` progress (or any
|
|
@@ -104,6 +121,7 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
104
121
|
if not isinstance(entry, dict):
|
|
105
122
|
entry = {}
|
|
106
123
|
phases[phase] = entry
|
|
124
|
+
append_round_history(entry)
|
|
107
125
|
entry["started_at"] = now_iso()
|
|
108
126
|
entry["completed_at"] = None
|
|
109
127
|
entry["duration_ms"] = None
|
|
@@ -121,7 +139,7 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
121
139
|
|
|
122
140
|
|
|
123
141
|
def do_complete(state: dict, phase: str, verdict: str | None,
|
|
124
|
-
findings_file: str | None, log_file: str | None,
|
|
142
|
+
post_sha: str | None, findings_file: str | None, log_file: str | None,
|
|
125
143
|
engine: str | None, model: str | None) -> None:
|
|
126
144
|
phases = state.setdefault("phases", {})
|
|
127
145
|
entry = phases.get(phase)
|
|
@@ -156,6 +174,8 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
156
174
|
entry["engine"] = engine
|
|
157
175
|
if model is not None:
|
|
158
176
|
entry["model"] = model
|
|
177
|
+
if post_sha is not None:
|
|
178
|
+
entry["post_sha"] = post_sha
|
|
159
179
|
|
|
160
180
|
|
|
161
181
|
def self_test() -> int:
|
|
@@ -174,7 +194,7 @@ def self_test() -> int:
|
|
|
174
194
|
|
|
175
195
|
time.sleep(0.05)
|
|
176
196
|
state = read_state(state_path)
|
|
177
|
-
do_complete(state, "implement", "NEEDS_WORK", None, None, None, "test-model-id")
|
|
197
|
+
do_complete(state, "implement", "NEEDS_WORK", None, None, None, None, "test-model-id")
|
|
178
198
|
write_state(state_path, state)
|
|
179
199
|
entry = read_state(state_path)["phases"]["implement"]
|
|
180
200
|
assert entry["completed_at"] is not None
|
|
@@ -196,10 +216,19 @@ def self_test() -> int:
|
|
|
196
216
|
assert respawned["engine"] == "claude", "engine preserved when not re-supplied"
|
|
197
217
|
assert respawned["round"] == 1
|
|
198
218
|
assert respawned["triggered_by"] == "verify"
|
|
219
|
+
assert len(respawned["rounds_history"]) == 1
|
|
220
|
+
history0 = respawned["rounds_history"][0]
|
|
221
|
+
assert history0["round"] == 0
|
|
222
|
+
assert history0["started_at"] == round0_started
|
|
223
|
+
assert history0["completed_at"] == entry["completed_at"]
|
|
224
|
+
assert history0["duration_ms"] == entry["duration_ms"]
|
|
225
|
+
assert history0["verdict"] == "NEEDS_WORK"
|
|
226
|
+
assert history0["triggered_by"] is None
|
|
227
|
+
assert history0["engine"] == "claude"
|
|
199
228
|
|
|
200
229
|
time.sleep(0.05)
|
|
201
230
|
state = read_state(state_path)
|
|
202
|
-
do_complete(state, "implement", "PASS", ".devlyn/x.jsonl", None, None, None)
|
|
231
|
+
do_complete(state, "implement", "PASS", None, ".devlyn/x.jsonl", None, None, None)
|
|
203
232
|
write_state(state_path, state)
|
|
204
233
|
final = read_state(state_path)["phases"]["implement"]
|
|
205
234
|
assert final["verdict"] == "PASS"
|
|
@@ -211,12 +240,32 @@ def self_test() -> int:
|
|
|
211
240
|
write_state(state_path, {"phases": {}})
|
|
212
241
|
state = read_state(state_path)
|
|
213
242
|
try:
|
|
214
|
-
do_complete(state, "build_gate", "PASS", None, None, None, None)
|
|
243
|
+
do_complete(state, "build_gate", "PASS", None, None, None, None, None)
|
|
215
244
|
except SystemExit as e:
|
|
216
245
|
assert "never spawned" in str(e)
|
|
217
246
|
else:
|
|
218
247
|
raise AssertionError("complete() without a prior spawn() must raise")
|
|
219
248
|
|
|
249
|
+
# A completed FAIL round must be retained before a fix-loop respawn
|
|
250
|
+
# resets the live record.
|
|
251
|
+
write_state(state_path, {"phases": {}})
|
|
252
|
+
state = read_state(state_path)
|
|
253
|
+
do_spawn(state, "build_gate", 0, None, None, "claude", None)
|
|
254
|
+
write_state(state_path, state)
|
|
255
|
+
time.sleep(0.05)
|
|
256
|
+
state = read_state(state_path)
|
|
257
|
+
do_complete(state, "build_gate", "FAIL", None, None, None, None, None)
|
|
258
|
+
write_state(state_path, state)
|
|
259
|
+
failed_round = read_state(state_path)["phases"]["build_gate"]
|
|
260
|
+
state = read_state(state_path)
|
|
261
|
+
do_spawn(state, "build_gate", 1, "build_gate", None, None, None)
|
|
262
|
+
write_state(state_path, state)
|
|
263
|
+
respawned_fail = read_state(state_path)["phases"]["build_gate"]
|
|
264
|
+
assert respawned_fail["verdict"] is None
|
|
265
|
+
assert len(respawned_fail["rounds_history"]) == 1
|
|
266
|
+
assert respawned_fail["rounds_history"][0]["verdict"] == "FAIL"
|
|
267
|
+
assert respawned_fail["rounds_history"][0]["completed_at"] == failed_round["completed_at"]
|
|
268
|
+
|
|
220
269
|
# VERIFY flow: verify-merge-findings.py already wrote verdict; complete()
|
|
221
270
|
# must preserve it when --verdict is omitted.
|
|
222
271
|
write_state(state_path, {"phases": {}})
|
|
@@ -226,7 +275,7 @@ def self_test() -> int:
|
|
|
226
275
|
state["phases"]["verify"]["sub_verdicts"] = {"mechanical": "PASS", "judge": "PASS"}
|
|
227
276
|
write_state(state_path, state)
|
|
228
277
|
state = read_state(state_path)
|
|
229
|
-
do_complete(state, "verify", None, None, None, None, None)
|
|
278
|
+
do_complete(state, "verify", None, None, None, None, None, None)
|
|
230
279
|
write_state(state_path, state)
|
|
231
280
|
verify_entry = read_state(state_path)["phases"]["verify"]
|
|
232
281
|
assert verify_entry["verdict"] == "PASS", "complete() must preserve pre-set verdict when omitted"
|
|
@@ -237,7 +286,7 @@ def self_test() -> int:
|
|
|
237
286
|
# owned exclusively by verify-merge-findings.py --write-state.
|
|
238
287
|
state = read_state(state_path)
|
|
239
288
|
try:
|
|
240
|
-
do_complete(state, "verify", "PASS", None, None, None, None)
|
|
289
|
+
do_complete(state, "verify", "PASS", None, None, None, None, None)
|
|
241
290
|
except SystemExit as e:
|
|
242
291
|
assert "owned by verify-merge-findings.py" in str(e)
|
|
243
292
|
else:
|
|
@@ -252,7 +301,7 @@ def self_test() -> int:
|
|
|
252
301
|
write_state(state_path, state)
|
|
253
302
|
state = read_state(state_path)
|
|
254
303
|
try:
|
|
255
|
-
do_complete(state, "plan", None, None, None, None, None)
|
|
304
|
+
do_complete(state, "plan", None, None, None, None, None, None)
|
|
256
305
|
except SystemExit as e:
|
|
257
306
|
assert "is required" in str(e)
|
|
258
307
|
else:
|
|
@@ -266,7 +315,7 @@ def self_test() -> int:
|
|
|
266
315
|
write_state(state_path, state)
|
|
267
316
|
state = read_state(state_path)
|
|
268
317
|
try:
|
|
269
|
-
do_complete(state, "verify", None, None, None, None, None)
|
|
318
|
+
do_complete(state, "verify", None, None, None, None, None, None)
|
|
270
319
|
except SystemExit as e:
|
|
271
320
|
assert "still null" in str(e)
|
|
272
321
|
else:
|
|
@@ -311,7 +360,7 @@ def self_test() -> int:
|
|
|
311
360
|
}
|
|
312
361
|
write_state(state_path, state)
|
|
313
362
|
state = read_state(state_path)
|
|
314
|
-
do_complete(state, "implement", "PASS", None, None, None, None)
|
|
363
|
+
do_complete(state, "implement", "PASS", None, None, None, None, None)
|
|
315
364
|
write_state(state_path, state)
|
|
316
365
|
state = read_state(state_path)
|
|
317
366
|
do_spawn(state, "implement", 1, "verify", None, None, None)
|
|
@@ -319,6 +368,54 @@ def self_test() -> int:
|
|
|
319
368
|
respawned_exec = read_state(state_path)["phases"]["implement"]
|
|
320
369
|
assert respawned_exec["exec"]["current"] == 3, "spawn must not clobber unowned fields like exec"
|
|
321
370
|
assert respawned_exec["verdict"] is None, "respawn still nulls owned fields even with exec present"
|
|
371
|
+
assert len(respawned_exec["rounds_history"]) == 1
|
|
372
|
+
assert respawned_exec["rounds_history"][0]["verdict"] == "PASS"
|
|
373
|
+
|
|
374
|
+
# Existing history is append-only; a respawn must not clobber prior
|
|
375
|
+
# entries that were already preserved from older rounds.
|
|
376
|
+
write_state(state_path, {
|
|
377
|
+
"phases": {
|
|
378
|
+
"implement": {
|
|
379
|
+
"started_at": "2026-01-01T00:00:02.000Z",
|
|
380
|
+
"completed_at": "2026-01-01T00:00:03.000Z",
|
|
381
|
+
"duration_ms": 1000,
|
|
382
|
+
"round": 2,
|
|
383
|
+
"triggered_by": "verify",
|
|
384
|
+
"verdict": "FAIL",
|
|
385
|
+
"engine": "codex",
|
|
386
|
+
"rounds_history": [{
|
|
387
|
+
"round": 1,
|
|
388
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
389
|
+
"completed_at": "2026-01-01T00:00:01.000Z",
|
|
390
|
+
"duration_ms": 1000,
|
|
391
|
+
"verdict": "FAIL",
|
|
392
|
+
"triggered_by": "build_gate",
|
|
393
|
+
"engine": "claude",
|
|
394
|
+
}],
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
state = read_state(state_path)
|
|
399
|
+
do_spawn(state, "implement", 3, "verify", None, None, None)
|
|
400
|
+
write_state(state_path, state)
|
|
401
|
+
history_preserved = read_state(state_path)["phases"]["implement"]["rounds_history"]
|
|
402
|
+
assert len(history_preserved) == 2
|
|
403
|
+
assert history_preserved[0]["round"] == 1
|
|
404
|
+
assert history_preserved[1]["round"] == 2
|
|
405
|
+
assert history_preserved[1]["engine"] == "codex"
|
|
406
|
+
|
|
407
|
+
# complete() records a post-state commit when a bounded phase needs an
|
|
408
|
+
# exact diff window for later mechanical checks.
|
|
409
|
+
write_state(state_path, {"phases": {}})
|
|
410
|
+
state = read_state(state_path)
|
|
411
|
+
do_spawn(state, "cleanup", 0, None, "pre-sha", None, None)
|
|
412
|
+
write_state(state_path, state)
|
|
413
|
+
state = read_state(state_path)
|
|
414
|
+
do_complete(state, "cleanup", "PASS", "post-sha", None, None, None, None)
|
|
415
|
+
write_state(state_path, state)
|
|
416
|
+
cleanup_entry = read_state(state_path)["phases"]["cleanup"]
|
|
417
|
+
assert cleanup_entry["pre_sha"] == "pre-sha"
|
|
418
|
+
assert cleanup_entry["post_sha"] == "post-sha"
|
|
322
419
|
|
|
323
420
|
return 0
|
|
324
421
|
|
|
@@ -339,6 +436,7 @@ def main() -> int:
|
|
|
339
436
|
|
|
340
437
|
complete_p = sub.add_parser("complete")
|
|
341
438
|
complete_p.add_argument("--verdict", choices=sorted(VALID_VERDICTS), default=None)
|
|
439
|
+
complete_p.add_argument("--post-sha", default=None)
|
|
342
440
|
complete_p.add_argument("--findings-file", default=None)
|
|
343
441
|
complete_p.add_argument("--log-file", default=None)
|
|
344
442
|
complete_p.add_argument("--engine", default=None)
|
|
@@ -363,7 +461,7 @@ def main() -> int:
|
|
|
363
461
|
clear_verify_round_artifacts(devlyn)
|
|
364
462
|
do_spawn(state, args.phase, args.round, args.triggered_by, args.pre_sha, args.engine, args.model)
|
|
365
463
|
else:
|
|
366
|
-
do_complete(state, args.phase, args.verdict, args.findings_file, args.log_file, args.engine, args.model)
|
|
464
|
+
do_complete(state, args.phase, args.verdict, args.post_sha, args.findings_file, args.log_file, args.engine, args.model)
|
|
367
465
|
|
|
368
466
|
write_state(state_path, state)
|
|
369
467
|
sys.stdout.write(f"ok: phases.{args.phase}.{args.event}\n")
|
|
@@ -27,6 +27,7 @@ SOURCE_FILES = (
|
|
|
27
27
|
|
|
28
28
|
VERDICT_RANK = {
|
|
29
29
|
"PASS": 0,
|
|
30
|
+
"TIMEOUT": 0,
|
|
30
31
|
"PASS_WITH_ISSUES": 1,
|
|
31
32
|
"FAIL": 2,
|
|
32
33
|
"NEEDS_WORK": 2,
|
|
@@ -627,6 +628,45 @@ def pair_blocker(id_: str, message: str, file_: str | None = None) -> dict[str,
|
|
|
627
628
|
}
|
|
628
629
|
|
|
629
630
|
|
|
631
|
+
def read_pair_timeout_marker(devlyn: pathlib.Path) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
632
|
+
path = devlyn / "verify.pair.timeout.json"
|
|
633
|
+
if not path.is_file():
|
|
634
|
+
return None, None
|
|
635
|
+
try:
|
|
636
|
+
marker = loads_strict_json(path.read_text(encoding="utf-8"))
|
|
637
|
+
except ValueError as exc:
|
|
638
|
+
return None, {
|
|
639
|
+
"id": "verify-pair-timeout-marker-malformed",
|
|
640
|
+
"message": f"verify.pair.timeout.json is malformed JSON: {exc}",
|
|
641
|
+
"file": "verify.pair.timeout.json",
|
|
642
|
+
}
|
|
643
|
+
if not isinstance(marker, dict):
|
|
644
|
+
return None, {
|
|
645
|
+
"id": "verify-pair-timeout-marker-malformed",
|
|
646
|
+
"message": "verify.pair.timeout.json must be a JSON object.",
|
|
647
|
+
"file": "verify.pair.timeout.json",
|
|
648
|
+
}
|
|
649
|
+
engine = marker.get("engine")
|
|
650
|
+
budget_seconds = marker.get("budget_seconds")
|
|
651
|
+
if not isinstance(engine, str) or not engine:
|
|
652
|
+
return None, {
|
|
653
|
+
"id": "verify-pair-timeout-marker-malformed",
|
|
654
|
+
"message": "verify.pair.timeout.json engine must be a non-empty string.",
|
|
655
|
+
"file": "verify.pair.timeout.json",
|
|
656
|
+
}
|
|
657
|
+
if (
|
|
658
|
+
not isinstance(budget_seconds, int)
|
|
659
|
+
or isinstance(budget_seconds, bool)
|
|
660
|
+
or budget_seconds <= 0
|
|
661
|
+
):
|
|
662
|
+
return None, {
|
|
663
|
+
"id": "verify-pair-timeout-marker-malformed",
|
|
664
|
+
"message": "verify.pair.timeout.json budget_seconds must be a positive integer.",
|
|
665
|
+
"file": "verify.pair.timeout.json",
|
|
666
|
+
}
|
|
667
|
+
return {"engine": engine, "budget_seconds": budget_seconds}, None
|
|
668
|
+
|
|
669
|
+
|
|
630
670
|
def detect_pair_stdout_contract_violations(
|
|
631
671
|
devlyn: pathlib.Path,
|
|
632
672
|
source_verdicts: dict[str, str | None],
|
|
@@ -635,6 +675,16 @@ def detect_pair_stdout_contract_violations(
|
|
|
635
675
|
# claude-judge.stdout — adapters/claude.md ## Invocation). The emission
|
|
636
676
|
# contract is engine-neutral.
|
|
637
677
|
stdout_paths = sorted(devlyn.glob("*-judge.stdout"))
|
|
678
|
+
timeout_marker, timeout_violation = read_pair_timeout_marker(devlyn)
|
|
679
|
+
if timeout_violation is not None:
|
|
680
|
+
source_verdicts["pair_judge"] = "BLOCKED"
|
|
681
|
+
return [
|
|
682
|
+
pair_blocker(
|
|
683
|
+
timeout_violation["id"],
|
|
684
|
+
timeout_violation["message"],
|
|
685
|
+
timeout_violation["file"],
|
|
686
|
+
)
|
|
687
|
+
]
|
|
638
688
|
flag_violation = pair_flag_contract_violation(devlyn)
|
|
639
689
|
if flag_violation is not None:
|
|
640
690
|
source_verdicts["pair_judge"] = "BLOCKED"
|
|
@@ -699,6 +749,9 @@ def detect_pair_stdout_contract_violations(
|
|
|
699
749
|
if has_pair_findings(devlyn):
|
|
700
750
|
return []
|
|
701
751
|
if not stdout_paths:
|
|
752
|
+
if timeout_marker is not None:
|
|
753
|
+
source_verdicts["pair_judge"] = "TIMEOUT"
|
|
754
|
+
return []
|
|
702
755
|
if required:
|
|
703
756
|
source_verdicts["pair_judge"] = "BLOCKED"
|
|
704
757
|
return [
|
|
@@ -709,11 +762,13 @@ def detect_pair_stdout_contract_violations(
|
|
|
709
762
|
)
|
|
710
763
|
]
|
|
711
764
|
return []
|
|
712
|
-
if source_verdicts["pair_judge"] is None:
|
|
765
|
+
if source_verdicts["pair_judge"] is None and timeout_marker is None:
|
|
713
766
|
source_verdicts["pair_judge"] = "PASS"
|
|
714
767
|
for stdout_path in stdout_paths:
|
|
715
768
|
raw_text = stdout_path.read_text(encoding="utf-8")
|
|
716
769
|
if not raw_text.strip():
|
|
770
|
+
if timeout_marker is not None:
|
|
771
|
+
continue
|
|
717
772
|
source_verdicts["pair_judge"] = "BLOCKED"
|
|
718
773
|
return [
|
|
719
774
|
pair_blocker(
|
|
@@ -761,6 +816,8 @@ def detect_pair_stdout_contract_violations(
|
|
|
761
816
|
stdout_path.name,
|
|
762
817
|
)
|
|
763
818
|
]
|
|
819
|
+
if timeout_marker is not None:
|
|
820
|
+
source_verdicts["pair_judge"] = "TIMEOUT"
|
|
764
821
|
return []
|
|
765
822
|
|
|
766
823
|
|
|
@@ -783,6 +840,11 @@ def write_outputs(
|
|
|
783
840
|
"findings_count": len(findings),
|
|
784
841
|
"findings_file": str(merged_path),
|
|
785
842
|
}
|
|
843
|
+
if source_verdicts.get("pair_judge") == "TIMEOUT":
|
|
844
|
+
timeout_marker, _timeout_violation = read_pair_timeout_marker(devlyn)
|
|
845
|
+
if timeout_marker is not None:
|
|
846
|
+
summary["pair_timeout"] = timeout_marker
|
|
847
|
+
summary["report_header_note"] = "solo verdict after pair TIMEOUT"
|
|
786
848
|
summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
787
849
|
return summary
|
|
788
850
|
|
|
@@ -972,6 +1034,140 @@ def self_test() -> int:
|
|
|
972
1034
|
for finding in findings
|
|
973
1035
|
), findings
|
|
974
1036
|
(devlyn / "claude-judge.stdout").unlink()
|
|
1037
|
+
|
|
1038
|
+
# iter-0065 case 1: a valid pair timeout marker plus empty pair output
|
|
1039
|
+
# records TIMEOUT and leaves the merged verdict to mechanical+primary.
|
|
1040
|
+
(devlyn / "pipeline.state.json").write_text(
|
|
1041
|
+
json.dumps({
|
|
1042
|
+
"phases": {
|
|
1043
|
+
"verify": {
|
|
1044
|
+
"verdict": "PASS",
|
|
1045
|
+
"sub_verdicts": {},
|
|
1046
|
+
"pair_trigger": {
|
|
1047
|
+
"eligible": True,
|
|
1048
|
+
"reasons": ["judge.warning"],
|
|
1049
|
+
"skipped_reason": None,
|
|
1050
|
+
},
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}),
|
|
1054
|
+
encoding="utf-8",
|
|
1055
|
+
)
|
|
1056
|
+
(devlyn / "verify.findings.jsonl").write_text(
|
|
1057
|
+
json.dumps({"id": "j-timeout-low", "severity": "LOW"}) + "\n",
|
|
1058
|
+
encoding="utf-8",
|
|
1059
|
+
)
|
|
1060
|
+
(devlyn / "verify.pair.findings.jsonl").write_text("", encoding="utf-8")
|
|
1061
|
+
(devlyn / "claude-judge.stdout").write_text("", encoding="utf-8")
|
|
1062
|
+
(devlyn / "verify.pair.timeout.json").write_text(
|
|
1063
|
+
json.dumps({"engine": "claude", "budget_seconds": 600}),
|
|
1064
|
+
encoding="utf-8",
|
|
1065
|
+
)
|
|
1066
|
+
findings, source_verdicts = read_findings(devlyn)
|
|
1067
|
+
summary = write_outputs(devlyn, findings, source_verdicts)
|
|
1068
|
+
write_state(devlyn, summary)
|
|
1069
|
+
state = loads_strict_json((devlyn / "pipeline.state.json").read_text(encoding="utf-8"))
|
|
1070
|
+
assert summary["verdict"] == "PASS_WITH_ISSUES", summary
|
|
1071
|
+
assert summary["source_verdicts"]["pair_judge"] == "TIMEOUT", summary
|
|
1072
|
+
assert summary["pair_timeout"] == {"engine": "claude", "budget_seconds": 600}, summary
|
|
1073
|
+
assert summary["report_header_note"] == "solo verdict after pair TIMEOUT", summary
|
|
1074
|
+
assert state["phases"]["verify"]["sub_verdicts"]["pair_judge"] == "TIMEOUT", state
|
|
1075
|
+
(devlyn / "claude-judge.stdout").unlink()
|
|
1076
|
+
(devlyn / "verify.pair.timeout.json").unlink()
|
|
1077
|
+
(devlyn / "verify.findings.jsonl").write_text("", encoding="utf-8")
|
|
1078
|
+
|
|
1079
|
+
# iter-0065 case 2: canonical pair findings still bind after timeout.
|
|
1080
|
+
(devlyn / "pipeline.state.json").write_text(
|
|
1081
|
+
json.dumps({"phases": {"verify": {"verdict": "PASS", "sub_verdicts": {}}}}),
|
|
1082
|
+
encoding="utf-8",
|
|
1083
|
+
)
|
|
1084
|
+
(devlyn / "verify.pair.findings.jsonl").write_text(
|
|
1085
|
+
json.dumps({"id": "p-timeout-high", "severity": "HIGH"}) + "\n",
|
|
1086
|
+
encoding="utf-8",
|
|
1087
|
+
)
|
|
1088
|
+
(devlyn / "verify.pair.timeout.json").write_text(
|
|
1089
|
+
json.dumps({"engine": "codex", "budget_seconds": 600}),
|
|
1090
|
+
encoding="utf-8",
|
|
1091
|
+
)
|
|
1092
|
+
findings, source_verdicts = read_findings(devlyn)
|
|
1093
|
+
summary = write_outputs(devlyn, findings, source_verdicts)
|
|
1094
|
+
assert summary["verdict"] == "NEEDS_WORK", summary
|
|
1095
|
+
assert summary["source_verdicts"]["pair_judge"] == "NEEDS_WORK", summary
|
|
1096
|
+
assert "pair_timeout" not in summary, summary
|
|
1097
|
+
(devlyn / "verify.pair.timeout.json").unlink()
|
|
1098
|
+
(devlyn / "verify.pair.findings.jsonl").write_text("", encoding="utf-8")
|
|
1099
|
+
|
|
1100
|
+
# iter-0065 case 2b: stdout-only HIGH still blocks on emission contract;
|
|
1101
|
+
# timeout never converts an observed finding into a solo pass.
|
|
1102
|
+
(devlyn / "pipeline.state.json").write_text(
|
|
1103
|
+
json.dumps({"phases": {"verify": {"verdict": "PASS", "sub_verdicts": {}}}}),
|
|
1104
|
+
encoding="utf-8",
|
|
1105
|
+
)
|
|
1106
|
+
(devlyn / "codex-judge.stdout").write_text(
|
|
1107
|
+
json.dumps({"id": "cj-timeout-high", "severity": "HIGH"}) + "\n",
|
|
1108
|
+
encoding="utf-8",
|
|
1109
|
+
)
|
|
1110
|
+
(devlyn / "verify.pair.timeout.json").write_text(
|
|
1111
|
+
json.dumps({"engine": "codex", "budget_seconds": 600}),
|
|
1112
|
+
encoding="utf-8",
|
|
1113
|
+
)
|
|
1114
|
+
findings, source_verdicts = read_findings(devlyn)
|
|
1115
|
+
summary = write_outputs(devlyn, findings, source_verdicts)
|
|
1116
|
+
assert summary["verdict"] == "BLOCKED", summary
|
|
1117
|
+
assert summary["source_verdicts"]["pair_judge"] == "BLOCKED", summary
|
|
1118
|
+
assert any(
|
|
1119
|
+
finding.get("id") == "verify-pair-emission-contract-violated"
|
|
1120
|
+
for finding in findings
|
|
1121
|
+
), findings
|
|
1122
|
+
(devlyn / "codex-judge.stdout").unlink()
|
|
1123
|
+
(devlyn / "verify.pair.timeout.json").unlink()
|
|
1124
|
+
|
|
1125
|
+
# iter-0065 case 3: without a marker, required empty pair output remains BLOCKED.
|
|
1126
|
+
(devlyn / "pipeline.state.json").write_text(
|
|
1127
|
+
json.dumps({
|
|
1128
|
+
"phases": {
|
|
1129
|
+
"verify": {
|
|
1130
|
+
"verdict": "PASS",
|
|
1131
|
+
"sub_verdicts": {},
|
|
1132
|
+
"pair_trigger": {
|
|
1133
|
+
"eligible": True,
|
|
1134
|
+
"reasons": ["risk.high"],
|
|
1135
|
+
"skipped_reason": None,
|
|
1136
|
+
},
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}),
|
|
1140
|
+
encoding="utf-8",
|
|
1141
|
+
)
|
|
1142
|
+
(devlyn / "claude-judge.stdout").write_text("", encoding="utf-8")
|
|
1143
|
+
findings, source_verdicts = read_findings(devlyn)
|
|
1144
|
+
summary = write_outputs(devlyn, findings, source_verdicts)
|
|
1145
|
+
assert summary["verdict"] == "BLOCKED", summary
|
|
1146
|
+
assert any(
|
|
1147
|
+
finding.get("id") == "verify-pair-empty-output"
|
|
1148
|
+
for finding in findings
|
|
1149
|
+
), findings
|
|
1150
|
+
(devlyn / "claude-judge.stdout").unlink()
|
|
1151
|
+
|
|
1152
|
+
# iter-0065 malformed timeout markers fail closed as a CRITICAL pair blocker.
|
|
1153
|
+
(devlyn / "pipeline.state.json").write_text(
|
|
1154
|
+
json.dumps({"phases": {"verify": {"verdict": "PASS", "sub_verdicts": {}}}}),
|
|
1155
|
+
encoding="utf-8",
|
|
1156
|
+
)
|
|
1157
|
+
(devlyn / "verify.pair.timeout.json").write_text(
|
|
1158
|
+
json.dumps({"engine": "claude", "budget_seconds": 0}),
|
|
1159
|
+
encoding="utf-8",
|
|
1160
|
+
)
|
|
1161
|
+
findings, source_verdicts = read_findings(devlyn)
|
|
1162
|
+
summary = write_outputs(devlyn, findings, source_verdicts)
|
|
1163
|
+
assert summary["verdict"] == "BLOCKED", summary
|
|
1164
|
+
assert summary["source_verdicts"]["pair_judge"] == "BLOCKED", summary
|
|
1165
|
+
assert any(
|
|
1166
|
+
finding.get("id") == "verify-pair-timeout-marker-malformed"
|
|
1167
|
+
and finding.get("severity") == "CRITICAL"
|
|
1168
|
+
for finding in findings
|
|
1169
|
+
), findings
|
|
1170
|
+
(devlyn / "verify.pair.timeout.json").unlink()
|
|
975
1171
|
(devlyn / "pipeline.state.json").write_text(
|
|
976
1172
|
json.dumps({
|
|
977
1173
|
"phases": {
|