devlyn-cli 4.0.1 → 4.2.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 (72) hide show
  1. package/AGENTS.md +11 -95
  2. package/CLAUDE.md +7 -160
  3. package/LICENSE +21 -0
  4. package/README.md +83 -114
  5. package/bin/devlyn.js +330 -472
  6. package/bin/instruction-templates.json +120 -2
  7. package/bin/instructions.js +35 -8
  8. package/config/skills/_shared/adapters/claude.md +8 -85
  9. package/config/skills/_shared/adapters/codex.md +14 -34
  10. package/config/skills/_shared/adapters/omp.md +1 -11
  11. package/config/skills/_shared/codex-monitored.sh +4 -54
  12. package/config/skills/_shared/engine-doctor.sh +18 -58
  13. package/config/skills/_shared/engine-preflight.md +15 -47
  14. package/config/skills/_shared/expected-contract.py +242 -0
  15. package/config/skills/_shared/expected.schema.json +10 -95
  16. package/config/skills/_shared/invocation-receipt.py +26 -757
  17. package/config/skills/_shared/platform-support.py +37 -28
  18. package/config/skills/_shared/role-config.py +100 -421
  19. package/config/skills/_shared/runtime-principles.md +17 -93
  20. package/config/skills/_shared/task-complete.py +490 -285
  21. package/config/skills/_shared/task-completion.md +235 -0
  22. package/config/skills/devlyn-engines/SKILL.md +9 -11
  23. package/config/skills/devlyn-ideate/SKILL.md +57 -107
  24. package/config/skills/devlyn-ideate/references/elicitation.md +18 -99
  25. package/config/skills/devlyn-ideate/references/loop.md +110 -0
  26. package/config/skills/devlyn-ideate/references/package-format.md +128 -0
  27. package/config/skills/devlyn-ideate/scripts/acceptance.py +795 -0
  28. package/config/skills/devlyn-ideate/scripts/queue.py +1780 -0
  29. package/package.json +2 -3
  30. package/config/commit-conventions.md +0 -28
  31. package/config/skills/_shared/adapters/README.md +0 -74
  32. package/config/skills/_shared/adapters/grok.md +0 -100
  33. package/config/skills/_shared/archive_run.py +0 -969
  34. package/config/skills/_shared/codex-config.md +0 -81
  35. package/config/skills/_shared/collect-codex-findings.py +0 -505
  36. package/config/skills/_shared/finish-gate.py +0 -521
  37. package/config/skills/_shared/grok-anchor-guard.py +0 -78
  38. package/config/skills/_shared/judge-output-parser.py +0 -200
  39. package/config/skills/_shared/judge-role-evidence.py +0 -320
  40. package/config/skills/_shared/phase-prompt-render.py +0 -183
  41. package/config/skills/_shared/process-evidence.py +0 -926
  42. package/config/skills/_shared/resolve-bootstrap.py +0 -1410
  43. package/config/skills/_shared/resolve-stop-hook.py +0 -359
  44. package/config/skills/_shared/run-bounded.py +0 -58
  45. package/config/skills/_shared/spec-verify-check.py +0 -5083
  46. package/config/skills/_shared/state-phase-write.py +0 -5989
  47. package/config/skills/_shared/terminal-claim-check.py +0 -777
  48. package/config/skills/_shared/verify-merge-findings.py +0 -3528
  49. package/config/skills/devlyn-design-ui/SKILL.md +0 -650
  50. package/config/skills/devlyn-ideate/references/from-spec-mode.md +0 -61
  51. package/config/skills/devlyn-ideate/references/project-mode.md +0 -80
  52. package/config/skills/devlyn-ideate/references/spec-template.md +0 -121
  53. package/config/skills/devlyn-ideate/references/templates/decision.md +0 -48
  54. package/config/skills/devlyn-ideate/references/templates/roadmap.md +0 -50
  55. package/config/skills/devlyn-ideate/references/templates/vision.md +0 -44
  56. package/config/skills/devlyn-queue/SKILL.md +0 -66
  57. package/config/skills/devlyn-queue/scripts/append.py +0 -94
  58. package/config/skills/devlyn-resolve/SKILL.md +0 -379
  59. package/config/skills/devlyn-resolve/references/free-form-mode.md +0 -71
  60. package/config/skills/devlyn-resolve/references/outer-loop.md +0 -23
  61. package/config/skills/devlyn-resolve/references/phases/build-gate.md +0 -75
  62. package/config/skills/devlyn-resolve/references/phases/cleanup.md +0 -33
  63. package/config/skills/devlyn-resolve/references/phases/implement.md +0 -44
  64. package/config/skills/devlyn-resolve/references/phases/plan.md +0 -39
  65. package/config/skills/devlyn-resolve/references/phases/probe-derive.md +0 -261
  66. package/config/skills/devlyn-resolve/references/phases/surface-close.md +0 -34
  67. package/config/skills/devlyn-resolve/references/phases/verify.md +0 -310
  68. package/config/skills/devlyn-resolve/references/state-schema.md +0 -152
  69. package/config/skills/devlyn-resolve/references/task-completion.md +0 -160
  70. package/config/templates/prompt-templates.md +0 -71
  71. package/config/templates/template-feature.spec.md +0 -255
  72. package/config/templates/template-product-spec.md +0 -680
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Resolve explicit devlyn roles; no model dispatch or lifecycle changes."""
2
+ """Resolve and pin the devlyn executor; no model dispatch."""
3
3
  from __future__ import annotations
4
4
 
5
5
  import runpy
@@ -16,8 +16,9 @@ import sys
16
16
  import tempfile
17
17
 
18
18
  sys.dont_write_bytecode = True
19
- ROLES = ("worker", "primary_judge", "pair_judge")
20
19
  SHARED = Path(__file__).resolve().parent
20
+ # Earlier releases also pinned pipeline judge and worker roles; those keys stay as written and select nothing.
21
+ RETIRED = ("pair_judge_priority", "roles")
21
22
 
22
23
 
23
24
  def fail(detail, reason="invalid-engine-config"):
@@ -55,46 +56,26 @@ def name(value, field):
55
56
  return value
56
57
 
57
58
 
58
- def adapter(engine, judge=False, shared=SHARED):
59
+ def adapter(engine, shared=SHARED):
59
60
  name(engine, "engine")
60
61
  path = shared / "adapters" / f"{engine}.md"
61
62
  if not path.is_file():
62
- fail(f"no adapter for {engine}")
63
+ fail(f"no adapter for {engine}; valid: {', '.join(sorted(p.stem for p in (shared / 'adapters').glob('*.md')))}")
63
64
  text = path.read_text(encoding="utf-8")
64
- field = "pair_judge" if judge else "executor"
65
- if re.search(rf"^{field}: no\s*$", text, re.M):
66
- fail(f"{engine} is ineligible for {'judge' if judge else 'worker'}")
65
+ if re.search(r"^executor: no\s*$", text, re.M):
66
+ fail(f"{engine} is ineligible as executor")
67
67
  return text
68
68
 
69
69
 
70
- def validate(config, *, run=False, shared=SHARED):
71
- if not isinstance(config, dict) or (run and set(config) != {"roles"}):
72
- fail("configuration must be an object; --role-config accepts only roles")
70
+ def validate(config, shared=SHARED):
71
+ if not isinstance(config, dict):
72
+ fail("configuration must be an object")
73
73
  if "executor" in config:
74
74
  adapter(config["executor"], shared=shared)
75
- if "pair_judge_priority" in config:
76
- priority = config["pair_judge_priority"]
77
- if not isinstance(priority, list):
78
- fail("pair_judge_priority must be an array")
79
- for engine in priority:
80
- adapter(engine, judge=True, shared=shared)
81
- roles = config.get("roles", {})
82
- if not isinstance(roles, dict) or set(roles) - set(ROLES):
83
- fail("roles must contain only worker, primary_judge, pair_judge")
84
- for role, entry in roles.items():
85
- if not isinstance(entry, dict) or set(entry) - {"engine", "model", "effort"} or "engine" not in entry:
86
- fail(f"{role} requires engine and accepts only model/effort")
87
- adapter(entry["engine"], judge=role != "worker", shared=shared)
88
- for field in ("model", "effort"):
89
- if field in entry:
90
- if not isinstance(entry[field], str) or not entry[field].strip() or entry[field] != entry[field].strip() or "\x00" in entry[field]:
91
- fail(f"{role}.{field} must be a nonempty exact string")
92
- if "model" in entry and entry["model"] in {"default", "auto", "opus", "sonnet", "fable", "codex"}:
93
- fail(f"{role}.model requires an exact model ID, not an alias")
94
75
  return config
95
76
 
96
77
 
97
- def read_config(path, *, run=False, optional=False, shared=SHARED):
78
+ def read_config(path, *, optional=False, shared=SHARED):
98
79
  path = Path(path)
99
80
  try:
100
81
  if optional:
@@ -111,225 +92,44 @@ def read_config(path, *, run=False, optional=False, shared=SHARED):
111
92
  return {}, {"path": str(path.absolute()), "sha256": None}
112
93
  raise
113
94
  raw = path.read_bytes()
114
- value = validate(loads(raw), run=run, shared=shared)
95
+ value = validate(loads(raw), shared=shared)
115
96
  except (OSError, UnicodeError, json.JSONDecodeError) as exc:
116
97
  fail(f"cannot read role configuration {path}: {exc}")
117
98
  return value, {"path": str(path.resolve()), "sha256": digest(raw)}
118
99
 
119
100
 
120
- def channel(engine, role, explicit=False):
121
- if engine == "codex":
122
- return "codex-monitored" if role == "worker" else "codex-monitored-isolated"
123
- if engine == "claude":
124
- if role == "worker":
125
- return "native-Agent"
126
- return "claude-CLI" if explicit or role == "pair_judge" else "inherited/adapter-defined"
127
- return "adapter"
128
-
129
-
130
- def validate_channel(entry, role):
131
- fields = [field for field in ("model_requested", "effort_requested") if entry.get(field) is not None]
132
- engine = entry["engine"]
133
- if fields and (engine not in {"codex", "claude"} or (engine == "claude" and role == "worker")):
134
- fail(f"{role}/{engine}: unsupported explicit {', '.join(fields)}; use a supported Codex role, Claude CLI judge, or engine-only native worker",
135
- "unsupported-role-option")
136
-
137
-
138
- def resolve(work, default_engine, *, flag_engine=None, run_input=None, no_pair=False,
139
- available=None, shared=SHARED, proposed=None, for_status=False):
140
- project, source = (read_config(Path(work) / ".devlyn/engines.json", optional=True, shared=shared)
141
- if proposed is None else (validate(proposed, shared=shared), {}))
142
- run_config = validate(run_input["value"], run=True, shared=shared) if run_input else {}
143
- legacy = flag_engine or project.get("executor", default_engine)
144
- adapter(legacy, shared=shared)
145
- legacy_source = "flag" if flag_engine else "engines.json" if "executor" in project else "default"
146
- inputs = [source] + ([{k: run_input[k] for k in ("path", "sha256")}] if run_input else [])
147
- available = available or (lambda engine: shutil.which(engine) is not None)
148
- selected = {}
149
- for role in ROLES:
150
- if role in run_config.get("roles", {}):
151
- entry, origin = run_config["roles"][role], "role-config"
152
- elif flag_engine and role != "pair_judge":
153
- entry, origin = {"engine": flag_engine}, "flag"
154
- elif role in project.get("roles", {}):
155
- entry, origin = project["roles"][role], "engines.json.roles"
156
- elif role != "pair_judge":
157
- entry, origin = {"engine": legacy}, legacy_source
158
- else:
159
- continue
160
- selected[role] = {"engine": entry["engine"], "model_requested": entry.get("model"),
161
- "effort_requested": entry.get("effort"), "source": origin,
162
- "channel": channel(entry["engine"], role, any(field in entry for field in ("model", "effort")))}
163
- if role != "pair_judge" or not no_pair:
164
- validate_channel(selected[role], role)
165
- primary = selected["primary_judge"]["engine"]
166
- adapter(primary, judge=True, shared=shared)
167
- if not no_pair and "pair_judge" in selected and selected["pair_judge"]["engine"] == primary:
168
- fail("explicit pair_judge must be a different engine from primary_judge")
169
- if "pair_judge" not in selected:
170
- choices = project.get("pair_judge_priority", ["codex" if primary == "claude" else "claude"])
171
- other = next((engine for engine in choices if engine != primary and available(engine)), None)
172
- if other:
173
- adapter(other, judge=True, shared=shared)
174
- elif not no_pair and "pair_judge_priority" in project:
175
- candidates = [engine for engine in choices if engine != primary]
176
- if not candidates:
177
- fail("pair_judge_priority contains no OTHER engine")
178
- if not for_status:
179
- fail(f"pinned OTHER engines unavailable: {', '.join(candidates)}; install/authenticate an engine or explicitly use --no-pair", candidates[0] + "-unavailable")
180
- selected["pair_judge"] = {"engine": other, "model_requested": None, "effort_requested": None,
181
- "source": "engines.json" if "pair_judge_priority" in project else "default",
182
- "channel": channel(other, "pair_judge") if other else None}
183
- if for_status:
184
- selected["pair_judge"]["priority_requested"] = choices
185
- selected["pair_judge"]["skipped_reason"] = "user_no_pair" if no_pair else (
186
- "auto_pair_other_engine_unavailable" if selected["pair_judge"]["engine"] is None and "pair_judge_priority" not in project else None)
187
- if for_status:
188
- for role, entry in selected.items():
189
- entry["availability"] = ("unused" if role == "pair_judge" and no_pair else
190
- "unresolved/no-available-engine" if entry["engine"] is None else
191
- "CLI-present/auth-unchecked" if available(entry["engine"]) else "CLI-unavailable")
192
- result = {"roles": selected, "legacy_engine": legacy, "legacy_source": legacy_source, "inputs": inputs}
193
- return {**result, "sha256": digest(encoded(result))}
194
-
195
-
196
- def snapshot(state):
197
- if not isinstance(state, dict):
198
- fail("state must be an object")
199
- if "role_resolution" not in state:
200
- return None
201
- value = state["role_resolution"]
202
- if not isinstance(value, dict) or value.get("sha256") != digest(encoded({k: v for k, v in value.items() if k != "sha256"})):
203
- fail("frozen role resolution is malformed or altered")
204
- if not isinstance(value.get("roles"), dict) or set(value["roles"]) != set(ROLES):
205
- fail("frozen role resolution has missing roles")
206
- for role, entry in value["roles"].items():
207
- if not isinstance(entry, dict):
208
- fail(f"frozen {role} is malformed")
209
- if role != "pair_judge" or entry.get("engine") is not None:
210
- name(entry.get("engine"), f"frozen {role}.engine")
211
- for field in ("model_requested", "effort_requested"):
212
- if field not in entry or (entry[field] is not None and (not isinstance(entry[field], str) or not entry[field])):
213
- fail(f"frozen {role}.{field} is malformed")
214
- return value
215
-
216
-
217
- def observations(state):
218
- """Report only state-bound observations; argv is never effective effort."""
219
- result = {role: {"model_effective": None, "effort_effective": None, "evidence_basis": None} for role in ROLES}
220
- phases = state.get("phases", {})
221
- if not isinstance(phases, dict):
222
- fail("state phases must be an object")
223
- for phase in ("implement", "cleanup"):
224
- entry = phases.get(phase)
225
- if isinstance(entry, dict) and entry.get("model_effective") and entry.get("verdict") in {"PASS", "PASS_WITH_ISSUES"} and entry.get("completed_at"):
226
- basis = ("completed-phase/session-model-attestation"
227
- if entry.get("engine") == "claude" and not entry.get("invocation_receipt") else None)
228
- if basis:
229
- result["worker"] = {"model_effective": entry["model_effective"], "effort_effective": None,
230
- "evidence_basis": basis}
231
- verify = phases.get("verify")
232
- if isinstance(verify, dict):
233
- evidence = verify.get("role_evidence") or {}
234
- if not isinstance(evidence, dict):
235
- fail("judge role evidence must be an object")
236
- for role, entry in evidence.items():
237
- if role in result and isinstance(entry, dict) and entry.get("identity_basis"):
238
- result[role] = {"model_effective": entry.get("model_observed"), "effort_effective": entry.get("effort_observed"),
239
- "evidence_basis": entry.get("identity_basis")}
101
+ def resolve(work, default_engine, *, available=None, shared=SHARED):
102
+ """The project's executor pin, else the invoking CLI; status only, never a dispatch decision."""
103
+ config, source = read_config(Path(work) / ".devlyn/engines.json", optional=True, shared=shared)
104
+ engine = config.get("executor", default_engine)
105
+ adapter(engine, shared=shared)
106
+ available = available or (lambda candidate: shutil.which(candidate) is not None)
107
+ return {"executor": {"engine": engine, "source": "engines.json" if "executor" in config else "default",
108
+ "availability": "CLI-present/auth-unchecked" if available(engine) else "CLI-unavailable"},
109
+ "inactive": [key for key in RETIRED if key in config], "input": source}
110
+
111
+
112
+ def select(work, default_engine, *, available=None, shared=SHARED):
113
+ """The executor for dispatch. A pin is a promise: an unavailable pinned engine is never substituted."""
114
+ result = resolve(work, default_engine, available=available, shared=shared)
115
+ executor = result["executor"]
116
+ if executor["source"] == "engines.json" and executor["availability"] == "CLI-unavailable":
117
+ fail(f"pinned executor {executor['engine']} is unavailable; install and authenticate its CLI, "
118
+ f"verify `{executor['engine']} --version`, and retry, or pin an available engine with `devlyn-engines executor <name>`",
119
+ f"{executor['engine']}-unavailable")
240
120
  return result
241
121
 
242
122
 
243
- def primary_engine(state):
244
- verify = state.get("phases", {}).get("verify")
245
- value = verify["engine"] if isinstance(verify, dict) and "engine" in verify else state.get("engine")
246
- return name(value, "primary engine")
247
-
248
-
249
- def native_version(engine):
250
- binary = shutil.which(engine)
251
- if binary is None:
252
- fail(f"install/authenticate {engine} and retry", f"{engine}-unavailable")
253
- try:
254
- proc = subprocess.run(runpy.run_path(Path(__file__).with_name("platform-support.py"))["native_argv"]([binary, "--version"]), capture_output=True, text=True, timeout=20, encoding="utf-8")
255
- except (OSError, subprocess.SubprocessError) as exc:
256
- fail(f"{engine} --version failed: {exc}; install/authenticate the native CLI and retry", f"{engine}-unavailable")
257
- if proc.returncode != 0:
258
- fail(f"{engine} --version exited {proc.returncode}; install/authenticate the native CLI and retry", f"{engine}-unavailable")
259
- match = re.search(r"\b\d+\.\d+\.\d+\b", proc.stdout)
260
- if not match:
261
- fail(f"{engine} --version did not establish a native version", "unsupported-role-option")
262
- return match[0]
263
-
264
-
265
- def options(entry, role, *, model=None, version=None, cache=None, shared=SHARED):
266
- """Return additional argv only; caller retains the existing phase sandbox/tools."""
267
- engine = entry["engine"]
268
- requested_model, effort = entry.get("model_requested"), entry.get("effort_requested")
269
- model = requested_model or model
270
- if not requested_model and not effort:
271
- return {"argv": [], "capability": None}
272
- detail = f"{role}/{engine}: explicit model/effort unsupported; use a supported exact model or omit the override"
273
- validate_channel(entry, role)
274
- if not model:
275
- fail(detail + "; resolve the inherited model before validating effort", "unsupported-role-option")
276
- version = version or native_version(engine)
277
- capability = {"native_version": version, "model": model}
278
- if engine == "codex":
279
- path = Path(cache) if cache else Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))) / "models_cache.json"
280
- try:
281
- raw = path.read_bytes()
282
- metadata = loads(raw)
283
- if not isinstance(metadata, dict) or not isinstance(metadata.get("models"), list) or any(not isinstance(row, dict) for row in metadata["models"]):
284
- raise ValueError("malformed native model metadata")
285
- rows = [row for row in metadata["models"] if row.get("slug") == model]
286
- if metadata.get("client_version") != version or len(rows) != 1:
287
- raise ValueError("native version or exact model is absent/ambiguous")
288
- advertised = rows[0]["supported_reasoning_levels"]
289
- 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):
290
- raise ValueError("malformed native effort metadata")
291
- levels = [item["effort"] for item in advertised]
292
- except (OSError, UnicodeError, ValueError, TypeError, KeyError) as exc:
293
- fail(f"{detail}; refresh native Codex model metadata: {exc}", "unsupported-role-option")
294
- capability.update(path=str(path.resolve()), sha256=digest(raw))
295
- else:
296
- text = adapter(engine, judge=True, shared=shared)
297
- if effort is not None:
298
- matches = re.findall(r"^<!-- devlyn-effort (\S+) (\S+) ([a-z,]+) -->$", text, re.M)
299
- levels = next((levels.split(",") for ver, selected, levels in matches if ver == version and selected == model), None)
300
- if levels is None:
301
- fail(detail + "; this Claude version/model has no validated adapter effort declaration; omit effort to select only the model", "unsupported-role-option")
302
- capability.update(path=str(shared / "adapters/claude.md"), sha256=digest(text.encode()))
303
- if effort is not None and effort not in levels:
304
- fail(f"{role}/{engine}/{model}: effort {effort!r} unsupported; supported: {', '.join(levels)}", "unsupported-role-option")
305
- args = (["-m" if engine == "codex" else "--model", requested_model] if requested_model else [])
306
- if effort:
307
- args += ["-c", "model_reasoning_effort=" + effort] if engine == "codex" else ["--effort", effort]
308
- return {"argv": args, "capability": capability}
309
-
310
-
311
- def edit(work, role, value, shared=SHARED, default_engine="claude"):
123
+ def edit(work, executor, shared=SHARED):
124
+ """Pin `executor`, or with None clear devlyn's pins; unrelated keys keep their values."""
312
125
  path = Path(work) / ".devlyn/engines.json"
313
126
  config, _ = read_config(path, optional=True, shared=shared)
314
- if role == "clear":
315
- for key in ("executor", "pair_judge_priority", "roles"):
127
+ if executor is None:
128
+ for key in ("executor", *RETIRED):
316
129
  config.pop(key, None)
317
- elif role in ROLES:
318
- roles = config.setdefault("roles", {})
319
- if value == "clear":
320
- roles.pop(role, None)
321
- if not roles:
322
- config.pop("roles", None)
323
- else:
324
- if not isinstance(value, str):
325
- fail("role update requires --value JSON or clear")
326
- roles[role] = loads(value)
327
130
  else:
328
- fail("unknown role")
329
- validate(config, shared=shared)
330
- # Validate the complete proposed selection before replacing any bytes. Writes
331
- # may pin an engine installed later; availability is checked at dispatch.
332
- resolve(work, default_engine, proposed=config, available=lambda engine: True, shared=shared)
131
+ adapter(executor, shared=shared)
132
+ config["executor"] = executor
333
133
  path.parent.mkdir(parents=True, exist_ok=True)
334
134
  if not config:
335
135
  path.unlink(missing_ok=True)
@@ -346,173 +146,71 @@ def edit(work, role, value, shared=SHARED, default_engine="claude"):
346
146
 
347
147
 
348
148
  def self_test():
349
- from unittest.mock import patch
350
-
351
149
  with tempfile.TemporaryDirectory() as temp:
352
150
  work = Path(temp)
353
151
  (work / ".devlyn").mkdir()
354
152
  path = work / ".devlyn/engines.json"
355
- base = resolve(work, "codex", available=lambda e: True)
356
- assert [base["roles"][r]["engine"] for r in ROLES] == ["codex", "codex", "claude"]
357
- assert resolve(work, "claude", available=lambda e: True)["roles"]["primary_judge"]["channel"] == "inherited/adapter-defined"
358
- assert channel("claude", "primary_judge", explicit=True) == "claude-CLI"
359
- for role, engine in (("worker", "claude"), ("pair_judge", "grok")):
360
- unsupported = {"value": {"roles": {role: {"engine": engine, "model": "fixture-model"}}}, "path": "fixture", "sha256": "fixture"}
361
- try:
362
- resolve(work, "codex", run_input=unsupported, available=lambda e: True)
363
- except ValueError as exc:
364
- assert "unsupported-role-option" in str(exc)
365
- else:
366
- raise AssertionError("unsupported selected channel survived pre-PLAN resolution")
367
- if role == "pair_judge":
368
- assert resolve(work, "codex", run_input=unsupported, no_pair=True)["roles"][role]["skipped_reason"] == "user_no_pair"
369
- path.write_bytes(encoded({"executor": "claude", "custom": 7, "roles": {"worker": {"engine": "codex", "model": "gpt-6-astra", "effort": "high"}}}))
370
- selected = resolve(work, "codex", available=lambda e: True)
371
- assert selected["roles"]["primary_judge"]["engine"] == "claude"
372
- assert selected["roles"]["pair_judge"]["engine"] == "codex"
373
- over = {"value": {"roles": {"worker": {"engine": "claude"}}}, "path": "fixture", "sha256": "test"}
374
- assert resolve(work, "codex", run_input=over, available=lambda e: True)["roles"]["worker"]["model_requested"] is None
375
- flag = resolve(work, "claude", flag_engine="codex", available=lambda e: True)
376
- assert flag["roles"]["worker"]["effort_requested"] is None
377
- state = {"role_resolution": selected}
378
- path.write_bytes(encoded({"executor": "codex"}))
379
- assert snapshot(state) == selected
380
- assert observations({"phases": {"implement": {"engine": "codex", "model_effective": "gpt-6-astra", "invocation_receipt": {"path": "fixture"}, "verdict": "PASS", "completed_at": "fixture",
381
- "role_argv": {"effort_requested": "high"}}, "verify": None}})["worker"] == {
382
- "model_effective": None, "effort_effective": None, "evidence_basis": None}
383
- attested = {"engine": "claude", "model_effective": "fixture-claude-model", "verdict": "PASS", "completed_at": "fixture"}
384
- assert observations({"phases": {"implement": attested}})["worker"] == {
385
- "model_effective": "fixture-claude-model", "effort_effective": None, "evidence_basis": "completed-phase/session-model-attestation"}
386
- assert observations({"phases": {"implement": {**attested, "verdict": "BLOCKED"}}})["worker"]["model_effective"] is None
387
- for malformed in (None, {}, {**selected, "sha256": "altered"}):
388
- try:
389
- snapshot({"role_resolution": malformed})
390
- except ValueError:
391
- pass
392
- else:
393
- raise AssertionError("present malformed snapshot became legacy")
394
- assert primary_engine({"engine": "codex", "phases": {"verify": {"engine": "claude"}}}) == "claude"
395
- for bad in ('{"roles":null}', '{"roles":{"worker":{"engine":"codex","effort":null}}}', '{"roles":{"x":{}}}', '{"roles":{"worker":{"engine":"codex","extra":1}}}', '{"executor":"codex","executor":"claude"}'):
396
- before = path.read_bytes()
397
- try:
398
- validate(loads(bad))
399
- except ValueError:
400
- pass
401
- else:
402
- raise AssertionError(bad)
403
- assert path.read_bytes() == before
404
- edit(work, "worker", '{"engine":"codex","model":"gpt-6-astra"}')
405
- before = path.read_bytes()
406
- try:
407
- edit(work, "worker", '{"engine":"grok"}')
408
- except ValueError:
409
- pass
410
- else:
411
- raise AssertionError("ineligible worker")
412
- assert path.read_bytes() == before
413
- path.write_bytes(encoded({"executor": "claude", "keep": 7}))
414
- before = path.read_bytes()
415
- for role, arguments in (("pair_judge", ["--value", '{"engine":"claude"}']),
416
- ("worker", ["--value", "{"]), ("worker", [])):
417
- proc = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--workdir", str(work),
418
- "--set-role", role, *arguments], capture_output=True, text=True, encoding="utf-8")
419
- assert proc.returncode == 1 and "BLOCKED:invalid-engine-config" in proc.stderr, proc.stderr
420
- assert "Traceback" not in proc.stderr and path.read_bytes() == before
421
- path.write_bytes(encoded({"executor": "claude", "pair_judge_priority": ["codex"]}))
153
+ here, absent = (lambda engine: True), (lambda engine: False)
154
+ assert resolve(work, "claude", available=here)["executor"] == {
155
+ "engine": "claude", "source": "default", "availability": "CLI-present/auth-unchecked"}
156
+ # An explicit pin binds; retired keys are reported, not validated, and their bytes stay.
157
+ legacy = b'{"executor":"codex","pair_judge_priority":["grok"],"roles":{"worker":{"engine":"unknown"}},"keep":7}\n'
158
+ path.write_bytes(legacy)
159
+ status = resolve(work, "claude", available=here)
160
+ assert status["executor"]["engine"] == "codex" and status["executor"]["source"] == "engines.json", status
161
+ assert status["inactive"] == ["pair_judge_priority", "roles"] and path.read_bytes() == legacy
162
+ # A worker profile never becomes the executor.
163
+ path.write_bytes(b'{"roles":{"worker":{"engine":"codex"}}}')
164
+ assert resolve(work, "claude", available=here)["executor"]["engine"] == "claude"
165
+ # Dispatch fails closed for an unavailable pin; the invoking CLI is reported, never blocked.
166
+ path.write_bytes(b'{"executor":"codex"}')
422
167
  try:
423
- resolve(work, "claude", available=lambda engine: False)
168
+ select(work, "claude", available=absent)
424
169
  except ValueError as exc:
425
- assert "BLOCKED:codex-unavailable" in str(exc)
170
+ assert str(exc).startswith("BLOCKED:codex-unavailable:"), exc
426
171
  else:
427
- raise AssertionError("explicit priority silently became solo")
428
- assert resolve(work, "claude", no_pair=True, available=lambda engine: False)["roles"]["pair_judge"]["skipped_reason"] == "user_no_pair"
429
- status = resolve(work, "claude", available=lambda engine: False, for_status=True)
430
- assert status["roles"]["pair_judge"]["engine"] is None
431
- assert status["roles"]["pair_judge"]["priority_requested"] == ["codex"]
432
- assert status["roles"]["pair_judge"]["skipped_reason"] is None
433
- assert status["roles"]["primary_judge"]["availability"] == "CLI-unavailable"
434
- before = path.read_bytes()
435
- proc = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--workdir", str(work)],
436
- capture_output=True, text=True, env={**os.environ, "PATH": str(work / "no-binaries")}, encoding="utf-8")
437
- assert proc.returncode == 0 and loads(proc.stdout)["roles"]["pair_judge"]["engine"] is None, proc.stderr
438
- assert path.read_bytes() == before
439
- for engine in (None, ""):
172
+ raise AssertionError("an unavailable pinned executor was dispatched")
173
+ assert resolve(work, "claude", available=absent)["executor"]["availability"] == "CLI-unavailable"
174
+ path.unlink()
175
+ assert select(work, "claude", available=absent)["executor"]["source"] == "default"
176
+ # Pins are validated before any byte changes.
177
+ ineligible = work / "shared"
178
+ (ineligible / "adapters").mkdir(parents=True)
179
+ (ineligible / "adapters/judge.md").write_text("## Role eligibility\n\nexecutor: no\n", encoding="utf-8")
180
+ (ineligible / "adapters/codex.md").write_text("# Codex adapter\n", encoding="utf-8")
181
+ path.write_bytes(legacy)
182
+ for engine, shared, message in (("grok", SHARED, "no adapter for grok"), ("judge", ineligible, "ineligible as executor"),
183
+ ("../codex", SHARED, "nonempty identifier")):
440
184
  try:
441
- primary_engine({"engine": "codex", "phases": {"verify": {"engine": engine}}})
442
- except ValueError:
443
- pass
185
+ edit(work, engine, shared=shared)
186
+ except ValueError as exc:
187
+ assert "BLOCKED:invalid-engine-config" in str(exc) and message in str(exc), exc
444
188
  else:
445
- raise AssertionError("malformed primary fell back")
446
- path.write_bytes(encoded({"roles": {"pair_judge": {"engine": "codex"}}}))
447
- try:
448
- resolve(work, "codex", available=lambda e: True)
449
- except ValueError:
450
- pass
451
- else:
452
- raise AssertionError("same engine pair")
453
- assert resolve(work, "codex", no_pair=True)["roles"]["pair_judge"]["skipped_reason"] == "user_no_pair"
454
- cache = work / "models.json"
455
- cache.write_bytes(encoded({"client_version": "0.153.4", "models": [{"slug": "gpt-6-astra", "supported_reasoning_levels": [{"effort": "ultra"}]}]}))
456
- entry = {"engine": "codex", "model_requested": "gpt-6-astra", "effort_requested": "ultra"}
457
- assert options(entry, "worker", version="0.153.4", cache=cache)["argv"][-1] == "model_reasoning_effort=ultra"
458
- cache.write_bytes(encoded({"client_version": "0.153.4", "models": [None]}))
459
- try:
460
- options(entry, "worker", version="0.153.4", cache=cache)
461
- except ValueError as exc:
462
- assert "unsupported-role-option" in str(exc)
463
- else:
464
- raise AssertionError("malformed native cache accepted")
465
- declaration = re.search(r"^<!-- devlyn-effort (\S+) (\S+) ([a-z,]+) -->$", adapter("claude", judge=True), re.M)
466
- assert declaration is not None
467
- version, known_model, levels = declaration.groups()
468
- explicit_effort = levels.split(",")[-1]
469
- assert options({"engine": "claude", "model_requested": known_model, "effort_requested": explicit_effort},
470
- "primary_judge", version=version)["argv"][-1] == explicit_effort
471
- for future_model, future_version in (("fixture-next-model", "9.0.0"), ("fixture-other-model", "9.1.0")):
472
- future = {"engine": "claude", "model_requested": future_model, "effort_requested": None}
473
- for judge in ("primary_judge", "pair_judge"):
474
- result = options(future, judge, version=future_version)
475
- assert result["argv"] == ["--model", future_model]
476
- assert result["capability"]["native_version"] == future_version
477
- assert result["capability"]["sha256"] == digest(adapter("claude", judge=True).encode())
478
- try:
479
- options({**future, "effort_requested": "high"}, judge, version=future_version)
480
- except ValueError as exc:
481
- assert "adapter effort declaration" in str(exc)
482
- else:
483
- raise AssertionError("unknown judge effort support was assumed")
484
- ineligible = work / "adapters"
485
- ineligible.mkdir()
486
- (ineligible / "claude.md").write_text("pair_judge: no\n", encoding="utf-8")
487
- try:
488
- options(future, "primary_judge", version=future_version, shared=work)
489
- except ValueError as exc:
490
- assert "ineligible for judge" in str(exc)
491
- else:
492
- raise AssertionError("model-only selection bypassed adapter eligibility")
493
- for invalid in ({**entry, "effort_requested": "bogus"}, {"engine": "claude", "model_requested": "fixture-claude-model", "effort_requested": "high"}):
189
+ raise AssertionError(f"{engine} was pinned")
190
+ assert path.read_bytes() == legacy
191
+ for bad in (b'{"executor":"codex","executor":"claude"}', b'{"executor":NaN}', b'[]', b'{"executor":"grok"}'):
192
+ path.write_bytes(bad)
494
193
  try:
495
- options(invalid, "worker", version="0.153.4", cache=cache)
194
+ resolve(work, "claude", available=here)
496
195
  except ValueError as exc:
497
- assert "unsupported-role-option" in str(exc)
196
+ assert "BLOCKED:invalid-engine-config" in str(exc), exc
498
197
  else:
499
- raise AssertionError("unsupported option")
500
- edit(work, "clear", None)
198
+ raise AssertionError(bad)
199
+ path.write_bytes(legacy)
200
+ edit(work, "claude")
201
+ assert loads(path.read_bytes()) == {**loads(legacy), "executor": "claude"}
202
+ edit(work, None)
203
+ assert loads(path.read_bytes()) == {"keep": 7}
204
+ path.write_bytes(b'{"executor":"codex","roles":{}}')
205
+ edit(work, None)
501
206
  assert not path.exists()
502
- with patch.object(shutil, "which", return_value="/fixture/codex"):
503
- for response, reason in ((FileNotFoundError("fixture"), "codex-unavailable"),
504
- (subprocess.TimeoutExpired(["codex", "--version"], 20), "codex-unavailable"),
505
- (subprocess.CompletedProcess([], 1, "", "failed"), "codex-unavailable"),
506
- (subprocess.CompletedProcess([], 0, "unparsable", ""), "unsupported-role-option")):
507
- kwargs = {"side_effect": response} if isinstance(response, Exception) else {"return_value": response}
508
- with patch.object(subprocess, "run", **kwargs):
509
- try:
510
- native_version("codex")
511
- except ValueError as exc:
512
- assert f"BLOCKED:{reason}:" in str(exc)
513
- else:
514
- raise AssertionError("failed native version probe accepted")
515
- print("PASS role-config self-test: precedence, isolation, atomic validation, capability and legacy identity")
207
+ # The CLI reports blocks on stderr without a traceback and changes nothing.
208
+ path.write_bytes(b'{"executor":"codex"}')
209
+ proc = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--workdir", str(work), "--select"],
210
+ capture_output=True, text=True, encoding="utf-8", env={**os.environ, "PATH": str(work / "no-binaries")})
211
+ assert proc.returncode == 1 and proc.stderr.startswith("BLOCKED:codex-unavailable:"), proc.stderr
212
+ assert "Traceback" not in proc.stderr and path.read_bytes() == b'{"executor":"codex"}'
213
+ print("PASS role-config self-test: executor pin, retired keys inactive, fail-closed selection and atomic validated writes")
516
214
  return 0
517
215
 
518
216
 
@@ -521,42 +219,23 @@ def main():
521
219
  parser.add_argument("--self-test", action="store_true")
522
220
  parser.add_argument("--workdir", type=Path, default=Path.cwd())
523
221
  parser.add_argument("--default-engine", default="claude")
524
- parser.add_argument("--engine")
525
- parser.add_argument("--role-config", type=Path)
526
- parser.add_argument("--no-pair", action="store_true")
527
- parser.add_argument("--state", type=Path)
528
- parser.add_argument("--role", choices=ROLES)
529
- parser.add_argument("--resolved-model")
530
- parser.add_argument("--set-role", choices=(*ROLES, "clear"))
531
- parser.add_argument("--value")
222
+ action = parser.add_mutually_exclusive_group()
223
+ action.add_argument("--select", action="store_true", help="resolve for dispatch: an unavailable pinned executor fails closed")
224
+ action.add_argument("--set-executor", metavar="ENGINE")
225
+ action.add_argument("--clear", action="store_true", help="remove the executor pin and the inactive retired keys")
532
226
  args = parser.parse_args()
533
227
  try:
534
228
  if args.self_test:
535
229
  return self_test()
536
- if args.set_role:
537
- edit(args.workdir, args.set_role, args.value, default_engine=args.default_engine)
538
- if args.state:
539
- state = loads(args.state.read_bytes())
540
- value = snapshot(state)
541
- if value is None:
542
- fail("run has no frozen roles; complete PHASE0 role resolution first")
543
- value = {**value, "observations": observations(state)}
544
- else:
545
- run_input = None
546
- if args.role_config:
547
- config, pin = read_config(args.role_config, run=True)
548
- run_input = {**pin, "value": config}
549
- value = resolve(args.workdir, args.default_engine, flag_engine=args.engine, run_input=run_input, no_pair=args.no_pair,
550
- for_status=not args.role)
551
- if args.set_role:
552
- print("Configuration saved; CLI presence is status only. Availability/authentication is enforced before dispatch.", file=sys.stderr)
553
- if args.role:
554
- entry = value["roles"][args.role]
555
- value = {**entry, **options(entry, args.role, model=args.resolved_model),
556
- "observation": value.get("observations", {}).get(args.role)}
230
+ if args.set_executor is not None or args.clear:
231
+ edit(args.workdir, args.set_executor)
232
+ value = (select if args.select else resolve)(args.workdir, args.default_engine)
233
+ if args.set_executor and value["executor"]["availability"] == "CLI-unavailable":
234
+ print(f"Pinned {args.set_executor}, which is unavailable here: dispatch stops with "
235
+ f"BLOCKED:{args.set_executor}-unavailable until it is installed.", file=sys.stderr)
557
236
  print(json.dumps(value, sort_keys=True))
558
237
  return 0
559
- except (ValueError, OSError, subprocess.SubprocessError) as exc:
238
+ except (ValueError, OSError) as exc:
560
239
  print(str(exc), file=sys.stderr)
561
240
  return 1
562
241