devlyn-cli 2.10.0 → 3.0.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 (82) hide show
  1. package/AGENTS.md +4 -9
  2. package/CLAUDE.md +11 -15
  3. package/README.md +31 -7
  4. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.json +25 -0
  5. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.md +10 -0
  6. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.json +18 -0
  7. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.md +9 -0
  8. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.json +18 -0
  9. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.md +9 -0
  10. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F16-cli-quote-tax-rules/l2_risk_probes/result.json +28 -0
  11. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F23-cli-fulfillment-wave/l2_risk_probes/result.json +28 -0
  12. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F25-cli-cart-promotion-rules/l2_risk_probes/result.json +30 -0
  13. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.json +28 -0
  14. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.md +15 -0
  15. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.json +28 -0
  16. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.md +15 -0
  17. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/F21-cli-scheduler-priority/l2_risk_probes/result.json +32 -0
  18. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.json +44 -0
  19. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.md +16 -0
  20. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.json +18 -0
  21. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.md +9 -0
  22. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.json +28 -0
  23. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.md +15 -0
  24. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.json +28 -0
  25. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.md +15 -0
  26. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.json +28 -0
  27. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.md +15 -0
  28. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.json +28 -0
  29. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.md +15 -0
  30. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.json +28 -0
  31. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.md +15 -0
  32. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.json +28 -0
  33. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.md +15 -0
  34. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.json +28 -0
  35. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.md +15 -0
  36. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.json +28 -0
  37. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.md +15 -0
  38. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.json +28 -0
  39. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.md +15 -0
  40. package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
  41. package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
  42. package/bin/devlyn.js +59 -9
  43. package/config/skills/_shared/.ruff.toml +2 -0
  44. package/config/skills/_shared/adapters/claude.md +9 -1
  45. package/config/skills/_shared/adapters/codex.md +5 -0
  46. package/config/skills/_shared/adapters/grok.md +37 -13
  47. package/config/skills/_shared/archive_run.py +738 -56
  48. package/config/skills/_shared/codex-config.md +19 -4
  49. package/config/skills/_shared/codex-monitored.sh +81 -12
  50. package/config/skills/_shared/collect-codex-findings.py +419 -60
  51. package/config/skills/_shared/engine-preflight.md +8 -7
  52. package/config/skills/_shared/expected.schema.json +68 -0
  53. package/config/skills/_shared/finish-gate.py +12 -11
  54. package/config/skills/_shared/grok-anchor-guard.py +78 -0
  55. package/config/skills/_shared/invocation-receipt.py +1041 -0
  56. package/config/skills/_shared/judge-output-parser.py +200 -0
  57. package/config/skills/_shared/judge-role-evidence.py +273 -0
  58. package/config/skills/_shared/phase-prompt-render.py +181 -0
  59. package/config/skills/_shared/process-evidence.py +909 -0
  60. package/config/skills/_shared/resolve-bootstrap.py +776 -84
  61. package/config/skills/_shared/resolve-stop-hook.py +33 -1
  62. package/config/skills/_shared/role-config.py +552 -0
  63. package/config/skills/_shared/run-bounded.py +1 -1
  64. package/config/skills/_shared/spec-verify-check.py +1037 -192
  65. package/config/skills/_shared/state-phase-write.py +2551 -83
  66. package/config/skills/_shared/terminal-claim-check.py +15 -0
  67. package/config/skills/_shared/verify-merge-findings.py +1127 -50
  68. package/config/skills/devlyn:engines/SKILL.md +8 -16
  69. package/config/skills/devlyn:ideate/SKILL.md +1 -1
  70. package/config/skills/devlyn:ideate/references/spec-template.md +1 -0
  71. package/config/skills/devlyn:queue/SKILL.md +7 -4
  72. package/config/skills/devlyn:resolve/SKILL.md +94 -35
  73. package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
  74. package/config/skills/devlyn:resolve/references/phases/build-gate.md +33 -4
  75. package/config/skills/devlyn:resolve/references/phases/implement.md +4 -3
  76. package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
  77. package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
  78. package/config/skills/devlyn:resolve/references/phases/verify.md +120 -80
  79. package/config/skills/devlyn:resolve/references/state-schema.md +23 -9
  80. package/package.json +5 -1
  81. package/scripts/lint-fixtures.sh +4 -1
  82. package/scripts/lint-skills.sh +447 -290
@@ -25,6 +25,19 @@ sys.dont_write_bytecode = True
25
25
  RECEIPT_PATTERN = "resolve-stop-hook.*.json"
26
26
 
27
27
 
28
+ def reject_json_constant(token: str) -> None:
29
+ raise ValueError(f"invalid JSON numeric constant: {token}")
30
+
31
+
32
+ def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
33
+ result = {}
34
+ for key, value in pairs:
35
+ if key in result:
36
+ raise ValueError(f"duplicate JSON key: {key}")
37
+ result[key] = value
38
+ return result
39
+
40
+
28
41
  def load_module(path: pathlib.Path, name: str) -> ModuleType:
29
42
  spec = importlib.util.spec_from_file_location(name, path)
30
43
  if spec is None or spec.loader is None:
@@ -41,7 +54,11 @@ def allow(reason: str) -> int:
41
54
 
42
55
 
43
56
  def parse_hook_input(raw: bytes) -> dict[str, Any]:
44
- value = json.loads(raw.decode("utf-8"))
57
+ value = json.loads(
58
+ raw.decode("utf-8"),
59
+ parse_constant=reject_json_constant,
60
+ object_pairs_hook=reject_duplicate_keys,
61
+ )
45
62
  if not isinstance(value, dict):
46
63
  raise ValueError("hook input must be a JSON object")
47
64
  if value.get("hook_event_name") != "Stop":
@@ -176,6 +193,21 @@ def tree_snapshot(root: pathlib.Path) -> dict[str, str]:
176
193
 
177
194
 
178
195
  def self_test() -> int:
196
+ try:
197
+ parse_hook_input(
198
+ b'{"hook_event_name":"Stop","stop_hook_active":true,'
199
+ b'"stop_hook_active":false}'
200
+ )
201
+ except ValueError as exc:
202
+ assert "duplicate JSON key" in str(exc)
203
+ else:
204
+ raise AssertionError("duplicate stop-hook input key was accepted")
205
+ try:
206
+ parse_hook_input(b'{"hook_event_name":"Stop","stop_hook_active":NaN}')
207
+ except ValueError as exc:
208
+ assert "invalid JSON numeric constant" in str(exc)
209
+ else:
210
+ raise AssertionError("non-standard stop-hook JSON constant was accepted")
179
211
  repo = pathlib.Path(__file__).resolve().parents[3]
180
212
  k2a_source = repo / (
181
213
  "benchmark/ceiling/results/iter0077-probe-a/"
@@ -0,0 +1,552 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve explicit devlyn roles; no model dispatch or lifecycle changes."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+
16
+ sys.dont_write_bytecode = True
17
+ ROLES = ("worker", "primary_judge", "pair_judge")
18
+ SHARED = Path(__file__).resolve().parent
19
+
20
+
21
+ def fail(detail, reason="invalid-engine-config"):
22
+ raise ValueError(f"BLOCKED:{reason}: {detail}")
23
+
24
+
25
+ def pairs(items):
26
+ result = {}
27
+ for key, value in items:
28
+ if key in result:
29
+ fail(f"duplicate JSON key: {key}")
30
+ result[key] = value
31
+ return result
32
+
33
+
34
+ def loads(raw):
35
+ try:
36
+ return json.loads(raw, object_pairs_hook=pairs,
37
+ parse_constant=lambda value: fail(f"invalid JSON constant: {value}"))
38
+ except (json.JSONDecodeError, UnicodeError, TypeError) as exc:
39
+ fail(f"invalid JSON: {exc}")
40
+
41
+
42
+ def digest(raw):
43
+ return hashlib.sha256(raw).hexdigest()
44
+
45
+
46
+ def encoded(value):
47
+ return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode()
48
+
49
+
50
+ def name(value, field):
51
+ if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", value):
52
+ fail(f"{field} must be a nonempty identifier")
53
+ return value
54
+
55
+
56
+ def adapter(engine, judge=False, shared=SHARED):
57
+ name(engine, "engine")
58
+ path = shared / "adapters" / f"{engine}.md"
59
+ if not path.is_file():
60
+ fail(f"no adapter for {engine}")
61
+ text = path.read_text()
62
+ field = "pair_judge" if judge else "executor"
63
+ if re.search(rf"^{field}: no\s*$", text, re.M):
64
+ fail(f"{engine} is ineligible for {'judge' if judge else 'worker'}")
65
+ return text
66
+
67
+
68
+ def validate(config, *, run=False, shared=SHARED):
69
+ if not isinstance(config, dict) or (run and set(config) != {"roles"}):
70
+ fail("configuration must be an object; --role-config accepts only roles")
71
+ if "executor" in config:
72
+ adapter(config["executor"], shared=shared)
73
+ if "pair_judge_priority" in config:
74
+ priority = config["pair_judge_priority"]
75
+ if not isinstance(priority, list):
76
+ fail("pair_judge_priority must be an array")
77
+ for engine in priority:
78
+ adapter(engine, judge=True, shared=shared)
79
+ roles = config.get("roles", {})
80
+ if not isinstance(roles, dict) or set(roles) - set(ROLES):
81
+ fail("roles must contain only worker, primary_judge, pair_judge")
82
+ for role, entry in roles.items():
83
+ if not isinstance(entry, dict) or set(entry) - {"engine", "model", "effort"} or "engine" not in entry:
84
+ fail(f"{role} requires engine and accepts only model/effort")
85
+ adapter(entry["engine"], judge=role != "worker", shared=shared)
86
+ for field in ("model", "effort"):
87
+ if field in entry:
88
+ if not isinstance(entry[field], str) or not entry[field].strip() or entry[field] != entry[field].strip() or "\x00" in entry[field]:
89
+ fail(f"{role}.{field} must be a nonempty exact string")
90
+ if "model" in entry and entry["model"] in {"default", "auto", "opus", "sonnet", "fable", "codex"}:
91
+ fail(f"{role}.model requires an exact model ID, not an alias")
92
+ return config
93
+
94
+
95
+ def read_config(path, *, run=False, optional=False, shared=SHARED):
96
+ path = Path(path)
97
+ if optional and not path.exists():
98
+ return {}, {"path": str(path.absolute()), "sha256": None}
99
+ try:
100
+ raw = path.read_bytes()
101
+ value = validate(loads(raw), run=run, shared=shared)
102
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
103
+ fail(f"cannot read role configuration {path}: {exc}")
104
+ return value, {"path": str(path.resolve()), "sha256": digest(raw)}
105
+
106
+
107
+ def channel(engine, role, explicit=False):
108
+ if engine == "codex":
109
+ return "codex-monitored" if role == "worker" else "codex-monitored-isolated"
110
+ if engine == "claude":
111
+ if role == "worker":
112
+ return "native-Agent"
113
+ return "claude-CLI" if explicit or role == "pair_judge" else "inherited/adapter-defined"
114
+ return "adapter"
115
+
116
+
117
+ def validate_channel(entry, role):
118
+ fields = [field for field in ("model_requested", "effort_requested") if entry.get(field) is not None]
119
+ engine = entry["engine"]
120
+ if fields and (engine not in {"codex", "claude"} or (engine == "claude" and role == "worker")):
121
+ fail(f"{role}/{engine}: unsupported explicit {', '.join(fields)}; use a supported Codex role, Claude CLI judge, or engine-only native worker",
122
+ "unsupported-role-option")
123
+
124
+
125
+ def resolve(work, default_engine, *, flag_engine=None, run_input=None, no_pair=False,
126
+ available=None, shared=SHARED, proposed=None, for_status=False):
127
+ project, source = (read_config(Path(work) / ".devlyn/engines.json", optional=True, shared=shared)
128
+ if proposed is None else (validate(proposed, shared=shared), {}))
129
+ run_config = validate(run_input["value"], run=True, shared=shared) if run_input else {}
130
+ legacy = flag_engine or project.get("executor", default_engine)
131
+ adapter(legacy, shared=shared)
132
+ legacy_source = "flag" if flag_engine else "engines.json" if "executor" in project else "default"
133
+ inputs = [source] + ([{k: run_input[k] for k in ("path", "sha256")}] if run_input else [])
134
+ available = available or (lambda engine: shutil.which(engine) is not None)
135
+ selected = {}
136
+ for role in ROLES:
137
+ if role in run_config.get("roles", {}):
138
+ entry, origin = run_config["roles"][role], "role-config"
139
+ elif flag_engine and role != "pair_judge":
140
+ entry, origin = {"engine": flag_engine}, "flag"
141
+ elif role in project.get("roles", {}):
142
+ entry, origin = project["roles"][role], "engines.json.roles"
143
+ elif role != "pair_judge":
144
+ entry, origin = {"engine": legacy}, legacy_source
145
+ else:
146
+ continue
147
+ selected[role] = {"engine": entry["engine"], "model_requested": entry.get("model"),
148
+ "effort_requested": entry.get("effort"), "source": origin,
149
+ "channel": channel(entry["engine"], role, any(field in entry for field in ("model", "effort")))}
150
+ if role != "pair_judge" or not no_pair:
151
+ validate_channel(selected[role], role)
152
+ primary = selected["primary_judge"]["engine"]
153
+ adapter(primary, judge=True, shared=shared)
154
+ if not no_pair and "pair_judge" in selected and selected["pair_judge"]["engine"] == primary:
155
+ fail("explicit pair_judge must be a different engine from primary_judge")
156
+ if "pair_judge" not in selected:
157
+ choices = project.get("pair_judge_priority", ["codex" if primary == "claude" else "claude"])
158
+ other = next((engine for engine in choices if engine != primary and available(engine)), None)
159
+ if other:
160
+ adapter(other, judge=True, shared=shared)
161
+ elif not no_pair and "pair_judge_priority" in project:
162
+ candidates = [engine for engine in choices if engine != primary]
163
+ if not candidates:
164
+ fail("pair_judge_priority contains no OTHER engine")
165
+ if not for_status:
166
+ fail(f"pinned OTHER engines unavailable: {', '.join(candidates)}; install/authenticate an engine or explicitly use --no-pair", candidates[0] + "-unavailable")
167
+ selected["pair_judge"] = {"engine": other, "model_requested": None, "effort_requested": None,
168
+ "source": "engines.json" if "pair_judge_priority" in project else "default",
169
+ "channel": channel(other, "pair_judge") if other else None}
170
+ if for_status:
171
+ selected["pair_judge"]["priority_requested"] = choices
172
+ selected["pair_judge"]["skipped_reason"] = "user_no_pair" if no_pair else (
173
+ "auto_pair_other_engine_unavailable" if selected["pair_judge"]["engine"] is None and "pair_judge_priority" not in project else None)
174
+ if for_status:
175
+ for role, entry in selected.items():
176
+ entry["availability"] = ("unused" if role == "pair_judge" and no_pair else
177
+ "unresolved/no-available-engine" if entry["engine"] is None else
178
+ "CLI-present/auth-unchecked" if available(entry["engine"]) else "CLI-unavailable")
179
+ result = {"roles": selected, "legacy_engine": legacy, "legacy_source": legacy_source, "inputs": inputs}
180
+ return {**result, "sha256": digest(encoded(result))}
181
+
182
+
183
+ def snapshot(state):
184
+ if not isinstance(state, dict):
185
+ fail("state must be an object")
186
+ if "role_resolution" not in state:
187
+ return None
188
+ value = state["role_resolution"]
189
+ if not isinstance(value, dict) or value.get("sha256") != digest(encoded({k: v for k, v in value.items() if k != "sha256"})):
190
+ fail("frozen role resolution is malformed or altered")
191
+ if not isinstance(value.get("roles"), dict) or set(value["roles"]) != set(ROLES):
192
+ fail("frozen role resolution has missing roles")
193
+ for role, entry in value["roles"].items():
194
+ if not isinstance(entry, dict):
195
+ fail(f"frozen {role} is malformed")
196
+ if role != "pair_judge" or entry.get("engine") is not None:
197
+ name(entry.get("engine"), f"frozen {role}.engine")
198
+ for field in ("model_requested", "effort_requested"):
199
+ if field not in entry or (entry[field] is not None and (not isinstance(entry[field], str) or not entry[field])):
200
+ fail(f"frozen {role}.{field} is malformed")
201
+ return value
202
+
203
+
204
+ def observations(state):
205
+ """Report only state-bound observations; argv is never effective effort."""
206
+ result = {role: {"model_effective": None, "effort_effective": None, "evidence_basis": None} for role in ROLES}
207
+ phases = state.get("phases", {})
208
+ if not isinstance(phases, dict):
209
+ fail("state phases must be an object")
210
+ for phase in ("implement", "cleanup"):
211
+ entry = phases.get(phase)
212
+ if isinstance(entry, dict) and entry.get("model_effective") and entry.get("verdict") in {"PASS", "PASS_WITH_ISSUES"} and entry.get("completed_at"):
213
+ basis = ("completed-phase/session-model-attestation"
214
+ if entry.get("engine") == "claude" and not entry.get("invocation_receipt") else None)
215
+ if basis:
216
+ result["worker"] = {"model_effective": entry["model_effective"], "effort_effective": None,
217
+ "evidence_basis": basis}
218
+ verify = phases.get("verify")
219
+ if isinstance(verify, dict):
220
+ evidence = verify.get("role_evidence") or {}
221
+ if not isinstance(evidence, dict):
222
+ fail("judge role evidence must be an object")
223
+ for role, entry in evidence.items():
224
+ if role in result and isinstance(entry, dict) and entry.get("identity_basis"):
225
+ result[role] = {"model_effective": entry.get("model_observed"), "effort_effective": entry.get("effort_observed"),
226
+ "evidence_basis": entry.get("identity_basis")}
227
+ return result
228
+
229
+
230
+ def primary_engine(state):
231
+ verify = state.get("phases", {}).get("verify")
232
+ value = verify["engine"] if isinstance(verify, dict) and "engine" in verify else state.get("engine")
233
+ return name(value, "primary engine")
234
+
235
+
236
+ def native_version(engine):
237
+ binary = shutil.which(engine)
238
+ if binary is None:
239
+ fail(f"install/authenticate {engine} and retry", f"{engine}-unavailable")
240
+ try:
241
+ proc = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=20)
242
+ except (OSError, subprocess.SubprocessError) as exc:
243
+ fail(f"{engine} --version failed: {exc}; install/authenticate the native CLI and retry", f"{engine}-unavailable")
244
+ if proc.returncode != 0:
245
+ fail(f"{engine} --version exited {proc.returncode}; install/authenticate the native CLI and retry", f"{engine}-unavailable")
246
+ match = re.search(r"\b\d+\.\d+\.\d+\b", proc.stdout)
247
+ if not match:
248
+ fail(f"{engine} --version did not establish a native version", "unsupported-role-option")
249
+ return match[0]
250
+
251
+
252
+ def options(entry, role, *, model=None, version=None, cache=None, shared=SHARED):
253
+ """Return additional argv only; caller retains the existing phase sandbox/tools."""
254
+ engine = entry["engine"]
255
+ requested_model, effort = entry.get("model_requested"), entry.get("effort_requested")
256
+ model = requested_model or model
257
+ if not requested_model and not effort:
258
+ return {"argv": [], "capability": None}
259
+ detail = f"{role}/{engine}: explicit model/effort unsupported; use a supported exact model or omit the override"
260
+ validate_channel(entry, role)
261
+ if not model:
262
+ fail(detail + "; resolve the inherited model before validating effort", "unsupported-role-option")
263
+ version = version or native_version(engine)
264
+ capability = {"native_version": version, "model": model}
265
+ if engine == "codex":
266
+ path = Path(cache) if cache else Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))) / "models_cache.json"
267
+ try:
268
+ raw = path.read_bytes()
269
+ metadata = loads(raw)
270
+ if not isinstance(metadata, dict) or not isinstance(metadata.get("models"), list) or any(not isinstance(row, dict) for row in metadata["models"]):
271
+ raise ValueError("malformed native model metadata")
272
+ rows = [row for row in metadata["models"] if row.get("slug") == model]
273
+ if metadata.get("client_version") != version or len(rows) != 1:
274
+ raise ValueError("native version or exact model is absent/ambiguous")
275
+ advertised = rows[0]["supported_reasoning_levels"]
276
+ if not isinstance(advertised, list) or any(not isinstance(item, dict) or not isinstance(item.get("effort"), str) or not item["effort"] for item in advertised):
277
+ raise ValueError("malformed native effort metadata")
278
+ levels = [item["effort"] for item in advertised]
279
+ except (OSError, UnicodeError, ValueError, TypeError, KeyError) as exc:
280
+ fail(f"{detail}; refresh native Codex model metadata: {exc}", "unsupported-role-option")
281
+ capability.update(path=str(path.resolve()), sha256=digest(raw))
282
+ else:
283
+ text = adapter(engine, judge=True, shared=shared)
284
+ if effort is not None:
285
+ matches = re.findall(r"^<!-- devlyn-effort (\S+) (\S+) ([a-z,]+) -->$", text, re.M)
286
+ levels = next((levels.split(",") for ver, selected, levels in matches if ver == version and selected == model), None)
287
+ if levels is None:
288
+ fail(detail + "; this Claude version/model has no validated adapter effort declaration; omit effort to select only the model", "unsupported-role-option")
289
+ capability.update(path=str(shared / "adapters/claude.md"), sha256=digest(text.encode()))
290
+ if effort is not None and effort not in levels:
291
+ fail(f"{role}/{engine}/{model}: effort {effort!r} unsupported; supported: {', '.join(levels)}", "unsupported-role-option")
292
+ args = (["-m" if engine == "codex" else "--model", requested_model] if requested_model else [])
293
+ if effort:
294
+ args += ["-c", "model_reasoning_effort=" + effort] if engine == "codex" else ["--effort", effort]
295
+ return {"argv": args, "capability": capability}
296
+
297
+
298
+ def edit(work, role, value, shared=SHARED, default_engine="claude"):
299
+ path = Path(work) / ".devlyn/engines.json"
300
+ config, _ = read_config(path, optional=True, shared=shared)
301
+ if role == "clear":
302
+ for key in ("executor", "pair_judge_priority", "roles"):
303
+ config.pop(key, None)
304
+ elif role in ROLES:
305
+ roles = config.setdefault("roles", {})
306
+ if value == "clear":
307
+ roles.pop(role, None)
308
+ if not roles:
309
+ config.pop("roles", None)
310
+ else:
311
+ if not isinstance(value, str):
312
+ fail("role update requires --value JSON or clear")
313
+ roles[role] = loads(value)
314
+ else:
315
+ fail("unknown role")
316
+ validate(config, shared=shared)
317
+ # Validate the complete proposed selection before replacing any bytes. Writes
318
+ # may pin an engine installed later; availability is checked at dispatch.
319
+ resolve(work, default_engine, proposed=config, available=lambda engine: True, shared=shared)
320
+ path.parent.mkdir(parents=True, exist_ok=True)
321
+ if not config:
322
+ path.unlink(missing_ok=True)
323
+ return
324
+ fd, temp = tempfile.mkstemp(prefix="engines.json.tmp.", dir=path.parent)
325
+ try:
326
+ with os.fdopen(fd, "wb") as handle:
327
+ handle.write(encoded(config))
328
+ if path.exists():
329
+ os.chmod(temp, path.stat().st_mode & 0o777)
330
+ os.replace(temp, path)
331
+ finally:
332
+ Path(temp).unlink(missing_ok=True)
333
+
334
+
335
+ def self_test():
336
+ from unittest.mock import patch
337
+
338
+ with tempfile.TemporaryDirectory() as temp:
339
+ work = Path(temp)
340
+ (work / ".devlyn").mkdir()
341
+ path = work / ".devlyn/engines.json"
342
+ base = resolve(work, "codex", available=lambda e: True)
343
+ assert [base["roles"][r]["engine"] for r in ROLES] == ["codex", "codex", "claude"]
344
+ assert resolve(work, "claude", available=lambda e: True)["roles"]["primary_judge"]["channel"] == "inherited/adapter-defined"
345
+ assert channel("claude", "primary_judge", explicit=True) == "claude-CLI"
346
+ for role, engine in (("worker", "claude"), ("pair_judge", "grok")):
347
+ unsupported = {"value": {"roles": {role: {"engine": engine, "model": "fixture-model"}}}, "path": "fixture", "sha256": "fixture"}
348
+ try:
349
+ resolve(work, "codex", run_input=unsupported, available=lambda e: True)
350
+ except ValueError as exc:
351
+ assert "unsupported-role-option" in str(exc)
352
+ else:
353
+ raise AssertionError("unsupported selected channel survived pre-PLAN resolution")
354
+ if role == "pair_judge":
355
+ assert resolve(work, "codex", run_input=unsupported, no_pair=True)["roles"][role]["skipped_reason"] == "user_no_pair"
356
+ path.write_bytes(encoded({"executor": "claude", "custom": 7, "roles": {"worker": {"engine": "codex", "model": "gpt-6-astra", "effort": "high"}}}))
357
+ selected = resolve(work, "codex", available=lambda e: True)
358
+ assert selected["roles"]["primary_judge"]["engine"] == "claude"
359
+ assert selected["roles"]["pair_judge"]["engine"] == "codex"
360
+ over = {"value": {"roles": {"worker": {"engine": "claude"}}}, "path": "fixture", "sha256": "test"}
361
+ assert resolve(work, "codex", run_input=over, available=lambda e: True)["roles"]["worker"]["model_requested"] is None
362
+ flag = resolve(work, "claude", flag_engine="codex", available=lambda e: True)
363
+ assert flag["roles"]["worker"]["effort_requested"] is None
364
+ state = {"role_resolution": selected}
365
+ path.write_bytes(encoded({"executor": "codex"}))
366
+ assert snapshot(state) == selected
367
+ assert observations({"phases": {"implement": {"engine": "codex", "model_effective": "gpt-6-astra", "invocation_receipt": {"path": "fixture"}, "verdict": "PASS", "completed_at": "fixture",
368
+ "role_argv": {"effort_requested": "high"}}, "verify": None}})["worker"] == {
369
+ "model_effective": None, "effort_effective": None, "evidence_basis": None}
370
+ attested = {"engine": "claude", "model_effective": "fixture-claude-model", "verdict": "PASS", "completed_at": "fixture"}
371
+ assert observations({"phases": {"implement": attested}})["worker"] == {
372
+ "model_effective": "fixture-claude-model", "effort_effective": None, "evidence_basis": "completed-phase/session-model-attestation"}
373
+ assert observations({"phases": {"implement": {**attested, "verdict": "BLOCKED"}}})["worker"]["model_effective"] is None
374
+ for malformed in (None, {}, {**selected, "sha256": "altered"}):
375
+ try:
376
+ snapshot({"role_resolution": malformed})
377
+ except ValueError:
378
+ pass
379
+ else:
380
+ raise AssertionError("present malformed snapshot became legacy")
381
+ assert primary_engine({"engine": "codex", "phases": {"verify": {"engine": "claude"}}}) == "claude"
382
+ for bad in ('{"roles":null}', '{"roles":{"worker":{"engine":"codex","effort":null}}}', '{"roles":{"x":{}}}', '{"roles":{"worker":{"engine":"codex","extra":1}}}', '{"executor":"codex","executor":"claude"}'):
383
+ before = path.read_bytes()
384
+ try:
385
+ validate(loads(bad))
386
+ except ValueError:
387
+ pass
388
+ else:
389
+ raise AssertionError(bad)
390
+ assert path.read_bytes() == before
391
+ edit(work, "worker", '{"engine":"codex","model":"gpt-6-astra"}')
392
+ before = path.read_bytes()
393
+ try:
394
+ edit(work, "worker", '{"engine":"grok"}')
395
+ except ValueError:
396
+ pass
397
+ else:
398
+ raise AssertionError("ineligible worker")
399
+ assert path.read_bytes() == before
400
+ path.write_bytes(encoded({"executor": "claude", "keep": 7}))
401
+ before = path.read_bytes()
402
+ for role, arguments in (("pair_judge", ["--value", '{"engine":"claude"}']),
403
+ ("worker", ["--value", "{"]), ("worker", [])):
404
+ proc = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--workdir", str(work),
405
+ "--set-role", role, *arguments], capture_output=True, text=True)
406
+ assert proc.returncode == 1 and "BLOCKED:invalid-engine-config" in proc.stderr, proc.stderr
407
+ assert "Traceback" not in proc.stderr and path.read_bytes() == before
408
+ path.write_bytes(encoded({"executor": "claude", "pair_judge_priority": ["codex"]}))
409
+ try:
410
+ resolve(work, "claude", available=lambda engine: False)
411
+ except ValueError as exc:
412
+ assert "BLOCKED:codex-unavailable" in str(exc)
413
+ else:
414
+ raise AssertionError("explicit priority silently became solo")
415
+ assert resolve(work, "claude", no_pair=True, available=lambda engine: False)["roles"]["pair_judge"]["skipped_reason"] == "user_no_pair"
416
+ status = resolve(work, "claude", available=lambda engine: False, for_status=True)
417
+ assert status["roles"]["pair_judge"]["engine"] is None
418
+ assert status["roles"]["pair_judge"]["priority_requested"] == ["codex"]
419
+ assert status["roles"]["pair_judge"]["skipped_reason"] is None
420
+ assert status["roles"]["primary_judge"]["availability"] == "CLI-unavailable"
421
+ before = path.read_bytes()
422
+ proc = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--workdir", str(work)],
423
+ capture_output=True, text=True, env={**os.environ, "PATH": str(work / "no-binaries")})
424
+ assert proc.returncode == 0 and loads(proc.stdout)["roles"]["pair_judge"]["engine"] is None, proc.stderr
425
+ assert path.read_bytes() == before
426
+ for engine in (None, ""):
427
+ try:
428
+ primary_engine({"engine": "codex", "phases": {"verify": {"engine": engine}}})
429
+ except ValueError:
430
+ pass
431
+ else:
432
+ raise AssertionError("malformed primary fell back")
433
+ path.write_bytes(encoded({"roles": {"pair_judge": {"engine": "codex"}}}))
434
+ try:
435
+ resolve(work, "codex", available=lambda e: True)
436
+ except ValueError:
437
+ pass
438
+ else:
439
+ raise AssertionError("same engine pair")
440
+ assert resolve(work, "codex", no_pair=True)["roles"]["pair_judge"]["skipped_reason"] == "user_no_pair"
441
+ cache = work / "models.json"
442
+ cache.write_bytes(encoded({"client_version": "0.153.4", "models": [{"slug": "gpt-6-astra", "supported_reasoning_levels": [{"effort": "ultra"}]}]}))
443
+ entry = {"engine": "codex", "model_requested": "gpt-6-astra", "effort_requested": "ultra"}
444
+ assert options(entry, "worker", version="0.153.4", cache=cache)["argv"][-1] == "model_reasoning_effort=ultra"
445
+ cache.write_bytes(encoded({"client_version": "0.153.4", "models": [None]}))
446
+ try:
447
+ options(entry, "worker", version="0.153.4", cache=cache)
448
+ except ValueError as exc:
449
+ assert "unsupported-role-option" in str(exc)
450
+ else:
451
+ raise AssertionError("malformed native cache accepted")
452
+ declaration = re.search(r"^<!-- devlyn-effort (\S+) (\S+) ([a-z,]+) -->$", adapter("claude", judge=True), re.M)
453
+ assert declaration is not None
454
+ version, known_model, levels = declaration.groups()
455
+ explicit_effort = levels.split(",")[-1]
456
+ assert options({"engine": "claude", "model_requested": known_model, "effort_requested": explicit_effort},
457
+ "primary_judge", version=version)["argv"][-1] == explicit_effort
458
+ for future_model, future_version in (("fixture-next-model", "9.0.0"), ("fixture-other-model", "9.1.0")):
459
+ future = {"engine": "claude", "model_requested": future_model, "effort_requested": None}
460
+ for judge in ("primary_judge", "pair_judge"):
461
+ result = options(future, judge, version=future_version)
462
+ assert result["argv"] == ["--model", future_model]
463
+ assert result["capability"]["native_version"] == future_version
464
+ assert result["capability"]["sha256"] == digest(adapter("claude", judge=True).encode())
465
+ try:
466
+ options({**future, "effort_requested": "high"}, judge, version=future_version)
467
+ except ValueError as exc:
468
+ assert "adapter effort declaration" in str(exc)
469
+ else:
470
+ raise AssertionError("unknown judge effort support was assumed")
471
+ ineligible = work / "adapters"
472
+ ineligible.mkdir()
473
+ (ineligible / "claude.md").write_text("pair_judge: no\n")
474
+ try:
475
+ options(future, "primary_judge", version=future_version, shared=work)
476
+ except ValueError as exc:
477
+ assert "ineligible for judge" in str(exc)
478
+ else:
479
+ raise AssertionError("model-only selection bypassed adapter eligibility")
480
+ for invalid in ({**entry, "effort_requested": "bogus"}, {"engine": "claude", "model_requested": "fixture-claude-model", "effort_requested": "high"}):
481
+ try:
482
+ options(invalid, "worker", version="0.153.4", cache=cache)
483
+ except ValueError as exc:
484
+ assert "unsupported-role-option" in str(exc)
485
+ else:
486
+ raise AssertionError("unsupported option")
487
+ edit(work, "clear", None)
488
+ assert not path.exists()
489
+ with patch.object(shutil, "which", return_value="/fixture/codex"):
490
+ for response, reason in ((FileNotFoundError("fixture"), "codex-unavailable"),
491
+ (subprocess.TimeoutExpired(["codex", "--version"], 20), "codex-unavailable"),
492
+ (subprocess.CompletedProcess([], 1, "", "failed"), "codex-unavailable"),
493
+ (subprocess.CompletedProcess([], 0, "unparsable", ""), "unsupported-role-option")):
494
+ kwargs = {"side_effect": response} if isinstance(response, Exception) else {"return_value": response}
495
+ with patch.object(subprocess, "run", **kwargs):
496
+ try:
497
+ native_version("codex")
498
+ except ValueError as exc:
499
+ assert f"BLOCKED:{reason}:" in str(exc)
500
+ else:
501
+ raise AssertionError("failed native version probe accepted")
502
+ print("PASS role-config self-test: precedence, isolation, atomic validation, capability and legacy identity")
503
+ return 0
504
+
505
+
506
+ def main():
507
+ parser = argparse.ArgumentParser(description=__doc__)
508
+ parser.add_argument("--self-test", action="store_true")
509
+ parser.add_argument("--workdir", type=Path, default=Path.cwd())
510
+ parser.add_argument("--default-engine", default="claude")
511
+ parser.add_argument("--engine")
512
+ parser.add_argument("--role-config", type=Path)
513
+ parser.add_argument("--no-pair", action="store_true")
514
+ parser.add_argument("--state", type=Path)
515
+ parser.add_argument("--role", choices=ROLES)
516
+ parser.add_argument("--resolved-model")
517
+ parser.add_argument("--set-role", choices=(*ROLES, "clear"))
518
+ parser.add_argument("--value")
519
+ args = parser.parse_args()
520
+ try:
521
+ if args.self_test:
522
+ return self_test()
523
+ if args.set_role:
524
+ edit(args.workdir, args.set_role, args.value, default_engine=args.default_engine)
525
+ if args.state:
526
+ state = loads(args.state.read_bytes())
527
+ value = snapshot(state)
528
+ if value is None:
529
+ fail("run has no frozen roles; complete PHASE0 role resolution first")
530
+ value = {**value, "observations": observations(state)}
531
+ else:
532
+ run_input = None
533
+ if args.role_config:
534
+ config, pin = read_config(args.role_config, run=True)
535
+ run_input = {**pin, "value": config}
536
+ value = resolve(args.workdir, args.default_engine, flag_engine=args.engine, run_input=run_input, no_pair=args.no_pair,
537
+ for_status=not args.role)
538
+ if args.set_role:
539
+ print("Configuration saved; CLI presence is status only. Availability/authentication is enforced before dispatch.", file=sys.stderr)
540
+ if args.role:
541
+ entry = value["roles"][args.role]
542
+ value = {**entry, **options(entry, args.role, model=args.resolved_model),
543
+ "observation": value.get("observations", {}).get(args.role)}
544
+ print(json.dumps(value, sort_keys=True))
545
+ return 0
546
+ except (ValueError, OSError, subprocess.SubprocessError) as exc:
547
+ print(str(exc), file=sys.stderr)
548
+ return 1
549
+
550
+
551
+ if __name__ == "__main__":
552
+ raise SystemExit(main())
@@ -21,7 +21,7 @@ def main(argv: list[str]) -> int:
21
21
  if argv[2] != "--":
22
22
  return fail("expected -- before command")
23
23
 
24
- child = subprocess.Popen(argv[3:], start_new_session=True)
24
+ child = subprocess.Popen(argv[3:], stdin=subprocess.DEVNULL, start_new_session=True)
25
25
  try:
26
26
  return child.wait(timeout=seconds)
27
27
  except subprocess.TimeoutExpired: