devlyn-cli 2.9.3 → 2.10.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.
Files changed (40) hide show
  1. package/AGENTS.md +3 -3
  2. package/CLAUDE.md +5 -5
  3. package/README.md +5 -5
  4. package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
  5. package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
  6. package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
  7. package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
  8. package/bin/devlyn.js +108 -15
  9. package/config/skills/_shared/adapters/README.md +2 -2
  10. package/config/skills/_shared/adapters/grok.md +99 -0
  11. package/config/skills/_shared/archive_run.py +35 -1
  12. package/config/skills/_shared/collect-codex-findings.py +231 -50
  13. package/config/skills/_shared/engine-doctor.sh +6 -3
  14. package/config/skills/_shared/engine-preflight.md +12 -5
  15. package/config/skills/_shared/expected.schema.json +7 -0
  16. package/config/skills/_shared/grok-anchor-guard.py +61 -0
  17. package/config/skills/_shared/phase-prompt-render.py +181 -0
  18. package/config/skills/_shared/resolve-bootstrap.py +630 -0
  19. package/config/skills/_shared/resolve-stop-hook.py +325 -0
  20. package/config/skills/_shared/spec-verify-check.py +308 -31
  21. package/config/skills/_shared/state-phase-write.py +2966 -27
  22. package/config/skills/_shared/terminal-claim-check.py +537 -0
  23. package/config/skills/_shared/verify-merge-findings.py +533 -16
  24. package/config/skills/devlyn:engines/SKILL.md +2 -2
  25. package/config/skills/devlyn:ideate/SKILL.md +1 -1
  26. package/config/skills/devlyn:queue/SKILL.md +12 -0
  27. package/config/skills/devlyn:resolve/SKILL.md +60 -78
  28. package/config/skills/devlyn:resolve/references/phases/build-gate.md +2 -2
  29. package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
  30. package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
  31. package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
  32. package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
  33. package/config/skills/devlyn:resolve/references/phases/verify.md +70 -97
  34. package/config/skills/devlyn:resolve/references/state-schema.md +26 -19
  35. package/optional-skills/devlyn:reap/SKILL.md +1 -1
  36. package/optional-skills/devlyn:reap/scripts/reap.sh +25 -13
  37. package/optional-skills/devlyn:reap/scripts/scan.sh +15 -1
  38. package/package.json +1 -1
  39. package/scripts/lint-fixtures.sh +4 -1
  40. package/scripts/lint-skills.sh +269 -77
@@ -41,6 +41,7 @@ ALLOWED_PAIR_SKIP_REASONS = {
41
41
  "auto_pair_other_engine_unavailable",
42
42
  }
43
43
  KNOWN_PAIR_TRIGGER_REASONS = {
44
+ "pair.default",
44
45
  "mode.verify-only",
45
46
  "mode.pair-verify",
46
47
  "complexity.high",
@@ -109,6 +110,10 @@ def all_known_pair_trigger_reasons(reasons: list[str]) -> bool:
109
110
  return all(is_known_pair_trigger_reason(reason) for reason in reasons)
110
111
 
111
112
 
113
+ def state_uses_default_pair_contract(state: dict[str, Any]) -> bool:
114
+ return state.get("version") == "3.0"
115
+
116
+
112
117
  def finding_rank(finding: dict[str, Any]) -> int:
113
118
  severity = str(finding.get("severity") or "").upper()
114
119
  if severity in {"CRITICAL", "HIGH"}:
@@ -164,6 +169,44 @@ def read_findings(devlyn: pathlib.Path) -> tuple[list[dict[str, Any]], dict[str,
164
169
  source_verdicts[source], RANK_VERDICT[finding_rank(item)]
165
170
  )
166
171
  findings.extend(detect_pair_stdout_contract_violations(devlyn, source_verdicts))
172
+ pair_summary_path = devlyn / "pair-judge.summary.json"
173
+ pair_carrier_exists = any(
174
+ (devlyn / name).is_file()
175
+ for name in ("verify.pair.findings.jsonl", "verify.pair-judge.findings.jsonl")
176
+ )
177
+ if pair_carrier_exists and pair_summary_path.is_file():
178
+ try:
179
+ pair_summary = loads_strict_json(pair_summary_path.read_text(encoding="utf-8"))
180
+ except ValueError as exc:
181
+ summary_error = f"pair-judge.summary.json is malformed JSON: {exc}"
182
+ else:
183
+ pair_verdict = pair_summary.get("verdict") if isinstance(pair_summary, dict) else None
184
+ summary_error = None
185
+ if not isinstance(pair_summary, dict):
186
+ summary_error = "pair-judge.summary.json must be a JSON object."
187
+ elif not isinstance(pair_verdict, str) or pair_verdict not in {
188
+ "PASS", "PASS_WITH_ISSUES", "FAIL", "NEEDS_WORK", "BLOCKED"
189
+ }:
190
+ summary_error = (
191
+ "pair-judge.summary.json verdict must be PASS, PASS_WITH_ISSUES, "
192
+ "FAIL, NEEDS_WORK, or BLOCKED."
193
+ )
194
+ if summary_error is not None:
195
+ findings.append(
196
+ pair_blocker(
197
+ "verify-pair-summary-invalid",
198
+ summary_error,
199
+ pair_summary_path.name,
200
+ )
201
+ )
202
+ source_verdicts["pair_judge"] = "BLOCKED"
203
+ elif (
204
+ source_verdicts["pair_judge"] != "TIMEOUT"
205
+ or rank(pair_verdict) > rank("TIMEOUT")
206
+ ):
207
+ source_verdicts["pair_judge"] = worse(
208
+ source_verdicts["pair_judge"], pair_verdict
209
+ )
167
210
  return findings, source_verdicts
168
211
 
169
212
 
@@ -338,6 +381,63 @@ def risk_profile_contract_violation(devlyn: pathlib.Path) -> dict[str, Any] | No
338
381
  return None
339
382
 
340
383
 
384
+ def verify_state_contract_violation(devlyn: pathlib.Path) -> dict[str, Any] | None:
385
+ state_path = devlyn / "pipeline.state.json"
386
+ if not state_path.is_file():
387
+ return None
388
+ try:
389
+ state = loads_strict_json(state_path.read_text(encoding="utf-8"))
390
+ except ValueError:
391
+ return None
392
+ if not isinstance(state, dict) or not state_uses_default_pair_contract(state):
393
+ return None
394
+ source = state.get("source")
395
+ if not isinstance(source, dict) or source.get("type") != "generated":
396
+ return None
397
+ goal_path = source.get("goal_path")
398
+ goal_sha256 = source.get("goal_sha256")
399
+ if (
400
+ not isinstance(goal_path, str)
401
+ or not goal_path
402
+ or not isinstance(goal_sha256, str)
403
+ or re.fullmatch(r"[0-9a-f]{64}", goal_sha256) is None
404
+ ):
405
+ rule = "verify.state.goal-persistence-missing"
406
+ return {
407
+ "id": rule,
408
+ "rule_id": rule,
409
+ "message": (
410
+ "schema-v3 generated runs require source.goal_path as a non-empty string "
411
+ "and source.goal_sha256 as 64 lowercase hexadecimal characters."
412
+ ),
413
+ "file": "pipeline.state.json",
414
+ }
415
+ if state.get("complexity") in {"trivial", "medium"}:
416
+ phases = state.get("phases")
417
+ surface_close = phases.get("surface_close") if isinstance(phases, dict) else None
418
+ skipped = (
419
+ isinstance(surface_close, dict)
420
+ and surface_close.get("verdict") is None
421
+ and surface_close.get("started_at") is None
422
+ and surface_close.get("skipped_reason") == "auto_surface_close_claude_unavailable"
423
+ )
424
+ if (
425
+ not isinstance(surface_close, dict)
426
+ or (surface_close.get("verdict") is None and not skipped)
427
+ ):
428
+ rule = "verify.state.surface-close-skipped"
429
+ return {
430
+ "id": rule,
431
+ "rule_id": rule,
432
+ "message": (
433
+ "schema-v3 generated trivial/medium runs require completed SURFACE_CLOSE "
434
+ "or its canonical automatic Claude-unavailable skip before VERIFY merge."
435
+ ),
436
+ "file": "pipeline.state.json",
437
+ }
438
+ return None
439
+
440
+
341
441
  def source_spec_text(state: dict[str, Any]) -> str | None:
342
442
  source = state.get("source") if isinstance(state.get("source"), dict) else {}
343
443
  for key in ("spec_path", "criteria_path"):
@@ -424,6 +524,11 @@ def state_pair_trigger_reasons(
424
524
  verify_state = state.get("verify") if isinstance(state.get("verify"), dict) else {}
425
525
  risk_profile = state.get("risk_profile") if isinstance(state.get("risk_profile"), dict) else {}
426
526
  reasons: list[str] = []
527
+ if (
528
+ state_uses_default_pair_contract(state)
529
+ and risk_profile.get("pair_default_enabled") is not False
530
+ ):
531
+ reasons.append("pair.default")
427
532
  if state.get("mode") == "verify-only":
428
533
  reasons.append("mode.verify-only")
429
534
  if state.get("pair_verify") is True:
@@ -455,11 +560,19 @@ def state_pair_trigger_reasons(
455
560
  return reasons
456
561
 
457
562
 
563
+ def outcome_independent_reasons(devlyn: pathlib.Path) -> list[str]:
564
+ return [
565
+ reason
566
+ for reason in state_pair_trigger_reasons(devlyn, {})
567
+ if reason not in ("coverage.failed", "mechanical.warning", "judge.warning")
568
+ ]
569
+
570
+
458
571
  def pair_trigger_missing_contract_violation(
459
572
  devlyn: pathlib.Path,
460
573
  source_verdicts: dict[str, str | None],
461
574
  ) -> dict[str, Any] | None:
462
- if rank(source_verdicts.get("mechanical")) >= 2 or rank(source_verdicts.get("judge")) >= 2:
575
+ if rank(source_verdicts.get("mechanical")) >= 2:
463
576
  return None
464
577
  reasons = state_pair_trigger_reasons(devlyn, source_verdicts)
465
578
  if not reasons:
@@ -558,15 +671,36 @@ def pair_trigger_skip_contract_violation(
558
671
  ),
559
672
  "file": "pipeline.state.json",
560
673
  }
561
- if skipped_reason == "primary_judge_blocker" and rank(source_verdicts.get("judge")) < 2:
562
- return {
563
- "id": "verify-pair-trigger-primary-judge-blocker-unsupported",
564
- "message": (
565
- "pair_trigger skipped_reason primary_judge_blocker requires a "
566
- "verdict-binding primary JUDGE finding."
567
- ),
568
- "file": "pipeline.state.json",
569
- }
674
+ if skipped_reason == "primary_judge_blocker":
675
+ if state_uses_default_pair_contract(state):
676
+ return {
677
+ "id": "verify-pair-trigger-primary-judge-blocker-retired",
678
+ "message": (
679
+ "pair_trigger skipped_reason primary_judge_blocker is archived-v2.0 "
680
+ "state only; schema-v3 runs must dispatch the pair-JUDGE."
681
+ ),
682
+ "file": "pipeline.state.json",
683
+ }
684
+ if rank(source_verdicts.get("judge")) < 2:
685
+ return {
686
+ "id": "verify-pair-trigger-primary-judge-blocker-unsupported",
687
+ "message": (
688
+ "pair_trigger skipped_reason primary_judge_blocker requires a "
689
+ "verdict-binding primary JUDGE finding."
690
+ ),
691
+ "file": "pipeline.state.json",
692
+ }
693
+ preknown_reasons = outcome_independent_reasons(devlyn)
694
+ if preknown_reasons:
695
+ return {
696
+ "id": "verify-pair-trigger-primary-judge-blocker-preknown",
697
+ "message": (
698
+ "pair_trigger cannot skip the pair-JUDGE for a primary JUDGE blocker "
699
+ "when outcome-independent reasons applied at spawn: "
700
+ + ", ".join(preknown_reasons)
701
+ ),
702
+ "file": "pipeline.state.json",
703
+ }
570
704
  return None
571
705
 
572
706
 
@@ -574,7 +708,7 @@ def pair_trigger_reason_completeness_violation(
574
708
  devlyn: pathlib.Path,
575
709
  source_verdicts: dict[str, str | None],
576
710
  ) -> dict[str, Any] | None:
577
- if rank(source_verdicts.get("mechanical")) >= 2 or rank(source_verdicts.get("judge")) >= 2:
711
+ if rank(source_verdicts.get("mechanical")) >= 2:
578
712
  return None
579
713
  state_path = devlyn / "pipeline.state.json"
580
714
  if not state_path.is_file():
@@ -614,10 +748,15 @@ def pair_trigger_reason_completeness_violation(
614
748
  }
615
749
 
616
750
 
617
- def pair_blocker(id_: str, message: str, file_: str | None = None) -> dict[str, Any]:
751
+ def pair_blocker(
752
+ id_: str,
753
+ message: str,
754
+ file_: str | None = None,
755
+ rule_id: str = "verify.pair.emission-contract",
756
+ ) -> dict[str, Any]:
618
757
  return {
619
758
  "id": id_,
620
- "rule_id": "verify.pair.emission-contract",
759
+ "rule_id": rule_id,
621
760
  "severity": "CRITICAL",
622
761
  "confidence": "high",
623
762
  "file": file_,
@@ -715,6 +854,17 @@ def detect_pair_stdout_contract_violations(
715
854
  risk_profile_violation["file"],
716
855
  )
717
856
  ]
857
+ state_violation = verify_state_contract_violation(devlyn)
858
+ if state_violation is not None:
859
+ source_verdicts["pair_judge"] = "BLOCKED"
860
+ return [
861
+ pair_blocker(
862
+ state_violation["id"],
863
+ state_violation["message"],
864
+ state_violation["file"],
865
+ state_violation["rule_id"],
866
+ )
867
+ ]
718
868
  if not required and not pair_trigger_present(devlyn):
719
869
  missing_violation = pair_trigger_missing_contract_violation(devlyn, source_verdicts)
720
870
  if missing_violation is not None:
@@ -892,7 +1042,15 @@ def self_test() -> int:
892
1042
  # the real shape write_state() sees on every VERIFY completion, not
893
1043
  # the pre-populated {} the other scenarios below seed for brevity.
894
1044
  (devlyn / "pipeline.state.json").write_text(
895
- json.dumps({"phases": {"verify": {"verdict": None, "sub_verdicts": None}}}),
1045
+ json.dumps({
1046
+ "phases": {
1047
+ "verify": {
1048
+ "verdict": None,
1049
+ "sub_verdicts": None,
1050
+ "judge_durations_ms": {"judge": 23, "pair_judge": 31},
1051
+ }
1052
+ }
1053
+ }),
896
1054
  encoding="utf-8",
897
1055
  )
898
1056
  (devlyn / "verify.findings.jsonl").write_text("", encoding="utf-8")
@@ -904,13 +1062,130 @@ def self_test() -> int:
904
1062
  assert state["phases"]["verify"]["sub_verdicts"] == {
905
1063
  "mechanical": "PASS", "judge": "PASS", "pair_judge": None,
906
1064
  }, state
1065
+ assert state["phases"]["verify"]["judge_durations_ms"] == {
1066
+ "judge": 23, "pair_judge": 31,
1067
+ }, state
1068
+
1069
+ # iter-0072 Amendment 3: generated schema-v3 runs mechanically prove
1070
+ # raw-goal persistence and required SURFACE_CLOSE dispatch.
1071
+ (devlyn / "pipeline.state.json").write_text(
1072
+ json.dumps({
1073
+ "version": "3.0",
1074
+ "complexity": "large",
1075
+ "source": {"type": "generated"},
1076
+ "phases": {"verify": {"verdict": None, "sub_verdicts": None}},
1077
+ }),
1078
+ encoding="utf-8",
1079
+ )
1080
+ findings, source_verdicts = read_findings(devlyn)
1081
+ summary = write_outputs(devlyn, findings, source_verdicts)
1082
+ assert summary["verdict"] == "BLOCKED", summary
1083
+ assert summary["source_verdicts"]["pair_judge"] == "BLOCKED", summary
1084
+ assert any(
1085
+ finding.get("rule_id") == "verify.state.goal-persistence-missing"
1086
+ for finding in findings
1087
+ ), findings
1088
+
1089
+ (devlyn / "pipeline.state.json").write_text(
1090
+ json.dumps({
1091
+ "version": "3.0",
1092
+ "complexity": "large",
1093
+ "source": {"type": "generated", "goal_path": "", "goal_sha256": "A" * 64},
1094
+ }),
1095
+ encoding="utf-8",
1096
+ )
1097
+ violation = verify_state_contract_violation(devlyn)
1098
+ assert violation is not None, violation
1099
+ assert violation["rule_id"] == "verify.state.goal-persistence-missing", violation
1100
+
1101
+ generated_source = {
1102
+ "type": "generated",
1103
+ "goal_path": ".devlyn/goal.raw.txt",
1104
+ "goal_sha256": "a" * 64,
1105
+ }
1106
+ (devlyn / "pipeline.state.json").write_text(
1107
+ json.dumps({
1108
+ "version": "3.0",
1109
+ "complexity": "medium",
1110
+ "source": generated_source,
1111
+ "phases": {
1112
+ "surface_close": None,
1113
+ "verify": {"verdict": None, "sub_verdicts": None},
1114
+ },
1115
+ }),
1116
+ encoding="utf-8",
1117
+ )
1118
+ findings, source_verdicts = read_findings(devlyn)
1119
+ summary = write_outputs(devlyn, findings, source_verdicts)
1120
+ assert summary["verdict"] == "BLOCKED", summary
1121
+ assert any(
1122
+ finding.get("rule_id") == "verify.state.surface-close-skipped"
1123
+ for finding in findings
1124
+ ), findings
1125
+
1126
+ for surface_entry in (
1127
+ {"verdict": "PASS"},
1128
+ {
1129
+ "verdict": None,
1130
+ "started_at": None,
1131
+ "skipped_reason": "auto_surface_close_claude_unavailable",
1132
+ },
1133
+ ):
1134
+ (devlyn / "pipeline.state.json").write_text(
1135
+ json.dumps({
1136
+ "version": "3.0",
1137
+ "complexity": "medium",
1138
+ "source": generated_source,
1139
+ "phases": {"surface_close": surface_entry},
1140
+ }),
1141
+ encoding="utf-8",
1142
+ )
1143
+ assert verify_state_contract_violation(devlyn) is None
1144
+
1145
+ (devlyn / "pipeline.state.json").write_text(
1146
+ json.dumps({
1147
+ "version": "3.0",
1148
+ "complexity": "medium",
1149
+ "source": generated_source,
1150
+ "phases": {
1151
+ "surface_close": {
1152
+ "verdict": None,
1153
+ "started_at": None,
1154
+ "skipped_reason": "claude-unavailable",
1155
+ },
1156
+ },
1157
+ }),
1158
+ encoding="utf-8",
1159
+ )
1160
+ assert verify_state_contract_violation(devlyn)["rule_id"] == "verify.state.surface-close-skipped"
1161
+
1162
+ for state_shape in (
1163
+ {
1164
+ "version": "3.0", "complexity": "medium",
1165
+ "source": {"type": "spec"}, "phases": {"surface_close": None},
1166
+ },
1167
+ {
1168
+ "version": "3.0", "complexity": "large",
1169
+ "source": generated_source, "phases": {"surface_close": None},
1170
+ },
1171
+ {
1172
+ "version": "2.0", "complexity": "medium",
1173
+ "source": {"type": "generated"}, "phases": {"surface_close": None},
1174
+ },
1175
+ ):
1176
+ (devlyn / "pipeline.state.json").write_text(
1177
+ json.dumps(state_shape), encoding="utf-8",
1178
+ )
1179
+ assert verify_state_contract_violation(devlyn) is None
907
1180
 
908
1181
  # 2026-07-04 field bug (iter-0060 G1): an AUTO pair trigger skipped on
909
1182
  # OTHER-engine unavailability spawns no second judge — pair_judge must
910
1183
  # be recorded null, never a synthesized PASS.
911
1184
  (devlyn / "pipeline.state.json").write_text(
912
1185
  json.dumps({
1186
+ "version": "3.0",
913
1187
  "mode": "spec",
1188
+ "risk_profile": {"pair_default_enabled": True},
914
1189
  "phases": {
915
1190
  "verify": {
916
1191
  "verdict": None,
@@ -1936,18 +2211,128 @@ def self_test() -> int:
1936
2211
  for finding in findings
1937
2212
  ), findings
1938
2213
 
2214
+ # Self-test: preknown_primary_blocker_requires_pair.
2215
+ (devlyn / "verify.pair.findings.jsonl").unlink(missing_ok=True)
1939
2216
  (devlyn / "verify.findings.jsonl").write_text(
1940
- json.dumps({"id": "j2", "severity": "HIGH"}) + "\n",
2217
+ json.dumps({"id": "j-preknown", "severity": "HIGH"}) + "\n",
2218
+ encoding="utf-8",
2219
+ )
2220
+ (devlyn / "pipeline.state.json").write_text(
2221
+ json.dumps({
2222
+ "version": "2.0",
2223
+ "mode": "spec",
2224
+ "pair_verify": True,
2225
+ "phases": {
2226
+ "verify": {
2227
+ "verdict": "PASS",
2228
+ "sub_verdicts": {},
2229
+ "pair_trigger": {
2230
+ "eligible": False,
2231
+ "reasons": [],
2232
+ "skipped_reason": "primary_judge_blocker",
2233
+ },
2234
+ }
2235
+ },
2236
+ }),
2237
+ encoding="utf-8",
2238
+ )
2239
+ findings, source_verdicts = read_findings(devlyn)
2240
+ summary = write_outputs(devlyn, findings, source_verdicts)
2241
+ assert summary["verdict"] == "BLOCKED", summary
2242
+ assert any(
2243
+ finding.get("id") == "verify-pair-trigger-primary-judge-blocker-preknown"
2244
+ and "mode.pair-verify" in str(finding.get("message"))
2245
+ for finding in findings
2246
+ ), findings
2247
+
2248
+ # Self-test: schema-v3 default pair reason is required before merge.
2249
+ (devlyn / "pipeline.state.json").write_text(
2250
+ json.dumps({
2251
+ "version": "3.0",
2252
+ "mode": "spec",
2253
+ "pair_verify": True,
2254
+ "phases": {
2255
+ "verify": {
2256
+ "verdict": "PASS",
2257
+ "sub_verdicts": {},
2258
+ "pair_trigger": {
2259
+ "eligible": True,
2260
+ "reasons": ["mode.pair-verify"],
2261
+ "skipped_reason": None,
2262
+ },
2263
+ }
2264
+ },
2265
+ }),
2266
+ encoding="utf-8",
2267
+ )
2268
+ (devlyn / "verify.pair.findings.jsonl").write_text(
2269
+ json.dumps({"id": "p-preknown", "severity": "LOW"}) + "\n",
2270
+ encoding="utf-8",
2271
+ )
2272
+ findings, source_verdicts = read_findings(devlyn)
2273
+ summary = write_outputs(devlyn, findings, source_verdicts)
2274
+ assert summary["verdict"] == "BLOCKED", summary
2275
+ assert any(
2276
+ finding.get("id") == "verify-pair-trigger-reasons-incomplete"
2277
+ and "pair.default" in str(finding.get("message"))
2278
+ for finding in findings
2279
+ ), findings
2280
+ state = loads_strict_json((devlyn / "pipeline.state.json").read_text(encoding="utf-8"))
2281
+ state["phases"]["verify"]["pair_trigger"]["reasons"].insert(0, "pair.default")
2282
+ (devlyn / "pipeline.state.json").write_text(json.dumps(state), encoding="utf-8")
2283
+ findings, source_verdicts = read_findings(devlyn)
2284
+ summary = write_outputs(devlyn, findings, source_verdicts)
2285
+ assert summary["verdict"] == "NEEDS_WORK", summary
2286
+ assert summary["source_verdicts"]["judge"] == "NEEDS_WORK", summary
2287
+ assert summary["source_verdicts"]["pair_judge"] == "PASS_WITH_ISSUES", summary
2288
+ assert '"id":"p-preknown"' in (
2289
+ devlyn / "verify-merged.findings.jsonl"
2290
+ ).read_text(encoding="utf-8"), findings
2291
+
2292
+ # Self-test: archived-v2 sequential primary blocker remains legal.
2293
+ (devlyn / "verify.pair.findings.jsonl").unlink()
2294
+ (devlyn / "pipeline.state.json").write_text(
2295
+ json.dumps({
2296
+ "version": "2.0",
2297
+ "mode": "spec",
2298
+ "phases": {
2299
+ "verify": {
2300
+ "verdict": "PASS",
2301
+ "sub_verdicts": {},
2302
+ "pair_trigger": {
2303
+ "eligible": False,
2304
+ "reasons": [],
2305
+ "skipped_reason": "primary_judge_blocker",
2306
+ },
2307
+ }
2308
+ },
2309
+ }),
1941
2310
  encoding="utf-8",
1942
2311
  )
1943
2312
  findings, source_verdicts = read_findings(devlyn)
1944
2313
  summary = write_outputs(devlyn, findings, source_verdicts)
1945
2314
  assert summary["verdict"] == "NEEDS_WORK", summary
2315
+ assert summary["source_verdicts"]["pair_judge"] is None, summary
1946
2316
  assert not any(
1947
- finding.get("id") == "verify-pair-trigger-primary-judge-blocker-unsupported"
2317
+ finding.get("id") in {
2318
+ "verify-pair-trigger-primary-judge-blocker-unsupported",
2319
+ "verify-pair-trigger-primary-judge-blocker-preknown",
2320
+ }
2321
+ for finding in findings
2322
+ ), findings
2323
+ # Replay the same state as schema v3 and require the retired-skip blocker.
2324
+ state = loads_strict_json((devlyn / "pipeline.state.json").read_text(encoding="utf-8"))
2325
+ state["version"] = "3.0"
2326
+ (devlyn / "pipeline.state.json").write_text(json.dumps(state), encoding="utf-8")
2327
+ findings, source_verdicts = read_findings(devlyn)
2328
+ summary = write_outputs(devlyn, findings, source_verdicts)
2329
+ assert summary["verdict"] == "BLOCKED", summary
2330
+ assert any(
2331
+ finding.get("id") == "verify-pair-trigger-primary-judge-blocker-retired"
1948
2332
  for finding in findings
1949
2333
  ), findings
1950
2334
  (devlyn / "verify.findings.jsonl").write_text("", encoding="utf-8")
2335
+ (devlyn / "verify.pair.findings.jsonl").write_text("", encoding="utf-8")
1951
2336
 
1952
2337
  (devlyn / "pipeline.state.json").write_text(
1953
2338
  json.dumps({
@@ -1984,6 +2369,138 @@ def self_test() -> int:
1984
2369
  state = loads_strict_json((devlyn / "pipeline.state.json").read_text(encoding="utf-8"))
1985
2370
  assert summary["verdict"] == "PASS", summary
1986
2371
  assert state["phases"]["verify"]["sub_verdicts"]["pair_judge"] == "PASS", state
2372
+
2373
+ # iter-0083: canonical summary verdict conservation.
2374
+ iter_0083_paths = (
2375
+ "pipeline.state.json",
2376
+ "verify-mechanical.findings.jsonl",
2377
+ "verify.findings.jsonl",
2378
+ "verify.pair.findings.jsonl",
2379
+ "verify.pair-judge.findings.jsonl",
2380
+ "pair-judge.summary.json",
2381
+ "codex-primary-judge.summary.json",
2382
+ "grok-judge.summary.json",
2383
+ "verify.pair.timeout.json",
2384
+ "codex-judge.stdout",
2385
+ "claude-judge.stdout",
2386
+ )
2387
+
2388
+ def iter_0083_reset() -> None:
2389
+ for name in iter_0083_paths:
2390
+ (devlyn / name).unlink(missing_ok=True)
2391
+
2392
+ def iter_0083_case(
2393
+ summary_payload: object | None,
2394
+ severity: str | None,
2395
+ *,
2396
+ carrier: str | None = "verify.pair.findings.jsonl",
2397
+ other_summary: str | None = None,
2398
+ timeout: bool = False,
2399
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
2400
+ iter_0083_reset()
2401
+ if carrier is not None:
2402
+ content = (
2403
+ json.dumps({"id": "iter-0083", "severity": severity}) + "\n"
2404
+ if severity
2405
+ else ""
2406
+ )
2407
+ (devlyn / carrier).write_text(content, encoding="utf-8")
2408
+ if summary_payload is not None:
2409
+ content = (
2410
+ summary_payload
2411
+ if isinstance(summary_payload, str)
2412
+ else json.dumps(summary_payload)
2413
+ )
2414
+ (devlyn / "pair-judge.summary.json").write_text(content, encoding="utf-8")
2415
+ if other_summary is not None:
2416
+ (devlyn / other_summary).write_text(
2417
+ json.dumps({"verdict": "BLOCKED"}), encoding="utf-8"
2418
+ )
2419
+ if timeout:
2420
+ (devlyn / "verify.pair.timeout.json").write_text(
2421
+ json.dumps({"engine": "claude", "budget_seconds": 600}),
2422
+ encoding="utf-8",
2423
+ )
2424
+ case_findings, case_source_verdicts = read_findings(devlyn)
2425
+ return case_findings, write_outputs(devlyn, case_findings, case_source_verdicts)
2426
+
2427
+ for case_id, verdict, severity, expected in (
2428
+ ("P1", "NEEDS_WORK", "INFO", "NEEDS_WORK"),
2429
+ ("P2", "NEEDS_WORK", "LOW", "NEEDS_WORK"),
2430
+ ("P3", "NEEDS_WORK", "MEDIUM", "NEEDS_WORK"),
2431
+ ("P4", "PASS", "HIGH", "NEEDS_WORK"),
2432
+ ("P5", "BLOCKED", "INFO", "BLOCKED"),
2433
+ ("P6", "FAIL", "INFO", "NEEDS_WORK"),
2434
+ ("P7", "PASS", None, "PASS"),
2435
+ ):
2436
+ _, summary = iter_0083_case({"verdict": verdict}, severity)
2437
+ assert summary["source_verdicts"]["pair_judge"] == expected, case_id
2438
+ assert summary["verdict"] == expected, case_id
2439
+
2440
+ for case_id, severity, expected in (
2441
+ ("P8-INFO", "INFO", "PASS"),
2442
+ ("P8-LOW", "LOW", "PASS_WITH_ISSUES"),
2443
+ ):
2444
+ _, summary = iter_0083_case(None, severity)
2445
+ assert summary["source_verdicts"]["pair_judge"] == expected, case_id
2446
+ assert summary["verdict"] == expected, case_id
2447
+
2448
+ def assert_iter_0083_blocked(
2449
+ case_id: str,
2450
+ case_findings: list[dict[str, Any]],
2451
+ summary: dict[str, Any],
2452
+ ) -> None:
2453
+ assert summary["source_verdicts"]["pair_judge"] == "BLOCKED", case_id
2454
+ assert summary["verdict"] == "BLOCKED", case_id
2455
+ assert any(
2456
+ finding.get("source") == "pair_judge"
2457
+ and finding.get("severity") == "CRITICAL"
2458
+ and finding.get("file") == "pair-judge.summary.json"
2459
+ for finding in case_findings
2460
+ ), case_id
2461
+
2462
+ for case_id, payload in (
2463
+ ("N1-malformed", "{"),
2464
+ ("N1-non-object", []),
2465
+ ("N1-unknown", {"verdict": "UNKNOWN"}),
2466
+ ("N2", {}),
2467
+ ("N7", {"verdict": "TIMEOUT"}),
2468
+ ):
2469
+ case_findings, summary = iter_0083_case(payload, "INFO")
2470
+ assert_iter_0083_blocked(case_id, case_findings, summary)
2471
+
2472
+ _, summary = iter_0083_case({"verdict": "BLOCKED"}, None, carrier=None)
2473
+ assert summary["source_verdicts"]["pair_judge"] is None, summary
2474
+ assert summary["verdict"] == "PASS", summary
2475
+
2476
+ _, summary = iter_0083_case(
2477
+ {"verdict": "PASS"},
2478
+ "INFO",
2479
+ other_summary="codex-primary-judge.summary.json",
2480
+ )
2481
+ assert summary["source_verdicts"]["pair_judge"] == "PASS", "N4"
2482
+ assert summary["verdict"] == "PASS", "N4"
2483
+
2484
+ _, summary = iter_0083_case(
2485
+ None,
2486
+ "INFO",
2487
+ other_summary="grok-judge.summary.json",
2488
+ )
2489
+ assert summary["source_verdicts"]["pair_judge"] == "PASS", "N5"
2490
+ assert summary["verdict"] == "PASS", "N5"
2491
+
2492
+ _, summary = iter_0083_case(
2493
+ {"verdict": "NEEDS_WORK"},
2494
+ "INFO",
2495
+ carrier="verify.pair-judge.findings.jsonl",
2496
+ )
2497
+ assert summary["source_verdicts"]["pair_judge"] == "NEEDS_WORK", "N6"
2498
+ assert summary["verdict"] == "NEEDS_WORK", "N6"
2499
+
2500
+ case_findings, summary = iter_0083_case({"verdict": "BLOCKED"}, None, timeout=True)
2501
+ assert case_findings == [], "VERIFY-JUDGE-001"
2502
+ assert summary["source_verdicts"]["pair_judge"] == "BLOCKED", "VERIFY-JUDGE-001"
2503
+ assert summary["verdict"] == "BLOCKED", "VERIFY-JUDGE-001"
1987
2504
  return 0
1988
2505
 
1989
2506