Coverage for src/keel/standalone.py: 100%
174 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +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 consent, contracts, github_transport, runtime
16from . import orchestrator as orch
17from .extensions import load_extensions
18from .gates import GateError
21def _issue_labels(args: argparse.Namespace) -> tuple[str, ...]:
22 labels: list[str] = []
23 for raw in getattr(args, "issue_label", ()) or ():
24 labels.extend(part.strip() for part in raw.split(",") if part.strip())
25 return tuple(dict.fromkeys(labels))
28def _issue_context_provided(args: argparse.Namespace) -> bool:
29 return bool(
30 (getattr(args, "issue_title", None) or "").strip()
31 or (getattr(args, "issue_body", None) or "").strip()
32 or _issue_labels(args)
33 )
36def _standalone_target(args: argparse.Namespace) -> str | None:
37 if getattr(args, "issue", None) is not None:
38 issue = f"issue #{args.issue}"
39 extra = getattr(args, "target", None)
40 return f"{issue} ({extra})" if extra else issue
41 if getattr(args, "pr", None) is not None:
42 return f"PR #{args.pr}"
43 if getattr(args, "since", None) is not None:
44 extra = getattr(args, "target", None)
45 target = f"since {args.since}"
46 return f"{target} ({extra})" if extra else target
47 if getattr(args, "scope", None) is not None:
48 scope = f"scope {args.scope}"
49 extra = getattr(args, "target", None)
50 if getattr(args, "days", None) is not None:
51 scope = f"{args.days} day scan ({scope})"
52 return f"{scope} ({extra})" if extra else scope
53 if getattr(args, "days", None) is not None:
54 return f"{args.days} day scan"
55 if getattr(args, "issues", None):
56 target = "issues " + ", ".join(f"#{issue}" for issue in args.issues)
57 max_items = getattr(args, "max_items", None)
58 extra = getattr(args, "target", None)
59 if max_items is not None:
60 target = f"{target} (max {max_items})"
61 return f"{target} ({extra})" if extra else target
62 if getattr(args, "queue", None) is not None:
63 target = f"queue {args.queue}"
64 max_items = getattr(args, "max_items", None)
65 extra = getattr(args, "target", None)
66 if max_items is not None:
67 target = f"{target} (max {max_items})"
68 return f"{target} ({extra})" if extra else target
69 if getattr(args, "title", None) is not None:
70 return args.title
71 if getattr(args, "hours", None) is not None:
72 target = f"{args.hours:g}h session"
73 max_items = getattr(args, "max_items", None)
74 return f"{target} (max {max_items})" if max_items is not None else target
75 return getattr(args, "target", None)
78def _has_live_consent_scope(
79 args: argparse.Namespace,
80 command: str,
81 config: cfg.ProjectConfig,
82 requirement: runtime.CapabilityRequirement,
83 loaded: dict,
84) -> bool:
85 if not getattr(args, "live", False):
86 return False
87 side_effects = contracts.command_side_effects(command, config, requirement, loaded)
88 return bool(consent.side_effect_scopes(side_effects))
91def cmd_standalone(args: argparse.Namespace) -> int:
92 """Execute any standalone subagent command."""
93 if getattr(args, "dry_run", False) and getattr(args, "live", False):
94 print("--dry-run and --live cannot be used together", file=sys.stderr)
95 return 1
96 command = args.standalone_command
97 try:
98 config = cfg.load_config(args.path)
99 except FileNotFoundError:
100 print(f"no such config: {args.path}", file=sys.stderr)
101 return 1
102 except cfg.ConfigError as exc:
103 print(str(exc), file=sys.stderr)
104 return 1
106 loaded, problems = load_extensions(config, args.root, strict=False)
107 for prob in problems:
108 print(f" ! extension not loaded: {prob}", file=sys.stderr)
110 requirement = (
111 runtime.ci_check_capability_requirement(config)
112 if command == "ci-check"
113 else runtime.morning_capability_requirement(config)
114 if command == "morning"
115 else runtime.scan_capability_requirement(command, config)
116 if command in {"regression", "review-all-day"}
117 else runtime.build_capability_requirement(
118 command, config, loaded, pr=getattr(args, "pr", None)
119 )
120 )
121 report = runtime.detect(args.root)
122 evaluation = runtime.evaluate(requirement, report)
123 if not evaluation.ok:
124 print(evaluation.render(), file=sys.stderr)
125 return 1
126 transport = github_transport.resolve(report)
127 target = _standalone_target(args)
128 try:
129 consent_mode = consent.resolve_consent_mode(
130 getattr(args, "consent_mode", None),
131 config.consent_mode,
132 env_mode=os.environ.get("KEEL_CONSENT_MODE"),
133 )
134 approved_scopes, approval_source, approval_operator, consent_mode = (
135 consent.resolve_approved_consent(
136 mode=consent_mode,
137 explicit_scopes=tuple(getattr(args, "approve_scope", ()) or ()),
138 operator=getattr(args, "operator", None),
139 is_live=getattr(args, "live", False),
140 has_standing_scope=_has_live_consent_scope(
141 args, command, config, requirement, loaded
142 ),
143 env_scopes=os.environ.get("KEEL_APPROVE_SCOPE"),
144 env_operator=os.environ.get("KEEL_OPERATOR"),
145 config_approved_scopes=config.automation.approved_scopes,
146 config_operator=config.automation.operator,
147 )
148 )
149 except ValueError as exc:
150 print(str(exc), file=sys.stderr)
151 return 1
152 try:
153 plan = orch.build_plan(config, loaded)
154 except GateError as exc:
155 print(str(exc), file=sys.stderr)
156 return 1
157 contract = contracts.build_command_contract(
158 command=command,
159 config=config,
160 loaded=loaded,
161 plan=plan,
162 requirement=requirement,
163 evaluation=evaluation,
164 transport=transport,
165 extension_problems=tuple(problems),
166 dry_run=not getattr(args, "live", False),
167 approved_consent_scopes=approved_scopes,
168 consent_approval_source=approval_source,
169 consent_mode=consent_mode,
170 operator=approval_operator,
171 target=target,
172 reviewer_override=getattr(args, "reviewers", None),
173 review_comments=getattr(args, "review_comments", "inline"),
174 issue_title=getattr(args, "issue_title", None),
175 issue_body=getattr(args, "issue_body", None),
176 issue_labels=_issue_labels(args),
177 )
178 consent_ok, consent_message = consent.assert_operator_consent(contract["operator_consent"])
179 result = contracts.standalone_result_as_dict(
180 command=command,
181 config=config,
182 target=target,
183 delegate=getattr(args, "delegate", None),
184 transport=transport,
185 evaluation=evaluation,
186 )
187 if not consent_ok:
188 if args.json:
189 print(json.dumps({"contract": contract, "result": result}, indent=2,
190 sort_keys=True))
191 else:
192 print(consent_message, file=sys.stderr)
193 return 1
194 intake_record = contract.get("issue_intake")
195 is_live_implement = command == "implement" and getattr(args, "live", False)
196 if is_live_implement and _issue_context_provided(args):
197 if intake_record and not intake_record["can_mutate_code"]:
198 if args.json:
199 print(json.dumps({"contract": contract, "result": result}, indent=2,
200 sort_keys=True))
201 else:
202 print(f"issue intake: {intake_record['status']} — {intake_record['reason']}",
203 file=sys.stderr)
204 for question in intake_record["questions"]:
205 print(f" question: {question}", file=sys.stderr)
206 return 1
207 if args.json:
208 print(json.dumps({"contract": contract, "result": result}, indent=2, sort_keys=True))
209 return 0
211 name = config.repo or config.extends
212 print(f"keel {command} — {name} (base {config.base_branch})")
213 print(f" target : {target or 'not specified'}")
214 print(f" profile : {contract['workflow_profile']['profile']}")
215 print(f" github : {transport.name}")
216 print(f" consent : {contract['operator_consent']['status']}")
217 if evaluation.missing_optional:
218 print(f" degraded opt. : {', '.join(evaluation.missing_optional)}")
219 if command == "implement":
220 print(f" worktree : {result['worktree_path_pattern']}")
221 print(f" branch : {result['branch_pattern']}")
222 print(" merge : never in standalone implement")
223 if args.delegate:
224 print(f" delegate : {args.delegate}")
225 elif command == "ci-check":
226 workflows = ", ".join(result["ci_workflows"]) or "not configured"
227 print(f" workflows : {workflows}")
228 print(" mode : read-only; propose one fix, never apply")
229 elif command == "morning":
230 brief = result["brief"]
231 health = brief["health_providers"]
232 unavailable = [p["name"] for p in health if p["status"] in {"blocked", "unavailable"}]
233 report_names = ", ".join(brief["reports"]) or "not configured"
234 print(f" reports : {report_names}")
235 print(f" health : {len(health)} provider(s)")
236 if unavailable:
237 print(f" unavailable : {', '.join(unavailable)}")
238 print(f" deferrals : {brief['deferral_queue']['status']}")
239 elif command in {"wrap", "work-block", "overnight"}:
240 session = result["session"]
241 report_names = ", ".join(session["reports"]) or "not configured"
242 print(f" reports : {report_names}")
243 print(f" deferrals : {session['deferral_queue']['status']}")
244 if command == "wrap":
245 linked_required = (
246 session["wrap"]["workspace_preflight"]["must_run_from_linked_worktree"]
247 )
248 print(f" worktree : linked required={linked_required}")
249 print(" pr : ready PR after configured gates")
250 elif command == "work-block":
251 print(" mode source : keel work-block")
252 print(" queue : explicit issues or selector")
253 print(" handoff : ship per issue")
254 print(" outcomes : shipped, PR-open, deferred, blocked, skipped, needs-input")
255 else:
256 print(f" window : {session['merge_window'] or 'not configured'}")
257 print(f" mode source : {session['overnight']['mode_source']['command']}")
258 print(" merge policy : ship window + no-night-merge")
259 elif command in {"regression", "review-all-day"}:
260 scan = result["scan"]
261 print(f" areas : {len(scan['areas'])} configured")
262 print(f" dedupe : similarity>={scan['dedupe']['near_text_similarity']}")
263 print(" writes : issues only after consent; no code/PR mutation")
264 if command == "review-all-day":
265 print(f" title prefix : {scan['review_all_day']['issue_creation']['title_prefix']}")
266 else:
267 print(f" handoff : {scan['regression']['issue_creation']['route_to']}")
268 mode = "live preflight contract" if getattr(args, "live", False) else "dry-run contract"
269 print(f" note : {mode}; adapters perform any approved live work.")
270 return 0