Coverage for src/keel/standalone.py: 100%
183 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
1"""Standalone subagent command handler.
3Supports implement, ci-check, morning, wrap, work-block, overnight, regression,
4and review-all-day commands.
5"""
7from __future__ import annotations
9import argparse
10import json
11import os
12import sys
14from . import config as cfg
15from . import (
16 consent,
17 contracts,
18 github_transport,
19 providerprobe,
20 runtime,
21 wizardrun,
22 workblock,
23)
24from . import orchestrator as orch
25from .extensions import load_extensions
26from .gates import GateError
29def _issue_labels(args: argparse.Namespace) -> tuple[str, ...]:
30 labels: list[str] = []
31 for raw in getattr(args, "issue_label", ()) or ():
32 labels.extend(part.strip() for part in raw.split(",") if part.strip())
33 return tuple(dict.fromkeys(labels))
36def _issue_context_provided(args: argparse.Namespace) -> bool:
37 return bool(
38 (getattr(args, "issue_title", None) or "").strip()
39 or (getattr(args, "issue_body", None) or "").strip()
40 or _issue_labels(args)
41 )
44def _standalone_target(args: argparse.Namespace) -> str | None:
45 if getattr(args, "issue", None) is not None:
46 issue = f"issue #{args.issue}"
47 extra = getattr(args, "target", None)
48 return f"{issue} ({extra})" if extra else issue
49 if getattr(args, "pr", None) is not None:
50 return f"PR #{args.pr}"
51 if getattr(args, "since", None) is not None:
52 extra = getattr(args, "target", None)
53 target = f"since {args.since}"
54 return f"{target} ({extra})" if extra else target
55 if getattr(args, "scope", None) is not None:
56 scope = f"scope {args.scope}"
57 extra = getattr(args, "target", None)
58 if getattr(args, "days", None) is not None:
59 scope = f"{args.days} day scan ({scope})"
60 return f"{scope} ({extra})" if extra else scope
61 if getattr(args, "days", None) is not None:
62 return f"{args.days} day scan"
63 if getattr(args, "issues", None):
64 target = "issues " + ", ".join(f"#{issue}" for issue in args.issues)
65 max_items = getattr(args, "max_items", None)
66 extra = getattr(args, "target", None)
67 if max_items is not None:
68 target = f"{target} (max {max_items})"
69 return f"{target} ({extra})" if extra else target
70 if getattr(args, "queue", None) is not None:
71 target = f"queue {args.queue}"
72 max_items = getattr(args, "max_items", None)
73 extra = getattr(args, "target", None)
74 if max_items is not None:
75 target = f"{target} (max {max_items})"
76 return f"{target} ({extra})" if extra else target
77 if getattr(args, "title", None) is not None:
78 return args.title
79 if getattr(args, "hours", None) is not None:
80 target = f"{args.hours:g}h session"
81 max_items = getattr(args, "max_items", None)
82 return f"{target} (max {max_items})" if max_items is not None else target
83 return getattr(args, "target", None)
86def _has_live_consent_scope(
87 args: argparse.Namespace,
88 command: str,
89 config: cfg.ProjectConfig,
90 requirement: runtime.CapabilityRequirement,
91 loaded: dict,
92) -> bool:
93 if not getattr(args, "live", False):
94 return False
95 side_effects = contracts.command_side_effects(command, config, requirement, loaded)
96 return bool(consent.side_effect_scopes(side_effects))
99def cmd_standalone(args: argparse.Namespace) -> int:
100 """Execute any standalone subagent command."""
101 if getattr(args, "dry_run", False) and getattr(args, "live", False):
102 print("--dry-run and --live cannot be used together", file=sys.stderr)
103 return 1
104 command = args.standalone_command
105 try:
106 config = cfg.load_config(args.path)
107 except FileNotFoundError:
108 print(f"no such config: {args.path}", file=sys.stderr)
109 return 1
110 except cfg.ConfigError as exc:
111 print(str(exc), file=sys.stderr)
112 return 1
114 # `keel work-block --wizard` picks the seats each child ship inherits (#1018). A
115 # command without the flag never reaches the probe, and a non-interactive one is a
116 # logged no-op that leaves the parsed flags exactly as they are.
117 if wizardrun.run_option_wizard(args, config, command=command) != 0:
118 return 1
120 loaded, problems = load_extensions(config, args.root, strict=False)
121 for prob in problems:
122 print(f" ! extension not loaded: {prob}", file=sys.stderr)
124 requirement = (
125 runtime.ci_check_capability_requirement(config)
126 if command == "ci-check"
127 else runtime.morning_capability_requirement(config)
128 if command == "morning"
129 else runtime.scan_capability_requirement(command, config)
130 if command in {"regression", "review-all-day"}
131 else runtime.build_capability_requirement(
132 command, config, loaded, pr=getattr(args, "pr", None)
133 )
134 )
135 report = runtime.detect(args.root)
136 evaluation = runtime.evaluate(requirement, report)
137 if not evaluation.ok:
138 print(evaluation.render(), file=sys.stderr)
139 return 1
140 transport = github_transport.resolve(report)
141 target = _standalone_target(args)
142 try:
143 consent_mode = consent.resolve_consent_mode(
144 getattr(args, "consent_mode", None),
145 config.consent_mode,
146 env_mode=os.environ.get("KEEL_CONSENT_MODE"),
147 )
148 approved_scopes, approval_source, approval_operator, consent_mode = (
149 consent.resolve_approved_consent(
150 mode=consent_mode,
151 explicit_scopes=tuple(getattr(args, "approve_scope", ()) or ()),
152 operator=getattr(args, "operator", None),
153 is_live=getattr(args, "live", False),
154 has_standing_scope=_has_live_consent_scope(
155 args, command, config, requirement, loaded
156 ),
157 env_scopes=os.environ.get("KEEL_APPROVE_SCOPE"),
158 env_operator=os.environ.get("KEEL_OPERATOR"),
159 config_approved_scopes=config.automation.approved_scopes,
160 config_operator=config.automation.operator,
161 )
162 )
163 except ValueError as exc:
164 print(str(exc), file=sys.stderr)
165 return 1
166 try:
167 plan = orch.build_plan(config, loaded)
168 except GateError as exc:
169 print(str(exc), file=sys.stderr)
170 return 1
171 # Resolved once and handed to both the contract and the result: the two are read side
172 # by side out of one `--json` blob, and a staffing record that appeared in one of them
173 # only would be read as "the children were told something else".
174 review_delegates = tuple(getattr(args, "review_delegate", None) or ())
175 effort = getattr(args, "effort", None)
176 team_profile = getattr(args, "team_profile", None)
177 delegation = workblock.delegation_as_dict(
178 delegate=getattr(args, "delegate", None),
179 review_delegates=review_delegates,
180 effort=effort,
181 team_profile=team_profile,
182 reviewer_override=getattr(args, "reviewers", None),
183 )
184 contract = contracts.build_command_contract(
185 command=command,
186 config=config,
187 loaded=loaded,
188 plan=plan,
189 requirement=requirement,
190 evaluation=evaluation,
191 transport=transport,
192 extension_problems=tuple(problems),
193 dry_run=not getattr(args, "live", False),
194 approved_consent_scopes=approved_scopes,
195 consent_approval_source=approval_source,
196 consent_mode=consent_mode,
197 operator=approval_operator,
198 target=target,
199 reviewer_override=getattr(args, "reviewers", None),
200 review_comments=getattr(args, "review_comments", "inline"),
201 issue_title=getattr(args, "issue_title", None),
202 issue_body=getattr(args, "issue_body", None),
203 issue_labels=_issue_labels(args),
204 role=getattr(args, "role", None),
205 delegate=getattr(args, "delegate", None),
206 review_delegates=review_delegates,
207 effort=effort,
208 team_profile=team_profile,
209 # A standalone command resolves no risk tier, so only a `review.default: jury` —
210 # or the `--team` profile's own `review` — reaches the probe here; the same rule
211 # the preflight contract follows (#1066).
212 jury_availability=providerprobe.jury_availability(config, tier=None, profile=team_profile),
213 )
214 consent_ok, consent_message = consent.assert_operator_consent(contract["operator_consent"])
215 result = contracts.standalone_result_as_dict(
216 command=command,
217 config=config,
218 target=target,
219 delegate=getattr(args, "delegate", None),
220 transport=transport,
221 evaluation=evaluation,
222 delegation=delegation,
223 )
224 if not consent_ok:
225 if args.json:
226 print(json.dumps({"contract": contract, "result": result}, indent=2, sort_keys=True))
227 else:
228 print(consent_message, file=sys.stderr)
229 return 1
230 intake_record = contract.get("issue_intake")
231 is_live_implement = command == "implement" and getattr(args, "live", False)
232 if is_live_implement and _issue_context_provided(args):
233 if intake_record and not intake_record["can_mutate_code"]:
234 if args.json:
235 print(
236 json.dumps({"contract": contract, "result": result}, indent=2, sort_keys=True)
237 )
238 else:
239 print(
240 f"issue intake: {intake_record['status']} — {intake_record['reason']}",
241 file=sys.stderr,
242 )
243 for question in intake_record["questions"]:
244 print(f" question: {question}", file=sys.stderr)
245 return 1
246 if args.json:
247 print(json.dumps({"contract": contract, "result": result}, indent=2, sort_keys=True))
248 return 0
250 name = config.repo or config.extends
251 print(f"keel {command} — {name} (base {config.base_branch})")
252 print(f" target : {target or 'not specified'}")
253 print(f" profile : {contract['workflow_profile']['profile']}")
254 print(f" github : {transport.name}")
255 print(f" consent : {contract['operator_consent']['status']}")
256 if evaluation.missing_optional:
257 print(f" degraded opt. : {', '.join(evaluation.missing_optional)}")
258 if command == "implement":
259 print(f" worktree : {result['worktree_path_pattern']}")
260 print(f" branch : {result['branch_pattern']}")
261 print(" merge : never in standalone implement")
262 if args.delegate:
263 print(f" delegate : {args.delegate}")
264 elif command == "ci-check":
265 workflows = ", ".join(result["ci_workflows"]) or "not configured"
266 print(f" workflows : {workflows}")
267 print(" mode : read-only; propose one fix, never apply")
268 elif command == "morning":
269 brief = result["brief"]
270 health = brief["health_providers"]
271 unavailable = [p["name"] for p in health if p["status"] in {"blocked", "unavailable"}]
272 report_names = ", ".join(brief["reports"]) or "not configured"
273 print(f" reports : {report_names}")
274 print(f" health : {len(health)} provider(s)")
275 if unavailable:
276 print(f" unavailable : {', '.join(unavailable)}")
277 print(f" deferrals : {brief['deferral_queue']['status']}")
278 elif command in {"wrap", "work-block", "overnight"}:
279 session = result["session"]
280 report_names = ", ".join(session["reports"]) or "not configured"
281 print(f" reports : {report_names}")
282 print(f" deferrals : {session['deferral_queue']['status']}")
283 if command in {"work-block", "overnight"}:
284 child_args = session["work_block"]["delegation"]["child_args"]
285 print(f" staffing : {' '.join(child_args) or 'project team policy'}")
286 if command == "wrap":
287 linked_required = session["wrap"]["workspace_preflight"][
288 "must_run_from_linked_worktree"
289 ]
290 print(f" worktree : linked required={linked_required}")
291 print(" pr : ready PR after configured gates")
292 elif command == "work-block":
293 print(" mode source : keel work-block")
294 print(" queue : explicit issues or selector")
295 print(" handoff : ship per issue")
296 print(" outcomes : shipped, PR-open, deferred, blocked, skipped, needs-input")
297 else:
298 print(f" window : {session['merge_window'] or 'not configured'}")
299 print(f" mode source : {session['overnight']['mode_source']['command']}")
300 print(" merge policy : ship window + no-night-merge")
301 elif command in {"regression", "review-all-day"}:
302 scan = result["scan"]
303 print(f" areas : {len(scan['areas'])} configured")
304 print(f" dedupe : similarity>={scan['dedupe']['near_text_similarity']}")
305 print(" writes : issues only after consent; no code/PR mutation")
306 if command == "review-all-day":
307 print(f" title prefix : {scan['review_all_day']['issue_creation']['title_prefix']}")
308 else:
309 print(f" handoff : {scan['regression']['issue_creation']['route_to']}")
310 mode = "live preflight contract" if getattr(args, "live", False) else "dry-run contract"
311 print(f" note : {mode}; adapters perform any approved live work.")
312 return 0