@julioborges/gantry 1.0.1 → 1.0.3
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/skills/gantry/SKILL.md +32 -1
- package/.agents/skills/gantry/capabilities/antigravity.json +15 -0
- package/.agents/skills/gantry/hooks/antigravity.hooks.json +26 -0
- package/.agents/skills/gantry/reference/plan-workflow.md +52 -14
- package/.agents/skills/gantry/reference/round-workflow.md +277 -11
- package/.agents/skills/gantry/scripts/budget.py +49 -7
- package/.agents/skills/gantry/scripts/caveman.py +243 -0
- package/.agents/skills/gantry/scripts/common.py +2 -0
- package/.agents/skills/gantry/scripts/discovery.py +262 -0
- package/.agents/skills/gantry/scripts/execution.py +797 -0
- package/.agents/skills/gantry/scripts/frontier.py +1 -1
- package/.agents/skills/gantry/scripts/guard.py +91 -25
- package/.agents/skills/gantry/scripts/runlog.py +38 -1
- package/.agents/skills/gantry/scripts/setup.py +39 -0
- package/.agents/skills/gantry-setup/SKILL.md +16 -3
- package/package.json +22 -6
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Role execution resolution, validation, and preflight across harnesses."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import copy
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import datetime
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
18
|
+
|
|
19
|
+
from common import repo_root, resolve_policy
|
|
20
|
+
import result # noqa: E402
|
|
21
|
+
import runlog # noqa: E402
|
|
22
|
+
|
|
23
|
+
SUPPORTED_HARNESSES = {"antigravity", "claude-code", "codex", "opencode"}
|
|
24
|
+
|
|
25
|
+
MIN_SUPPORTED_VERSIONS = {
|
|
26
|
+
"antigravity": "1.0.0",
|
|
27
|
+
"claude-code": "1.0.0",
|
|
28
|
+
"opencode": "0.1.0",
|
|
29
|
+
"codex": "0.1.0",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
CLI_NAMES = {
|
|
33
|
+
"antigravity": "agy",
|
|
34
|
+
"claude-code": "claude",
|
|
35
|
+
"opencode": "opencode",
|
|
36
|
+
"codex": "codex",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
ROLE_TO_SCHEMA = {
|
|
40
|
+
"plan": "planner",
|
|
41
|
+
"planner": "planner",
|
|
42
|
+
"implement": "implementer",
|
|
43
|
+
"implementer": "implementer",
|
|
44
|
+
"review": "reviewer",
|
|
45
|
+
"reviewer": "reviewer",
|
|
46
|
+
"critic": "critic",
|
|
47
|
+
"requirement-critic": "requirement-critic",
|
|
48
|
+
"plan-critic": "plan-critic",
|
|
49
|
+
"research": None,
|
|
50
|
+
"learner": "learner",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UnsupportedHarnessVersionError(Exception):
|
|
55
|
+
"""Raised when an installed harness version is below the supported threshold or invalid."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ModelFallbackError(Exception):
|
|
59
|
+
"""Raised when native or configured automatic model fallback is detected."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ProtocolFailureError(Exception):
|
|
63
|
+
"""Raised when an external role result is missing, undecodable or fails the schema contract."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_semver(version_str: str) -> tuple[int, int, int]:
|
|
67
|
+
match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", version_str)
|
|
68
|
+
if not match:
|
|
69
|
+
raise ValueError(f"Cannot parse version string: {version_str!r}")
|
|
70
|
+
major = int(match.group(1))
|
|
71
|
+
minor = int(match.group(2))
|
|
72
|
+
patch = int(match.group(3) or 0)
|
|
73
|
+
return (major, minor, patch)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_harness_version(
|
|
77
|
+
harness: str,
|
|
78
|
+
runner: Callable[..., Any] | None = None,
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
cli_name = CLI_NAMES.get(harness, harness)
|
|
81
|
+
try:
|
|
82
|
+
if runner:
|
|
83
|
+
try:
|
|
84
|
+
proc = runner([cli_name, "--version"])
|
|
85
|
+
except TypeError:
|
|
86
|
+
proc = runner([cli_name, "--version"], capture_output=True, text=True, check=False)
|
|
87
|
+
else:
|
|
88
|
+
proc = subprocess.run([cli_name, "--version"], capture_output=True, text=True, check=False)
|
|
89
|
+
if proc.returncode != 0:
|
|
90
|
+
return {
|
|
91
|
+
"valid": False,
|
|
92
|
+
"version": None,
|
|
93
|
+
"error": f"Harness CLI {cli_name} authentication/execution validation failed: return code {proc.returncode}",
|
|
94
|
+
}
|
|
95
|
+
stdout = (proc.stdout or "").strip() or (proc.stderr or "").strip()
|
|
96
|
+
version_tuple = parse_semver(stdout)
|
|
97
|
+
min_ver_str = MIN_SUPPORTED_VERSIONS.get(harness, "0.0.0")
|
|
98
|
+
min_tuple = parse_semver(min_ver_str)
|
|
99
|
+
if version_tuple < min_tuple:
|
|
100
|
+
ver_formatted = f"{version_tuple[0]}.{version_tuple[1]}.{version_tuple[2]}"
|
|
101
|
+
return {
|
|
102
|
+
"valid": False,
|
|
103
|
+
"version": ver_formatted,
|
|
104
|
+
"error": f"Unsupported installed version {ver_formatted} for {harness}. Minimum supported is {min_ver_str}.",
|
|
105
|
+
}
|
|
106
|
+
ver_formatted = f"{version_tuple[0]}.{version_tuple[1]}.{version_tuple[2]}"
|
|
107
|
+
return {"valid": True, "version": ver_formatted}
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
return {
|
|
110
|
+
"valid": False,
|
|
111
|
+
"version": None,
|
|
112
|
+
"error": f"Harness CLI {cli_name} version check failed: {exc}",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
BASE_ROLES = ("plan", "implement", "review", "critic")
|
|
117
|
+
DERIVED_ROLES = {
|
|
118
|
+
"requirement-critic": "critic",
|
|
119
|
+
"plan-critic": "critic",
|
|
120
|
+
"research": "plan",
|
|
121
|
+
"learner": "critic",
|
|
122
|
+
}
|
|
123
|
+
ALL_ROLES = (*BASE_ROLES, *DERIVED_ROLES.keys())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def validate_effort(
|
|
127
|
+
model_id: str,
|
|
128
|
+
effort: str | None,
|
|
129
|
+
supported_efforts: list[str] | None = None,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""Validate that the requested reasoning effort is supported by the model."""
|
|
132
|
+
if not effort:
|
|
133
|
+
return {"valid": True}
|
|
134
|
+
if not supported_efforts:
|
|
135
|
+
return {
|
|
136
|
+
"valid": False,
|
|
137
|
+
"error": f"Model {model_id} does not declare supported reasoning effort values.",
|
|
138
|
+
}
|
|
139
|
+
normalized_supported = [e.lower() for e in supported_efforts]
|
|
140
|
+
if effort.lower() not in normalized_supported:
|
|
141
|
+
return {
|
|
142
|
+
"valid": False,
|
|
143
|
+
"error": f"Unsupported effort value {effort!r} for model {model_id}. Supported: {supported_efforts}",
|
|
144
|
+
}
|
|
145
|
+
return {"valid": True}
|
|
146
|
+
|
|
147
|
+
HARNESS_DEFAULT_MODELS = {
|
|
148
|
+
"antigravity": "gemini-3.8-flash-medium",
|
|
149
|
+
"claude-code": "claude-3-7-sonnet-20250219",
|
|
150
|
+
"codex": "gpt-5.2-codex",
|
|
151
|
+
"opencode": "claude-3-7-sonnet-20250219",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
HARNESS_SUPPORTED_EFFORTS = {
|
|
155
|
+
"antigravity": ["low", "medium", "high"],
|
|
156
|
+
"codex": ["low", "medium", "high"],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
ENVIRONMENT_DEFAULTS = {
|
|
160
|
+
"plan": {"harness": "claude-code", "model": "claude-3-7-sonnet-20250219"},
|
|
161
|
+
"implement": {"harness": "claude-code", "model": "claude-3-7-sonnet-20250219"},
|
|
162
|
+
"review": {"harness": "claude-code", "model": "claude-3-7-sonnet-20250219"},
|
|
163
|
+
"critic": {"harness": "claude-code", "model": "claude-3-7-sonnet-20250219"},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _fill_defaults(selection: dict[str, Any]) -> dict[str, Any]:
|
|
168
|
+
res = copy.deepcopy(selection)
|
|
169
|
+
harness = res.get("harness", "claude-code")
|
|
170
|
+
res["harness"] = harness
|
|
171
|
+
if "model" not in res or not res["model"]:
|
|
172
|
+
res["model"] = HARNESS_DEFAULT_MODELS.get(harness, "claude-3-7-sonnet-20250219")
|
|
173
|
+
return res
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def resolve_single_role(
|
|
177
|
+
role: str,
|
|
178
|
+
policy: dict | None = None,
|
|
179
|
+
run_overrides: dict | None = None,
|
|
180
|
+
issue_overrides: dict | None = None,
|
|
181
|
+
environment_defaults: dict | None = None,
|
|
182
|
+
) -> dict[str, Any]:
|
|
183
|
+
"""Resolve the effective execution selection for a single role.
|
|
184
|
+
|
|
185
|
+
Resolution order:
|
|
186
|
+
1. Issue-role override
|
|
187
|
+
2. Run-role override
|
|
188
|
+
3. Repository role default (policy["execution"]["roles"])
|
|
189
|
+
4. Explicitly confirmed environment default
|
|
190
|
+
|
|
191
|
+
Derived roles inherit from their parent role at each level unless explicitly set.
|
|
192
|
+
"""
|
|
193
|
+
parent = DERIVED_ROLES.get(role)
|
|
194
|
+
|
|
195
|
+
# 1. Issue-role override
|
|
196
|
+
if issue_overrides:
|
|
197
|
+
if role in issue_overrides:
|
|
198
|
+
return _fill_defaults(issue_overrides[role])
|
|
199
|
+
if parent and parent in issue_overrides:
|
|
200
|
+
return _fill_defaults(issue_overrides[parent])
|
|
201
|
+
|
|
202
|
+
# 2. Run-role override
|
|
203
|
+
if run_overrides:
|
|
204
|
+
if role in run_overrides:
|
|
205
|
+
return _fill_defaults(run_overrides[role])
|
|
206
|
+
if parent and parent in run_overrides:
|
|
207
|
+
return _fill_defaults(run_overrides[parent])
|
|
208
|
+
|
|
209
|
+
# 3. Repository role default
|
|
210
|
+
roles_policy = (policy or {}).get("execution", {}).get("roles", {})
|
|
211
|
+
if role in roles_policy:
|
|
212
|
+
return _fill_defaults(roles_policy[role])
|
|
213
|
+
if parent and parent in roles_policy:
|
|
214
|
+
return _fill_defaults(roles_policy[parent])
|
|
215
|
+
|
|
216
|
+
# 4. Environment default
|
|
217
|
+
env_defs = environment_defaults or ENVIRONMENT_DEFAULTS
|
|
218
|
+
if role in env_defs:
|
|
219
|
+
return _fill_defaults(env_defs[role])
|
|
220
|
+
if parent and parent in env_defs:
|
|
221
|
+
return _fill_defaults(env_defs[parent])
|
|
222
|
+
|
|
223
|
+
return _fill_defaults(ENVIRONMENT_DEFAULTS.get("plan", {"harness": "claude-code", "model": "claude-3-7-sonnet-20250219"}))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def resolve_roles(
|
|
227
|
+
policy: dict | None = None,
|
|
228
|
+
run_overrides: dict | None = None,
|
|
229
|
+
issue_overrides: dict | None = None,
|
|
230
|
+
environment_defaults: dict | None = None,
|
|
231
|
+
) -> dict[str, dict[str, Any]]:
|
|
232
|
+
"""Resolve all execution roles."""
|
|
233
|
+
return {
|
|
234
|
+
r: resolve_single_role(
|
|
235
|
+
r,
|
|
236
|
+
policy=policy,
|
|
237
|
+
run_overrides=run_overrides,
|
|
238
|
+
issue_overrides=issue_overrides,
|
|
239
|
+
environment_defaults=environment_defaults,
|
|
240
|
+
)
|
|
241
|
+
for r in ALL_ROLES
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def assess_model_strength(roles: dict[str, dict[str, Any]]) -> list[str]:
|
|
246
|
+
"""Model strength guidance is advisory; returns non-blocking observations."""
|
|
247
|
+
notes = []
|
|
248
|
+
plan_model = roles.get("plan", {}).get("model", "")
|
|
249
|
+
critic_model = roles.get("critic", {}).get("model", "")
|
|
250
|
+
impl_model = roles.get("implement", {}).get("model", "")
|
|
251
|
+
|
|
252
|
+
plan_harness = roles.get("plan", {}).get("harness", "")
|
|
253
|
+
critic_harness = roles.get("critic", {}).get("harness", "")
|
|
254
|
+
impl_harness = roles.get("implement", {}).get("harness", "")
|
|
255
|
+
|
|
256
|
+
if critic_harness and impl_harness and critic_harness != impl_harness:
|
|
257
|
+
notes.append(f"Advisory: Critic uses external harness {critic_harness!r} differing from Implementer {impl_harness!r}.")
|
|
258
|
+
if "flash" in critic_model.lower() and "pro" in impl_model.lower():
|
|
259
|
+
notes.append(f"Advisory: Critic model {critic_model!r} may have lower reasoning capacity than Implementer {impl_model!r}.")
|
|
260
|
+
return notes
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def validate_selection(
|
|
264
|
+
selection: dict[str, Any],
|
|
265
|
+
role: str = "unknown",
|
|
266
|
+
root: Path | None = None,
|
|
267
|
+
check_auth: bool = True,
|
|
268
|
+
runner: Callable[..., Any] | None = None,
|
|
269
|
+
) -> dict[str, Any]:
|
|
270
|
+
"""Validate a single role execution selection."""
|
|
271
|
+
harness = selection.get("harness", "")
|
|
272
|
+
if harness not in SUPPORTED_HARNESSES:
|
|
273
|
+
return {
|
|
274
|
+
"valid": False,
|
|
275
|
+
"role": role,
|
|
276
|
+
"error": f"Unsupported harness {harness!r} for role {role}. Supported: {sorted(SUPPORTED_HARNESSES)}",
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
model = selection.get("model", "")
|
|
280
|
+
if not model:
|
|
281
|
+
return {
|
|
282
|
+
"valid": False,
|
|
283
|
+
"role": role,
|
|
284
|
+
"error": f"No model specified for role {role} on harness {harness}.",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
effort = selection.get("effort")
|
|
288
|
+
if effort:
|
|
289
|
+
cap_file = Path(__file__).resolve().parents[1] / "capabilities" / f"{harness}.json"
|
|
290
|
+
supported_efforts = None
|
|
291
|
+
if cap_file.exists():
|
|
292
|
+
try:
|
|
293
|
+
cap_data = json.loads(cap_file.read_text(encoding="utf-8"))
|
|
294
|
+
model_meta = cap_data.get("models", {}).get(model, {})
|
|
295
|
+
supported_efforts = model_meta.get("supportedEfforts")
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
if supported_efforts is None:
|
|
299
|
+
supported_efforts = HARNESS_SUPPORTED_EFFORTS.get(harness)
|
|
300
|
+
eff_check = validate_effort(model, effort, supported_efforts=supported_efforts)
|
|
301
|
+
if not eff_check["valid"]:
|
|
302
|
+
return {
|
|
303
|
+
"valid": False,
|
|
304
|
+
"role": role,
|
|
305
|
+
"error": eff_check.get("error", f"Invalid effort {effort} for model {model}"),
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if root is not None and not root.is_dir():
|
|
309
|
+
return {
|
|
310
|
+
"valid": False,
|
|
311
|
+
"role": role,
|
|
312
|
+
"error": f"Working directory {root} does not exist",
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if check_auth:
|
|
316
|
+
ver_check = validate_harness_version(harness, runner=runner)
|
|
317
|
+
if not ver_check["valid"]:
|
|
318
|
+
return {
|
|
319
|
+
"valid": False,
|
|
320
|
+
"role": role,
|
|
321
|
+
"error": ver_check["error"],
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {"valid": True, "role": role}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def build_dispatch_command(
|
|
328
|
+
harness: str,
|
|
329
|
+
model: str,
|
|
330
|
+
prompt: str,
|
|
331
|
+
effort: str | None = None,
|
|
332
|
+
output_format: str = "json",
|
|
333
|
+
) -> list[str]:
|
|
334
|
+
"""Build the bounded native CLI dispatch command for a role invocation."""
|
|
335
|
+
h = (harness or "").lower()
|
|
336
|
+
if h == "antigravity":
|
|
337
|
+
cmd = ["agy", "--print", "--model", model]
|
|
338
|
+
if effort:
|
|
339
|
+
cmd.extend(["--effort", effort])
|
|
340
|
+
cmd.extend(["--output-format", output_format, "--dangerously-skip-permissions", prompt])
|
|
341
|
+
return cmd
|
|
342
|
+
elif h == "claude-code":
|
|
343
|
+
return ["claude", "-p", prompt, "--model", model, "--dangerously-skip-permissions"]
|
|
344
|
+
elif h == "opencode":
|
|
345
|
+
return ["opencode", "run", prompt, "--model", model]
|
|
346
|
+
elif h == "codex":
|
|
347
|
+
return ["codex", "exec", prompt, "--model", model]
|
|
348
|
+
else:
|
|
349
|
+
raise ValueError(f"Unsupported harness: {harness}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def check_model_fallback(requested_model: str, output_data: dict[str, Any] | str) -> None:
|
|
353
|
+
"""Detect if the harness silently replaced the requested model with a fallback model."""
|
|
354
|
+
if isinstance(output_data, str):
|
|
355
|
+
if "fallback" in output_data.lower() and "model" in output_data.lower():
|
|
356
|
+
fb_match = re.search(r"falling back to (\S+)|fallback model:?\s*(\S+)", output_data, re.IGNORECASE)
|
|
357
|
+
if fb_match:
|
|
358
|
+
reported = fb_match.group(1) or fb_match.group(2)
|
|
359
|
+
raise ModelFallbackError(
|
|
360
|
+
f"Automatic model fallback detected: requested {requested_model!r} but fell back to {reported!r}"
|
|
361
|
+
)
|
|
362
|
+
try:
|
|
363
|
+
parsed = json.loads(output_data)
|
|
364
|
+
if isinstance(parsed, dict):
|
|
365
|
+
output_data = parsed
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
if isinstance(output_data, dict):
|
|
370
|
+
reported = output_data.get("model") or output_data.get("effective_model") or output_data.get("actual_model")
|
|
371
|
+
if reported and str(reported).strip().lower() != requested_model.strip().lower():
|
|
372
|
+
raise ModelFallbackError(
|
|
373
|
+
f"Automatic model fallback detected: requested {requested_model!r} but harness used {reported!r}"
|
|
374
|
+
)
|
|
375
|
+
if output_data.get("fallback_occurred") or output_data.get("model_fallback"):
|
|
376
|
+
raise ModelFallbackError(
|
|
377
|
+
f"Automatic model fallback detected for model {requested_model!r}"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def parse_and_validate_result(
|
|
382
|
+
role: str,
|
|
383
|
+
raw_output: str,
|
|
384
|
+
schema: dict[str, Any] | None = None,
|
|
385
|
+
) -> dict[str, Any]:
|
|
386
|
+
"""Parse output and validate against the Result Contract; protocol failure on error."""
|
|
387
|
+
if not raw_output or not raw_output.strip():
|
|
388
|
+
raise ProtocolFailureError(f"Protocol failure: empty output returned for role {role}")
|
|
389
|
+
|
|
390
|
+
text = raw_output.strip()
|
|
391
|
+
if text.startswith("```json") and text.endswith("```"):
|
|
392
|
+
text = text[7:-3].strip()
|
|
393
|
+
elif text.startswith("```") and text.endswith("```"):
|
|
394
|
+
text = text[3:-3].strip()
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
data = json.loads(text)
|
|
398
|
+
except json.JSONDecodeError as exc:
|
|
399
|
+
raise ProtocolFailureError(f"Protocol failure: invalid JSON output for role {role}: {exc}") from exc
|
|
400
|
+
|
|
401
|
+
if not isinstance(data, dict):
|
|
402
|
+
raise ProtocolFailureError(f"Protocol failure: expected JSON object for role {role}, got {type(data).__name__}")
|
|
403
|
+
|
|
404
|
+
schema_role = ROLE_TO_SCHEMA.get(role, role)
|
|
405
|
+
if schema_role:
|
|
406
|
+
if schema is None:
|
|
407
|
+
try:
|
|
408
|
+
schema = result.load_schema(schema_role)
|
|
409
|
+
except Exception as exc:
|
|
410
|
+
raise ProtocolFailureError(f"Protocol failure: failed to load schema for {schema_role}: {exc}") from exc
|
|
411
|
+
errors = result.validate(data, schema)
|
|
412
|
+
if errors:
|
|
413
|
+
raise ProtocolFailureError(f"Protocol failure: schema validation failed for {role}: {errors}")
|
|
414
|
+
|
|
415
|
+
return data
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def verify_critic_result(critic_result: dict[str, Any]) -> None:
|
|
419
|
+
"""Verify that Critic independently verified criteria and gates without accepting a summary.
|
|
420
|
+
|
|
421
|
+
Permission or tool limitations must fail visibly rather than accepting a summary.
|
|
422
|
+
"""
|
|
423
|
+
if not isinstance(critic_result, dict):
|
|
424
|
+
raise ProtocolFailureError("Critic result must be a JSON object")
|
|
425
|
+
|
|
426
|
+
if critic_result.get("permission_error") or critic_result.get("tool_limitation"):
|
|
427
|
+
err = critic_result.get("error") or "Critic encountered permission or tool limitation"
|
|
428
|
+
raise RuntimeError(f"Critic execution failed visibly: {err}")
|
|
429
|
+
|
|
430
|
+
if critic_result.get("complete") is True:
|
|
431
|
+
gate_result = critic_result.get("gateResult")
|
|
432
|
+
if not gate_result or not isinstance(gate_result, dict):
|
|
433
|
+
raise ProtocolFailureError("Critic claimed complete=true without required gateResult")
|
|
434
|
+
if gate_result.get("verdict") != "pass":
|
|
435
|
+
raise ProtocolFailureError(f"Critic claimed complete=true but gateResult verdict is {gate_result.get('verdict')!r}")
|
|
436
|
+
|
|
437
|
+
criteria = critic_result.get("criteria")
|
|
438
|
+
if not isinstance(criteria, list) or len(criteria) == 0:
|
|
439
|
+
raise ProtocolFailureError("Critic claimed complete=true with empty criteria list")
|
|
440
|
+
for c in criteria:
|
|
441
|
+
if not isinstance(c, dict) or not c.get("met") or not str(c.get("evidence", "")).strip():
|
|
442
|
+
raise ProtocolFailureError(f"Critic claimed complete=true but criterion lacks verified evidence: {c}")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def dispatch_role(
|
|
446
|
+
role: str,
|
|
447
|
+
prompt: str,
|
|
448
|
+
cwd: Path | str,
|
|
449
|
+
selection: dict[str, Any] | None = None,
|
|
450
|
+
policy: dict | None = None,
|
|
451
|
+
run_overrides: dict | None = None,
|
|
452
|
+
issue_overrides: dict | None = None,
|
|
453
|
+
environment_defaults: dict | None = None,
|
|
454
|
+
runner: Callable[..., Any] | None = None,
|
|
455
|
+
run_id: str | None = None,
|
|
456
|
+
unit_id: str | None = None,
|
|
457
|
+
state_root: str | None = None,
|
|
458
|
+
retry_on_invalid: bool = True,
|
|
459
|
+
) -> dict[str, Any]:
|
|
460
|
+
"""Execute a bounded role invocation in the selected native harness."""
|
|
461
|
+
cwd_path = Path(cwd).resolve()
|
|
462
|
+
if not cwd_path.is_dir():
|
|
463
|
+
raise ValueError(f"Working directory {cwd_path} does not exist")
|
|
464
|
+
|
|
465
|
+
eff_selection = selection or resolve_single_role(
|
|
466
|
+
role,
|
|
467
|
+
policy=policy,
|
|
468
|
+
run_overrides=run_overrides,
|
|
469
|
+
issue_overrides=issue_overrides,
|
|
470
|
+
environment_defaults=environment_defaults,
|
|
471
|
+
)
|
|
472
|
+
harness = eff_selection.get("harness", "")
|
|
473
|
+
model = eff_selection.get("model", "")
|
|
474
|
+
effort = eff_selection.get("effort")
|
|
475
|
+
|
|
476
|
+
ver_check = validate_harness_version(harness, runner=runner)
|
|
477
|
+
if not ver_check["valid"]:
|
|
478
|
+
raise UnsupportedHarnessVersionError(ver_check["error"])
|
|
479
|
+
|
|
480
|
+
eff_val = validate_selection(eff_selection, role=role, root=cwd_path, check_auth=False)
|
|
481
|
+
if not eff_val["valid"]:
|
|
482
|
+
raise ValueError(eff_val["error"])
|
|
483
|
+
|
|
484
|
+
if run_id and unit_id:
|
|
485
|
+
try:
|
|
486
|
+
root = runlog.state_root(state_root)
|
|
487
|
+
log_path = runlog.run_log_path(root, unit_id, run_id)
|
|
488
|
+
if log_path.exists():
|
|
489
|
+
event_data = {
|
|
490
|
+
"role": role,
|
|
491
|
+
"harness": harness,
|
|
492
|
+
"model": model,
|
|
493
|
+
"cwd": str(cwd_path),
|
|
494
|
+
}
|
|
495
|
+
if effort:
|
|
496
|
+
event_data["effort"] = effort
|
|
497
|
+
runlog.append_event(
|
|
498
|
+
log_path,
|
|
499
|
+
runlog.validate_event({
|
|
500
|
+
"ts": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
501
|
+
"run": run_id,
|
|
502
|
+
"event": "subagent.started",
|
|
503
|
+
"data": event_data,
|
|
504
|
+
}),
|
|
505
|
+
)
|
|
506
|
+
except Exception:
|
|
507
|
+
pass
|
|
508
|
+
|
|
509
|
+
cmd = build_dispatch_command(harness, model, prompt, effort=effort)
|
|
510
|
+
run_fn = runner or subprocess.run
|
|
511
|
+
proc = run_fn(cmd, cwd=str(cwd_path), capture_output=True, text=True, check=False)
|
|
512
|
+
if proc.returncode != 0:
|
|
513
|
+
err = (proc.stderr or "").strip() or (proc.stdout or "").strip()
|
|
514
|
+
raise RuntimeError(f"Harness {harness} execution failed (exit {proc.returncode}): {err}")
|
|
515
|
+
|
|
516
|
+
check_model_fallback(model, proc.stdout)
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
data = parse_and_validate_result(role, proc.stdout)
|
|
520
|
+
if role in ("critic", "requirement-critic", "plan-critic"):
|
|
521
|
+
verify_critic_result(data)
|
|
522
|
+
except ProtocolFailureError as exc:
|
|
523
|
+
if retry_on_invalid:
|
|
524
|
+
retry_prompt = f"{prompt}\n\nYour prior result was invalid: {exc}\nReturn the complete {role} result contract."
|
|
525
|
+
retry_cmd = build_dispatch_command(harness, model, retry_prompt, effort=effort)
|
|
526
|
+
proc2 = run_fn(retry_cmd, cwd=str(cwd_path), capture_output=True, text=True, check=False)
|
|
527
|
+
if proc2.returncode != 0:
|
|
528
|
+
raise RuntimeError(f"Harness {harness} retry failed (exit {proc2.returncode}): {(proc2.stderr or '').strip()}")
|
|
529
|
+
check_model_fallback(model, proc2.stdout)
|
|
530
|
+
data = parse_and_validate_result(role, proc2.stdout)
|
|
531
|
+
if role in ("critic", "requirement-critic", "plan-critic"):
|
|
532
|
+
verify_critic_result(data)
|
|
533
|
+
else:
|
|
534
|
+
raise
|
|
535
|
+
|
|
536
|
+
if run_id and unit_id:
|
|
537
|
+
try:
|
|
538
|
+
root = runlog.state_root(state_root)
|
|
539
|
+
log_path = runlog.run_log_path(root, unit_id, run_id)
|
|
540
|
+
if log_path.exists():
|
|
541
|
+
event_data = {
|
|
542
|
+
"role": role,
|
|
543
|
+
"harness": harness,
|
|
544
|
+
"model": model,
|
|
545
|
+
"result": data,
|
|
546
|
+
}
|
|
547
|
+
if effort:
|
|
548
|
+
event_data["effort"] = effort
|
|
549
|
+
runlog.append_event(
|
|
550
|
+
log_path,
|
|
551
|
+
runlog.validate_event({
|
|
552
|
+
"ts": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
553
|
+
"run": run_id,
|
|
554
|
+
"event": "subagent.stopped",
|
|
555
|
+
"data": event_data,
|
|
556
|
+
}),
|
|
557
|
+
)
|
|
558
|
+
except Exception:
|
|
559
|
+
pass
|
|
560
|
+
|
|
561
|
+
return data
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def preflight_validate(
|
|
565
|
+
policy: dict | None = None,
|
|
566
|
+
run_overrides: dict | None = None,
|
|
567
|
+
issue_overrides: dict | None = None,
|
|
568
|
+
environment_defaults: dict | None = None,
|
|
569
|
+
root: Path | None = None,
|
|
570
|
+
check_auth: bool = True,
|
|
571
|
+
runner: Callable[..., Any] | None = None,
|
|
572
|
+
) -> dict[str, Any]:
|
|
573
|
+
"""Preflight check: resolve effective selections and validate all roles."""
|
|
574
|
+
roles = resolve_roles(
|
|
575
|
+
policy=policy,
|
|
576
|
+
run_overrides=run_overrides,
|
|
577
|
+
issue_overrides=issue_overrides,
|
|
578
|
+
environment_defaults=environment_defaults,
|
|
579
|
+
)
|
|
580
|
+
errors = []
|
|
581
|
+
for r, sel in roles.items():
|
|
582
|
+
val = validate_selection(sel, role=r, root=root, check_auth=check_auth, runner=runner)
|
|
583
|
+
if not val["valid"]:
|
|
584
|
+
errors.append(val["error"])
|
|
585
|
+
|
|
586
|
+
guidance = assess_model_strength(roles)
|
|
587
|
+
valid = len(errors) == 0
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
"valid": valid,
|
|
591
|
+
"roles": roles,
|
|
592
|
+
"errors": errors,
|
|
593
|
+
"guidance": guidance,
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def validate_role_replacement(
|
|
598
|
+
role: str,
|
|
599
|
+
selection: dict[str, Any],
|
|
600
|
+
issue_ref: str,
|
|
601
|
+
root: Path | None = None,
|
|
602
|
+
check_auth: bool = False,
|
|
603
|
+
runner: Callable[..., Any] | None = None,
|
|
604
|
+
) -> dict[str, Any]:
|
|
605
|
+
"""Validate an explicit role replacement for an Issue without mutating defaults."""
|
|
606
|
+
if not isinstance(selection, dict):
|
|
607
|
+
return {"valid": False, "error": "Replacement selection must be an object"}
|
|
608
|
+
val = validate_selection(selection, role=role, root=root, check_auth=check_auth, runner=runner)
|
|
609
|
+
if not val["valid"]:
|
|
610
|
+
return {"valid": False, "error": val["error"]}
|
|
611
|
+
return {
|
|
612
|
+
"valid": True,
|
|
613
|
+
"role": role,
|
|
614
|
+
"issue": issue_ref,
|
|
615
|
+
"selection": selection,
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def check_unresolved_failures(
|
|
620
|
+
unit_id: str,
|
|
621
|
+
run_id: str,
|
|
622
|
+
state_root_path: Path | str | None = None,
|
|
623
|
+
) -> list[dict[str, Any]]:
|
|
624
|
+
"""Derive unresolved paused executions from the Run log.
|
|
625
|
+
|
|
626
|
+
An issue execution is paused if an `issue.paused` event was recorded.
|
|
627
|
+
It is considered resolved if a later `role.changed` event was recorded for that same issue and role.
|
|
628
|
+
"""
|
|
629
|
+
root = runlog.state_root(str(state_root_path) if state_root_path else None)
|
|
630
|
+
log_path = runlog.run_log_path(root, unit_id, run_id)
|
|
631
|
+
if not log_path.is_file():
|
|
632
|
+
return []
|
|
633
|
+
|
|
634
|
+
events = runlog.read_valid_events(log_path)
|
|
635
|
+
paused_map: dict[tuple[str, str], dict[str, Any]] = {}
|
|
636
|
+
|
|
637
|
+
for event in events:
|
|
638
|
+
ev_type = event.get("event")
|
|
639
|
+
issue = event.get("issue")
|
|
640
|
+
data = event.get("data", {})
|
|
641
|
+
if ev_type == "issue.paused" and issue:
|
|
642
|
+
role = data.get("role", "critic")
|
|
643
|
+
paused_map[(issue, role)] = {
|
|
644
|
+
"issue": issue,
|
|
645
|
+
"role": role,
|
|
646
|
+
"worktree": data.get("worktree"),
|
|
647
|
+
"reason": data.get("reason", "execution_unavailable"),
|
|
648
|
+
"error": data.get("error", "execution unavailable"),
|
|
649
|
+
}
|
|
650
|
+
elif ev_type == "role.changed" and issue:
|
|
651
|
+
role = data.get("role", "critic")
|
|
652
|
+
paused_map.pop((issue, role), None)
|
|
653
|
+
elif ev_type in ("issue.done", "issue.blocked") and issue:
|
|
654
|
+
for key in list(paused_map.keys()):
|
|
655
|
+
if key[0] == issue:
|
|
656
|
+
paused_map.pop(key, None)
|
|
657
|
+
|
|
658
|
+
return list(paused_map.values())
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def main() -> int:
|
|
662
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
663
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
664
|
+
|
|
665
|
+
resolve_parser = subparsers.add_parser("resolve", help="resolve effective role executions")
|
|
666
|
+
resolve_parser.add_argument("--role", help="specific role to resolve")
|
|
667
|
+
resolve_parser.add_argument("--run-overrides", help="JSON string of run-level overrides")
|
|
668
|
+
resolve_parser.add_argument("--issue-overrides", help="JSON string of issue-level overrides")
|
|
669
|
+
resolve_parser.add_argument("--cwd", default=".", help="repository root path")
|
|
670
|
+
resolve_parser.add_argument("--json", action="store_true", help="output JSON")
|
|
671
|
+
|
|
672
|
+
preflight_parser = subparsers.add_parser("preflight", help="run preflight validation across roles")
|
|
673
|
+
preflight_parser.add_argument("--run-overrides", help="JSON string of run-level overrides")
|
|
674
|
+
preflight_parser.add_argument("--issue-overrides", help="JSON string of issue-level overrides")
|
|
675
|
+
preflight_parser.add_argument("--cwd", default=".", help="repository root path")
|
|
676
|
+
preflight_parser.add_argument("--json", action="store_true", help="output JSON")
|
|
677
|
+
|
|
678
|
+
dispatch_parser = subparsers.add_parser("dispatch", help="dispatch bounded role invocation")
|
|
679
|
+
dispatch_parser.add_argument("--role", required=True, help="role to execute")
|
|
680
|
+
dispatch_parser.add_argument("--prompt", help="prompt text (or via stdin)")
|
|
681
|
+
dispatch_parser.add_argument("--cwd", default=".", help="working directory")
|
|
682
|
+
dispatch_parser.add_argument("--selection", help="JSON selection object with harness, model, effort")
|
|
683
|
+
dispatch_parser.add_argument("--run-overrides", help="JSON string of run-level overrides")
|
|
684
|
+
dispatch_parser.add_argument("--issue-overrides", help="JSON string of issue-level overrides")
|
|
685
|
+
dispatch_parser.add_argument("--run-id", help="Run ID for logging")
|
|
686
|
+
dispatch_parser.add_argument("--unit-id", help="Unit ID for logging")
|
|
687
|
+
dispatch_parser.add_argument("--state-root", help="State root directory")
|
|
688
|
+
dispatch_parser.add_argument("--json", action="store_true", help="output JSON")
|
|
689
|
+
|
|
690
|
+
failures_parser = subparsers.add_parser("unresolved-failures", help="check for unresolved paused executions")
|
|
691
|
+
failures_parser.add_argument("unit_id", help="repository unit ID")
|
|
692
|
+
failures_parser.add_argument("run_id", help="run ID")
|
|
693
|
+
failures_parser.add_argument("--state-root", help="override state root")
|
|
694
|
+
failures_parser.add_argument("--json", action="store_true", help="output JSON")
|
|
695
|
+
|
|
696
|
+
validate_rep_parser = subparsers.add_parser("validate-replacement", help="validate role replacement")
|
|
697
|
+
validate_rep_parser.add_argument("--issue", required=True, help="issue ref")
|
|
698
|
+
validate_rep_parser.add_argument("--role", required=True, help="role name")
|
|
699
|
+
validate_rep_parser.add_argument("--selection", required=True, help="selection JSON")
|
|
700
|
+
validate_rep_parser.add_argument("--cwd", default=".", help="repository root path")
|
|
701
|
+
validate_rep_parser.add_argument("--json", action="store_true", help="output JSON")
|
|
702
|
+
|
|
703
|
+
args = parser.parse_args()
|
|
704
|
+
root = Path(args.cwd).resolve() if getattr(args, "cwd", None) else Path(".").resolve()
|
|
705
|
+
policy = resolve_policy(root)
|
|
706
|
+
|
|
707
|
+
run_ov = json.loads(args.run_overrides) if getattr(args, "run_overrides", None) else None
|
|
708
|
+
issue_ov = json.loads(args.issue_overrides) if getattr(args, "issue_overrides", None) else None
|
|
709
|
+
|
|
710
|
+
if args.command == "resolve":
|
|
711
|
+
if args.role:
|
|
712
|
+
res = resolve_single_role(args.role, policy=policy, run_overrides=run_ov, issue_overrides=issue_ov)
|
|
713
|
+
else:
|
|
714
|
+
res = resolve_roles(policy=policy, run_overrides=run_ov, issue_overrides=issue_ov)
|
|
715
|
+
if args.json:
|
|
716
|
+
print(json.dumps(res, indent=2))
|
|
717
|
+
else:
|
|
718
|
+
print(res)
|
|
719
|
+
return 0
|
|
720
|
+
|
|
721
|
+
elif args.command == "preflight":
|
|
722
|
+
res = preflight_validate(policy=policy, run_overrides=run_ov, issue_overrides=issue_ov, root=root)
|
|
723
|
+
if args.json:
|
|
724
|
+
print(json.dumps(res, indent=2))
|
|
725
|
+
else:
|
|
726
|
+
if res["valid"]:
|
|
727
|
+
print("Preflight validation passed.")
|
|
728
|
+
for g in res["guidance"]:
|
|
729
|
+
print(f" {g}")
|
|
730
|
+
else:
|
|
731
|
+
print("Preflight validation FAILED:")
|
|
732
|
+
for err in res["errors"]:
|
|
733
|
+
print(f" - {err}", file=sys.stderr)
|
|
734
|
+
return 0 if res["valid"] else 1
|
|
735
|
+
|
|
736
|
+
elif args.command == "dispatch":
|
|
737
|
+
prompt = args.prompt
|
|
738
|
+
if not prompt:
|
|
739
|
+
prompt = sys.stdin.read()
|
|
740
|
+
selection = json.loads(args.selection) if getattr(args, "selection", None) else None
|
|
741
|
+
try:
|
|
742
|
+
res = dispatch_role(
|
|
743
|
+
role=args.role,
|
|
744
|
+
prompt=prompt,
|
|
745
|
+
cwd=root,
|
|
746
|
+
selection=selection,
|
|
747
|
+
policy=policy,
|
|
748
|
+
run_overrides=run_ov,
|
|
749
|
+
issue_overrides=issue_ov,
|
|
750
|
+
run_id=getattr(args, "run_id", None),
|
|
751
|
+
unit_id=getattr(args, "unit_id", None),
|
|
752
|
+
state_root=getattr(args, "state_root", None),
|
|
753
|
+
)
|
|
754
|
+
print(json.dumps(res, indent=2))
|
|
755
|
+
return 0
|
|
756
|
+
except ProtocolFailureError as exc:
|
|
757
|
+
print(f"protocol failure: {exc}", file=sys.stderr)
|
|
758
|
+
return 2
|
|
759
|
+
except Exception as exc:
|
|
760
|
+
print(f"execution failure: {exc}", file=sys.stderr)
|
|
761
|
+
return 1
|
|
762
|
+
|
|
763
|
+
elif args.command == "unresolved-failures":
|
|
764
|
+
failures = check_unresolved_failures(
|
|
765
|
+
unit_id=args.unit_id,
|
|
766
|
+
run_id=args.run_id,
|
|
767
|
+
state_root_path=getattr(args, "state_root", None),
|
|
768
|
+
)
|
|
769
|
+
if args.json:
|
|
770
|
+
print(json.dumps(failures, indent=2))
|
|
771
|
+
else:
|
|
772
|
+
print(f"Unresolved failures: {len(failures)}")
|
|
773
|
+
for f in failures:
|
|
774
|
+
print(f" - {f['issue']} ({f['role']}): {f['reason']}")
|
|
775
|
+
return 0
|
|
776
|
+
|
|
777
|
+
elif args.command == "validate-replacement":
|
|
778
|
+
sel = json.loads(args.selection)
|
|
779
|
+
res = validate_role_replacement(
|
|
780
|
+
role=args.role,
|
|
781
|
+
selection=sel,
|
|
782
|
+
issue_ref=args.issue,
|
|
783
|
+
root=root,
|
|
784
|
+
)
|
|
785
|
+
if args.json:
|
|
786
|
+
print(json.dumps(res, indent=2))
|
|
787
|
+
else:
|
|
788
|
+
print("Valid" if res["valid"] else f"Invalid: {res.get('error')}")
|
|
789
|
+
return 0 if res["valid"] else 1
|
|
790
|
+
|
|
791
|
+
else:
|
|
792
|
+
parser.print_help()
|
|
793
|
+
return 0
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
if __name__ == "__main__":
|
|
797
|
+
sys.exit(main())
|