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

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 consent, contracts, github_transport, runtime 

16from . import orchestrator as orch 

17from .extensions import load_extensions 

18from .gates import GateError 

19 

20 

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

26 

27 

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 ) 

34 

35 

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) 

76 

77 

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

89 

90 

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 

105 

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) 

109 

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 

210 

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