devlyn-cli 4.1.0 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -94
- package/CLAUDE.md +7 -160
- package/LICENSE +21 -0
- package/README.md +68 -111
- package/bin/devlyn.js +265 -259
- package/bin/instruction-templates.json +43 -2
- package/bin/instructions.js +29 -8
- package/bin/skill-history.json +140 -0
- package/bin/skill-ownership.js +67 -0
- package/config/skills/_shared/adapters/claude.md +8 -85
- package/config/skills/_shared/adapters/codex.md +14 -34
- package/config/skills/_shared/adapters/omp.md +1 -11
- package/config/skills/_shared/codex-monitored.sh +4 -54
- package/config/skills/_shared/engine-doctor.sh +18 -58
- package/config/skills/_shared/engine-preflight.md +15 -47
- package/config/skills/_shared/expected-contract.py +242 -0
- package/config/skills/_shared/expected.schema.json +10 -95
- package/config/skills/_shared/invocation-receipt.py +26 -757
- package/config/skills/_shared/platform-support.py +37 -28
- package/config/skills/_shared/role-config.py +100 -421
- package/config/skills/_shared/runtime-principles.md +17 -93
- package/config/skills/_shared/task-complete.py +490 -285
- package/config/skills/_shared/task-completion.md +235 -0
- package/config/skills/devlyn-engines/SKILL.md +9 -11
- package/config/skills/devlyn-ideate/SKILL.md +57 -107
- package/config/skills/devlyn-ideate/references/elicitation.md +18 -99
- package/config/skills/devlyn-ideate/references/loop.md +110 -0
- package/config/skills/devlyn-ideate/references/package-format.md +128 -0
- package/config/skills/devlyn-ideate/scripts/acceptance.py +795 -0
- package/config/skills/devlyn-ideate/scripts/queue.py +1780 -0
- package/node_modules/@inquirer/ansi/LICENSE +22 -0
- package/node_modules/@inquirer/ansi/README.md +121 -0
- package/node_modules/@inquirer/ansi/dist/index.d.ts +14 -0
- package/node_modules/@inquirer/ansi/dist/index.js +21 -0
- package/node_modules/@inquirer/ansi/package.json +79 -0
- package/node_modules/@inquirer/checkbox/LICENSE +22 -0
- package/node_modules/@inquirer/checkbox/README.md +265 -0
- package/node_modules/@inquirer/checkbox/dist/index.d.ts +67 -0
- package/node_modules/@inquirer/checkbox/dist/index.js +210 -0
- package/node_modules/@inquirer/checkbox/package.json +93 -0
- package/node_modules/@inquirer/core/LICENSE +22 -0
- package/node_modules/@inquirer/core/README.md +424 -0
- package/node_modules/@inquirer/core/dist/index.d.ts +13 -0
- package/node_modules/@inquirer/core/dist/index.js +12 -0
- package/node_modules/@inquirer/core/dist/lib/Separator.d.ts +10 -0
- package/node_modules/@inquirer/core/dist/lib/Separator.js +21 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p1.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p1.js +4 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p2.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p2.js +3 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p3.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p3.js +5 -0
- package/node_modules/@inquirer/core/dist/lib/create-prompt.d.ts +11 -0
- package/node_modules/@inquirer/core/dist/lib/create-prompt.js +185 -0
- package/node_modules/@inquirer/core/dist/lib/errors.d.ts +20 -0
- package/node_modules/@inquirer/core/dist/lib/errors.js +21 -0
- package/node_modules/@inquirer/core/dist/lib/hook-engine.d.ts +17 -0
- package/node_modules/@inquirer/core/dist/lib/hook-engine.js +109 -0
- package/node_modules/@inquirer/core/dist/lib/key.d.ts +15 -0
- package/node_modules/@inquirer/core/dist/lib/key.js +34 -0
- package/node_modules/@inquirer/core/dist/lib/make-theme.d.ts +3 -0
- package/node_modules/@inquirer/core/dist/lib/make-theme.js +33 -0
- package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.d.ts +15 -0
- package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.js +121 -0
- package/node_modules/@inquirer/core/dist/lib/screen-manager.d.ts +14 -0
- package/node_modules/@inquirer/core/dist/lib/screen-manager.js +84 -0
- package/node_modules/@inquirer/core/dist/lib/theme.d.ts +166 -0
- package/node_modules/@inquirer/core/dist/lib/theme.js +29 -0
- package/node_modules/@inquirer/core/dist/lib/use-effect.d.ts +2 -0
- package/node_modules/@inquirer/core/dist/lib/use-effect.js +11 -0
- package/node_modules/@inquirer/core/dist/lib/use-keypress.d.ts +3 -0
- package/node_modules/@inquirer/core/dist/lib/use-keypress.js +20 -0
- package/node_modules/@inquirer/core/dist/lib/use-memo.d.ts +1 -0
- package/node_modules/@inquirer/core/dist/lib/use-memo.js +14 -0
- package/node_modules/@inquirer/core/dist/lib/use-prefix.d.ts +5 -0
- package/node_modules/@inquirer/core/dist/lib/use-prefix.js +35 -0
- package/node_modules/@inquirer/core/dist/lib/use-ref.d.ts +6 -0
- package/node_modules/@inquirer/core/dist/lib/use-ref.js +4 -0
- package/node_modules/@inquirer/core/dist/lib/use-state.d.ts +7 -0
- package/node_modules/@inquirer/core/dist/lib/use-state.js +28 -0
- package/node_modules/@inquirer/core/dist/lib/utils.d.ts +13 -0
- package/node_modules/@inquirer/core/dist/lib/utils.js +25 -0
- package/node_modules/@inquirer/core/package.json +98 -0
- package/node_modules/@inquirer/figures/LICENSE +22 -0
- package/node_modules/@inquirer/figures/dist/index.d.ts +275 -0
- package/node_modules/@inquirer/figures/dist/index.js +315 -0
- package/node_modules/@inquirer/figures/package.json +81 -0
- package/node_modules/@inquirer/select/LICENSE +22 -0
- package/node_modules/@inquirer/select/README.md +258 -0
- package/node_modules/@inquirer/select/dist/index.d.ts +42 -0
- package/node_modules/@inquirer/select/dist/index.js +191 -0
- package/node_modules/@inquirer/select/package.json +93 -0
- package/node_modules/@inquirer/type/LICENSE +22 -0
- package/node_modules/@inquirer/type/dist/index.d.ts +2 -0
- package/node_modules/@inquirer/type/dist/index.js +2 -0
- package/node_modules/@inquirer/type/dist/inquirer.d.ts +35 -0
- package/node_modules/@inquirer/type/dist/inquirer.js +1 -0
- package/node_modules/@inquirer/type/dist/utils.d.ts +45 -0
- package/node_modules/@inquirer/type/dist/utils.js +16 -0
- package/node_modules/@inquirer/type/package.json +88 -0
- package/node_modules/cli-width/LICENSE +13 -0
- package/node_modules/cli-width/README.md +71 -0
- package/node_modules/cli-width/index.d.ts +13 -0
- package/node_modules/cli-width/index.js +49 -0
- package/node_modules/cli-width/package.json +40 -0
- package/node_modules/fast-string-truncated-width/dist/index.d.ts +4 -0
- package/node_modules/fast-string-truncated-width/dist/index.js +111 -0
- package/node_modules/fast-string-truncated-width/dist/types.d.ts +19 -0
- package/node_modules/fast-string-truncated-width/dist/types.js +2 -0
- package/node_modules/fast-string-truncated-width/dist/utils.d.ts +4 -0
- package/node_modules/fast-string-truncated-width/dist/utils.js +20 -0
- package/node_modules/fast-string-truncated-width/license +21 -0
- package/node_modules/fast-string-truncated-width/package.json +35 -0
- package/node_modules/fast-string-truncated-width/readme.md +59 -0
- package/node_modules/fast-string-width/dist/index.d.ts +4 -0
- package/node_modules/fast-string-width/dist/index.js +14 -0
- package/node_modules/fast-string-width/license +21 -0
- package/node_modules/fast-string-width/package.json +34 -0
- package/node_modules/fast-string-width/readme.md +42 -0
- package/node_modules/fast-wrap-ansi/LICENSE +23 -0
- package/node_modules/fast-wrap-ansi/README.md +26 -0
- package/node_modules/fast-wrap-ansi/lib/main.d.ts +6 -0
- package/node_modules/fast-wrap-ansi/lib/main.js +218 -0
- package/node_modules/fast-wrap-ansi/package.json +51 -0
- package/node_modules/mute-stream/LICENSE +15 -0
- package/node_modules/mute-stream/README.md +68 -0
- package/node_modules/mute-stream/lib/index.js +142 -0
- package/node_modules/mute-stream/package.json +54 -0
- package/node_modules/signal-exit/LICENSE.txt +16 -0
- package/node_modules/signal-exit/README.md +74 -0
- package/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
- package/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/browser.js +10 -0
- package/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
- package/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
- package/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/index.js +279 -0
- package/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
- package/node_modules/signal-exit/dist/cjs/package.json +3 -0
- package/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
- package/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/signals.js +42 -0
- package/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
- package/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/browser.js +4 -0
- package/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
- package/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/index.js +275 -0
- package/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/package.json +3 -0
- package/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
- package/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/signals.js +39 -0
- package/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
- package/node_modules/signal-exit/package.json +106 -0
- package/package.json +17 -4
- package/config/commit-conventions.md +0 -28
- package/config/skills/_shared/adapters/README.md +0 -74
- package/config/skills/_shared/adapters/grok.md +0 -100
- package/config/skills/_shared/archive_run.py +0 -969
- package/config/skills/_shared/codex-config.md +0 -81
- package/config/skills/_shared/collect-codex-findings.py +0 -505
- package/config/skills/_shared/finish-gate.py +0 -521
- package/config/skills/_shared/grok-anchor-guard.py +0 -78
- package/config/skills/_shared/judge-output-parser.py +0 -200
- package/config/skills/_shared/judge-role-evidence.py +0 -320
- package/config/skills/_shared/phase-prompt-render.py +0 -183
- package/config/skills/_shared/process-evidence.py +0 -926
- package/config/skills/_shared/resolve-bootstrap.py +0 -1410
- package/config/skills/_shared/resolve-stop-hook.py +0 -359
- package/config/skills/_shared/run-bounded.py +0 -58
- package/config/skills/_shared/spec-verify-check.py +0 -5083
- package/config/skills/_shared/state-phase-write.py +0 -5989
- package/config/skills/_shared/terminal-claim-check.py +0 -777
- package/config/skills/_shared/verify-merge-findings.py +0 -3528
- package/config/skills/devlyn-design-ui/SKILL.md +0 -650
- package/config/skills/devlyn-ideate/references/from-spec-mode.md +0 -61
- package/config/skills/devlyn-ideate/references/project-mode.md +0 -80
- package/config/skills/devlyn-ideate/references/spec-template.md +0 -121
- package/config/skills/devlyn-ideate/references/templates/decision.md +0 -48
- package/config/skills/devlyn-ideate/references/templates/roadmap.md +0 -50
- package/config/skills/devlyn-ideate/references/templates/vision.md +0 -44
- package/config/skills/devlyn-queue/SKILL.md +0 -66
- package/config/skills/devlyn-queue/scripts/append.py +0 -94
- package/config/skills/devlyn-resolve/SKILL.md +0 -379
- package/config/skills/devlyn-resolve/references/free-form-mode.md +0 -71
- package/config/skills/devlyn-resolve/references/outer-loop.md +0 -23
- package/config/skills/devlyn-resolve/references/phases/build-gate.md +0 -75
- package/config/skills/devlyn-resolve/references/phases/cleanup.md +0 -33
- package/config/skills/devlyn-resolve/references/phases/implement.md +0 -44
- package/config/skills/devlyn-resolve/references/phases/plan.md +0 -39
- package/config/skills/devlyn-resolve/references/phases/probe-derive.md +0 -261
- package/config/skills/devlyn-resolve/references/phases/surface-close.md +0 -34
- package/config/skills/devlyn-resolve/references/phases/verify.md +0 -310
- package/config/skills/devlyn-resolve/references/state-schema.md +0 -152
- package/config/skills/devlyn-resolve/references/task-completion.md +0 -160
- package/config/templates/prompt-templates.md +0 -71
- package/config/templates/template-feature.spec.md +0 -255
- package/config/templates/template-product-spec.md +0 -680
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Resolve
|
|
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,
|
|
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
|
-
|
|
65
|
-
|
|
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,
|
|
71
|
-
if not isinstance(config, dict)
|
|
72
|
-
fail("configuration must be an object
|
|
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, *,
|
|
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),
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
engine
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
244
|
-
|
|
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
|
|
315
|
-
for key in ("executor",
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
356
|
-
assert
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
168
|
+
select(work, "claude", available=absent)
|
|
424
169
|
except ValueError as exc:
|
|
425
|
-
assert "BLOCKED:codex-unavailable"
|
|
170
|
+
assert str(exc).startswith("BLOCKED:codex-unavailable:"), exc
|
|
426
171
|
else:
|
|
427
|
-
raise AssertionError("
|
|
428
|
-
assert resolve(work, "claude",
|
|
429
|
-
|
|
430
|
-
assert
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
442
|
-
except ValueError:
|
|
443
|
-
|
|
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("
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
194
|
+
resolve(work, "claude", available=here)
|
|
496
195
|
except ValueError as exc:
|
|
497
|
-
assert "
|
|
196
|
+
assert "BLOCKED:invalid-engine-config" in str(exc), exc
|
|
498
197
|
else:
|
|
499
|
-
raise AssertionError(
|
|
500
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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.
|
|
537
|
-
edit(args.workdir, args.
|
|
538
|
-
if args.
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
238
|
+
except (ValueError, OSError) as exc:
|
|
560
239
|
print(str(exc), file=sys.stderr)
|
|
561
240
|
return 1
|
|
562
241
|
|