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

1"""Standalone subagent command handler. 

2 

3Supports implement, ci-check, morning, wrap, work-block, overnight, regression, 

4and review-all-day commands. 

5""" 

6 

7from __future__ import annotations 

8 

9import argparse 

10import json 

11import os 

12import sys 

13 

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 

27 

28 

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)) 

34 

35 

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 ) 

42 

43 

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) 

84 

85 

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)) 

97 

98 

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 

113 

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 

119 

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) 

123 

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 

249 

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