devlyn-cli 2.10.1 → 3.0.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 +8 -9
- package/CLAUDE.md +17 -17
- package/README.md +33 -9
- package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.json +25 -0
- package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.md +10 -0
- package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.json +18 -0
- package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.md +9 -0
- package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.json +18 -0
- package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.md +9 -0
- package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F16-cli-quote-tax-rules/l2_risk_probes/result.json +28 -0
- package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F23-cli-fulfillment-wave/l2_risk_probes/result.json +28 -0
- package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F25-cli-cart-promotion-rules/l2_risk_probes/result.json +30 -0
- package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/F21-cli-scheduler-priority/l2_risk_probes/result.json +32 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.json +44 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.md +16 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.json +18 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.md +9 -0
- package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.md +15 -0
- package/bin/devlyn.js +9 -2
- package/config/skills/_shared/.ruff.toml +2 -0
- package/config/skills/_shared/adapters/claude.md +9 -1
- package/config/skills/_shared/adapters/codex.md +5 -0
- package/config/skills/_shared/adapters/grok.md +4 -2
- package/config/skills/_shared/archive_run.py +738 -56
- package/config/skills/_shared/codex-config.md +19 -4
- package/config/skills/_shared/codex-monitored.sh +81 -12
- package/config/skills/_shared/collect-codex-findings.py +311 -128
- package/config/skills/_shared/engine-preflight.md +8 -7
- package/config/skills/_shared/expected.schema.json +61 -0
- package/config/skills/_shared/finish-gate.py +12 -11
- package/config/skills/_shared/grok-anchor-guard.py +18 -1
- package/config/skills/_shared/invocation-receipt.py +1041 -0
- package/config/skills/_shared/judge-output-parser.py +200 -0
- package/config/skills/_shared/judge-role-evidence.py +273 -0
- package/config/skills/_shared/process-evidence.py +909 -0
- package/config/skills/_shared/resolve-bootstrap.py +776 -84
- package/config/skills/_shared/resolve-stop-hook.py +33 -1
- package/config/skills/_shared/role-config.py +552 -0
- package/config/skills/_shared/run-bounded.py +1 -1
- package/config/skills/_shared/spec-verify-check.py +749 -181
- package/config/skills/_shared/state-phase-write.py +1838 -58
- package/config/skills/_shared/terminal-claim-check.py +15 -0
- package/config/skills/_shared/verify-merge-findings.py +958 -51
- package/config/skills/devlyn:engines/SKILL.md +8 -16
- package/config/skills/devlyn:ideate/SKILL.md +2 -2
- package/config/skills/devlyn:ideate/references/spec-template.md +6 -5
- package/config/skills/devlyn:queue/SKILL.md +7 -4
- package/config/skills/devlyn:resolve/SKILL.md +93 -35
- package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -0
- package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +31 -2
- package/config/skills/devlyn:resolve/references/phases/implement.md +3 -2
- package/config/skills/devlyn:resolve/references/phases/verify.md +120 -84
- package/config/skills/devlyn:resolve/references/state-schema.md +22 -8
- package/package.json +5 -1
- package/scripts/lint-skills.sh +314 -285
|
@@ -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(
|
|
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:
|