devlyn-cli 2.9.2 → 2.10.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.
Files changed (29) hide show
  1. package/AGENTS.md +3 -3
  2. package/CLAUDE.md +4 -4
  3. package/README.md +6 -6
  4. package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
  5. package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
  6. package/bin/devlyn.js +68 -7
  7. package/config/skills/_shared/adapters/README.md +2 -2
  8. package/config/skills/_shared/adapters/grok.md +77 -0
  9. package/config/skills/_shared/archive_run.py +35 -1
  10. package/config/skills/_shared/collect-codex-findings.py +15 -10
  11. package/config/skills/_shared/engine-doctor.sh +6 -3
  12. package/config/skills/_shared/engine-preflight.md +12 -5
  13. package/config/skills/_shared/resolve-bootstrap.py +630 -0
  14. package/config/skills/_shared/resolve-stop-hook.py +325 -0
  15. package/config/skills/_shared/state-phase-write.py +2284 -41
  16. package/config/skills/_shared/terminal-claim-check.py +537 -0
  17. package/config/skills/_shared/verify-merge-findings.py +363 -16
  18. package/config/skills/devlyn:engines/SKILL.md +1 -1
  19. package/config/skills/devlyn:queue/SKILL.md +12 -0
  20. package/config/skills/devlyn:resolve/SKILL.md +55 -73
  21. package/config/skills/devlyn:resolve/references/free-form-mode.md +3 -2
  22. package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
  23. package/config/skills/devlyn:resolve/references/phases/verify.md +65 -96
  24. package/config/skills/devlyn:resolve/references/state-schema.md +29 -22
  25. package/optional-skills/devlyn:reap/SKILL.md +1 -1
  26. package/optional-skills/devlyn:reap/scripts/reap.sh +25 -13
  27. package/optional-skills/devlyn:reap/scripts/scan.sh +15 -1
  28. package/package.json +1 -1
  29. package/scripts/lint-skills.sh +131 -67
@@ -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"}:
@@ -338,6 +343,63 @@ def risk_profile_contract_violation(devlyn: pathlib.Path) -> dict[str, Any] | No
338
343
  return None
339
344
 
340
345
 
346
+ def verify_state_contract_violation(devlyn: pathlib.Path) -> dict[str, Any] | None:
347
+ state_path = devlyn / "pipeline.state.json"
348
+ if not state_path.is_file():
349
+ return None
350
+ try:
351
+ state = loads_strict_json(state_path.read_text(encoding="utf-8"))
352
+ except ValueError:
353
+ return None
354
+ if not isinstance(state, dict) or not state_uses_default_pair_contract(state):
355
+ return None
356
+ source = state.get("source")
357
+ if not isinstance(source, dict) or source.get("type") != "generated":
358
+ return None
359
+ goal_path = source.get("goal_path")
360
+ goal_sha256 = source.get("goal_sha256")
361
+ if (
362
+ not isinstance(goal_path, str)
363
+ or not goal_path
364
+ or not isinstance(goal_sha256, str)
365
+ or re.fullmatch(r"[0-9a-f]{64}", goal_sha256) is None
366
+ ):
367
+ rule = "verify.state.goal-persistence-missing"
368
+ return {
369
+ "id": rule,
370
+ "rule_id": rule,
371
+ "message": (
372
+ "schema-v3 generated runs require source.goal_path as a non-empty string "
373
+ "and source.goal_sha256 as 64 lowercase hexadecimal characters."
374
+ ),
375
+ "file": "pipeline.state.json",
376
+ }
377
+ if state.get("complexity") in {"trivial", "medium"}:
378
+ phases = state.get("phases")
379
+ surface_close = phases.get("surface_close") if isinstance(phases, dict) else None
380
+ skipped = (
381
+ isinstance(surface_close, dict)
382
+ and surface_close.get("verdict") is None
383
+ and surface_close.get("started_at") is None
384
+ and surface_close.get("skipped_reason") == "auto_surface_close_claude_unavailable"
385
+ )
386
+ if (
387
+ not isinstance(surface_close, dict)
388
+ or (surface_close.get("verdict") is None and not skipped)
389
+ ):
390
+ rule = "verify.state.surface-close-skipped"
391
+ return {
392
+ "id": rule,
393
+ "rule_id": rule,
394
+ "message": (
395
+ "schema-v3 generated trivial/medium runs require completed SURFACE_CLOSE "
396
+ "or its canonical automatic Claude-unavailable skip before VERIFY merge."
397
+ ),
398
+ "file": "pipeline.state.json",
399
+ }
400
+ return None
401
+
402
+
341
403
  def source_spec_text(state: dict[str, Any]) -> str | None:
342
404
  source = state.get("source") if isinstance(state.get("source"), dict) else {}
343
405
  for key in ("spec_path", "criteria_path"):
@@ -424,6 +486,11 @@ def state_pair_trigger_reasons(
424
486
  verify_state = state.get("verify") if isinstance(state.get("verify"), dict) else {}
425
487
  risk_profile = state.get("risk_profile") if isinstance(state.get("risk_profile"), dict) else {}
426
488
  reasons: list[str] = []
489
+ if (
490
+ state_uses_default_pair_contract(state)
491
+ and risk_profile.get("pair_default_enabled") is not False
492
+ ):
493
+ reasons.append("pair.default")
427
494
  if state.get("mode") == "verify-only":
428
495
  reasons.append("mode.verify-only")
429
496
  if state.get("pair_verify") is True:
@@ -455,11 +522,19 @@ def state_pair_trigger_reasons(
455
522
  return reasons
456
523
 
457
524
 
525
+ def outcome_independent_reasons(devlyn: pathlib.Path) -> list[str]:
526
+ return [
527
+ reason
528
+ for reason in state_pair_trigger_reasons(devlyn, {})
529
+ if reason not in ("coverage.failed", "mechanical.warning", "judge.warning")
530
+ ]
531
+
532
+
458
533
  def pair_trigger_missing_contract_violation(
459
534
  devlyn: pathlib.Path,
460
535
  source_verdicts: dict[str, str | None],
461
536
  ) -> dict[str, Any] | None:
462
- if rank(source_verdicts.get("mechanical")) >= 2 or rank(source_verdicts.get("judge")) >= 2:
537
+ if rank(source_verdicts.get("mechanical")) >= 2:
463
538
  return None
464
539
  reasons = state_pair_trigger_reasons(devlyn, source_verdicts)
465
540
  if not reasons:
@@ -558,15 +633,36 @@ def pair_trigger_skip_contract_violation(
558
633
  ),
559
634
  "file": "pipeline.state.json",
560
635
  }
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
- }
636
+ if skipped_reason == "primary_judge_blocker":
637
+ if state_uses_default_pair_contract(state):
638
+ return {
639
+ "id": "verify-pair-trigger-primary-judge-blocker-retired",
640
+ "message": (
641
+ "pair_trigger skipped_reason primary_judge_blocker is archived-v2.0 "
642
+ "state only; schema-v3 runs must dispatch the pair-JUDGE."
643
+ ),
644
+ "file": "pipeline.state.json",
645
+ }
646
+ if rank(source_verdicts.get("judge")) < 2:
647
+ return {
648
+ "id": "verify-pair-trigger-primary-judge-blocker-unsupported",
649
+ "message": (
650
+ "pair_trigger skipped_reason primary_judge_blocker requires a "
651
+ "verdict-binding primary JUDGE finding."
652
+ ),
653
+ "file": "pipeline.state.json",
654
+ }
655
+ preknown_reasons = outcome_independent_reasons(devlyn)
656
+ if preknown_reasons:
657
+ return {
658
+ "id": "verify-pair-trigger-primary-judge-blocker-preknown",
659
+ "message": (
660
+ "pair_trigger cannot skip the pair-JUDGE for a primary JUDGE blocker "
661
+ "when outcome-independent reasons applied at spawn: "
662
+ + ", ".join(preknown_reasons)
663
+ ),
664
+ "file": "pipeline.state.json",
665
+ }
570
666
  return None
571
667
 
572
668
 
@@ -574,7 +670,7 @@ def pair_trigger_reason_completeness_violation(
574
670
  devlyn: pathlib.Path,
575
671
  source_verdicts: dict[str, str | None],
576
672
  ) -> dict[str, Any] | None:
577
- if rank(source_verdicts.get("mechanical")) >= 2 or rank(source_verdicts.get("judge")) >= 2:
673
+ if rank(source_verdicts.get("mechanical")) >= 2:
578
674
  return None
579
675
  state_path = devlyn / "pipeline.state.json"
580
676
  if not state_path.is_file():
@@ -614,10 +710,15 @@ def pair_trigger_reason_completeness_violation(
614
710
  }
615
711
 
616
712
 
617
- def pair_blocker(id_: str, message: str, file_: str | None = None) -> dict[str, Any]:
713
+ def pair_blocker(
714
+ id_: str,
715
+ message: str,
716
+ file_: str | None = None,
717
+ rule_id: str = "verify.pair.emission-contract",
718
+ ) -> dict[str, Any]:
618
719
  return {
619
720
  "id": id_,
620
- "rule_id": "verify.pair.emission-contract",
721
+ "rule_id": rule_id,
621
722
  "severity": "CRITICAL",
622
723
  "confidence": "high",
623
724
  "file": file_,
@@ -715,6 +816,17 @@ def detect_pair_stdout_contract_violations(
715
816
  risk_profile_violation["file"],
716
817
  )
717
818
  ]
819
+ state_violation = verify_state_contract_violation(devlyn)
820
+ if state_violation is not None:
821
+ source_verdicts["pair_judge"] = "BLOCKED"
822
+ return [
823
+ pair_blocker(
824
+ state_violation["id"],
825
+ state_violation["message"],
826
+ state_violation["file"],
827
+ state_violation["rule_id"],
828
+ )
829
+ ]
718
830
  if not required and not pair_trigger_present(devlyn):
719
831
  missing_violation = pair_trigger_missing_contract_violation(devlyn, source_verdicts)
720
832
  if missing_violation is not None:
@@ -892,7 +1004,15 @@ def self_test() -> int:
892
1004
  # the real shape write_state() sees on every VERIFY completion, not
893
1005
  # the pre-populated {} the other scenarios below seed for brevity.
894
1006
  (devlyn / "pipeline.state.json").write_text(
895
- json.dumps({"phases": {"verify": {"verdict": None, "sub_verdicts": None}}}),
1007
+ json.dumps({
1008
+ "phases": {
1009
+ "verify": {
1010
+ "verdict": None,
1011
+ "sub_verdicts": None,
1012
+ "judge_durations_ms": {"judge": 23, "pair_judge": 31},
1013
+ }
1014
+ }
1015
+ }),
896
1016
  encoding="utf-8",
897
1017
  )
898
1018
  (devlyn / "verify.findings.jsonl").write_text("", encoding="utf-8")
@@ -904,13 +1024,130 @@ def self_test() -> int:
904
1024
  assert state["phases"]["verify"]["sub_verdicts"] == {
905
1025
  "mechanical": "PASS", "judge": "PASS", "pair_judge": None,
906
1026
  }, state
1027
+ assert state["phases"]["verify"]["judge_durations_ms"] == {
1028
+ "judge": 23, "pair_judge": 31,
1029
+ }, state
1030
+
1031
+ # iter-0072 Amendment 3: generated schema-v3 runs mechanically prove
1032
+ # raw-goal persistence and required SURFACE_CLOSE dispatch.
1033
+ (devlyn / "pipeline.state.json").write_text(
1034
+ json.dumps({
1035
+ "version": "3.0",
1036
+ "complexity": "large",
1037
+ "source": {"type": "generated"},
1038
+ "phases": {"verify": {"verdict": None, "sub_verdicts": None}},
1039
+ }),
1040
+ encoding="utf-8",
1041
+ )
1042
+ findings, source_verdicts = read_findings(devlyn)
1043
+ summary = write_outputs(devlyn, findings, source_verdicts)
1044
+ assert summary["verdict"] == "BLOCKED", summary
1045
+ assert summary["source_verdicts"]["pair_judge"] == "BLOCKED", summary
1046
+ assert any(
1047
+ finding.get("rule_id") == "verify.state.goal-persistence-missing"
1048
+ for finding in findings
1049
+ ), findings
1050
+
1051
+ (devlyn / "pipeline.state.json").write_text(
1052
+ json.dumps({
1053
+ "version": "3.0",
1054
+ "complexity": "large",
1055
+ "source": {"type": "generated", "goal_path": "", "goal_sha256": "A" * 64},
1056
+ }),
1057
+ encoding="utf-8",
1058
+ )
1059
+ violation = verify_state_contract_violation(devlyn)
1060
+ assert violation is not None, violation
1061
+ assert violation["rule_id"] == "verify.state.goal-persistence-missing", violation
1062
+
1063
+ generated_source = {
1064
+ "type": "generated",
1065
+ "goal_path": ".devlyn/goal.raw.txt",
1066
+ "goal_sha256": "a" * 64,
1067
+ }
1068
+ (devlyn / "pipeline.state.json").write_text(
1069
+ json.dumps({
1070
+ "version": "3.0",
1071
+ "complexity": "medium",
1072
+ "source": generated_source,
1073
+ "phases": {
1074
+ "surface_close": None,
1075
+ "verify": {"verdict": None, "sub_verdicts": None},
1076
+ },
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 any(
1084
+ finding.get("rule_id") == "verify.state.surface-close-skipped"
1085
+ for finding in findings
1086
+ ), findings
1087
+
1088
+ for surface_entry in (
1089
+ {"verdict": "PASS"},
1090
+ {
1091
+ "verdict": None,
1092
+ "started_at": None,
1093
+ "skipped_reason": "auto_surface_close_claude_unavailable",
1094
+ },
1095
+ ):
1096
+ (devlyn / "pipeline.state.json").write_text(
1097
+ json.dumps({
1098
+ "version": "3.0",
1099
+ "complexity": "medium",
1100
+ "source": generated_source,
1101
+ "phases": {"surface_close": surface_entry},
1102
+ }),
1103
+ encoding="utf-8",
1104
+ )
1105
+ assert verify_state_contract_violation(devlyn) is None
1106
+
1107
+ (devlyn / "pipeline.state.json").write_text(
1108
+ json.dumps({
1109
+ "version": "3.0",
1110
+ "complexity": "medium",
1111
+ "source": generated_source,
1112
+ "phases": {
1113
+ "surface_close": {
1114
+ "verdict": None,
1115
+ "started_at": None,
1116
+ "skipped_reason": "claude-unavailable",
1117
+ },
1118
+ },
1119
+ }),
1120
+ encoding="utf-8",
1121
+ )
1122
+ assert verify_state_contract_violation(devlyn)["rule_id"] == "verify.state.surface-close-skipped"
1123
+
1124
+ for state_shape in (
1125
+ {
1126
+ "version": "3.0", "complexity": "medium",
1127
+ "source": {"type": "spec"}, "phases": {"surface_close": None},
1128
+ },
1129
+ {
1130
+ "version": "3.0", "complexity": "large",
1131
+ "source": generated_source, "phases": {"surface_close": None},
1132
+ },
1133
+ {
1134
+ "version": "2.0", "complexity": "medium",
1135
+ "source": {"type": "generated"}, "phases": {"surface_close": None},
1136
+ },
1137
+ ):
1138
+ (devlyn / "pipeline.state.json").write_text(
1139
+ json.dumps(state_shape), encoding="utf-8",
1140
+ )
1141
+ assert verify_state_contract_violation(devlyn) is None
907
1142
 
908
1143
  # 2026-07-04 field bug (iter-0060 G1): an AUTO pair trigger skipped on
909
1144
  # OTHER-engine unavailability spawns no second judge — pair_judge must
910
1145
  # be recorded null, never a synthesized PASS.
911
1146
  (devlyn / "pipeline.state.json").write_text(
912
1147
  json.dumps({
1148
+ "version": "3.0",
913
1149
  "mode": "spec",
1150
+ "risk_profile": {"pair_default_enabled": True},
914
1151
  "phases": {
915
1152
  "verify": {
916
1153
  "verdict": None,
@@ -1936,18 +2173,128 @@ def self_test() -> int:
1936
2173
  for finding in findings
1937
2174
  ), findings
1938
2175
 
2176
+ # Self-test: preknown_primary_blocker_requires_pair.
2177
+ (devlyn / "verify.pair.findings.jsonl").unlink(missing_ok=True)
1939
2178
  (devlyn / "verify.findings.jsonl").write_text(
1940
- json.dumps({"id": "j2", "severity": "HIGH"}) + "\n",
2179
+ json.dumps({"id": "j-preknown", "severity": "HIGH"}) + "\n",
2180
+ encoding="utf-8",
2181
+ )
2182
+ (devlyn / "pipeline.state.json").write_text(
2183
+ json.dumps({
2184
+ "version": "2.0",
2185
+ "mode": "spec",
2186
+ "pair_verify": True,
2187
+ "phases": {
2188
+ "verify": {
2189
+ "verdict": "PASS",
2190
+ "sub_verdicts": {},
2191
+ "pair_trigger": {
2192
+ "eligible": False,
2193
+ "reasons": [],
2194
+ "skipped_reason": "primary_judge_blocker",
2195
+ },
2196
+ }
2197
+ },
2198
+ }),
2199
+ encoding="utf-8",
2200
+ )
2201
+ findings, source_verdicts = read_findings(devlyn)
2202
+ summary = write_outputs(devlyn, findings, source_verdicts)
2203
+ assert summary["verdict"] == "BLOCKED", summary
2204
+ assert any(
2205
+ finding.get("id") == "verify-pair-trigger-primary-judge-blocker-preknown"
2206
+ and "mode.pair-verify" in str(finding.get("message"))
2207
+ for finding in findings
2208
+ ), findings
2209
+
2210
+ # Self-test: schema-v3 default pair reason is required before merge.
2211
+ (devlyn / "pipeline.state.json").write_text(
2212
+ json.dumps({
2213
+ "version": "3.0",
2214
+ "mode": "spec",
2215
+ "pair_verify": True,
2216
+ "phases": {
2217
+ "verify": {
2218
+ "verdict": "PASS",
2219
+ "sub_verdicts": {},
2220
+ "pair_trigger": {
2221
+ "eligible": True,
2222
+ "reasons": ["mode.pair-verify"],
2223
+ "skipped_reason": None,
2224
+ },
2225
+ }
2226
+ },
2227
+ }),
2228
+ encoding="utf-8",
2229
+ )
2230
+ (devlyn / "verify.pair.findings.jsonl").write_text(
2231
+ json.dumps({"id": "p-preknown", "severity": "LOW"}) + "\n",
2232
+ encoding="utf-8",
2233
+ )
2234
+ findings, source_verdicts = read_findings(devlyn)
2235
+ summary = write_outputs(devlyn, findings, source_verdicts)
2236
+ assert summary["verdict"] == "BLOCKED", summary
2237
+ assert any(
2238
+ finding.get("id") == "verify-pair-trigger-reasons-incomplete"
2239
+ and "pair.default" in str(finding.get("message"))
2240
+ for finding in findings
2241
+ ), findings
2242
+ state = loads_strict_json((devlyn / "pipeline.state.json").read_text(encoding="utf-8"))
2243
+ state["phases"]["verify"]["pair_trigger"]["reasons"].insert(0, "pair.default")
2244
+ (devlyn / "pipeline.state.json").write_text(json.dumps(state), encoding="utf-8")
2245
+ findings, source_verdicts = read_findings(devlyn)
2246
+ summary = write_outputs(devlyn, findings, source_verdicts)
2247
+ assert summary["verdict"] == "NEEDS_WORK", summary
2248
+ assert summary["source_verdicts"]["judge"] == "NEEDS_WORK", summary
2249
+ assert summary["source_verdicts"]["pair_judge"] == "PASS_WITH_ISSUES", summary
2250
+ assert '"id":"p-preknown"' in (
2251
+ devlyn / "verify-merged.findings.jsonl"
2252
+ ).read_text(encoding="utf-8"), findings
2253
+
2254
+ # Self-test: archived-v2 sequential primary blocker remains legal.
2255
+ (devlyn / "verify.pair.findings.jsonl").unlink()
2256
+ (devlyn / "pipeline.state.json").write_text(
2257
+ json.dumps({
2258
+ "version": "2.0",
2259
+ "mode": "spec",
2260
+ "phases": {
2261
+ "verify": {
2262
+ "verdict": "PASS",
2263
+ "sub_verdicts": {},
2264
+ "pair_trigger": {
2265
+ "eligible": False,
2266
+ "reasons": [],
2267
+ "skipped_reason": "primary_judge_blocker",
2268
+ },
2269
+ }
2270
+ },
2271
+ }),
1941
2272
  encoding="utf-8",
1942
2273
  )
1943
2274
  findings, source_verdicts = read_findings(devlyn)
1944
2275
  summary = write_outputs(devlyn, findings, source_verdicts)
1945
2276
  assert summary["verdict"] == "NEEDS_WORK", summary
2277
+ assert summary["source_verdicts"]["pair_judge"] is None, summary
1946
2278
  assert not any(
1947
- finding.get("id") == "verify-pair-trigger-primary-judge-blocker-unsupported"
2279
+ finding.get("id") in {
2280
+ "verify-pair-trigger-primary-judge-blocker-unsupported",
2281
+ "verify-pair-trigger-primary-judge-blocker-preknown",
2282
+ }
2283
+ for finding in findings
2284
+ ), findings
2285
+ # Replay the same state as schema v3 and require the retired-skip blocker.
2286
+ state = loads_strict_json((devlyn / "pipeline.state.json").read_text(encoding="utf-8"))
2287
+ state["version"] = "3.0"
2288
+ (devlyn / "pipeline.state.json").write_text(json.dumps(state), encoding="utf-8")
2289
+ findings, source_verdicts = read_findings(devlyn)
2290
+ summary = write_outputs(devlyn, findings, source_verdicts)
2291
+ assert summary["verdict"] == "BLOCKED", summary
2292
+ assert any(
2293
+ finding.get("id") == "verify-pair-trigger-primary-judge-blocker-retired"
1948
2294
  for finding in findings
1949
2295
  ), findings
1950
2296
  (devlyn / "verify.findings.jsonl").write_text("", encoding="utf-8")
2297
+ (devlyn / "verify.pair.findings.jsonl").write_text("", encoding="utf-8")
1951
2298
 
1952
2299
  (devlyn / "pipeline.state.json").write_text(
1953
2300
  json.dumps({
@@ -25,7 +25,7 @@ DEVLYN_SHARED_DIR="$(cd "$DEVLYN_SKILL_DIR/../_shared" && pwd)"
25
25
  ## No args — status + how to choose
26
26
 
27
27
  1. Read `cwd/.devlyn/engines.json`. Absent → report "no pins — auto-detection active".
28
- 2. Run `bash "$DEVLYN_SHARED_DIR/engine-doctor.sh"` — read-only; never installs anything or writes `.devlyn/engines.json`. It detects, per target (`claude`, `codex`, `omp`, `pi`), whether its binary is present, whether `_shared/adapters/<name>.md` exists, and whether that makes it `pin_eligible` today. Its trailing line is either a pair-judge-diversity confirmation or a recommendation to add a second adapter-valid engine (`autoresearch/iterations/0045-model-arm-drift.md`: different model tiers hit different failure-mode blind spots, so VERIFY pair-judge/risk-probes need ≥2 to ever fire).
28
+ 2. Run `bash "$DEVLYN_SHARED_DIR/engine-doctor.sh"` — read-only; never installs anything or writes `.devlyn/engines.json`. It detects, per target in the doctor's catalog, whether its binary is present, whether `_shared/adapters/<name>.md` exists, and whether that makes it `pin_eligible` today. Its trailing line is either a pair-judge-diversity confirmation or a recommendation to add a second adapter-valid engine (`autoresearch/iterations/0045-model-arm-drift.md`: different model tiers hit different failure-mode blind spots, so VERIFY pair-judge/risk-probes need ≥2 to ever fire).
29
29
  3. Print the role table. Executor's "Available engines" = rows where `pin_eligible: yes` and `role` includes `executor` (i.e. not `judge-only`). Pair judge's "Available engines" = rows where `pin_eligible: yes` and `role` includes `judge` (`executor+judge` or `judge-only`):
30
30
 
31
31
  ```
@@ -9,6 +9,17 @@ Utility front-end for the intent-queue contract in the project instructions ("In
9
9
  $ARGUMENTS
10
10
  </args>
11
11
 
12
+ For `drain`, resolve the installed shared scripts before the first item:
13
+
14
+ ```bash
15
+ DEVLYN_SKILL_DIR="${CLAUDE_SKILL_DIR:-__DEVLYN_SKILL_DIR__}"
16
+ if [ "$DEVLYN_SKILL_DIR" = "__DEVLYN_SKILL_DIR__" ] || [ ! -d "$DEVLYN_SKILL_DIR/../_shared" ]; then
17
+ echo "BLOCKED:shared-dir-unresolved: $DEVLYN_SKILL_DIR/../_shared" >&2
18
+ exit 1
19
+ fi
20
+ DEVLYN_SHARED_DIR="$(cd "$DEVLYN_SKILL_DIR/../_shared" && pwd)"
21
+ ```
22
+
12
23
  ## No args — status
13
24
 
14
25
  Read `docs/specs/queue.md` (absent → report "queue empty — nothing staged" and how to add). Print pending `[ ]`, done `[x]`, and blocked `[F]` counts, the next item up, and one usage line per subcommand.
@@ -19,6 +30,7 @@ Read `docs/specs/queue.md` (absent → report "queue empty — nothing staged" a
19
30
  - `drain` — serial drain per the project-instructions contract. For each pending item, in order:
20
31
  1. Spec it if unspecced (the queue entry is the user's go-ahead). Unattended assumptions may only take scope-narrowing, reversible, non-user-visible defaults; material ambiguity (user-visible behavior, data/state semantics, new files/scripts/flags, implementation surface) → mark `[F] needs-review: <question>` and continue to the next item.
21
32
  2. Run `/devlyn:resolve --spec <path>` hands-free.
33
+ After every resolve invocation, run `python3 "$DEVLYN_SHARED_DIR/terminal-claim-check.py" .`; exit 79 marks `[F] FAILED-INCOMPLETE` from the predicate, never from the session self-report.
22
34
  3. Outer loop on the terminal verdict: PASS → mark `[x]`. Findings-backed verdicts (NEEDS_WORK, verify/build-gate exhaustion) → amend the spec (recorded in the spec file), re-run — at most 3 outer iterations. Infrastructure / invalid-input / engine-availability / implement-empty BLOCKED verdicts are not spec-amendable → mark `[F] <verdict>` immediately.
23
35
  4. A blocked item never halts the queue; continue.
24
36
  5. When the queue is drained (or the session must stop), emit the drain report: per-item verdict, every logged assumption, and the commit range produced.