@julioborges/gantry 1.0.1 → 1.0.4

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.
@@ -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())