devlyn-cli 2.8.0 → 2.9.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.
@@ -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
- iter-0042 (autoresearch/iterations/0042-compliance-drift-probes.md) found a
12
- hand-edited fix-loop respawn left `started_at` at its original round's value
13
- while `completed_at`/`duration_ms`/`round`/`triggered_by` advanced to the new
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": {