Coverage for src/keel/cli.py: 100%

2786 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-16 18:07 +0000

1"""The ``keel`` command-line interface (thin; logic lives in the pure modules). 

2 

3Subcommands 

4----------- 

5``keel version`` print the version 

6``keel validate <project.yaml…>`` validate config(s) against the schema (CI gate) 

7``keel plan <project.yaml>`` render the backbone plan for a project (dry-run view) 

8""" 

9 

10from __future__ import annotations 

11 

12import argparse 

13import json 

14import os 

15import re 

16import sys 

17from pathlib import Path 

18 

19from . import ( 

20 __version__, 

21 activity, 

22 artifacts, 

23 branchscope, 

24 capture, 

25 captureverify, 

26 checkpoint, 

27 classify, 

28 closeorder, 

29 closure, 

30 consent, 

31 consentverify, 

32 contracts, 

33 doctor, 

34 dryrunverify, 

35 evidence, 

36 flows, 

37 gates, 

38 git, 

39 github, 

40 github_transport, 

41 guard, 

42 install, 

43 jury, 

44 ledger, 

45 lock, 

46 project_commands, 

47 review, 

48 runcontrols, 

49 runtime, 

50 scaffold, 

51 scope, 

52 ship, 

53 status, 

54 stepverifier, 

55 window, 

56) 

57from . import config as cfg 

58from . import findings as fnd 

59from . import orchestrator as orch 

60from .extensions import ExtensionError, load_extensions 

61from .gates import GateSpec 

62from .runner import command_gate_runner, run_argv 

63 

64 

65def _gate_runner(root: str, diff_text: str, *, jury_mode: str = "gating"): 

66 """A gate runner that handles command gates plus the ``jury`` built-in (on the diff).""" 

67 commands = command_gate_runner(root) 

68 

69 def run(spec: GateSpec): 

70 if spec.kind == "builtin" and spec.id == "jury": 

71 return jury.run_gate(diff_text, cwd=root, mode=jury_mode) 

72 return commands(spec) 

73 

74 return run 

75 

76 

77def _cmd_version(args: argparse.Namespace) -> int: 

78 print(f"keel {__version__}") 

79 return 0 

80 

81 

82def _cmd_validate(args: argparse.Namespace) -> int: 

83 rc = 0 

84 for path in args.paths: 

85 try: 

86 config = cfg.load_config(path) 

87 except FileNotFoundError: 

88 print(f"MISSING {path}") 

89 rc = 1 

90 continue 

91 except cfg.ConfigError as exc: 

92 print(f"INVALID {path}") 

93 print(f" {exc}".replace("\n", "\n ")) 

94 rc = 1 

95 continue 

96 

97 if args.root is not None: 

98 try: 

99 load_extensions(config, args.root, strict=True) 

100 except ExtensionError as exc: 

101 print(f"INVALID {path} (extensions)") 

102 print(f" {exc}".replace("\n", "\n ")) 

103 rc = 1 

104 continue 

105 print(f"OK {path} ({config.repo or '-'}, base {config.base_branch})") 

106 return rc 

107 

108 

109def _autostamp(config: cfg.ProjectConfig, root: str, command: str, run_id: str | None, 

110 phase: str, *, status: str = "running", 

111 issue: int | None = None, pr: int | None = None) -> None: 

112 """Record a run on keel-visual's board at ``phase`` from a deterministic core call. 

113 

114 The agent orchestrates the backbone but reliably **skips** the per-phase 

115 ``keel activity`` calls, so runs (real ``keel ship`` included) went invisible. 

116 Instead, the commands the backbone *always* runs do the stamping: ``keel plan`` 

117 (Step 0 → first phase), ``keel run-gates`` (s8 test), and ``keel merge`` (s10, 

118 stamped ``merged`` — a real merge landed, distinct from a soft ``done``). 

119 A run therefore shows up **and advances** — start → test → merged — independent of 

120 agent discipline. Opt-in via ``--run-id``. Fail-soft: no run-id / unknown command / 

121 unknown phase / write error / pre-activity core is a no-op, never an aborted command. 

122 Never moves a run backward (a re-run of an earlier step won't undo later progress); 

123 ``merged`` is terminal and is never overwritten by a later stamp. 

124 """ 

125 if not run_id or not flows.is_known(command): 

126 return 

127 order = [p.id for p in flows.flow_for(command)] 

128 if phase not in order: 

129 return 

130 try: 

131 path = activity.record_path(root, config, run_id) 

132 existing = activity.read_activity(path) 

133 if existing and existing.get("status") == "merged": 

134 return # merged is terminal; never overwrite a landed run 

135 if (status == "running" and existing and existing.get("status") == "running" 

136 and existing.get("phase") in order 

137 and order.index(existing["phase"]) > order.index(phase)): 

138 return # don't regress a more-advanced still-running run 

139 record = activity.build_activity_record( 

140 command=command, run_id=run_id, phase=phase, status=status, 

141 issue=issue, pr=pr) 

142 activity.write_activity(path, record) 

143 except (activity.ActivityError, OSError): 

144 return 

145 

146 

147def _plan_stamp_activity(args: argparse.Namespace, config: cfg.ProjectConfig) -> None: 

148 """Stamp the run from the Step 0 ``keel plan`` call (first phase). See :func:`_autostamp`.""" 

149 command = args.command_contract 

150 if not flows.is_known(command): 

151 return 

152 _autostamp(config, args.root, command, getattr(args, "run_id", None), 

153 flows.flow_for(command)[0].id, 

154 issue=getattr(args, "issue", None), pr=getattr(args, "pull_request", None)) 

155 

156 

157def _cmd_plan(args: argparse.Namespace) -> int: 

158 try: 

159 config = cfg.load_config(args.path) 

160 except FileNotFoundError: 

161 print(f"no such config: {args.path}", file=sys.stderr) 

162 return 1 

163 except cfg.ConfigError as exc: 

164 print(str(exc), file=sys.stderr) 

165 return 1 

166 

167 try: 

168 ledger.resolve_path(args.root, config) 

169 checkpoint.resolve_path(args.root, config) 

170 except ledger.LedgerError as exc: 

171 print(f"invalid ledger path: {exc}", file=sys.stderr) 

172 return 1 

173 except checkpoint.CheckpointError as exc: 

174 print(f"invalid checkpoint path: {exc}", file=sys.stderr) 

175 return 1 

176 

177 loaded, problems = load_extensions(config, args.root, strict=False) 

178 try: 

179 plan = orch.build_plan(config, loaded) 

180 except gates.GateError as exc: 

181 print(str(exc), file=sys.stderr) 

182 return 1 

183 requirement = ( 

184 _scan_capability_requirement(args.command_contract, config) 

185 if args.command_contract in {"regression", "review-all-day"} 

186 else _capability_requirement(args.command_contract, config, loaded) 

187 ) 

188 report = runtime.detect(args.root) 

189 evaluation = runtime.evaluate(requirement, report) 

190 transport = github_transport.resolve(report) 

191 try: 

192 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent( 

193 args, 

194 config, 

195 _has_live_consent_scope( 

196 args, args.command_contract, config, requirement, loaded 

197 ), 

198 ) 

199 except ValueError as exc: 

200 print(str(exc), file=sys.stderr) 

201 return 1 

202 contract = contracts.build_command_contract( 

203 command=args.command_contract, 

204 profile=args.profile, 

205 config=config, 

206 loaded=loaded, 

207 plan=plan, 

208 requirement=requirement, 

209 evaluation=evaluation, 

210 transport=transport, 

211 extension_problems=tuple(problems), 

212 dry_run=not args.live, 

213 approved_consent_scopes=approved_scopes, 

214 consent_approval_source=approval_source, 

215 consent_mode=consent_mode, 

216 operator=approval_operator, 

217 target=args.target, 

218 reviewer_override=args.reviewers, 

219 review_comments=args.review_comments, 

220 jury=args.jury, 

221 no_jury=args.no_jury, 

222 jury_advisory=args.jury_advisory, 

223 issue_title=args.issue_title, 

224 issue_body=args.issue_body, 

225 issue_labels=_issue_labels(args), 

226 ) 

227 consent_ok, consent_message = consent.assert_operator_consent(contract["operator_consent"]) 

228 if args.json: 

229 print(json.dumps({ 

230 "contract": contract, 

231 "plan": orch.plan_as_dict(plan), 

232 "capabilities": evaluation.as_dict(), 

233 "github_transport": transport.as_dict(), 

234 }, indent=2, sort_keys=True)) 

235 else: 

236 print(orch.render_plan(config, plan)) 

237 print(evaluation.render()) 

238 print(f"operator consent: {contract['operator_consent']['status']}") 

239 print(f" {contract['operator_consent']['consent_prompt']}") 

240 for prob in problems: 

241 print(f" ! extension not loaded: {prob}", file=sys.stderr) 

242 if not consent_ok: 

243 print(consent_message, file=sys.stderr) 

244 return 1 

245 _plan_stamp_activity(args, config) # surface the run on the board from Step 0 

246 return 0 

247 

248 

249def _cmd_run_gates(args: argparse.Namespace) -> int: 

250 try: 

251 config = cfg.load_config(args.path) 

252 except FileNotFoundError: 

253 print(f"no such config: {args.path}", file=sys.stderr) 

254 return 1 

255 except cfg.ConfigError as exc: 

256 print(str(exc), file=sys.stderr) 

257 return 1 

258 

259 loaded, problems = load_extensions(config, args.root, strict=False) 

260 for prob in problems: 

261 print(f" ! extension not loaded: {prob}", file=sys.stderr) 

262 

263 try: 

264 specs = gates.plan_gates(config, loaded) 

265 except gates.GateError as exc: 

266 print(str(exc), file=sys.stderr) 

267 return 1 

268 

269 requirement = _capability_requirement("run-gates", config, loaded) 

270 report = runtime.detect(args.root) 

271 evaluation = runtime.evaluate(requirement, report) 

272 if not evaluation.ok: 

273 print(evaluation.render(), file=sys.stderr) 

274 return 1 

275 if evaluation.missing_optional: 

276 print(evaluation.render(), file=sys.stderr) 

277 

278 diff_text = git.diff(config.base_branch, "HEAD", cwd=args.root) 

279 outcomes = gates.run_gates(specs, _gate_runner(args.root, diff_text, jury_mode="gating")) 

280 _autostamp(config, args.root, args.gate_command, getattr(args, "run_id", None), 

281 args.gate_phase, issue=getattr(args, "issue", None), 

282 pr=getattr(args, "pull_request", None)) # the run reached the test gate (s8) 

283 for o in outcomes: 

284 status = "ok" if o.ok else "FAIL" 

285 print(f" {status:>4} {o.gate}") 

286 

287 verdict = fnd.summarize(gates.collect_findings(outcomes)) 

288 for f in verdict.findings: 

289 print(f" [{f.severity}] {f.source}: {f.message.splitlines()[0]}") 

290 if verdict.blocked: 

291 print("BLOCKED — merge is gated by the findings above") 

292 return 1 

293 return 0 

294 

295 

296def _cmd_window(args: argparse.Namespace) -> int: 

297 try: 

298 config = cfg.load_config(args.path) 

299 except FileNotFoundError: 

300 print(f"no such config: {args.path}", file=sys.stderr) 

301 return 1 

302 except cfg.ConfigError as exc: 

303 print(str(exc), file=sys.stderr) 

304 return 1 

305 

306 if not config.timezone or not config.merge_window: 

307 print("no merge window configured (needs timezone + merge_window)") 

308 return 0 

309 is_open = window.is_merge_open(config.timezone, config.merge_window) 

310 state = "OPEN" if is_open else "CLOSED (night no-merge)" 

311 print(f"merge window {state} [{config.timezone} {config.merge_window}]") 

312 return 0 

313 

314 

315def _cmd_claim(args: argparse.Namespace) -> int: 

316 result = lock.claim_resource(_lock_root(args.root), args.resource, owner=args.owner) 

317 if args.json: 

318 print(json.dumps(result.as_dict(), indent=2, sort_keys=True)) 

319 else: 

320 print(f"keel claim — {result.status} {result.resource}") 

321 print(f" owner : {result.owner}") 

322 print(f" path : {result.path}") 

323 if result.holder: 

324 print(f" holder: {result.holder}") 

325 return 0 if result.granted else 1 

326 

327 

328def _cmd_release(args: argparse.Namespace) -> int: 

329 result = lock.release_resource(_lock_root(args.root), args.resource, owner=args.owner) 

330 if args.json: 

331 print(json.dumps(result.as_dict(), indent=2, sort_keys=True)) 

332 else: 

333 print(f"keel release — {result.status} {result.resource}") 

334 print(f" owner : {result.owner}") 

335 print(f" path : {result.path}") 

336 if result.holder: 

337 print(f" holder: {result.holder}") 

338 return 0 if result.status in {"released", "missing"} else 1 

339 

340 

341def _cmd_worktree_remove(args: argparse.Namespace) -> int: 

342 try: 

343 worktree_path = _validated_worktree_path(args.root, args.worktree) 

344 except ValueError as exc: 

345 print(str(exc), file=sys.stderr) 

346 return 1 

347 result = git.worktree_remove(str(worktree_path), cwd=args.root) 

348 if args.json: 

349 print(json.dumps({ 

350 "worktree": str(worktree_path), 

351 "removed": result.ok, 

352 "code": result.code, 

353 "output": result.output, 

354 }, indent=2, sort_keys=True)) 

355 else: 

356 print(f"keel worktree-remove — {'removed' if result.ok else 'failed'} {worktree_path}") 

357 if result.output.strip(): 

358 print(result.output.strip()) 

359 return 0 if result.ok else 1 

360 

361 

362def _parse_labels(raw: str | None) -> tuple[str, ...]: 

363 """Split a comma-separated ``--issue-labels`` value into clean labels.""" 

364 if not raw: 

365 return () 

366 return tuple(part.strip() for part in raw.split(",") if part.strip()) 

367 

368 

369def _gather_issue_facts(args: argparse.Namespace) -> tuple[str, tuple[str, ...], bool]: 

370 """Resolve the issue title + labels for blocker evaluation. 

371 

372 Offline path: take ``--issue-title`` / ``--issue-labels`` verbatim. Live 

373 path: when ``--issue`` is given, fetch the authoritative title/labels from 

374 the host via ``gh`` (fail-soft — a failed fetch falls back to the args). 

375 

376 Returns ``(title, labels, authoritative)``. ``authoritative`` is True ONLY 

377 when ``--issue N`` was given AND the live ``gh`` fetch succeeded and parsed 

378 into a real dict carrying the host's title/labels. When ``--issue`` is 

379 absent, or the fetch failed / fell back to the agent-supplied args, it is 

380 False — the facts are agent-supplied and must not self-justify a window 

381 bypass (audit GAP-11). 

382 """ 

383 title = args.issue_title or "" 

384 labels = _parse_labels(args.issue_labels) 

385 authoritative = False 

386 issue = getattr(args, "issue", None) 

387 if issue is not None: 

388 result = github.issue_facts(issue, cwd=args.root) 

389 if result.ok: 

390 try: 

391 data = json.loads(result.output) 

392 except json.JSONDecodeError: 

393 data = None 

394 if isinstance(data, dict): 

395 authoritative = True 

396 if isinstance(data.get("title"), str): 

397 title = data["title"] 

398 raw_labels = data.get("labels") 

399 if isinstance(raw_labels, list): 

400 labels = tuple( 

401 str(item.get("name")) 

402 for item in raw_labels 

403 if isinstance(item, dict) and isinstance(item.get("name"), str) 

404 ) 

405 return title, labels, authoritative 

406 

407 

408def _cmd_guard(args: argparse.Namespace) -> int: 

409 try: 

410 config = cfg.load_config(args.path) 

411 except FileNotFoundError: 

412 print(f"no such config: {args.path}", file=sys.stderr) 

413 return 1 

414 except cfg.ConfigError as exc: 

415 print(str(exc), file=sys.stderr) 

416 return 1 

417 

418 try: 

419 rules = guard.resolve_rules(config) 

420 except guard.GuardError as exc: 

421 print(f"invalid blocker rules: {exc}", file=sys.stderr) 

422 return 1 

423 

424 title, labels, _authoritative = _gather_issue_facts(args) 

425 result = guard.evaluate(title, labels, rules=rules) 

426 if args.json: 

427 print(json.dumps(result.as_dict(), indent=2, sort_keys=True)) 

428 else: 

429 verdict = "BLOCKER" if result.is_blocker else "not a blocker" 

430 print(f"keel guard — {verdict}") 

431 print(f" title : {title}") 

432 print(f" labels : {', '.join(labels) or '(none)'}") 

433 if result.matched: 

434 print(f" matched: {', '.join(result.matched)}") 

435 else: 

436 print(" matched: (none)") 

437 return 0 

438 

439 

440def _hotfix_justification( 

441 args: argparse.Namespace, config: cfg.ProjectConfig, operator: str | None 

442) -> tuple[dict[str, object] | None, str | None]: 

443 """Resolve the justification required for a ``--hotfix`` window bypass. 

444 

445 Returns ``(justification, None)`` on success or ``(None, error)`` when the 

446 hotfix is refused. A hotfix must carry one of two justifications, recorded 

447 in the ledger: 

448 

449 * ``matched-rule`` — ``--blocker-rule <id>`` names a rule that actually fires 

450 for this issue under :mod:`keel.guard`; or 

451 * ``operator-override`` — an explicit ``--operator-override`` paired with a 

452 named ``--operator`` (the audited human override). 

453 

454 With neither, the bypass is refused (closing audit GAP-11: an agent can no 

455 longer flip ``--hotfix`` on the flag alone). 

456 

457 The ``matched-rule`` path requires **host-authoritative** issue facts: it 

458 refuses unless ``--issue N`` was given and the live ``gh`` fetch succeeded, 

459 so an agent cannot self-justify a window bypass with a fabricated 

460 ``--issue-title``. The ``operator-override`` path (the audited human escape) 

461 is unaffected. 

462 """ 

463 if args.blocker_rule: 

464 try: 

465 rules = guard.resolve_rules(config) 

466 except guard.GuardError as exc: 

467 return None, f"invalid blocker rules: {exc}" 

468 title, labels, authoritative = _gather_issue_facts(args) 

469 if not authoritative: 

470 return None, ( 

471 "hotfix matched-rule justification requires --issue <N> " 

472 "(host-authoritative title/labels); agent-supplied --issue-title " 

473 "is not accepted for a window bypass — use --operator-override instead" 

474 ) 

475 result = guard.evaluate(title, labels, rules=rules) 

476 if args.blocker_rule not in result.rule_ids: 

477 return None, f"unknown blocker rule {args.blocker_rule!r}" 

478 if args.blocker_rule not in result.matched: 

479 return None, ( 

480 f"blocker rule {args.blocker_rule!r} did not match the issue " 

481 "(title/labels do not satisfy the rule)" 

482 ) 

483 return { 

484 "kind": "matched-rule", 

485 "rule_id": args.blocker_rule, 

486 "matched": list(result.matched), 

487 }, None 

488 if args.operator_override: 

489 if not operator: 

490 return None, "--operator-override requires a named --operator for the audit trail" 

491 return {"kind": "operator-override", "operator": operator}, None 

492 return None, ( 

493 "hotfix requires a justification: pass --blocker-rule <id> matching a " 

494 "keel guard rule for the issue, or --operator-override with a named --operator" 

495 ) 

496 

497 

498CHECKPOINT_MERGE_STEP = "s10" 

499 

500 

501def _checkpoint_gate( 

502 args: argparse.Namespace, 

503 config: cfg.ProjectConfig, 

504 *, 

505 run_id: str | None, 

506 operator: str | None, 

507) -> tuple[dict[str, object], str | None]: 

508 """Gate the merge on a covering checkpoint for the run at step s10. 

509 

510 Returns ``(payload, None)`` when the merge may proceed or ``(payload, error)`` 

511 when it must be refused. The gate is enforced only when checkpointing is 

512 *configured* for the project (``policy_pack.reports.checkpoint``); when it is 

513 absent the gate degrades to advisory (back-compat — flows that never wrote a 

514 checkpoint still merge). The ``--no-checkpoint-gate`` escape requires a named 

515 ``--operator`` (mirroring ``--operator-override``) and records the bypass in 

516 the merge payload audit trail. 

517 

518 This is the thin I/O layer: it reads the checkpoint file and delegates the 

519 decision to :func:`keel.checkpoint.covering_checkpoint` (pure). 

520 """ 

521 _, path_source = checkpoint.configured_checkpoint_path(config) 

522 configured = path_source == "policy_pack.reports.checkpoint" 

523 

524 if args.no_checkpoint_gate: 

525 if not operator: 

526 return ( 

527 { 

528 "enforced": configured, 

529 "status": "bypass-refused", 

530 "bypassed": False, 

531 "reason": "--no-checkpoint-gate requires a named --operator", 

532 }, 

533 "--no-checkpoint-gate requires a named --operator for the audit trail", 

534 ) 

535 return ( 

536 { 

537 "enforced": configured, 

538 "status": "bypassed", 

539 "bypassed": True, 

540 "operator": operator, 

541 "expected_step": CHECKPOINT_MERGE_STEP, 

542 "reason": "checkpoint gate bypassed by operator", 

543 }, 

544 None, 

545 ) 

546 

547 if not configured: 

548 return ( 

549 { 

550 "enforced": False, 

551 "status": "advisory-skip", 

552 "bypassed": False, 

553 "reason": "no checkpoint configured (policy_pack.reports.checkpoint); advisory", 

554 }, 

555 None, 

556 ) 

557 

558 if not run_id: 

559 return ( 

560 { 

561 "enforced": True, 

562 "status": "missing", 

563 "bypassed": False, 

564 "expected_step": CHECKPOINT_MERGE_STEP, 

565 "reason": ( 

566 "checkpointing is configured but no run-id is available " 

567 "(pass --run-id or record a gates-pass for this head)" 

568 ), 

569 }, 

570 ( 

571 "checkpointing is configured but no run-id is available for the " 

572 "checkpoint gate; pass --run-id or use --no-checkpoint-gate" 

573 ), 

574 ) 

575 

576 try: 

577 path = checkpoint.resolve_path(args.root, config) 

578 record = checkpoint.read_checkpoint(path) 

579 except checkpoint.CheckpointError as exc: 

580 return ( 

581 {"enforced": True, "status": "invalid", "bypassed": False, "reason": str(exc)}, 

582 f"invalid checkpoint: {exc}", 

583 ) 

584 coverage = checkpoint.covering_checkpoint(record, run_id, CHECKPOINT_MERGE_STEP) 

585 gate_payload = { 

586 "enforced": True, 

587 "status": coverage["status"], 

588 "bypassed": False, 

589 "expected_step": CHECKPOINT_MERGE_STEP, 

590 "run_id": run_id, 

591 "checkpoint_step": coverage["checkpoint_step"], 

592 "reason": coverage["reason"], 

593 } 

594 if coverage["covered"]: 

595 return gate_payload, None 

596 return gate_payload, coverage["reason"] 

597 

598 

599def _cmd_merge(args: argparse.Namespace) -> int: 

600 args.live = True 

601 try: 

602 config = cfg.load_config(args.path) 

603 except FileNotFoundError: 

604 print(f"no such config: {args.path}", file=sys.stderr) 

605 return 1 

606 except cfg.ConfigError as exc: 

607 print(str(exc), file=sys.stderr) 

608 return 1 

609 

610 loaded, problems = load_extensions(config, args.root, strict=False) 

611 for prob in problems: 

612 print(f" ! extension not loaded: {prob}", file=sys.stderr) 

613 requirement = runtime.CapabilityRequirement(required=("git",), optional=("gh", "gh-auth")) 

614 report = runtime.detect(args.root) 

615 evaluation = runtime.evaluate(requirement, report) 

616 transport = github_transport.resolve(report) 

617 if not evaluation.ok or not transport.supports("pr_merge"): 

618 print(evaluation.render(), file=sys.stderr) 

619 print(transport.render(), file=sys.stderr) 

620 return 1 

621 try: 

622 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent( 

623 args, config, True 

624 ) 

625 except ValueError as exc: 

626 print(str(exc), file=sys.stderr) 

627 return 1 

628 operator_consent = consent.build_consent_contract( 

629 command="merge", 

630 side_effects=("git_worktree", "merge"), 

631 dry_run=False, 

632 approved_scopes=approved_scopes, 

633 approval_source=approval_source, 

634 mode=consent_mode, 

635 operator=approval_operator, 

636 target=f"PR #{args.pr}", 

637 ) 

638 consent_ok, consent_message = consent.assert_operator_consent(operator_consent) 

639 if not consent_ok: 

640 print(consent_message, file=sys.stderr) 

641 return 1 

642 escalation = consent.evaluate_escalation( 

643 risk_tier=args.risk_tier, 

644 trust_signal=args.trust_signal, 

645 side_effects=("git_worktree", "merge", *args.escalation_side_effect), 

646 retry_count=args.retry_count, 

647 conflicting_sources=args.conflicting_sources, 

648 changed_lines=args.changed_lines, 

649 ) 

650 missing_escalation_scope = [ 

651 scope for scope in escalation["consent_scope"] if scope not in approved_scopes 

652 ] 

653 if escalation["operator_required"] and missing_escalation_scope: 

654 payload = { 

655 "schema_version": "keel.merge.v1", 

656 "pull_request": args.pr, 

657 "status": "fail", 

658 "reason": "operator escalation required", 

659 "escalation": escalation, 

660 "missing_scope": missing_escalation_scope, 

661 } 

662 if args.json: 

663 print(json.dumps(payload, indent=2, sort_keys=True)) 

664 else: 

665 print( 

666 "operator escalation required: missing approved scope " 

667 f"{', '.join(missing_escalation_scope)}", 

668 file=sys.stderr, 

669 ) 

670 return 1 

671 

672 hotfix_justification: dict[str, object] | None = None 

673 if args.hotfix: 

674 hotfix_justification, hotfix_error = _hotfix_justification( 

675 args, config, approval_operator 

676 ) 

677 if hotfix_justification is None: 

678 payload = { 

679 "schema_version": "keel.merge.v1", 

680 "pull_request": args.pr, 

681 "status": "fail", 

682 "reason": "hotfix justification required", 

683 "hotfix_justification": None, 

684 } 

685 if args.json: 

686 print(json.dumps(payload, indent=2, sort_keys=True)) 

687 else: 

688 print(hotfix_error, file=sys.stderr) 

689 return 1 

690 

691 owner = args.owner or f"keel-merge-pr-{args.pr}" 

692 claim = lock.claim_resource(_lock_root(args.root), "merge", owner=owner) 

693 payload: dict[str, object] = { 

694 "schema_version": "keel.merge.v1", 

695 "pull_request": args.pr, 

696 "lock": claim.as_dict(), 

697 "window": None, 

698 "ci": None, 

699 "evidence": None, 

700 "gates_sha": None, 

701 "escalation": escalation, 

702 "hotfix_justification": hotfix_justification, 

703 "checkpoint_gate": None, 

704 "merged": False, 

705 } 

706 if not claim.granted: 

707 return _finish_merge(args, payload, "resource lock is already held", code=1) 

708 try: 

709 if not args.hotfix and config.timezone and config.merge_window: 

710 open_now = window.is_merge_open(config.timezone, config.merge_window) 

711 payload["window"] = { 

712 "open": open_now, 

713 "timezone": config.timezone, 

714 "merge_window": config.merge_window, 

715 } 

716 if not open_now: 

717 return _finish_merge(args, payload, "merge window is closed", code=1) 

718 elif args.hotfix: 

719 payload["window"] = {"bypassed": True, "reason": "hotfix"} 

720 

721 try: 

722 snapshot = _merge_snapshot(args.pr, cwd=args.root) 

723 except ValueError as exc: 

724 return _finish_merge(args, payload, str(exc), code=1) 

725 payload["ci"] = snapshot["ci"] 

726 if snapshot["merge_state"] not in {"CLEAN", "HAS_HOOKS", "UNKNOWN"}: 

727 return _finish_merge( 

728 args, payload, f"PR merge state is {snapshot['merge_state']}", code=1 

729 ) 

730 if snapshot["ci"]["state"] != "pass": 

731 return _finish_merge(args, payload, f"CI is {snapshot['ci']['state']}", code=1) 

732 

733 evidence_payload = _verify_merge_evidence(args, config) 

734 payload["evidence"] = evidence_payload 

735 if not evidence_payload["enforced"]: 

736 return _finish_merge(args, payload, "evidence gate is not enforced", code=1) 

737 if evidence_payload["verification"]["status"] != "pass": 

738 missing = ", ".join(evidence_payload["verification"]["missing"]) 

739 return _finish_merge(args, payload, f"missing evidence: {missing}", code=1) 

740 

741 head_sha = snapshot["head_sha"] 

742 gates_run_id: str | None = None 

743 if args.hotfix: 

744 payload["gates_sha"] = {"bypassed": True, "reason": "hotfix", "head_sha": head_sha} 

745 else: 

746 try: 

747 gates_records = ledger.read_records(ledger.resolve_path(args.root, config)) 

748 except ledger.LedgerError as exc: 

749 return _finish_merge(args, payload, f"invalid run ledger: {exc}", code=1) 

750 matched, record = ledger.gates_pass_for_head( 

751 gates_records, args.pr, head_sha if isinstance(head_sha, str) else "" 

752 ) 

753 gates_run_id = record.get("run_id") if record else None 

754 payload["gates_sha"] = { 

755 "bypassed": False, 

756 "head_sha": head_sha, 

757 "matched": matched, 

758 "run_id": gates_run_id, 

759 } 

760 if not matched: 

761 return _finish_merge( 

762 args, 

763 payload, 

764 f"no gates-pass recorded for the current head {head_sha}", 

765 code=1, 

766 ) 

767 

768 gate_payload, gate_error = _checkpoint_gate( 

769 args, config, run_id=args.run_id or gates_run_id, operator=approval_operator 

770 ) 

771 payload["checkpoint_gate"] = gate_payload 

772 if gate_error is not None: 

773 return _finish_merge(args, payload, gate_error, code=1) 

774 

775 if args.dry_run: 

776 return _finish_merge(args, payload, "dry-run: merge not performed", code=0) 

777 merged = github.merge_pr(args.pr, method=args.method, cwd=args.root) 

778 payload["merged"] = merged.ok 

779 payload["merge_output"] = merged.output 

780 if not merged.ok: 

781 return _finish_merge(args, payload, "gh merge failed", code=1) 

782 _autostamp(config, args.root, "ship", getattr(args, "run_id", None), "s10", 

783 status="merged", # real merge landed → green "merged", not soft "done" 

784 issue=getattr(args, "issue", None), pr=args.pr) 

785 return _finish_merge(args, payload, "merged", code=0) 

786 finally: 

787 lock.release_resource(_lock_root(args.root), "merge", owner=owner, best_effort=True) 

788 

789 

790def _cmd_ship(args: argparse.Namespace) -> int: 

791 if args.dry_run and args.live: 

792 print("--dry-run and --live cannot be used together", file=sys.stderr) 

793 return 1 

794 command = getattr(args, "ship_command", "ship") 

795 profile = "compound" if args.compound or args.profile == "compound" else "standard" 

796 

797 try: 

798 config = cfg.load_config(args.path) 

799 except FileNotFoundError: 

800 print(f"no such config: {args.path}", file=sys.stderr) 

801 return 1 

802 except cfg.ConfigError as exc: 

803 print(str(exc), file=sys.stderr) 

804 return 1 

805 

806 loaded, problems = load_extensions(config, args.root, strict=False) 

807 for prob in problems: 

808 print(f" ! extension not loaded: {prob}", file=sys.stderr) 

809 

810 requirement = _capability_requirement(command, config, loaded, pr=args.pr) 

811 report = runtime.detect(args.root) 

812 evaluation = runtime.evaluate(requirement, report) 

813 if not evaluation.ok: 

814 print(evaluation.render(), file=sys.stderr) 

815 return 1 

816 transport = github_transport.resolve(report) 

817 if args.pr is not None and not transport.supports("check_runs"): 

818 print(transport.render(), file=sys.stderr) 

819 print("missing required GitHub transport capability: check_runs", file=sys.stderr) 

820 return 1 

821 try: 

822 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent( 

823 args, config, _has_live_consent_scope(args, command, config, requirement, loaded) 

824 ) 

825 except ValueError as exc: 

826 print(str(exc), file=sys.stderr) 

827 return 1 

828 try: 

829 plan = orch.build_plan(config, loaded) 

830 except gates.GateError as exc: 

831 print(str(exc), file=sys.stderr) 

832 return 1 

833 contract = contracts.build_command_contract( 

834 command=command, 

835 profile=profile, 

836 config=config, 

837 loaded=loaded, 

838 plan=plan, 

839 requirement=requirement, 

840 evaluation=evaluation, 

841 transport=transport, 

842 extension_problems=tuple(problems), 

843 dry_run=not args.live, 

844 approved_consent_scopes=approved_scopes, 

845 consent_approval_source=approval_source, 

846 consent_mode=consent_mode, 

847 operator=approval_operator, 

848 target=args.target or (f"PR #{args.pr}" if args.pr is not None else None), 

849 reviewer_override=args.reviewers, 

850 review_comments=args.review_comments, 

851 jury=args.jury, 

852 no_jury=args.no_jury, 

853 jury_advisory=args.jury_advisory, 

854 issue_title=args.issue_title, 

855 issue_body=args.issue_body, 

856 issue_labels=_issue_labels(args), 

857 ) 

858 consent_ok, consent_message = consent.assert_operator_consent(contract["operator_consent"]) 

859 if not consent_ok: 

860 if args.json: 

861 print(json.dumps({"contract": contract}, indent=2, sort_keys=True)) 

862 else: 

863 print(consent_message, file=sys.stderr) 

864 return 1 

865 intake_record = contract["issue_intake"] 

866 if args.live and _issue_context_provided(args) and intake_record: 

867 if not intake_record["can_mutate_code"]: 

868 if args.json: 

869 print(json.dumps({"contract": contract}, indent=2, sort_keys=True)) 

870 else: 

871 print(f"issue intake: {intake_record['status']}{intake_record['reason']}", 

872 file=sys.stderr) 

873 for question in intake_record["questions"]: 

874 print(f" question: {question}", file=sys.stderr) 

875 return 1 

876 if args.live and args.append_ledger and args.capture_status is None: 

877 message = "--capture-status is required when --live --append-ledger is used" 

878 if args.json: 

879 print(json.dumps({"contract": contract, "error": message}, indent=2, 

880 sort_keys=True)) 

881 else: 

882 print(message, file=sys.stderr) 

883 return 1 

884 run_context_warnings = _run_context_warnings(args) 

885 if args.live and args.append_ledger and args.strict_run_context and run_context_warnings: 

886 message = "; ".join(run_context_warnings) 

887 if args.json: 

888 print(json.dumps({"contract": contract, "error": message}, indent=2, 

889 sort_keys=True)) 

890 else: 

891 print(message, file=sys.stderr) 

892 return 1 

893 

894 changed = git.changed_files(config.base_branch, "HEAD", cwd=args.root) 

895 tier = classify.tier_for_files( 

896 changed, 

897 tier3_globs=config.knobs.tier3_globs, 

898 docs_globs=config.knobs.docs_gate_paths, 

899 ) 

900 review_contract = ship.resolve_review_contract( 

901 tier=tier, 

902 reviewer_override=args.reviewers, 

903 review_comments=args.review_comments, 

904 gates=config.gates, 

905 policy_pack=config.policy_pack, 

906 jury=args.jury, 

907 no_jury=args.no_jury, 

908 jury_advisory=args.jury_advisory, 

909 ) 

910 # unreachable: orch.build_plan() above already calls plan_gates and surfaces GateError. 

911 try: 

912 specs = gates.plan_gates(config, loaded) 

913 except gates.GateError as exc: # pragma: no cover - defensive duplicate of the build_plan guard 

914 print(str(exc), file=sys.stderr) 

915 return 1 

916 diff_text = git.diff(config.base_branch, "HEAD", cwd=args.root) 

917 outcomes = gates.run_gates( 

918 specs, 

919 _gate_runner(args.root, diff_text, jury_mode=review_contract["jury"]["mode"]), 

920 ) 

921 verdict = fnd.summarize(gates.collect_findings(outcomes)) 

922 ci_conclusion = ( 

923 github.ci_conclusion(args.pr, cwd=args.root) 

924 if args.pr and transport.name == "gh" 

925 else None 

926 ) 

927 

928 a = ship.assess( 

929 changed_files=changed, 

930 gate_verdict=verdict, 

931 tier3_globs=config.knobs.tier3_globs, 

932 docs_globs=config.knobs.docs_gate_paths, 

933 timezone=config.timezone, 

934 merge_window=config.merge_window, 

935 merge_window_mode=config.merge_window_mode, 

936 ci_conclusion=ci_conclusion, 

937 is_blocker=args.hotfix, 

938 reviewer_override=args.reviewers, 

939 review_comments=args.review_comments, 

940 gates=config.gates, 

941 policy_pack=config.policy_pack, 

942 jury=args.jury, 

943 no_jury=args.no_jury, 

944 jury_advisory=args.jury_advisory, 

945 ) 

946 contract["review_merge_contract"] = a.review_contract 

947 ledger_path = ledger.resolve_path(args.root, config) 

948 try: 

949 existing_ledger_records = ledger.read_records(ledger_path) 

950 except ledger.LedgerError as exc: 

951 print(f"invalid ledger {ledger_path}: {exc}", file=sys.stderr) 

952 return 1 

953 try: 

954 run_control_events = ( 

955 _read_json_list(args.run_events_file, missing_ok=True) 

956 if args.run_events_file else [] 

957 ) 

958 except ValueError as exc: 

959 print(str(exc), file=sys.stderr) 

960 return 1 

961 run_control_report = runcontrols.evaluate_run_controls( 

962 run_control_events, 

963 max_work_units=args.max_rounds or runcontrols.DEFAULT_RUN_BUDGET, 

964 ) 

965 ledger_record = ledger.build_ship_run_record( 

966 command=command, 

967 base_branch=config.base_branch, 

968 changed_files=changed, 

969 declared_files=args.declared_file, 

970 outcomes=outcomes, 

971 verdict=verdict, 

972 assessment=a, 

973 issue_intake=contract.get("issue_intake"), 

974 target=args.target or (f"PR #{args.pr}" if args.pr is not None else None), 

975 run_id=args.run_id, 

976 issue_number=args.issue, 

977 pr_number=args.ledger_pr or args.pr, 

978 branch=args.branch, 

979 head_sha=args.head_sha, 

980 capture_status=args.capture_status, 

981 capture_reason=args.capture_reason, 

982 capture_artifact=args.capture_artifact, 

983 issue_title=args.issue_title, 

984 issue_labels=_issue_labels(args), 

985 existing_records=existing_ledger_records, 

986 config=config, 

987 implementer=args.implementer, 

988 reviewer_agents=args.reviewer_agent, 

989 tester=args.tester, 

990 host_agent=args.host_agent, 

991 transport=args.transport or transport.name, 

992 profile=profile, 

993 jury_mode=a.review_contract["jury"]["mode"], 

994 consent_status=contract["operator_consent"]["status"], 

995 consent_scopes=contract["operator_consent"]["effective_approved_scope"], 

996 run_controls=run_control_report, 

997 ) 

998 try: 

999 ledger_record = ledger.sanitize_record(ledger_record, config) 

1000 except ledger.redaction.RedactionError as exc: 

1001 message = f"capture redaction policy invalid; skipping ledger append: {exc}" 

1002 if args.append_ledger and args.live: 

1003 if args.json: 

1004 print(json.dumps({"contract": contract, "error": message}, indent=2, 

1005 sort_keys=True)) 

1006 else: 

1007 print(message, file=sys.stderr) 

1008 return 1 

1009 fallback = ledger.redaction.sanitize( 

1010 ledger_record, 

1011 ledger.redaction.policy_from_config(config, strict=False), 

1012 ) 

1013 ledger_record = dict(fallback.value) 

1014 ledger_record["redaction"] = { 

1015 "schema_version": ledger.redaction.REDACTION_SCHEMA_VERSION, 

1016 "status": "partial", 

1017 "reason": "invalid-policy", 

1018 "rules": fallback.audit["rules"], 

1019 "redaction_count": fallback.audit["redaction_count"], 

1020 } 

1021 ledger_result = { 

1022 "contract": contract["run_ledger"], 

1023 "path": str(ledger_path), 

1024 "appended": False, 

1025 "record": ledger_record, 

1026 "warnings": run_context_warnings, 

1027 } 

1028 if args.append_ledger and args.live: 

1029 ledger.append_record(ledger_path, ledger_record) 

1030 ledger_result["appended"] = True 

1031 

1032 if args.json: 

1033 print(json.dumps({ 

1034 "contract": contract, 

1035 "result": contracts.ship_result_as_dict( 

1036 changed_files=changed, 

1037 outcomes=outcomes, 

1038 verdict=verdict, 

1039 assessment=a, 

1040 issue_intake=contract.get("issue_intake"), 

1041 run_ledger=ledger_result, 

1042 ), 

1043 }, indent=2, sort_keys=True)) 

1044 if run_control_report["hard_halt"]: 

1045 return 1 

1046 return 0 if a.merge.action != "block" else 1 

1047 

1048 name = config.repo or config.extends 

1049 print(f"keel {command}{name} (base {config.base_branch})") 

1050 print(f" changed files : {len(changed)}") 

1051 print(f" profile : {contract['workflow_profile']['profile']}") 

1052 print(f" risk tier : TIER-{a.tier}{a.reviewers} reviewer(s)") 

1053 jury_state = a.review_contract["jury"]["mode"] 

1054 print(f" review posts : {a.review_contract['posting']['mode']}") 

1055 print(f" jury : {jury_state} ({a.review_contract['jury']['reason']})") 

1056 window = "OPEN" if a.window_open else f"CLOSED ({config.merge_window_mode}, night no-merge)" 

1057 print(f" merge window : {window}") 

1058 ci_str = "unknown" if a.ci_ok is None else ("passing" if a.ci_ok else "FAILING") 

1059 print(f" ci : {ci_str}") 

1060 print(f" github : {transport.name}") 

1061 print(f" consent : {contract['operator_consent']['status']}") 

1062 print(f" intake : {intake_record['status']}") 

1063 print(f" run ledger : {ledger_result['path']}") 

1064 print(f" run controls : {run_control_report['status']}") 

1065 if args.append_ledger: 

1066 print(f" ledger append : {'yes' if ledger_result['appended'] else 'dry-run/no-live'}") 

1067 for warning in run_context_warnings: 

1068 print(f" run context : warning: {warning}") 

1069 if intake_record["questions"]: 

1070 print(f" questions : {len(intake_record['questions'])}") 

1071 if transport.degraded: 

1072 print(f" github degraded: {', '.join(transport.degraded)}") 

1073 for o in outcomes: 

1074 print(f" gate {o.gate:<14} {'ok' if o.ok else 'FAIL'}") 

1075 if evaluation.missing_optional: 

1076 print(f" degraded opt. : {', '.join(evaluation.missing_optional)}") 

1077 if a.halted: # pragma: no cover - display only; logic covered in ship.assess tests 

1078 print(" pipeline : HALTED (merge window paused)") 

1079 if a.bypassed_window: # pragma: no cover - display only; logic covered in ship.assess 

1080 print(" audit : hotfix bypassed the merge window") 

1081 print(f" decision : {a.merge.action.upper()}{a.merge.reason}") 

1082 print(" note: dry assessment; live merge (s10) needs a configured runner (git + gh auth).") 

1083 if run_control_report["hard_halt"]: 

1084 return 1 

1085 return 0 if a.merge.action != "block" else 1 

1086 

1087 

1088def _cmd_ledger(args: argparse.Namespace) -> int: 

1089 try: 

1090 config = cfg.load_config(args.path) 

1091 except FileNotFoundError: 

1092 print(f"no such config: {args.path}", file=sys.stderr) 

1093 return 1 

1094 except cfg.ConfigError as exc: 

1095 print(str(exc), file=sys.stderr) 

1096 return 1 

1097 

1098 contract = ledger.ledger_contract_as_dict(config) 

1099 path = ledger.resolve_path(args.root, config) 

1100 try: 

1101 records = ledger.read_records(path) 

1102 except ledger.LedgerError as exc: 

1103 print(f"invalid ledger {path}: {exc}", file=sys.stderr) 

1104 return 1 

1105 if args.limit is not None: 

1106 records = records[-args.limit:] 

1107 payload = { 

1108 "contract": contract, 

1109 "path": str(path), 

1110 "status": "present" if path.exists() else "missing", 

1111 "records": records, 

1112 "record_count": len(records), 

1113 "capture_health": ledger.capture_health_summary(records), 

1114 } 

1115 if args.json: 

1116 print(json.dumps(payload, indent=2, sort_keys=True)) 

1117 else: 

1118 print(f"keel ledger — {payload['status']} {path}") 

1119 print(f" schema : {contract['schema_version']}") 

1120 print(f" records : {payload['record_count']}") 

1121 print(f" missing : {contract['missing_handling']}") 

1122 print(f" capture : {payload['capture_health']['status']}") 

1123 print(f" capture gaps : {payload['capture_health']['counts']['needs_reconcile']}") 

1124 return 0 

1125 

1126 

1127def _cmd_capture_verify(args: argparse.Namespace) -> int: 

1128 try: 

1129 config = cfg.load_config(args.path) 

1130 except FileNotFoundError: 

1131 print(f"no such config: {args.path}", file=sys.stderr) 

1132 return 1 

1133 except cfg.ConfigError as exc: 

1134 print(str(exc), file=sys.stderr) 

1135 return 1 

1136 

1137 ledger_path = ledger.resolve_path(args.root, config) 

1138 try: 

1139 records = ledger.read_records(ledger_path) 

1140 except ledger.LedgerError as exc: 

1141 print(f"invalid ledger {ledger_path}: {exc}", file=sys.stderr) 

1142 return 1 

1143 try: 

1144 derived_prs, derivation = _capture_verify_merged_prs(args, config) 

1145 except ValueError as exc: 

1146 print(str(exc), file=sys.stderr) 

1147 return 1 

1148 report = capture.verify_session(records, derived_prs) 

1149 

1150 reconcile_active = derivation["source"] != "args" or _reconcile_inputs_active(args) 

1151 reconcile_report = None 

1152 if reconcile_active: 

1153 verdict_counts = _capture_verify_verdict_counts(args, config, derived_prs) 

1154 reconcile_report = captureverify.reconcile( 

1155 records, derived_prs, verdict_counts=verdict_counts 

1156 ) 

1157 

1158 payload = { 

1159 "contract": capture.contract_as_dict(config), 

1160 "ledger_path": str(ledger_path), 

1161 "merged_pr_source": derivation, 

1162 "verification": report, 

1163 } 

1164 if reconcile_report is not None: 

1165 payload["reconcile"] = reconcile_report 

1166 

1167 base_ok = report["status"] == "complete" 

1168 reconcile_ok = reconcile_report is None or reconcile_report["ok"] 

1169 

1170 if args.json: 

1171 print(json.dumps(payload, indent=2, sort_keys=True)) 

1172 else: 

1173 print(f"keel capture-verify — {report['status']} {ledger_path}") 

1174 print(f" merged-PR source: {derivation['source']} ({len(derived_prs)} PR(s))") 

1175 for result in report["results"]: 

1176 state = "ok" if result["ok"] else "FAIL" 

1177 print( 

1178 f" {state:>4} PR #{result['pr']} " 

1179 f"{result['status']} {result['reason'] or '-'}" 

1180 ) 

1181 if reconcile_report is not None: 

1182 for finding in reconcile_report["findings"]: 

1183 print(f" FAIL reconcile PR #{finding['pr']} " 

1184 f"{finding['type']} {finding['reason']}") 

1185 if reconcile_report["ok"]: 

1186 print(" reconcile: ok") 

1187 return 0 if base_ok and reconcile_ok else 1 

1188 

1189 

1190def _cmd_consent_verify(args: argparse.Namespace) -> int: 

1191 try: 

1192 config = cfg.load_config(args.path) 

1193 except FileNotFoundError: 

1194 print(f"no such config: {args.path}", file=sys.stderr) 

1195 return 1 

1196 except cfg.ConfigError as exc: 

1197 print(str(exc), file=sys.stderr) 

1198 return 1 

1199 

1200 try: 

1201 record = _consent_ledger_record(args, config) 

1202 except ledger.LedgerError as exc: 

1203 print(f"invalid run ledger: {exc}", file=sys.stderr) 

1204 return 1 

1205 try: 

1206 observed = _consent_observed_effects(args, config) 

1207 except ValueError as exc: 

1208 print(str(exc), file=sys.stderr) 

1209 return 1 

1210 

1211 has_record, approved_scopes = consentverify.consent_record_from_ledger(record) 

1212 report = consentverify.reconcile( 

1213 observed, approved_scopes, has_consent_record=has_record 

1214 ) 

1215 payload = { 

1216 "schema_version": consentverify.SCHEMA_VERSION, 

1217 "pull_request": args.pr, 

1218 "scope_effect_table": consentverify.scope_effect_table(), 

1219 "reconcile": report, 

1220 } 

1221 if args.json: 

1222 print(json.dumps(payload, indent=2, sort_keys=True)) 

1223 else: 

1224 print(f"keel consent-verify — {report['verdict']} PR #{args.pr}") 

1225 print(f" consent record : {'present' if has_record else 'absent (advisory)'}") 

1226 print(f" approved scopes: {', '.join(report['approved_scopes']) or 'none'}") 

1227 print(f" observed : {', '.join(report['observed_effects']) or 'none'}") 

1228 for finding in report["uncovered"]: 

1229 print(f" FAIL {finding['message']}") 

1230 if report["verdict"] == consentverify.VERDICT_PASS: 

1231 print(" all observed mutations covered by approved scopes") 

1232 return 0 if report["ok"] else 1 

1233 

1234 

1235def _consent_ledger_record( 

1236 args: argparse.Namespace, 

1237 config: cfg.ProjectConfig, 

1238) -> dict[str, object] | None: 

1239 """Load the latest ship_run ledger record for the PR under consent-verify. 

1240 

1241 Reads the run ledger (offline fixture via ``--ledger-jsonl`` or the configured 

1242 path under ``--root``) and returns the most recent matching ship_run record, 

1243 or ``None`` when no record matches — the advisory back-compat path. 

1244 """ 

1245 fixture = getattr(args, "ledger_jsonl", None) 

1246 if fixture is not None: 

1247 records = ledger.parse_records(Path(fixture).read_text(encoding="utf-8")) 

1248 else: 

1249 records = ledger.read_records(ledger.resolve_path(args.root, config)) 

1250 return ledger.latest_ship_run_for_pr(records, args.pr) 

1251 

1252 

1253def _consent_observed_effects( 

1254 args: argparse.Namespace, 

1255 config: cfg.ProjectConfig, 

1256) -> consentverify.ObservedEffects: 

1257 """Observe the mutating side effects on the PR for consent reconciliation. 

1258 

1259 Offline (``--offline``): the effect flags are taken straight from the 

1260 ``--pr-exists``/``--commented``/``--merged``/``--labeled`` switches, so tests 

1261 are deterministic with no transport. Live: the PR state is read via the thin 

1262 ``gh`` wrappers, fail-soft — a transport failure raises so the operator sees 

1263 the fetch error rather than a silently-empty observation. 

1264 """ 

1265 if args.offline: 

1266 return consentverify.ObservedEffects( 

1267 pr_exists=args.pr_exists, 

1268 commented=args.commented, 

1269 merged=args.merged, 

1270 labeled=args.labeled, 

1271 ) 

1272 owner_repo = _owner_repo(config) 

1273 pr = _gh_json(["repos", owner_repo, "pulls", str(args.pr)], cwd=args.root) 

1274 comments = _gh_json_list( 

1275 ["repos", owner_repo, "issues", str(args.pr), "comments"], cwd=args.root 

1276 ) 

1277 return consentverify.ObservedEffects( 

1278 pr_exists=True, 

1279 commented=bool(comments), 

1280 merged=pr.get("merged") is True, 

1281 labeled=bool(_label_names(pr.get("labels"))), 

1282 ) 

1283 

1284 

1285def _cmd_close_reconcile(args: argparse.Namespace) -> int: 

1286 try: 

1287 config = cfg.load_config(args.path) 

1288 except FileNotFoundError: 

1289 print(f"no such config: {args.path}", file=sys.stderr) 

1290 return 1 

1291 except cfg.ConfigError as exc: 

1292 print(str(exc), file=sys.stderr) 

1293 return 1 

1294 

1295 done_label = _done_label(config) 

1296 try: 

1297 records = _close_ledger_records(args, config) 

1298 except ledger.LedgerError as exc: 

1299 print(f"invalid run ledger: {exc}", file=sys.stderr) 

1300 return 1 

1301 try: 

1302 observed = _close_observed_issues(args, config, records, done_label) 

1303 except ValueError as exc: 

1304 print(str(exc), file=sys.stderr) 

1305 return 1 

1306 

1307 report = closeorder.reconcile(observed, done_label=done_label) 

1308 payload = { 

1309 "schema_version": closeorder.SCHEMA_VERSION, 

1310 "done_label": done_label, 

1311 "reconcile": report, 

1312 } 

1313 if args.json: 

1314 print(json.dumps(payload, indent=2, sort_keys=True)) 

1315 else: 

1316 observed_count = report["summary"]["observed"] 

1317 print(f"keel close-reconcile — {report['verdict']} ({observed_count} issue(s))") 

1318 for finding in report["findings"]: 

1319 print(f" FLAG {finding['message']}") 

1320 if report["ok"]: 

1321 print(" all observed issues consistent with the ledger") 

1322 return 0 if report["ok"] else 1 

1323 

1324 

1325def _done_label(config: cfg.ProjectConfig) -> str: 

1326 """Resolve the done-marking label from ``policy_pack.status_transitions.done``. 

1327 

1328 Falls back to :data:`keel.closeorder.DEFAULT_DONE_LABEL` when a project does 

1329 not configure a done transition, so the reconcile still has a label to check. 

1330 """ 

1331 policy = config.policy_pack if isinstance(config.policy_pack, dict) else {} 

1332 transitions = policy.get("status_transitions") 

1333 done = transitions.get("done") if isinstance(transitions, dict) else None 

1334 return done if isinstance(done, str) and done.strip() else closeorder.DEFAULT_DONE_LABEL 

1335 

1336 

1337def _close_ledger_records( 

1338 args: argparse.Namespace, 

1339 config: cfg.ProjectConfig, 

1340) -> list[dict[str, object]]: 

1341 """Load the ship_run ledger records for close-ordering reconciliation. 

1342 

1343 Reads the run ledger (offline fixture via ``--ledger-jsonl`` or the configured 

1344 path under ``--root``) and returns every record; the per-issue lookup is done 

1345 by :func:`keel.closeorder.latest_record_for_issue`. 

1346 """ 

1347 fixture = getattr(args, "ledger_jsonl", None) 

1348 if fixture is not None: 

1349 return ledger.parse_records(Path(fixture).read_text(encoding="utf-8")) 

1350 return ledger.read_records(ledger.resolve_path(args.root, config)) 

1351 

1352 

1353def _close_observed_issues( 

1354 args: argparse.Namespace, 

1355 config: cfg.ProjectConfig, 

1356 records: list[dict[str, object]], 

1357 done_label: str, 

1358) -> list[closeorder.ObservedIssue]: 

1359 """Observe each issue's lifecycle state for close-ordering reconciliation. 

1360 

1361 Offline (``--offline``): the ``--closed``/``--status-done`` switches apply to 

1362 every ``--issue`` so tests are deterministic with no transport. Live: each 

1363 issue's state and labels are read via ``gh``; a transport failure raises so 

1364 the operator sees the fetch error rather than a silently-empty observation. 

1365 """ 

1366 observed: list[closeorder.ObservedIssue] = [] 

1367 for number in args.issue: 

1368 record = closeorder.latest_record_for_issue(records, number) 

1369 if args.offline: 

1370 labels = (done_label,) if args.status_done else () 

1371 observed.append(closeorder.ObservedIssue( 

1372 number=number, closed=args.closed, labels=labels, record=record, 

1373 )) 

1374 continue 

1375 owner_repo = _owner_repo(config) 

1376 issue = _gh_json(["repos", owner_repo, "issues", str(number)], cwd=args.root) 

1377 observed.append(closeorder.ObservedIssue( 

1378 number=number, 

1379 closed=issue.get("state") == "closed", 

1380 labels=tuple(_label_names(issue.get("labels"))), 

1381 record=record, 

1382 )) 

1383 return observed 

1384 

1385 

1386def _cmd_dryrun_verify(args: argparse.Namespace) -> int: 

1387 try: 

1388 config = cfg.load_config(args.path) 

1389 except FileNotFoundError: 

1390 print(f"no such config: {args.path}", file=sys.stderr) 

1391 return 1 

1392 except cfg.ConfigError as exc: 

1393 print(str(exc), file=sys.stderr) 

1394 return 1 

1395 

1396 try: 

1397 before = _dryrun_snapshot_from_json(args.before_json) 

1398 except (OSError, ValueError) as exc: 

1399 print(f"invalid before snapshot: {exc}", file=sys.stderr) 

1400 return 1 

1401 try: 

1402 after = _dryrun_after_snapshot(args, config) 

1403 except (OSError, ValueError, ledger.LedgerError) as exc: 

1404 print(f"invalid after snapshot: {exc}", file=sys.stderr) 

1405 return 1 

1406 

1407 report = dryrunverify.reconcile(before, after, run_id=args.run_id, issue=args.issue) 

1408 payload = {"schema_version": dryrunverify.SCHEMA_VERSION, "reconcile": report} 

1409 if args.json: 

1410 print(json.dumps(payload, indent=2, sort_keys=True)) 

1411 else: 

1412 print(f"keel dryrun-verify — {report['verdict']} run {args.run_id!r} issue #{args.issue}") 

1413 for finding in report["findings"]: 

1414 print(f" LEAK {finding['message']}") 

1415 if report["ok"]: 

1416 print(" dry run left no new ledger record, branch, or PR") 

1417 return 0 if report["ok"] else 1 

1418 

1419 

1420def _dryrun_snapshot_from_json(path: str) -> dryrunverify.ArtifactSnapshot: 

1421 """Parse an artifact snapshot from a JSON file ``{ledger_run_ids, branches, pr_numbers}``.""" 

1422 data = json.loads(Path(path).read_text(encoding="utf-8")) 

1423 if not isinstance(data, dict): 

1424 raise ValueError("snapshot must be a JSON object") 

1425 return dryrunverify.ArtifactSnapshot( 

1426 ledger_run_ids=tuple(str(rid) for rid in data.get("ledger_run_ids", ())), 

1427 branches=tuple(str(name) for name in data.get("branches", ())), 

1428 pr_numbers=tuple(int(num) for num in data.get("pr_numbers", ())), 

1429 ) 

1430 

1431 

1432def _dryrun_after_snapshot( 

1433 args: argparse.Namespace, 

1434 config: cfg.ProjectConfig, 

1435) -> dryrunverify.ArtifactSnapshot: 

1436 """Gather the post-dry-run artifact snapshot. 

1437 

1438 Offline (``--after-json``): the snapshot is read straight from the fixture so 

1439 tests are deterministic. Live: ledger run_ids come from the configured ledger, 

1440 branches from ``git for-each-ref``, and PRs from ``gh pr list`` scoped to the 

1441 run's issue ship-branch pattern. 

1442 

1443 The after snapshot is read **fail-closed**: a corrupt ledger, a failed 

1444 ``git``/``gh`` read, all raise. An integrity detector that cannot observe the 

1445 after state must say "cannot certify", never silently report a clean diff — 

1446 an empty-on-error snapshot would mask a real leak (``after − before = ∅``). 

1447 """ 

1448 if args.after_json is not None: 

1449 return _dryrun_snapshot_from_json(args.after_json) 

1450 records = ledger.read_records(ledger.resolve_path(args.root, config)) 

1451 run_ids = tuple( 

1452 str(record["run_id"]) 

1453 for record in records 

1454 if record.get("record_type") == ledger.RECORD_TYPE_SHIP_RUN 

1455 and isinstance(record.get("run_id"), str) 

1456 ) 

1457 branches_result = git.list_branches(cwd=args.root) 

1458 if not branches_result.ok: 

1459 raise ValueError( 

1460 "after snapshot incomplete: git branch listing failed; cannot certify dry run" 

1461 ) 

1462 branches = tuple( 

1463 line.strip() for line in branches_result.output.splitlines() if line.strip() 

1464 ) 

1465 pattern = dryrunverify.issue_branch_pattern(args.issue) 

1466 pr_numbers = tuple( 

1467 number 

1468 for number, head in _dryrun_live_prs(args.root) 

1469 if pattern.search(head) 

1470 ) 

1471 return dryrunverify.ArtifactSnapshot( 

1472 ledger_run_ids=run_ids, branches=branches, pr_numbers=pr_numbers, 

1473 ) 

1474 

1475 

1476def _dryrun_live_prs(root: str) -> list[tuple[int, str]]: 

1477 """Return ``(number, headRefName)`` for the repo's PRs. 

1478 

1479 Fail-closed: a ``gh`` transport failure raises ``ValueError`` rather than 

1480 degrading to an empty list, so an unobservable PR set can never masquerade 

1481 as "no new PRs" and hide a leaked PR. Malformed entries are skipped (a 

1482 well-formed-but-empty list is a legitimate observation). 

1483 """ 

1484 result = github.list_prs(cwd=root) 

1485 if not result.ok: 

1486 raise ValueError( 

1487 "after snapshot incomplete: gh PR listing failed; cannot certify dry run" 

1488 ) 

1489 entries = json.loads(result.output or "[]") 

1490 pairs: list[tuple[int, str]] = [] 

1491 for entry in entries if isinstance(entries, list) else (): 

1492 if not isinstance(entry, dict): 

1493 continue 

1494 number = entry.get("number") 

1495 head = entry.get("headRefName") 

1496 if isinstance(number, int) and isinstance(head, str): 

1497 pairs.append((number, head)) 

1498 return pairs 

1499 

1500 

1501def _reconcile_inputs_active(args: argparse.Namespace) -> bool: 

1502 return bool( 

1503 getattr(args, "from_transport", False) 

1504 or getattr(args, "merged_prs_json", None) 

1505 or getattr(args, "verdict_count", None) 

1506 or getattr(args, "pr_reviews_json", None) 

1507 ) 

1508 

1509 

1510def _capture_verify_merged_prs( 

1511 args: argparse.Namespace, config: cfg.ProjectConfig 

1512) -> tuple[list[int], dict[str, object]]: 

1513 """Resolve the authoritative merged-PR set for capture reconciliation. 

1514 

1515 Priority: an explicit transport fixture (``--merged-prs-json``), then a live 

1516 transport derivation (``--from-transport``), then the explicit ``--merged-pr`` 

1517 override. ``--merged-pr`` always augments the derived set so an agent cannot 

1518 *shrink* the merged set by omitting PRs (the union is verified). 

1519 """ 

1520 explicit = list(args.merged_pr or ()) 

1521 fixture = getattr(args, "merged_prs_json", None) 

1522 if fixture is not None: 

1523 derived = _merged_prs_from_json(fixture) 

1524 merged = _dedupe_ints([*derived, *explicit]) 

1525 return merged, {"source": "transport-fixture", "transport_failed": False} 

1526 if getattr(args, "from_transport", False): 

1527 derived, failed = _merged_prs_from_transport(args) 

1528 merged = _dedupe_ints([*derived, *explicit]) 

1529 if not merged: 

1530 raise ValueError( 

1531 "no merged PRs derived from transport and none passed via --merged-pr" 

1532 ) 

1533 return merged, {"source": "transport", "transport_failed": failed} 

1534 if not explicit: 

1535 raise ValueError("provide --merged-pr or --from-transport") 

1536 return _dedupe_ints(explicit), {"source": "args", "transport_failed": False} 

1537 

1538 

1539def _merged_prs_from_json(path: str) -> list[int]: 

1540 items = _read_json_list(path) 

1541 numbers: list[int] = [] 

1542 for item in items: 

1543 number = item.get("number") 

1544 if isinstance(number, int) and number > 0: 

1545 numbers.append(number) 

1546 return numbers 

1547 

1548 

1549def _merged_prs_from_transport(args: argparse.Namespace) -> tuple[list[int], bool]: 

1550 search = getattr(args, "merged_since", None) 

1551 search_arg = f"merged:>={search}" if search else None 

1552 result = github.merged_prs(search=search_arg, cwd=args.root) 

1553 if not result.ok: 

1554 return [], True 

1555 try: 

1556 items = json.loads(result.output or "[]") 

1557 except json.JSONDecodeError: 

1558 return [], True 

1559 if not isinstance(items, list): 

1560 return [], True 

1561 numbers = [ 

1562 item["number"] 

1563 for item in items 

1564 if isinstance(item, dict) and isinstance(item.get("number"), int) 

1565 ] 

1566 return numbers, False 

1567 

1568 

1569def _capture_verify_verdict_counts( 

1570 args: argparse.Namespace, config: cfg.ProjectConfig, merged_prs: list[int] 

1571) -> dict[int, int]: 

1572 """Evidence-side review-verdict counts per PR for the reviewer cross-check. 

1573 

1574 Offline: ``--verdict-count PR=N`` fixtures. Live: counts are read from the 

1575 transport per PR via the shared evidence counter; a fetch failure simply 

1576 omits that PR (the cross-check degrades to advisory rather than failing). 

1577 """ 

1578 explicit = dict(getattr(args, "verdict_count", None) or ()) 

1579 if explicit: 

1580 return explicit 

1581 if not getattr(args, "from_transport", False): 

1582 return {} 

1583 try: 

1584 owner_repo = _owner_repo(config) 

1585 except ValueError: 

1586 # No owner/repo configured: the reviewer cross-check degrades to advisory. 

1587 return {} 

1588 counts: dict[int, int] = {} 

1589 for pr in merged_prs: 

1590 try: 

1591 pr_comments = _gh_json_list( 

1592 ["repos", owner_repo, "issues", str(pr), "comments"], cwd=args.root 

1593 ) 

1594 pr_reviews = _gh_json_list( 

1595 ["repos", owner_repo, "pulls", str(pr), "reviews"], cwd=args.root 

1596 ) 

1597 except ValueError: 

1598 continue 

1599 counts[pr] = evidence.count_review_verdicts( 

1600 pr_comments, pr_reviews, enforced=False 

1601 ) 

1602 return counts 

1603 

1604 

1605def _cmd_capture_reconcile(args: argparse.Namespace) -> int: 

1606 try: 

1607 config = cfg.load_config(args.path) 

1608 except FileNotFoundError: 

1609 print(f"no such config: {args.path}", file=sys.stderr) 

1610 return 1 

1611 except cfg.ConfigError as exc: 

1612 print(str(exc), file=sys.stderr) 

1613 return 1 

1614 

1615 ledger_path = ledger.resolve_path(args.root, config) 

1616 try: 

1617 records = ledger.read_records(ledger_path) 

1618 except ledger.LedgerError as exc: 

1619 print(f"invalid ledger {ledger_path}: {exc}", file=sys.stderr) 

1620 return 1 

1621 linked_issues: dict[int, list[int]] = {} 

1622 for pr, issue in args.linked_issue: 

1623 linked_issues.setdefault(pr, []).append(issue) 

1624 merged_prs = [ 

1625 {"number": pr, "issue_numbers": linked_issues.get(pr, [])} 

1626 for pr in args.merged_pr 

1627 ] 

1628 try: 

1629 plan = capture.reconcile_session( 

1630 records, 

1631 merged_prs, 

1632 config=config, 

1633 capture_capability_available=args.capture_capability == "available", 

1634 ) 

1635 except capture.CaptureError as exc: 

1636 print(str(exc), file=sys.stderr) 

1637 return 1 

1638 payload = { 

1639 "contract": capture.contract_as_dict(config)["reconcile"], 

1640 "mode": "live-plan" if args.live else "dry-run", 

1641 "no_mutations": True, 

1642 "ledger_path": str(ledger_path), 

1643 "reconcile": plan, 

1644 } 

1645 if args.json: 

1646 print(json.dumps(payload, indent=2, sort_keys=True)) 

1647 else: 

1648 print(f"keel capture-reconcile — {plan['status']} {ledger_path}") 

1649 for result in plan["results"]: 

1650 print(f" PR #{result['pr']} {result['status']} {result['reason']}") 

1651 for action in result["actions"]: 

1652 print(f" DRY-RUN: {action['type']} {action['idempotency_key']}") 

1653 return 0 if plan["status"] != "blocked" else 1 

1654 

1655 

1656def _cmd_step_verify(args: argparse.Namespace) -> int: 

1657 try: 

1658 handoff = _read_json_object(args.handoff_file) 

1659 evidence_report = _read_json_object(args.evidence_report) 

1660 except ValueError as exc: 

1661 print(str(exc), file=sys.stderr) 

1662 return 1 

1663 review_contract = ship.resolve_review_contract( 

1664 tier=None, 

1665 reviewer_override=args.reviewers, 

1666 review_comments=args.review_comments, 

1667 gates=(), 

1668 policy_pack={}, 

1669 jury=args.jury, 

1670 no_jury=args.no_jury, 

1671 jury_advisory=args.jury_advisory, 

1672 ) 

1673 try: 

1674 report = stepverifier.verify_step_completion( 

1675 step_id=args.step, 

1676 handoff=handoff, 

1677 evidence_report=evidence_report, 

1678 review_contract=review_contract, 

1679 dry_run=args.dry_run, 

1680 enforced=not args.not_enforced, 

1681 ) 

1682 except KeyError as exc: 

1683 print(str(exc), file=sys.stderr) 

1684 return 1 

1685 payload = { 

1686 "contract": stepverifier.contract_as_dict( 

1687 review_contract, 

1688 dry_run=args.dry_run, 

1689 enforced=not args.not_enforced, 

1690 ), 

1691 "verification": report, 

1692 } 

1693 if args.json: 

1694 print(json.dumps(payload, indent=2, sort_keys=True)) 

1695 else: 

1696 print(f"keel step-verify — {report['status']} {args.step}") 

1697 if report["missing"]: 

1698 print(f" missing : {', '.join(report['missing'])}") 

1699 print(f" required : {len(report['required_evidence'])}") 

1700 return 0 if report["status"] == "pass" else 1 

1701 

1702 

1703def _cmd_runcontrols(args: argparse.Namespace) -> int: 

1704 try: 

1705 events = _read_json_list(args.events_file, missing_ok=True) 

1706 event = _event_from_args(args) 

1707 step_caps = _step_caps_from_args(args.step_cap) 

1708 except ValueError as exc: 

1709 print(str(exc), file=sys.stderr) 

1710 return 1 

1711 if event: 

1712 events.append(event) 

1713 if not args.dry_run: 

1714 _write_json_list(args.events_file, events) 

1715 report = runcontrols.evaluate_run_controls( 

1716 events, 

1717 max_work_units=args.max_work_units, 

1718 default_step_cap=args.default_step_cap, 

1719 step_caps=step_caps, 

1720 identical_action_threshold=args.identical_action_threshold, 

1721 alternating_diff_window=args.alternating_diff_window, 

1722 ) 

1723 payload = { 

1724 "contract": runcontrols.contract_as_dict(), 

1725 "path": args.events_file, 

1726 "appended": bool(event) and not args.dry_run, 

1727 "event": event, 

1728 "run_controls": report, 

1729 } 

1730 if args.json: 

1731 print(json.dumps(payload, indent=2, sort_keys=True)) 

1732 else: 

1733 print(f"keel runcontrols — {report['status']} {args.events_file}") 

1734 print(f" events : {report['summary']['event_count']}") 

1735 print(f" work units : {report['summary']['work_units']}") 

1736 if report["reason"]: 

1737 reason = report["reason"] 

1738 print(f" halt : {reason['reason']} ({reason['scope']})") 

1739 return 0 if report["status"] == "pass" else 1 

1740 

1741 

1742def _cmd_review(args: argparse.Namespace) -> int: 

1743 if args.dry_run and args.live: 

1744 print("--dry-run and --live cannot be used together", file=sys.stderr) 

1745 return 1 

1746 dry_run = not args.live 

1747 try: 

1748 config = cfg.load_config(args.path) 

1749 except FileNotFoundError: 

1750 print(f"no such config: {args.path}", file=sys.stderr) 

1751 return 1 

1752 except cfg.ConfigError as exc: 

1753 print(str(exc), file=sys.stderr) 

1754 return 1 

1755 

1756 report = runtime.detect(args.root) 

1757 evaluation = runtime.evaluate( 

1758 runtime.CapabilityRequirement(required=("gh", "gh-auth")), report 

1759 ) 

1760 transport = github_transport.resolve(report) 

1761 if not evaluation.ok or not transport.supports("comments"): 

1762 print(evaluation.render(), file=sys.stderr) 

1763 print(transport.render(), file=sys.stderr) 

1764 return 1 

1765 

1766 try: 

1767 raw_reviews = json.loads(Path(args.reviews).read_text(encoding="utf-8")) 

1768 reviews = review.parse_reviews(raw_reviews) 

1769 except OSError as exc: 

1770 print(f"cannot read --reviews {args.reviews}: {exc}", file=sys.stderr) 

1771 return 1 

1772 except json.JSONDecodeError as exc: 

1773 print(f"--reviews {args.reviews} is not valid JSON: {exc}", file=sys.stderr) 

1774 return 1 

1775 except review.ReviewError as exc: 

1776 print(str(exc), file=sys.stderr) 

1777 return 1 

1778 

1779 closure_record: dict[str, object] | None = None 

1780 if args.closure is not None: 

1781 try: 

1782 closure_record = _read_json_object(args.closure) 

1783 except OSError as exc: 

1784 print(f"cannot read --closure {args.closure}: {exc}", file=sys.stderr) 

1785 return 1 

1786 except (json.JSONDecodeError, ValueError) as exc: 

1787 print(f"--closure {args.closure} must be a JSON object: {exc}", file=sys.stderr) 

1788 return 1 

1789 

1790 try: 

1791 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent( 

1792 args, config, True 

1793 ) 

1794 except ValueError as exc: 

1795 print(str(exc), file=sys.stderr) 

1796 return 1 

1797 operator_consent = consent.build_consent_contract( 

1798 command="review", 

1799 side_effects=("comments",), 

1800 dry_run=dry_run, 

1801 approved_scopes=approved_scopes, 

1802 approval_source=approval_source, 

1803 mode=consent_mode, 

1804 operator=approval_operator, 

1805 target=f"PR #{args.pr}", 

1806 ) 

1807 consent_ok, consent_message = consent.assert_operator_consent(operator_consent) 

1808 if not consent_ok: 

1809 print(consent_message, file=sys.stderr) 

1810 return 1 

1811 

1812 try: 

1813 owner_repo = _owner_repo(config) 

1814 except ValueError as exc: 

1815 print(str(exc), file=sys.stderr) 

1816 return 1 

1817 

1818 evidence_args = argparse.Namespace( 

1819 pr=args.pr, 

1820 issue=args.issue, 

1821 pr_body_file=None, 

1822 pr_comments_json=None, 

1823 issue_comments_json=None, 

1824 pr_reviews_json=None, 

1825 changed_file=tuple(args.changed_file or ()), 

1826 head_sha=args.head_sha, 

1827 head_ref=None, 

1828 pr_label=(), 

1829 dry_run=dry_run, 

1830 root=args.root, 

1831 ) 

1832 try: 

1833 artifacts_ctx = _load_evidence_artifacts(evidence_args, config) 

1834 except ValueError as exc: 

1835 print(str(exc), file=sys.stderr) 

1836 return 1 

1837 changed_files = artifacts_ctx["changed_files"] 

1838 tier = ( 

1839 classify.tier_for_files( 

1840 changed_files, 

1841 tier3_globs=config.knobs.tier3_globs, 

1842 docs_globs=config.knobs.docs_gate_paths, 

1843 ) 

1844 if changed_files else None 

1845 ) 

1846 review_contract = ship.resolve_review_contract( 

1847 tier=tier, 

1848 reviewer_override=args.reviewers, 

1849 gates=config.gates, 

1850 policy_pack=config.policy_pack, 

1851 ) 

1852 required_count = review_contract["reviewers"]["count"] 

1853 

1854 try: 

1855 plan = review.build_review_plan( 

1856 reviews, 

1857 required_count=required_count, 

1858 head_sha=artifacts_ctx["head_sha"], 

1859 pull_request=args.pr, 

1860 issue=artifacts_ctx["issue"], 

1861 run_id=args.run_id, 

1862 tier=tier, 

1863 closure_record=closure_record, 

1864 ) 

1865 except review.ReviewError as exc: 

1866 print(str(exc), file=sys.stderr) 

1867 return 1 

1868 

1869 posted: list[dict[str, object]] = [] 

1870 for post in plan.posts: 

1871 if dry_run: 

1872 posted.append({ 

1873 "artifact": post.artifact, 

1874 "target": {"kind": post.target_kind, "number": post.target_number}, 

1875 "run_id": post.run_id, 

1876 "action": "dry-run", 

1877 }) 

1878 if not args.json: 

1879 print(f"DRY-RUN: would post {post.artifact} to " 

1880 f"{post.target_kind}:{post.target_number} (run-id {post.run_id})") 

1881 continue 

1882 try: 

1883 result, error = _post_artifact_comment( 

1884 owner_repo, 

1885 target_kind=post.target_kind, 

1886 target_number=post.target_number, 

1887 artifact=post.artifact, 

1888 marker=post.marker, 

1889 body=post.body, 

1890 run_id=post.run_id, 

1891 transport_name=transport.name, 

1892 dry_run=False, 

1893 cwd=args.root, 

1894 ) 

1895 except ValueError as exc: 

1896 print(str(exc), file=sys.stderr) 

1897 return 1 

1898 if error is not None: 

1899 print(error, file=sys.stderr) 

1900 return 1 

1901 posted.append(result) 

1902 

1903 verification: dict[str, object] | None = None 

1904 if args.verify and not dry_run: 

1905 verification = _verify_merge_evidence( 

1906 argparse.Namespace( 

1907 pr=args.pr, 

1908 issue=args.issue, 

1909 root=args.root, 

1910 reviewers=args.reviewers, 

1911 review_comments="inline", 

1912 jury=False, 

1913 no_jury=False, 

1914 jury_advisory=False, 

1915 gate_label=None, 

1916 waiver_label=None, 

1917 ), 

1918 config, 

1919 ) 

1920 

1921 result_payload = { 

1922 "schema_version": review.SCHEMA_VERSION, 

1923 "plan": plan.as_dict(), 

1924 "dry_run": dry_run, 

1925 "transport": transport.name, 

1926 "posted": posted, 

1927 "consent": operator_consent["status"], 

1928 "verification": verification, 

1929 } 

1930 if args.json: 

1931 print(json.dumps(result_payload, indent=2, sort_keys=True)) 

1932 else: 

1933 mode = "dry-run" if dry_run else "live" 

1934 print(f"keel review — {mode} PR #{args.pr}") 

1935 print(f" tier : {tier if tier is not None else 'unresolved'}") 

1936 print(f" required : {required_count}") 

1937 print(f" supplied : {plan.supplied_count}") 

1938 print(f" posts : {len(plan.posts)}") 

1939 if verification is not None: 

1940 print(f" verify : {verification['verification']['status']}") 

1941 if verification is not None and verification["verification"]["status"] != "pass": 

1942 return 1 

1943 return 0 

1944 

1945 

1946def _cmd_post_comment(args: argparse.Namespace) -> int: 

1947 try: 

1948 config = cfg.load_config(args.path) 

1949 except FileNotFoundError: 

1950 print(f"no such config: {args.path}", file=sys.stderr) 

1951 return 1 

1952 except cfg.ConfigError as exc: 

1953 print(str(exc), file=sys.stderr) 

1954 return 1 

1955 

1956 marker = _comment_artifact_marker(args.artifact) 

1957 try: 

1958 target_kind, target_number = _parse_comment_target(args.target) 

1959 except ValueError as exc: 

1960 print(str(exc), file=sys.stderr) 

1961 return 1 

1962 try: 

1963 body = Path(args.body_file).read_text(encoding="utf-8") 

1964 except OSError as exc: 

1965 print(f"cannot read --body-file {args.body_file}: {exc}", file=sys.stderr) 

1966 return 1 

1967 if marker not in body: 

1968 print( 

1969 f"body for artifact {args.artifact} must contain marker {marker}", 

1970 file=sys.stderr, 

1971 ) 

1972 return 1 

1973 if _looks_like_body_file_literal(body): 

1974 print( 

1975 "body appears to be a literal @/path reference; pass rendered markdown, " 

1976 "not a shell-expanded placeholder", 

1977 file=sys.stderr, 

1978 ) 

1979 return 1 

1980 

1981 report = runtime.detect(args.root) 

1982 evaluation = runtime.evaluate( 

1983 runtime.CapabilityRequirement(required=("gh", "gh-auth")), report 

1984 ) 

1985 transport = github_transport.resolve(report) 

1986 if not evaluation.ok or not transport.supports("comments"): 

1987 print(evaluation.render(), file=sys.stderr) 

1988 print(transport.render(), file=sys.stderr) 

1989 return 1 

1990 try: 

1991 owner_repo = _owner_repo(config) 

1992 except ValueError as exc: 

1993 print(str(exc), file=sys.stderr) 

1994 return 1 

1995 

1996 try: 

1997 payload, error = _post_artifact_comment( 

1998 owner_repo, 

1999 target_kind=target_kind, 

2000 target_number=target_number, 

2001 artifact=args.artifact, 

2002 marker=marker, 

2003 body=body, 

2004 run_id=args.run_id, 

2005 transport_name=transport.name, 

2006 dry_run=args.dry_run, 

2007 cwd=args.root, 

2008 ) 

2009 except ValueError as exc: 

2010 print(str(exc), file=sys.stderr) 

2011 return 1 

2012 if error is not None: 

2013 print(error, file=sys.stderr) 

2014 return 1 

2015 return _finish_post_comment(args, payload, code=0) 

2016 

2017 

2018def _post_artifact_comment( 

2019 owner_repo: str, 

2020 *, 

2021 target_kind: str, 

2022 target_number: int, 

2023 artifact: str, 

2024 marker: str, 

2025 body: str, 

2026 run_id: str | None, 

2027 transport_name: str, 

2028 dry_run: bool, 

2029 cwd: str, 

2030) -> tuple[dict[str, object], str | None]: 

2031 """Post or update one marker/run-id comment via the existing gh path. 

2032 

2033 Returns a ``(payload, error)`` pair. ``error`` is ``None`` on success; on a gh 

2034 mutation failure it is the message to surface. Raises ``ValueError`` only when 

2035 the comment fetch itself fails. Dry runs plan the action and never mutate. 

2036 """ 

2037 existing = _gh_json_list( 

2038 ["repos", owner_repo, "issues", str(target_number), "comments"], cwd=cwd 

2039 ) 

2040 match = _find_comment_match(existing, marker=marker, run_id=run_id) 

2041 payload: dict[str, object] = { 

2042 "schema_version": "keel.post-comment.v1", 

2043 "target": {"kind": target_kind, "number": target_number}, 

2044 "artifact": artifact, 

2045 "marker": marker, 

2046 "transport": transport_name, 

2047 "run_id": run_id, 

2048 "dry_run": dry_run, 

2049 } 

2050 if dry_run: 

2051 payload["action"] = "edit" if match else "post" 

2052 payload["comment_id"] = match.get("id") if match else None 

2053 return payload, None 

2054 

2055 if match: 

2056 comment_id = match.get("id") 

2057 if not isinstance(comment_id, int): 

2058 return payload, "matching comment is missing an integer id" 

2059 result = github.edit_issue_comment(owner_repo, comment_id, body, cwd=cwd) 

2060 payload["action"] = "edited" 

2061 payload["comment_id"] = comment_id 

2062 else: 

2063 result = github.post_issue_comment(owner_repo, target_number, body, cwd=cwd) 

2064 payload["action"] = "posted" 

2065 if not result.ok: 

2066 return payload, result.output.strip() or "gh comment mutation failed" 

2067 try: 

2068 response = json.loads(result.output or "{}") 

2069 except json.JSONDecodeError: 

2070 response = {} 

2071 if isinstance(response, dict): 

2072 payload["comment_id"] = response.get("id", payload.get("comment_id")) 

2073 payload["html_url"] = response.get("html_url") 

2074 return payload, None 

2075 

2076 

2077def _cmd_evidence_verify(args: argparse.Namespace) -> int: 

2078 try: 

2079 config = cfg.load_config(args.path) 

2080 except FileNotFoundError: 

2081 print(f"no such config: {args.path}", file=sys.stderr) 

2082 return 1 

2083 except cfg.ConfigError as exc: 

2084 print(str(exc), file=sys.stderr) 

2085 return 1 

2086 

2087 try: 

2088 artifacts = _load_evidence_artifacts(args, config) 

2089 except ValueError as exc: 

2090 print(str(exc), file=sys.stderr) 

2091 return 1 

2092 changed_files = artifacts["changed_files"] 

2093 tier = ( 

2094 classify.tier_for_files( 

2095 changed_files, 

2096 tier3_globs=config.knobs.tier3_globs, 

2097 docs_globs=config.knobs.docs_gate_paths, 

2098 ) 

2099 if changed_files else None 

2100 ) 

2101 review_contract = ship.resolve_review_contract( 

2102 tier=tier, 

2103 reviewer_override=args.reviewers, 

2104 review_comments=args.review_comments, 

2105 gates=config.gates, 

2106 policy_pack=config.policy_pack, 

2107 jury=args.jury, 

2108 no_jury=args.no_jury, 

2109 jury_advisory=args.jury_advisory, 

2110 require_distinct_vendors=( 

2111 args.require_distinct_vendors 

2112 or config.knobs.evidence_require_distinct_vendors 

2113 ), 

2114 ) 

2115 gate_label = args.gate_label or config.knobs.evidence_gate_label 

2116 waiver_label = args.waiver_label or evidence.DEFAULT_WAIVER_LABEL 

2117 gate = evidence.gate_decision( 

2118 artifacts["pr_labels"], 

2119 gate_label, 

2120 waiver_label=waiver_label, 

2121 head_ref=artifacts.get("head_ref"), 

2122 pr_comments=artifacts["pr_comments"], 

2123 pr_reviews=artifacts["pr_reviews"], 

2124 ) 

2125 enforced = gate["enforced"] 

2126 try: 

2127 ledger_record = _evidence_ledger_record(args, config) 

2128 except ledger.LedgerError as exc: 

2129 print(f"invalid run ledger: {exc}", file=sys.stderr) 

2130 return 1 

2131 report = evidence.verify( 

2132 review_contract, 

2133 pr_comments=artifacts["pr_comments"], 

2134 issue_comments=artifacts["issue_comments"], 

2135 pr_reviews=artifacts["pr_reviews"], 

2136 pr_body=artifacts["pr_body"], 

2137 pr_labels=artifacts["pr_labels"], 

2138 head_sha=artifacts["head_sha"], 

2139 ledger_record=ledger_record, 

2140 dry_run=args.dry_run, 

2141 enforced=enforced, 

2142 deferrals=tuple(args.deferral or ()), 

2143 ) 

2144 payload = { 

2145 "contract": evidence.contract_as_dict( 

2146 review_contract, 

2147 dry_run=args.dry_run, 

2148 enforced=enforced, 

2149 deferrals=tuple(args.deferral or ()), 

2150 ), 

2151 "gate_label": gate_label, 

2152 "waiver_label": waiver_label, 

2153 "gate": gate, 

2154 "enforced": enforced, 

2155 "pr_labels": artifacts["pr_labels"], 

2156 "pull_request": args.pr, 

2157 "issue": artifacts["issue"], 

2158 "head_ref": artifacts.get("head_ref"), 

2159 "head_sha": artifacts["head_sha"], 

2160 "changed_files": artifacts["changed_files"], 

2161 "verification": report, 

2162 } 

2163 if args.json: 

2164 print(json.dumps(payload, indent=2, sort_keys=True)) 

2165 else: 

2166 print(f"keel evidence-verify — {report['status']} PR #{args.pr}") 

2167 print(f" issue : {artifacts['issue'] or 'not resolved'}") 

2168 print(f" dry-run : {str(args.dry_run).lower()}") 

2169 if not enforced: 

2170 print(f" enforced : false ({gate['reason']})") 

2171 print(" required : 0") 

2172 if gate.get("waived"): 

2173 print(" note : evidence gate disarmed by operator waiver label") 

2174 else: 

2175 print(" note : evidence gate not enforced; no ship provenance detected") 

2176 return 0 

2177 print(f" enforced : true ({gate['reason']})") 

2178 print(f" required : {report['required_count']}") 

2179 if report["missing"]: 

2180 print(f" missing : {', '.join(report['missing'])}") 

2181 for result in report["results"]: 

2182 state = "ok" if result["ok"] else "FAIL" 

2183 suffix = " (deferred)" if result["deferred"] else "" 

2184 print(f" {state:>4} {result['id']}{suffix}") 

2185 return 0 if report["status"] == "pass" else 1 

2186 

2187 

2188def _cmd_scope_verify(args: argparse.Namespace) -> int: 

2189 try: 

2190 config = cfg.load_config(args.path) 

2191 except FileNotFoundError: 

2192 print(f"no such config: {args.path}", file=sys.stderr) 

2193 return 1 

2194 except cfg.ConfigError as exc: 

2195 print(str(exc), file=sys.stderr) 

2196 return 1 

2197 

2198 try: 

2199 artifacts = _load_evidence_artifacts(args, config) 

2200 except ValueError as exc: 

2201 print(str(exc), file=sys.stderr) 

2202 return 1 

2203 try: 

2204 record = _scope_ledger_record(args, config) 

2205 except ledger.LedgerError as exc: 

2206 print(f"invalid run ledger: {exc}", file=sys.stderr) 

2207 return 1 

2208 declared = ledger.declared_files_for_record(record) if record is not None else None 

2209 report = scope.verify( 

2210 declared, 

2211 list(artifacts["changed_files"]), 

2212 docs_globs=config.knobs.docs_gate_paths, 

2213 deferrals=tuple(args.deferral or ()), 

2214 ) 

2215 payload = { 

2216 "schema_version": scope.SCHEMA_VERSION, 

2217 "pull_request": args.pr, 

2218 "head_sha": artifacts["head_sha"], 

2219 "changed_files": artifacts["changed_files"], 

2220 "deferrals": list(args.deferral or ()), 

2221 "verification": report, 

2222 } 

2223 if args.json: 

2224 print(json.dumps(payload, indent=2, sort_keys=True)) 

2225 else: 

2226 print(f"keel scope-verify — {report['status']} PR #{args.pr}") 

2227 if report["advisory"]: 

2228 print(f" note : {report['note']}") 

2229 return 0 

2230 print(f" declared : {len(report['declared'])} file(s)") 

2231 print(f" in-scope : {len(report['in_scope'])} file(s)") 

2232 if report["docs_exempt"]: 

2233 print(f" docs-exempt : {', '.join(report['docs_exempt'])}") 

2234 if report["scope_creep"]: 

2235 print(f" scope-creep : {', '.join(report['scope_creep'])}") 

2236 if report["note"]: 

2237 print(f" note : {report['note']}") 

2238 return 0 if report["status"] == "pass" else 1 

2239 

2240 

2241def _cmd_verify_branch(args: argparse.Namespace) -> int: 

2242 try: 

2243 config = cfg.load_config(args.path) 

2244 except FileNotFoundError: 

2245 print(f"no such config: {args.path}", file=sys.stderr) 

2246 return 1 

2247 except cfg.ConfigError as exc: 

2248 print(str(exc), file=sys.stderr) 

2249 return 1 

2250 

2251 base_branch = config.base_branch 

2252 try: 

2253 facts = _gather_branch_facts(args, base_branch) 

2254 except ValueError as exc: 

2255 print(str(exc), file=sys.stderr) 

2256 return 1 

2257 report = branchscope.verify( 

2258 base_branch=base_branch, 

2259 head_sha=facts["head_sha"], 

2260 merge_base_sha=facts["merge_base_sha"], 

2261 base_tip_sha=facts["base_tip_sha"], 

2262 base_distance=facts["base_distance"], 

2263 worktree_path=facts["worktree_path"], 

2264 repo_root=facts["repo_root"], 

2265 is_linked_worktree=facts["is_linked_worktree"], 

2266 tolerance=args.tolerance, 

2267 allow_stale_base=args.allow_stale_base, 

2268 ) 

2269 payload = { 

2270 "schema_version": branchscope.SCHEMA_VERSION, 

2271 "pull_request": args.pr, 

2272 "head_ref": facts["head_ref"], 

2273 "verification": report, 

2274 } 

2275 if args.json: 

2276 print(json.dumps(payload, indent=2, sort_keys=True)) 

2277 else: 

2278 print(f"keel verify-branch — {report['status']} PR #{args.pr}") 

2279 print(f" base : origin/{base_branch}") 

2280 print(f" verdict : {report['verdict']}") 

2281 ancestry = report["ancestry"] 

2282 if ancestry["base_distance"] is not None: 

2283 print( 

2284 f" base-distance : {ancestry['base_distance']} " 

2285 f"(tolerance {report['tolerance']})" 

2286 ) 

2287 isolation = report["isolation"] 

2288 print(f" isolation : {isolation['verdict']}") 

2289 if report["note"]: 

2290 print(f" note : {report['note']}") 

2291 return 0 if report["status"] == "pass" else 1 

2292 

2293 

2294def _gather_branch_facts(args: argparse.Namespace, base_branch: str) -> dict[str, object]: 

2295 """Collect the git/gh facts the pure branch verdict needs. 

2296 

2297 Offline fixtures (``--head-sha``, ``--base-tip-sha``, ``--merge-base-sha``, 

2298 ``--base-distance``, ``--worktree-path``/``--repo-root``/``--linked-worktree``) 

2299 short-circuit every live call so the gather path is deterministic in tests; a 

2300 live run resolves the PR head via ``gh`` and the ancestry/worktree facts via 

2301 the thin ``git`` wrappers, fail-soft (a missing fact becomes ``None`` and the 

2302 pure layer skips that check rather than hard-blocking). 

2303 """ 

2304 head_sha = args.head_sha 

2305 head_ref = args.head_ref 

2306 base_ref = f"origin/{base_branch}" 

2307 if head_sha is None and not args.offline: 

2308 owner_repo = _owner_repo_from_args(args) 

2309 pr = _gh_json(["repos", owner_repo, "pulls", str(args.pr)], cwd=args.root) 

2310 head = pr.get("head") if isinstance(pr.get("head"), dict) else {} 

2311 head_sha = head.get("sha") if isinstance(head.get("sha"), str) else None 

2312 head_ref = head.get("ref") if isinstance(head.get("ref"), str) else head_ref 

2313 

2314 base_tip_sha = args.base_tip_sha 

2315 merge_base_sha = args.merge_base_sha 

2316 base_distance = args.base_distance 

2317 if not args.offline: 

2318 if base_tip_sha is None: 

2319 base_tip_sha = git.rev_parse(base_ref, cwd=args.root) 

2320 if merge_base_sha is None and head_sha is not None and base_tip_sha is not None: 

2321 merge_base_sha = git.merge_base(head_sha, base_tip_sha, cwd=args.root) 

2322 if ( 

2323 base_distance is None 

2324 and merge_base_sha is not None 

2325 and base_tip_sha is not None 

2326 ): 

2327 base_distance = git.rev_count(merge_base_sha, base_tip_sha, cwd=args.root) 

2328 

2329 worktree_path = args.worktree_path 

2330 repo_root = args.repo_root 

2331 is_linked_worktree = _linked_flag(args.linked_worktree) 

2332 if not args.offline and head_ref is not None: 

2333 local = _local_worktree_facts(head_ref, cwd=args.root) 

2334 if local is not None: 

2335 worktree_path = worktree_path or local["worktree_path"] 

2336 repo_root = repo_root or local["repo_root"] 

2337 if is_linked_worktree is None: 

2338 is_linked_worktree = local["is_linked_worktree"] 

2339 return { 

2340 "head_sha": head_sha, 

2341 "head_ref": head_ref, 

2342 "base_tip_sha": base_tip_sha, 

2343 "merge_base_sha": merge_base_sha, 

2344 "base_distance": base_distance, 

2345 "worktree_path": worktree_path, 

2346 "repo_root": repo_root, 

2347 "is_linked_worktree": is_linked_worktree, 

2348 } 

2349 

2350 

2351def _owner_repo_from_args(args: argparse.Namespace) -> str: 

2352 config = cfg.load_config(args.path) 

2353 return _owner_repo(config) 

2354 

2355 

2356def _linked_flag(value: str | None) -> bool | None: 

2357 if value is None: 

2358 return None 

2359 return value == "true" 

2360 

2361 

2362def _local_worktree_facts(branch: str, *, cwd: str) -> dict[str, object] | None: 

2363 """Locate ``branch``'s checkout in ``git worktree list --porcelain`` (fail-soft). 

2364 

2365 Returns the worktree path, the repo root (the first/primary worktree), and 

2366 whether the branch lives in a *linked* (non-primary) worktree — or ``None`` 

2367 when the listing fails or the branch is not checked out locally (CI/PR-only). 

2368 """ 

2369 listed = git.worktree_list(cwd=cwd) 

2370 if not listed.ok: 

2371 return None 

2372 entries = _parse_worktree_porcelain(listed.output) 

2373 if not entries: 

2374 return None 

2375 repo_root = entries[0]["path"] 

2376 for index, entry in enumerate(entries): 

2377 if entry["branch"] == branch: 

2378 return { 

2379 "worktree_path": entry["path"], 

2380 "repo_root": repo_root, 

2381 "is_linked_worktree": index > 0, 

2382 } 

2383 return None 

2384 

2385 

2386def _parse_worktree_porcelain(output: str) -> list[dict[str, str | None]]: 

2387 """Parse ``git worktree list --porcelain`` into ``{path, branch}`` blocks.""" 

2388 entries: list[dict[str, str | None]] = [] 

2389 current: dict[str, str | None] | None = None 

2390 for line in output.splitlines(): 

2391 if line.startswith("worktree "): 

2392 current = {"path": line[len("worktree ") :], "branch": None} 

2393 entries.append(current) 

2394 elif line.startswith("branch ") and current is not None: 

2395 ref = line[len("branch ") :] 

2396 current["branch"] = ref[len("refs/heads/") :] if ref.startswith("refs/heads/") else ref 

2397 return entries 

2398 

2399 

2400def _scope_ledger_record( 

2401 args: argparse.Namespace, 

2402 config: cfg.ProjectConfig, 

2403) -> dict[str, object] | None: 

2404 """Load the latest ship_run ledger record for the PR under scope-verify. 

2405 

2406 Reads the run ledger (offline fixture via ``--ledger-jsonl`` or the configured 

2407 path under ``--root``) and returns the most recent matching ship_run record, 

2408 or ``None`` when no record matches — the advisory back-compat path. 

2409 """ 

2410 fixture = getattr(args, "ledger_jsonl", None) 

2411 if fixture is not None: 

2412 records = ledger.parse_records(Path(fixture).read_text(encoding="utf-8")) 

2413 else: 

2414 records = ledger.read_records(ledger.resolve_path(args.root, config)) 

2415 return ledger.latest_ship_run_for_pr(records, args.pr) 

2416 

2417 

2418def _cmd_status(args: argparse.Namespace) -> int: 

2419 try: 

2420 config = cfg.load_config(args.path) 

2421 except FileNotFoundError: 

2422 print(f"no such config: {args.path}", file=sys.stderr) 

2423 return 1 

2424 except cfg.ConfigError as exc: 

2425 print(str(exc), file=sys.stderr) 

2426 return 1 

2427 

2428 checkpoint_path = checkpoint.resolve_path(args.root, config) 

2429 ledger_path = ledger.resolve_path(args.root, config) 

2430 try: 

2431 checkpoint_record = checkpoint.read_checkpoint(checkpoint_path) 

2432 except checkpoint.CheckpointError as exc: 

2433 print(f"invalid checkpoint {checkpoint_path}: {exc}", file=sys.stderr) 

2434 return 1 

2435 try: 

2436 ledger_records = ledger.read_records(ledger_path) 

2437 except ledger.LedgerError as exc: 

2438 print(f"invalid ledger {ledger_path}: {exc}", file=sys.stderr) 

2439 return 1 

2440 snapshot = status.build_status_snapshot( 

2441 config=config, 

2442 checkpoint_record=checkpoint_record, 

2443 ledger_records=ledger_records, 

2444 live_branches=list(args.live_branch), 

2445 live_pull_requests=list(args.live_pr), 

2446 ) 

2447 payload = { 

2448 "contract": status.status_contract_as_dict(config), 

2449 "checkpoint_path": str(checkpoint_path), 

2450 "ledger_path": str(ledger_path), 

2451 "snapshot": snapshot, 

2452 } 

2453 if args.json: 

2454 print(json.dumps(payload, indent=2, sort_keys=True)) 

2455 else: 

2456 print(status.render_status(snapshot)) 

2457 return 0 

2458 

2459 

2460def _cmd_checkpoint(args: argparse.Namespace) -> int: 

2461 try: 

2462 config = cfg.load_config(args.path) 

2463 except FileNotFoundError: 

2464 print(f"no such config: {args.path}", file=sys.stderr) 

2465 return 1 

2466 except cfg.ConfigError as exc: 

2467 print(str(exc), file=sys.stderr) 

2468 return 1 

2469 

2470 contract = checkpoint.checkpoint_contract_as_dict(config) 

2471 path = checkpoint.resolve_path(args.root, config) 

2472 if args.write: 

2473 try: 

2474 record = checkpoint.build_checkpoint_record( 

2475 run_id=args.run_id, 

2476 command=args.checkpoint_command_name, 

2477 current_step=args.step, 

2478 base_branch=config.base_branch, 

2479 target=args.target, 

2480 issue_queue=args.issue_queue, 

2481 active_issue=args.active_issue, 

2482 branch=args.branch, 

2483 worktree=args.worktree, 

2484 pull_request=args.pull_request, 

2485 head_sha=args.head_sha, 

2486 completed_steps=args.completed_step, 

2487 last_gate=args.last_gate, 

2488 last_review=args.last_review, 

2489 last_check=args.last_check, 

2490 jury_mode=args.jury_mode, 

2491 merge_state=args.merge_state, 

2492 capture_state=args.capture_state, 

2493 close_state=args.close_state, 

2494 stop_reason=args.stop_reason, 

2495 ) 

2496 checkpoint.write_checkpoint(path, record) 

2497 except checkpoint.CheckpointError as exc: 

2498 print(str(exc), file=sys.stderr) 

2499 return 1 

2500 else: 

2501 try: 

2502 record = checkpoint.read_checkpoint(path) 

2503 except checkpoint.CheckpointError as exc: 

2504 print(f"invalid checkpoint {path}: {exc}", file=sys.stderr) 

2505 return 1 

2506 payload = { 

2507 "contract": contract, 

2508 "path": str(path), 

2509 "status": "present" if record is not None else "missing", 

2510 "checkpoint": record, 

2511 } 

2512 if args.json: 

2513 print(json.dumps(payload, indent=2, sort_keys=True)) 

2514 else: 

2515 print(f"keel checkpoint — {payload['status']} {path}") 

2516 print(f" schema : {contract['schema_version']}") 

2517 if record: 

2518 print(f" run : {record['run_id']}") 

2519 print(f" step : {record['position']['current_step']}") 

2520 print(f" safe boundary : {record['resume']['safe_boundary']}") 

2521 return 0 

2522 

2523 

2524def _cmd_activity(args: argparse.Namespace) -> int: 

2525 """Read / write / finish / clear an additive command-activity record.""" 

2526 try: 

2527 config = cfg.load_config(args.path) 

2528 except FileNotFoundError: 

2529 print(f"no such config: {args.path}", file=sys.stderr) 

2530 return 1 

2531 except cfg.ConfigError as exc: 

2532 print(str(exc), file=sys.stderr) 

2533 return 1 

2534 

2535 try: 

2536 if args.write: 

2537 path = activity.record_path(args.root, config, args.run_id) 

2538 record = activity.build_activity_record( 

2539 command=args.activity_command_name, 

2540 run_id=args.run_id, 

2541 phase=args.phase, 

2542 status=args.status, 

2543 issue=args.issue, 

2544 pr=args.pull_request, 

2545 note=args.note, 

2546 ) 

2547 activity.write_activity(path, record) 

2548 _emit_activity(args, [record], path=str(path)) 

2549 return 0 

2550 if args.done: 

2551 path = activity.record_path(args.root, config, args.run_id) 

2552 record = activity.read_activity(path) 

2553 if record is None: 

2554 print(f"no activity record for run {args.run_id}", file=sys.stderr) 

2555 return 1 

2556 record["status"] = "done" 

2557 activity.write_activity(path, record) 

2558 _emit_activity(args, [record], path=str(path)) 

2559 return 0 

2560 if args.clear: 

2561 path = activity.record_path(args.root, config, args.run_id) 

2562 removed = activity.remove_activity(path) 

2563 _emit_activity(args, [], path=str(path), removed=removed) 

2564 return 0 

2565 records = activity.read_all_activity(activity.resolve_dir(args.root, config)) 

2566 _emit_activity(args, records, path=str(activity.resolve_dir(args.root, config))) 

2567 return 0 

2568 except activity.ActivityError as exc: 

2569 print(str(exc), file=sys.stderr) 

2570 return 1 

2571 

2572 

2573def _emit_activity(args, records, *, path, removed=None): 

2574 """Render activity output (JSON or a short line).""" 

2575 if args.json: 

2576 print(json.dumps( 

2577 {"contract": activity.activity_contract_as_dict(), "path": path, 

2578 "removed": removed, "activity": records}, 

2579 indent=2, sort_keys=True)) 

2580 return 

2581 if removed is not None: 

2582 print(f"keel activity — {'cleared' if removed else 'nothing to clear'} {path}") 

2583 return 

2584 print(f"keel activity — {len(records)} record(s) {path}") 

2585 for record in records: 

2586 print(f" {record['command']:14} {record['run_id']:18} " 

2587 f"{record['phase']:12} {record['status']}") 

2588 

2589 

2590def _cmd_resume(args: argparse.Namespace) -> int: 

2591 try: 

2592 config = cfg.load_config(args.path) 

2593 except FileNotFoundError: 

2594 print(f"no such config: {args.path}", file=sys.stderr) 

2595 return 1 

2596 except cfg.ConfigError as exc: 

2597 print(str(exc), file=sys.stderr) 

2598 return 1 

2599 

2600 contract = checkpoint.checkpoint_contract_as_dict(config) 

2601 path = checkpoint.resolve_path(args.root, config) 

2602 try: 

2603 record = checkpoint.read_checkpoint(path) 

2604 plan = checkpoint.resume_plan_as_dict( 

2605 record, 

2606 live_pr_state=args.live_pr_state, 

2607 live_worktree_state=args.live_worktree_state, 

2608 ) 

2609 except checkpoint.CheckpointError as exc: 

2610 print(f"invalid checkpoint {path}: {exc}", file=sys.stderr) 

2611 return 1 

2612 payload = { 

2613 "contract": contract, 

2614 "path": str(path), 

2615 "resume_plan": plan, 

2616 } 

2617 if args.json: 

2618 print(json.dumps(payload, indent=2, sort_keys=True)) 

2619 else: 

2620 print(f"keel resume — {plan['status']} {path}") 

2621 print(f" can resume : {str(plan['can_resume']).lower()}") 

2622 print(f" next step : {plan['next_step'] or '-'}") 

2623 print(f" action : {plan['resume_action'] or '-'}") 

2624 print(f" reason : {plan['reason']}") 

2625 for warning in plan["warnings"]: 

2626 print(f" warning : {warning}") 

2627 return 0 if plan["status"] != "ambiguous" else 1 

2628 

2629 

2630def _cmd_standalone(args: argparse.Namespace) -> int: 

2631 if getattr(args, "dry_run", False) and getattr(args, "live", False): 

2632 print("--dry-run and --live cannot be used together", file=sys.stderr) 

2633 return 1 

2634 command = args.standalone_command 

2635 try: 

2636 config = cfg.load_config(args.path) 

2637 except FileNotFoundError: 

2638 print(f"no such config: {args.path}", file=sys.stderr) 

2639 return 1 

2640 except cfg.ConfigError as exc: 

2641 print(str(exc), file=sys.stderr) 

2642 return 1 

2643 

2644 loaded, problems = load_extensions(config, args.root, strict=False) 

2645 for prob in problems: 

2646 print(f" ! extension not loaded: {prob}", file=sys.stderr) 

2647 

2648 requirement = ( 

2649 _ci_check_capability_requirement(config) 

2650 if command == "ci-check" 

2651 else _morning_capability_requirement(config) 

2652 if command == "morning" 

2653 else _scan_capability_requirement(command, config) 

2654 if command in {"regression", "review-all-day"} 

2655 else _capability_requirement(command, config, loaded, pr=getattr(args, "pr", None)) 

2656 ) 

2657 report = runtime.detect(args.root) 

2658 evaluation = runtime.evaluate(requirement, report) 

2659 if not evaluation.ok: 

2660 print(evaluation.render(), file=sys.stderr) 

2661 return 1 

2662 transport = github_transport.resolve(report) 

2663 target = _standalone_target(args) 

2664 try: 

2665 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent( 

2666 args, config, _has_live_consent_scope(args, command, config, requirement, loaded) 

2667 ) 

2668 except ValueError as exc: 

2669 print(str(exc), file=sys.stderr) 

2670 return 1 

2671 try: 

2672 plan = orch.build_plan(config, loaded) 

2673 except gates.GateError as exc: 

2674 print(str(exc), file=sys.stderr) 

2675 return 1 

2676 contract = contracts.build_command_contract( 

2677 command=command, 

2678 config=config, 

2679 loaded=loaded, 

2680 plan=plan, 

2681 requirement=requirement, 

2682 evaluation=evaluation, 

2683 transport=transport, 

2684 extension_problems=tuple(problems), 

2685 dry_run=not getattr(args, "live", False), 

2686 approved_consent_scopes=approved_scopes, 

2687 consent_approval_source=approval_source, 

2688 consent_mode=consent_mode, 

2689 operator=approval_operator, 

2690 target=target, 

2691 reviewer_override=getattr(args, "reviewers", None), 

2692 review_comments=getattr(args, "review_comments", "inline"), 

2693 issue_title=getattr(args, "issue_title", None), 

2694 issue_body=getattr(args, "issue_body", None), 

2695 issue_labels=_issue_labels(args), 

2696 ) 

2697 consent_ok, consent_message = consent.assert_operator_consent(contract["operator_consent"]) 

2698 result = contracts.standalone_result_as_dict( 

2699 command=command, 

2700 config=config, 

2701 target=target, 

2702 delegate=getattr(args, "delegate", None), 

2703 transport=transport, 

2704 evaluation=evaluation, 

2705 ) 

2706 if not consent_ok: 

2707 if args.json: 

2708 print(json.dumps({"contract": contract, "result": result}, indent=2, 

2709 sort_keys=True)) 

2710 else: 

2711 print(consent_message, file=sys.stderr) 

2712 return 1 

2713 intake_record = contract.get("issue_intake") 

2714 if command == "implement" and getattr(args, "live", False) and _issue_context_provided(args): 

2715 if intake_record and not intake_record["can_mutate_code"]: 

2716 if args.json: 

2717 print(json.dumps({"contract": contract, "result": result}, indent=2, 

2718 sort_keys=True)) 

2719 else: 

2720 print(f"issue intake: {intake_record['status']}{intake_record['reason']}", 

2721 file=sys.stderr) 

2722 for question in intake_record["questions"]: 

2723 print(f" question: {question}", file=sys.stderr) 

2724 return 1 

2725 if args.json: 

2726 print(json.dumps({"contract": contract, "result": result}, indent=2, sort_keys=True)) 

2727 return 0 

2728 

2729 name = config.repo or config.extends 

2730 print(f"keel {command}{name} (base {config.base_branch})") 

2731 print(f" target : {target or 'not specified'}") 

2732 print(f" profile : {contract['workflow_profile']['profile']}") 

2733 print(f" github : {transport.name}") 

2734 print(f" consent : {contract['operator_consent']['status']}") 

2735 if evaluation.missing_optional: 

2736 print(f" degraded opt. : {', '.join(evaluation.missing_optional)}") 

2737 if command == "implement": 

2738 print(f" worktree : {result['worktree_path_pattern']}") 

2739 print(f" branch : {result['branch_pattern']}") 

2740 print(" merge : never in standalone implement") 

2741 if args.delegate: 

2742 print(f" delegate : {args.delegate}") 

2743 elif command == "ci-check": 

2744 workflows = ", ".join(result["ci_workflows"]) or "not configured" 

2745 print(f" workflows : {workflows}") 

2746 print(" mode : read-only; propose one fix, never apply") 

2747 elif command == "morning": 

2748 brief = result["brief"] 

2749 health = brief["health_providers"] 

2750 unavailable = [p["name"] for p in health if p["status"] in {"blocked", "unavailable"}] 

2751 report_names = ", ".join(brief["reports"]) or "not configured" 

2752 print(f" reports : {report_names}") 

2753 print(f" health : {len(health)} provider(s)") 

2754 if unavailable: 

2755 print(f" unavailable : {', '.join(unavailable)}") 

2756 print(f" deferrals : {brief['deferral_queue']['status']}") 

2757 elif command in {"wrap", "work-block", "overnight"}: 

2758 session = result["session"] 

2759 report_names = ", ".join(session["reports"]) or "not configured" 

2760 print(f" reports : {report_names}") 

2761 print(f" deferrals : {session['deferral_queue']['status']}") 

2762 if command == "wrap": 

2763 linked_required = ( 

2764 session["wrap"]["workspace_preflight"]["must_run_from_linked_worktree"] 

2765 ) 

2766 print(f" worktree : linked required={linked_required}") 

2767 print(" pr : ready PR after configured gates") 

2768 elif command == "work-block": 

2769 print(" mode source : keel work-block") 

2770 print(" queue : explicit issues or selector") 

2771 print(" handoff : ship per issue") 

2772 print(" outcomes : shipped, PR-open, deferred, blocked, skipped, needs-input") 

2773 else: 

2774 print(f" window : {session['merge_window'] or 'not configured'}") 

2775 print(f" mode source : {session['overnight']['mode_source']['command']}") 

2776 print(" merge policy : ship window + no-night-merge") 

2777 elif command in {"regression", "review-all-day"}: 

2778 scan = result["scan"] 

2779 print(f" areas : {len(scan['areas'])} configured") 

2780 print(f" dedupe : similarity>={scan['dedupe']['near_text_similarity']}") 

2781 print(" writes : issues only after consent; no code/PR mutation") 

2782 if command == "review-all-day": 

2783 print(f" title prefix : {scan['review_all_day']['issue_creation']['title_prefix']}") 

2784 else: 

2785 print(f" handoff : {scan['regression']['issue_creation']['route_to']}") 

2786 mode = "live preflight contract" if getattr(args, "live", False) else "dry-run contract" 

2787 print(f" note : {mode}; adapters perform any approved live work.") 

2788 return 0 

2789 

2790 

2791def _standalone_target(args: argparse.Namespace) -> str | None: 

2792 if getattr(args, "issue", None) is not None: 

2793 issue = f"issue #{args.issue}" 

2794 extra = getattr(args, "target", None) 

2795 return f"{issue} ({extra})" if extra else issue 

2796 if getattr(args, "pr", None) is not None: 

2797 return f"PR #{args.pr}" 

2798 if getattr(args, "since", None) is not None: 

2799 extra = getattr(args, "target", None) 

2800 target = f"since {args.since}" 

2801 return f"{target} ({extra})" if extra else target 

2802 if getattr(args, "scope", None) is not None: 

2803 scope = f"scope {args.scope}" 

2804 extra = getattr(args, "target", None) 

2805 if getattr(args, "days", None) is not None: 

2806 scope = f"{args.days} day scan ({scope})" 

2807 return f"{scope} ({extra})" if extra else scope 

2808 if getattr(args, "days", None) is not None: 

2809 return f"{args.days} day scan" 

2810 if getattr(args, "issues", None): 

2811 target = "issues " + ", ".join(f"#{issue}" for issue in args.issues) 

2812 max_items = getattr(args, "max_items", None) 

2813 extra = getattr(args, "target", None) 

2814 if max_items is not None: 

2815 target = f"{target} (max {max_items})" 

2816 return f"{target} ({extra})" if extra else target 

2817 if getattr(args, "queue", None) is not None: 

2818 target = f"queue {args.queue}" 

2819 max_items = getattr(args, "max_items", None) 

2820 extra = getattr(args, "target", None) 

2821 if max_items is not None: 

2822 target = f"{target} (max {max_items})" 

2823 return f"{target} ({extra})" if extra else target 

2824 if getattr(args, "title", None) is not None: 

2825 return args.title 

2826 if getattr(args, "hours", None) is not None: 

2827 target = f"{args.hours:g}h session" 

2828 max_items = getattr(args, "max_items", None) 

2829 return f"{target} (max {max_items})" if max_items is not None else target 

2830 return getattr(args, "target", None) 

2831 

2832 

2833def _issue_labels(args: argparse.Namespace) -> tuple[str, ...]: 

2834 labels: list[str] = [] 

2835 for raw in getattr(args, "issue_label", ()) or (): 

2836 labels.extend(part.strip() for part in raw.split(",") if part.strip()) 

2837 return tuple(dict.fromkeys(labels)) 

2838 

2839 

2840def _lock_root(root: str | Path) -> Path: 

2841 return Path(root) / ".keel" / "state" / "locks" 

2842 

2843 

2844def _finish_merge(args: argparse.Namespace, payload: dict[str, object], reason: str, *, 

2845 code: int) -> int: 

2846 payload["reason"] = reason 

2847 payload["status"] = "pass" if code == 0 else "fail" 

2848 if args.json: 

2849 print(json.dumps(payload, indent=2, sort_keys=True)) 

2850 else: 

2851 print(f"keel merge — {payload['status']} PR #{args.pr}") 

2852 print(f" reason : {reason}") 

2853 lock_payload = payload.get("lock") 

2854 if isinstance(lock_payload, dict): 

2855 print(f" lock : {lock_payload.get('status')}") 

2856 ci_payload = payload.get("ci") 

2857 if isinstance(ci_payload, dict): 

2858 print(f" ci : {ci_payload.get('state')}") 

2859 evidence_payload = payload.get("evidence") 

2860 if isinstance(evidence_payload, dict): 

2861 verification = evidence_payload.get("verification") 

2862 if isinstance(verification, dict): 

2863 print(f" evidence: {verification.get('status')}") 

2864 gates_sha = payload.get("gates_sha") 

2865 if isinstance(gates_sha, dict): 

2866 if gates_sha.get("bypassed"): 

2867 print(" gates-sha: bypassed (hotfix)") 

2868 else: 

2869 print(f" gates-sha: {'matched' if gates_sha.get('matched') else 'no-match'}") 

2870 justification = payload.get("hotfix_justification") 

2871 if isinstance(justification, dict): 

2872 detail = justification.get("rule_id") or justification.get("operator") or "" 

2873 print(f" hotfix : {justification.get('kind')} {detail}".rstrip()) 

2874 checkpoint_gate = payload.get("checkpoint_gate") 

2875 if isinstance(checkpoint_gate, dict): 

2876 detail = checkpoint_gate.get("operator") or checkpoint_gate.get("checkpoint_step") or "" 

2877 print(f" checkpoint: {checkpoint_gate.get('status')} {detail}".rstrip()) 

2878 return code 

2879 

2880 

2881def _merge_snapshot(pr: int, *, cwd: str) -> dict[str, object]: 

2882 result = github.pr_merge_snapshot(pr, cwd=cwd) 

2883 if not result.ok: 

2884 raise ValueError(f"unable to read PR merge snapshot: {result.output.strip()}") 

2885 try: 

2886 payload = json.loads(result.output or "{}") 

2887 except json.JSONDecodeError as exc: 

2888 raise ValueError("PR merge snapshot was not JSON") from exc 

2889 rollup = payload.get("statusCheckRollup") 

2890 rollup = rollup if isinstance(rollup, list) else [] 

2891 return { 

2892 "head_sha": payload.get("headRefOid"), 

2893 "merge_state": payload.get("mergeStateStatus") or "UNKNOWN", 

2894 "ci": _ci_rollup_state(rollup), 

2895 } 

2896 

2897 

2898def _ci_rollup_state(rollup: list[object]) -> dict[str, object]: 

2899 failures = { 

2900 "ACTION_REQUIRED", "CANCELLED", "ERROR", "FAILURE", 

2901 "STARTUP_FAILURE", "STALE", "TIMED_OUT", 

2902 } 

2903 pending_states = {"EXPECTED", "PENDING", "QUEUED", "REQUESTED", "WAITING", "IN_PROGRESS"} 

2904 saw_pending = False 

2905 saw_check = False 

2906 for item in rollup: 

2907 if not isinstance(item, dict): 

2908 continue 

2909 saw_check = True 

2910 conclusion = item.get("conclusion") 

2911 conclusion = conclusion.upper() if isinstance(conclusion, str) else "" 

2912 status_value = item.get("status") 

2913 status_value = status_value.upper() if isinstance(status_value, str) else "" 

2914 if conclusion in failures: 

2915 return {"state": "fail", "reason": conclusion} 

2916 if not conclusion and status_value in pending_states: 

2917 saw_pending = True 

2918 if saw_pending: 

2919 return {"state": "pending", "reason": "check-pending"} 

2920 return {"state": "pass", "reason": "all-checks-passing" if saw_check else "no-checks"} 

2921 

2922 

2923def _verify_merge_evidence( 

2924 args: argparse.Namespace, 

2925 config: cfg.ProjectConfig, 

2926) -> dict[str, object]: 

2927 evidence_args = argparse.Namespace( 

2928 pr=args.pr, 

2929 issue=args.issue, 

2930 pr_body_file=None, 

2931 pr_comments_json=None, 

2932 issue_comments_json=None, 

2933 pr_reviews_json=None, 

2934 changed_file=(), 

2935 head_sha=None, 

2936 pr_label=(), 

2937 dry_run=False, 

2938 root=args.root, 

2939 ) 

2940 artifacts = _load_evidence_artifacts(evidence_args, config) 

2941 changed_files = artifacts["changed_files"] 

2942 tier = ( 

2943 classify.tier_for_files( 

2944 changed_files, 

2945 tier3_globs=config.knobs.tier3_globs, 

2946 docs_globs=config.knobs.docs_gate_paths, 

2947 ) 

2948 if changed_files else None 

2949 ) 

2950 review_contract = ship.resolve_review_contract( 

2951 tier=tier, 

2952 reviewer_override=args.reviewers, 

2953 review_comments=args.review_comments, 

2954 gates=config.gates, 

2955 policy_pack=config.policy_pack, 

2956 jury=args.jury, 

2957 no_jury=args.no_jury, 

2958 jury_advisory=args.jury_advisory, 

2959 require_distinct_vendors=config.knobs.evidence_require_distinct_vendors, 

2960 ) 

2961 gate_label = args.gate_label or config.knobs.evidence_gate_label 

2962 waiver_label = getattr(args, "waiver_label", None) or evidence.DEFAULT_WAIVER_LABEL 

2963 gate = evidence.gate_decision( 

2964 artifacts["pr_labels"], 

2965 gate_label, 

2966 waiver_label=waiver_label, 

2967 head_ref=artifacts.get("head_ref"), 

2968 pr_comments=artifacts["pr_comments"], 

2969 pr_reviews=artifacts["pr_reviews"], 

2970 ) 

2971 enforced = gate["enforced"] 

2972 report = evidence.verify( 

2973 review_contract, 

2974 pr_comments=artifacts["pr_comments"], 

2975 issue_comments=artifacts["issue_comments"], 

2976 pr_reviews=artifacts["pr_reviews"], 

2977 pr_body=artifacts["pr_body"], 

2978 pr_labels=artifacts["pr_labels"], 

2979 head_sha=artifacts["head_sha"], 

2980 enforced=enforced, 

2981 ) 

2982 return { 

2983 "gate_label": gate_label, 

2984 "waiver_label": waiver_label, 

2985 "gate": gate, 

2986 "enforced": enforced, 

2987 "verification": report, 

2988 "head_sha": artifacts["head_sha"], 

2989 "head_ref": artifacts.get("head_ref"), 

2990 "changed_files": changed_files, 

2991 } 

2992 

2993 

2994def _validated_worktree_path(root: str | Path, worktree: str) -> Path: 

2995 root_path = Path(root).resolve() 

2996 raw = Path(worktree) 

2997 candidate = (root_path / raw if not raw.is_absolute() else raw).resolve() 

2998 if candidate == root_path or root_path not in candidate.parents: 

2999 raise ValueError("worktree path must be nested under the repository root") 

3000 listed = git.worktree_list(cwd=str(root_path)) 

3001 if not listed.ok: 

3002 raise ValueError(f"unable to list registered worktrees: {listed.output.strip()}") 

3003 registered = { 

3004 Path(line.split(" ", 1)[1]).resolve() 

3005 for line in listed.output.splitlines() 

3006 if line.startswith("worktree ") 

3007 } 

3008 if candidate not in registered: 

3009 raise ValueError("worktree path is not a registered git worktree") 

3010 return candidate 

3011 

3012 

3013def _issue_context_provided(args: argparse.Namespace) -> bool: 

3014 return bool( 

3015 (getattr(args, "issue_title", None) or "").strip() 

3016 or (getattr(args, "issue_body", None) or "").strip() 

3017 or _issue_labels(args) 

3018 ) 

3019 

3020 

3021def _load_evidence_artifacts( 

3022 args: argparse.Namespace, 

3023 config: cfg.ProjectConfig, 

3024) -> dict[str, object]: 

3025 pr_body = _read_optional_text(args.pr_body_file) 

3026 pr_comments = _read_optional_json_list(args.pr_comments_json) 

3027 issue_comments = _read_optional_json_list(args.issue_comments_json) 

3028 pr_reviews = _read_optional_json_list(args.pr_reviews_json) 

3029 changed_files = list(getattr(args, "changed_file", ()) or ()) 

3030 head_sha = args.head_sha 

3031 head_ref = getattr(args, "head_ref", None) 

3032 issue_number = args.issue 

3033 injected_labels = list(args.pr_label or ()) 

3034 pr_labels: list[str] = [] 

3035 using_fixtures = any( 

3036 path is not None for path in ( 

3037 args.pr_body_file, 

3038 args.pr_comments_json, 

3039 args.issue_comments_json, 

3040 args.pr_reviews_json, 

3041 ) 

3042 ) 

3043 if args.dry_run: 

3044 return { 

3045 "pr_body": pr_body, 

3046 "pr_comments": [], 

3047 "issue_comments": [], 

3048 "pr_reviews": [], 

3049 "issue": issue_number, 

3050 "head_sha": head_sha, 

3051 "head_ref": head_ref, 

3052 "changed_files": changed_files, 

3053 "pr_labels": _dedupe_preserve(injected_labels), 

3054 } 

3055 if not using_fixtures: 

3056 owner_repo = _owner_repo(config) 

3057 pr = _gh_json(["repos", owner_repo, "pulls", str(args.pr)], cwd=args.root) 

3058 pr_body = pr.get("body") if isinstance(pr.get("body"), str) else "" 

3059 head = pr.get("head") if isinstance(pr.get("head"), dict) else {} 

3060 head_sha = head.get("sha") if isinstance(head.get("sha"), str) else None 

3061 head_ref = head.get("ref") if isinstance(head.get("ref"), str) else None 

3062 pr_labels = _label_names(pr.get("labels")) 

3063 changed_files = _pr_changed_files(owner_repo, args.pr, cwd=args.root) 

3064 pr_comments = _gh_json_list( 

3065 ["repos", owner_repo, "issues", str(args.pr), "comments"], cwd=args.root 

3066 ) 

3067 pr_reviews = _gh_json_list( 

3068 ["repos", owner_repo, "pulls", str(args.pr), "reviews"], cwd=args.root 

3069 ) 

3070 if issue_number is None: 

3071 issue_number = _linked_issue_from_body(pr_body) 

3072 if issue_number is not None: 

3073 issue_comments = _gh_json_list( 

3074 ["repos", owner_repo, "issues", str(issue_number), "comments"], cwd=args.root 

3075 ) 

3076 elif issue_number is None: 

3077 issue_number = _linked_issue_from_body(pr_body) 

3078 return { 

3079 "pr_body": pr_body, 

3080 "pr_comments": pr_comments, 

3081 "issue_comments": issue_comments, 

3082 "pr_reviews": pr_reviews, 

3083 "issue": issue_number, 

3084 "head_sha": head_sha, 

3085 "head_ref": head_ref, 

3086 "changed_files": changed_files, 

3087 "pr_labels": _dedupe_preserve([*pr_labels, *injected_labels]), 

3088 } 

3089 

3090 

3091def _evidence_ledger_record( 

3092 args: argparse.Namespace, 

3093 config: cfg.ProjectConfig, 

3094) -> dict[str, object] | None: 

3095 """Load the ship_run ledger record for the PR under verification. 

3096 

3097 Reads the run ledger (offline fixture via ``--ledger-jsonl`` or the configured 

3098 path under ``--root``) and returns the latest matching ship_run record, or 

3099 ``None`` when no record matches — preserving marker-only closure behavior. 

3100 """ 

3101 fixture = getattr(args, "ledger_jsonl", None) 

3102 if fixture is not None: 

3103 records = ledger.parse_records(Path(fixture).read_text(encoding="utf-8")) 

3104 else: 

3105 records = ledger.read_records(ledger.resolve_path(args.root, config)) 

3106 return ledger.latest_ship_run_for_pr(records, args.pr) 

3107 

3108 

3109def _label_names(labels: object) -> list[str]: 

3110 if not isinstance(labels, list): 

3111 return [] 

3112 return [ 

3113 label["name"] 

3114 for label in labels 

3115 if isinstance(label, dict) and isinstance(label.get("name"), str) 

3116 ] 

3117 

3118 

3119def _dedupe_preserve(values: list[str]) -> list[str]: 

3120 return list(dict.fromkeys(values)) 

3121 

3122 

3123def _owner_repo(config: cfg.ProjectConfig) -> str: 

3124 if not config.owner or not config.repo: 

3125 raise ValueError("project config must define owner and repo for live evidence fetch") 

3126 return f"{config.owner}/{config.repo}" 

3127 

3128 

3129def _run_context_warnings(args: argparse.Namespace) -> list[str]: 

3130 if not getattr(args, "live", False) or not getattr(args, "append_ledger", False): 

3131 return [] 

3132 warnings = [] 

3133 if not _nonblank(getattr(args, "host_agent", None)): 

3134 warnings.append("missing host_agent in live run context") 

3135 return warnings 

3136 

3137 

3138def _nonblank(value: object) -> bool: 

3139 return isinstance(value, str) and bool(value.strip()) 

3140 

3141 

3142def _comment_artifact_marker(artifact: str) -> str: 

3143 markers = { 

3144 "closure-comment": closure.COMMENT_MARKER, 

3145 "issue-update": artifacts.ISSUE_UPDATE_MARKER, 

3146 "review-verdict": evidence.REVIEW_VERDICT_MARKER, 

3147 "jury-verdict": evidence.JURY_VERDICT_MARKER, 

3148 "extension-result": artifacts.EXTENSION_RESULT_MARKER, 

3149 "step-handoff": artifacts.STEP_HANDOFF_MARKER, 

3150 "run-control-halt": artifacts.RUN_CONTROL_HALT_MARKER, 

3151 } 

3152 return markers[artifact] 

3153 

3154 

3155def _parse_comment_target(raw: str) -> tuple[str, int]: 

3156 match = re.fullmatch(r"(?P<kind>issue|pr):(?P<number>[1-9]\d*)", raw.strip()) 

3157 if not match: 

3158 raise ValueError("--target must use issue:<number> or pr:<number>") 

3159 return match.group("kind"), int(match.group("number")) 

3160 

3161 

3162def _looks_like_body_file_literal(body: str) -> bool: 

3163 stripped = body.strip() 

3164 return bool(re.fullmatch(r"@(?:/|~|\.\.?/).+", stripped)) 

3165 

3166 

3167def _find_comment_match( 

3168 comments: list[dict[str, object]], 

3169 *, 

3170 marker: str, 

3171 run_id: str | None, 

3172) -> dict[str, object] | None: 

3173 if run_id is None: 

3174 return None 

3175 matches: list[dict[str, object]] = [] 

3176 for comment in comments: 

3177 body = comment.get("body") 

3178 if not isinstance(body, str) or marker not in body: 

3179 continue 

3180 if not _comment_has_run_id(body, run_id): 

3181 continue 

3182 matches.append(comment) 

3183 return matches[-1] if matches else None 

3184 

3185 

3186def _comment_has_run_id(body: str, run_id: str) -> bool: 

3187 patterns = ( 

3188 rf"^\s*(?:run[-_ ]?id)\s*:\s*{re.escape(run_id)}\s*$", 

3189 rf"<!--\s*keel\.run-id:\s*{re.escape(run_id)}\s*-->", 

3190 ) 

3191 return any(re.search(pattern, body, re.IGNORECASE | re.MULTILINE) for pattern in patterns) 

3192 

3193 

3194def _finish_post_comment(args: argparse.Namespace, payload: dict[str, object], *, code: int) -> int: 

3195 if args.json: 

3196 print(json.dumps(payload, indent=2, sort_keys=True)) 

3197 else: 

3198 target = payload["target"] 

3199 if isinstance(target, dict): 

3200 rendered_target = f"{target.get('kind')}:{target.get('number')}" 

3201 else: 

3202 rendered_target = str(target) 

3203 print(f"keel post-comment — {payload.get('action')} {rendered_target}") 

3204 print(f" artifact : {payload.get('artifact')}") 

3205 print(f" transport : {payload.get('transport')}") 

3206 if payload.get("comment_id") is not None: 

3207 print(f" comment : {payload.get('comment_id')}") 

3208 return code 

3209 

3210 

3211def _read_optional_text(path: str | None) -> str: 

3212 return Path(path).read_text(encoding="utf-8") if path else "" 

3213 

3214 

3215def _read_json_object(path: str) -> dict[str, object]: 

3216 value = json.loads(Path(path).read_text(encoding="utf-8")) 

3217 if not isinstance(value, dict): 

3218 raise ValueError(f"{path} must contain a JSON object") 

3219 return value 

3220 

3221 

3222def _dedupe_ints(values: list[int]) -> list[int]: 

3223 seen: set[int] = set() 

3224 out: list[int] = [] 

3225 for value in values: 

3226 if value not in seen: 

3227 seen.add(value) 

3228 out.append(value) 

3229 return out 

3230 

3231 

3232def _read_json_list(path: str, *, missing_ok: bool = False) -> list[dict[str, object]]: 

3233 p = Path(path) 

3234 if missing_ok and not p.exists(): 

3235 return [] 

3236 value = json.loads(p.read_text(encoding="utf-8")) 

3237 if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): 

3238 raise ValueError(f"{path} must contain a JSON array of objects") 

3239 return value 

3240 

3241 

3242def _write_json_list(path: str, value: list[dict[str, object]]) -> None: 

3243 p = Path(path) 

3244 p.parent.mkdir(parents=True, exist_ok=True) 

3245 p.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") 

3246 

3247 

3248def _read_optional_json_list(path: str | None) -> list[dict[str, object]]: 

3249 if path is None: 

3250 return [] 

3251 return _read_json_list(path) 

3252 

3253 

3254def _event_from_args(args: argparse.Namespace) -> dict[str, object] | None: 

3255 if args.event_json: 

3256 event = _read_json_object(args.event_json) 

3257 else: 

3258 fields = { 

3259 "step_id": args.step, 

3260 "slot": args.slot, 

3261 "action": args.action, 

3262 "output_fingerprint": args.output_fingerprint, 

3263 "diff_fingerprint": args.diff_fingerprint, 

3264 "work_units": args.work_units, 

3265 } 

3266 event = {key: value for key, value in fields.items() if value not in (None, "")} 

3267 if args.soft_failure: 

3268 event["soft_failure"] = True 

3269 return event or None 

3270 

3271 

3272def _step_caps_from_args(values: list[str]) -> dict[str, int]: 

3273 caps: dict[str, int] = {} 

3274 for raw in values: 

3275 if "=" not in raw: 

3276 raise ValueError("--step-cap must use SLOT=N") 

3277 slot, value = raw.split("=", 1) 

3278 slot = slot.strip() 

3279 try: 

3280 parsed = int(value) 

3281 except ValueError as exc: 

3282 raise ValueError("--step-cap value must be a positive integer") from exc 

3283 if not slot or parsed <= 0: 

3284 raise ValueError("--step-cap must use SLOT=N with N > 0") 

3285 caps[slot] = parsed 

3286 return caps 

3287 

3288 

3289def _verdict_count_arg(value: str) -> tuple[int, int]: 

3290 if "=" not in value: 

3291 raise argparse.ArgumentTypeError("--verdict-count must use PR=N") 

3292 pr_raw, count_raw = value.split("=", 1) 

3293 try: 

3294 pr = int(pr_raw) 

3295 count = int(count_raw) 

3296 except ValueError as exc: 

3297 raise argparse.ArgumentTypeError("--verdict-count must use PR=N with integers") from exc 

3298 if pr <= 0 or count < 0: 

3299 raise argparse.ArgumentTypeError("--verdict-count requires PR>0 and N>=0") 

3300 return pr, count 

3301 

3302 

3303def _gh_json(args: list[str], *, cwd: str) -> dict[str, object]: 

3304 endpoint = "/".join(args) 

3305 result = run_argv(["gh", "api", endpoint], cwd=cwd) 

3306 if not result.ok: 

3307 raise ValueError(f"gh api {endpoint} failed: {result.output.strip()}") 

3308 value = json.loads(result.output or "{}") 

3309 if not isinstance(value, dict): 

3310 raise ValueError(f"gh api {endpoint} did not return a JSON object") 

3311 return value 

3312 

3313 

3314def _gh_json_list(args: list[str], *, cwd: str) -> list[dict[str, object]]: 

3315 endpoint = "/".join(args) 

3316 result = run_argv(["gh", "api", "--paginate", "--slurp", endpoint], cwd=cwd) 

3317 if not result.ok: 

3318 raise ValueError(f"gh api {endpoint} failed: {result.output.strip()}") 

3319 value = json.loads(result.output or "[]") 

3320 if value and all(isinstance(item, list) for item in value): 

3321 value = [entry for page in value for entry in page] 

3322 if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): 

3323 raise ValueError(f"gh api {endpoint} did not return a JSON array") 

3324 return value 

3325 

3326 

3327def _pr_changed_files(owner_repo: str, pr: int, *, cwd: str) -> list[str]: 

3328 files = _gh_json_list(["repos", owner_repo, "pulls", str(pr), "files"], cwd=cwd) 

3329 return [item["filename"] for item in files if isinstance(item.get("filename"), str)] 

3330 

3331 

3332def _linked_issue_from_body(body: str) -> int | None: 

3333 match = re.search(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(?P<n>[1-9]\d*)", 

3334 body, re.IGNORECASE) 

3335 return int(match.group("n")) if match else None 

3336 

3337 

3338def _approved_consent( 

3339 args: argparse.Namespace, 

3340 config: cfg.ProjectConfig, 

3341 has_standing_scope: bool, 

3342) -> tuple[tuple[str, ...], str, str | None, str]: 

3343 mode = _consent_mode(args, config) 

3344 explicit = tuple(getattr(args, "approve_scope", ()) or ()) 

3345 if explicit: 

3346 return consent.normalize_scopes(explicit), "flag", getattr(args, "operator", None), mode 

3347 if mode == "agent" or not getattr(args, "live", False): 

3348 return (), "none", getattr(args, "operator", None), mode 

3349 if mode == "explicit": 

3350 return (), "none", getattr(args, "operator", None), mode 

3351 if not has_standing_scope: 

3352 return (), "none", getattr(args, "operator", None), mode 

3353 env_value = os.environ.get("KEEL_APPROVE_SCOPE") 

3354 if env_value: 

3355 operator = os.environ.get("KEEL_OPERATOR") 

3356 if not operator: 

3357 raise ValueError("KEEL_OPERATOR is required when KEEL_APPROVE_SCOPE is used") 

3358 return consent.normalize_scopes((env_value,)), "env", operator, mode 

3359 if config.automation.approved_scopes: 

3360 if not config.automation.operator: 

3361 raise ValueError( 

3362 "automation.operator is required when automation.approved_scopes is used" 

3363 ) 

3364 return ( 

3365 consent.normalize_scopes(config.automation.approved_scopes), 

3366 "config", 

3367 config.automation.operator, 

3368 mode, 

3369 ) 

3370 return (), "none", getattr(args, "operator", None), mode 

3371 

3372 

3373def _consent_mode(args: argparse.Namespace, config: cfg.ProjectConfig) -> str: 

3374 mode = getattr(args, "consent_mode", None) or os.environ.get("KEEL_CONSENT_MODE") 

3375 mode = mode or config.consent_mode 

3376 if mode not in consent.CONSENT_MODES: 

3377 raise ValueError( 

3378 f"unknown consent mode {mode!r}; valid: {', '.join(consent.CONSENT_MODES)}" 

3379 ) 

3380 return mode 

3381 

3382 

3383def _has_live_consent_scope( 

3384 args: argparse.Namespace, 

3385 command: str, 

3386 config: cfg.ProjectConfig, 

3387 requirement: runtime.CapabilityRequirement, 

3388 loaded: dict, 

3389) -> bool: 

3390 if not getattr(args, "live", False): 

3391 return False 

3392 side_effects = contracts.command_side_effects(command, config, requirement, loaded) 

3393 return bool(consent.side_effect_scopes(side_effects)) 

3394 

3395 

3396def _ci_check_capability_requirement(config: cfg.ProjectConfig) -> runtime.CapabilityRequirement: 

3397 optional = ["gh", "gh-auth"] 

3398 if config.knobs.ci_workflows: 

3399 optional.append("raw-actions-logs") 

3400 return runtime.CapabilityRequirement(optional=tuple(optional)) 

3401 

3402 

3403def _morning_capability_requirement(config: cfg.ProjectConfig) -> runtime.CapabilityRequirement: 

3404 required: list[str] = [] 

3405 optional: list[str] = ["gh", "gh-auth"] 

3406 pack = config.policy_pack or {} 

3407 health = pack.get("health_providers") if isinstance(pack.get("health_providers"), dict) else {} 

3408 for provider in health.values(): 

3409 if not isinstance(provider, dict): 

3410 continue 

3411 required.extend(provider.get("required_capabilities") or ()) 

3412 optional.extend(provider.get("optional_capabilities") or ()) 

3413 return runtime.CapabilityRequirement( 

3414 required=tuple(dict.fromkeys(required)), 

3415 optional=tuple(dict.fromkeys(optional)), 

3416 ) 

3417 

3418 

3419def _scan_capability_requirement( 

3420 command: str, 

3421 config: cfg.ProjectConfig, 

3422) -> runtime.CapabilityRequirement: 

3423 del config 

3424 if command == "regression": 

3425 return runtime.CapabilityRequirement( 

3426 required=("git", "worktree"), 

3427 optional=("gh", "gh-auth", "github-mcp", "parallel-subagents"), 

3428 ) 

3429 return runtime.CapabilityRequirement( 

3430 required=("git",), 

3431 optional=("gh", "gh-auth", "github-mcp", "parallel-subagents"), 

3432 ) 

3433 

3434 

3435def _cmd_capabilities(args: argparse.Namespace) -> int: 

3436 report = runtime.detect(args.root) 

3437 transport = github_transport.resolve(report) 

3438 requirement = runtime.CapabilityRequirement() 

3439 problems: list[str] = [] 

3440 if args.path: 

3441 try: 

3442 config = cfg.load_config(args.path) 

3443 except FileNotFoundError: 

3444 print(f"no such config: {args.path}", file=sys.stderr) 

3445 return 1 

3446 except cfg.ConfigError as exc: 

3447 print(str(exc), file=sys.stderr) 

3448 return 1 

3449 loaded, problems = load_extensions(config, args.root, strict=False) 

3450 requirement = _capability_requirement(args.for_command, config, loaded, pr=args.pr) 

3451 evaluation = runtime.evaluate(requirement, report) 

3452 if args.json: 

3453 print(json.dumps({ 

3454 "report": report.as_dict(), 

3455 "github_transport": transport.as_dict(), 

3456 "evaluation": evaluation.as_dict(), 

3457 "extension_problems": problems, 

3458 }, indent=2, sort_keys=True)) 

3459 else: 

3460 print(report.render()) 

3461 print(transport.render()) 

3462 if args.path: 

3463 print(evaluation.render()) 

3464 for prob in problems: 

3465 print(f" ! extension not loaded: {prob}", file=sys.stderr) 

3466 return 0 if evaluation.ok else 1 

3467 

3468 

3469def _cmd_project_commands(args: argparse.Namespace) -> int: 

3470 try: 

3471 config = cfg.load_config(args.path) 

3472 except FileNotFoundError: 

3473 print(f"no such config: {args.path}", file=sys.stderr) 

3474 return 1 

3475 except cfg.ConfigError as exc: 

3476 print(str(exc), file=sys.stderr) 

3477 return 1 

3478 

3479 commands = project_commands.list_project_commands(config) 

3480 if args.json: 

3481 print(json.dumps({"project_commands": [command.as_dict() for command in commands]}, 

3482 indent=2, sort_keys=True)) 

3483 else: 

3484 if not commands: 

3485 print("project commands: none") 

3486 return 0 

3487 print("project commands:") 

3488 for command in commands: 

3489 caps = [] 

3490 if command.required_capabilities: 

3491 caps.append("required=" + ",".join(command.required_capabilities)) 

3492 if command.optional_capabilities: 

3493 caps.append("optional=" + ",".join(command.optional_capabilities)) 

3494 cap_text = f" ({'; '.join(caps)})" if caps else "" 

3495 runner = f" -> {command.command}" if command.command else "" 

3496 print(f" {command.name}{runner}{cap_text}") 

3497 return 0 

3498 

3499 

3500def _ask(prompt: str, default: str) -> str: # pragma: no cover - interactive I/O 

3501 raw = input(f"{prompt} [{default}]: " if default else f"{prompt}: ").strip() 

3502 return raw or default 

3503 

3504 

3505def _cmd_init(args: argparse.Namespace) -> int: 

3506 root = Path(args.root) 

3507 target = root / ".keel" / "project.yaml" 

3508 if target.exists() and not args.force: 

3509 print( 

3510 f"{target} already exists; refusing to overwrite project config " 

3511 "(use --force only if you intentionally want to replace it). " 

3512 "Project extensions are not touched.", 

3513 file=sys.stderr, 

3514 ) 

3515 return 1 

3516 stack = scaffold.detect_stack(root) 

3517 repo = root.resolve().name 

3518 try: 

3519 if args.wizard: 

3520 print(f"keel init wizard — detected stack: {stack} (Enter accepts each default)") 

3521 text = scaffold.wizard(stack, _ask, repo=repo) 

3522 else: 

3523 text = scaffold.default_config(stack, repo=repo) 

3524 except ValueError as exc: 

3525 print(str(exc), file=sys.stderr) 

3526 return 1 

3527 target.parent.mkdir(parents=True, exist_ok=True) 

3528 target.write_text(text, encoding="utf-8") 

3529 if args.force: 

3530 print( 

3531 "warning: --force replaced .keel/project.yaml; .keel/extensions/ was not touched", 

3532 file=sys.stderr, 

3533 ) 

3534 print(f"wrote {target} (detected stack: {stack})") 

3535 return 0 

3536 

3537 

3538def _render_scaffolded_config(root: Path, *, wizard: bool) -> tuple[str, str]: 

3539 stack = scaffold.detect_stack(root) 

3540 repo = root.resolve().name 

3541 if wizard: 

3542 print(f"keel setup wizard — detected stack: {stack} (Enter accepts each default)") 

3543 return scaffold.wizard(stack, _ask, repo=repo), stack 

3544 return scaffold.default_config(stack, repo=repo), stack 

3545 

3546 

3547def _report_install(surface: str, installed: list[str], skipped: list[str]) -> None: 

3548 for name in installed: 

3549 print(f" installed [{surface}] {name}") 

3550 for name in skipped: 

3551 print(f" skipped [{surface}] {name} (exists; --force to overwrite)") 

3552 

3553 

3554def _report_adapter_rows(rows: dict[str, list[install.AdapterFileStatus]]) -> None: 

3555 for surface, statuses in rows.items(): 

3556 for row in statuses: 

3557 detail = f"{row.detail}" if row.detail else "" 

3558 print(f" {row.status:<16} [{surface}] {row.name} {row.path}{detail}") 

3559 

3560 

3561def _project_only_commands(root: str | Path) -> set[str]: 

3562 """Command names the project declares as project-only (never flagged as orphan). 

3563 

3564 Reads ``.keel/project.yaml`` from ``root`` if present; absent or invalid config yields an 

3565 empty set so the scan stays fail-soft and consumer-neutral. 

3566 """ 

3567 path = Path(root) / ".keel" / "project.yaml" 

3568 if not path.exists(): 

3569 return set() 

3570 try: 

3571 config = cfg.load_config(path) 

3572 except cfg.ConfigError: 

3573 return set() 

3574 return {cmd.name for cmd in project_commands.list_project_commands(config)} 

3575 

3576 

3577def _scan_orphans(root: str | Path, *, include_unmanaged: bool) -> list[install.OrphanFileStatus]: 

3578 """Run the pure orphan/unmanaged scan with the default known-command set.""" 

3579 return install.scan_surface_orphans( 

3580 root, 

3581 known_commands=install.default_known_commands(), 

3582 project_only=_project_only_commands(root), 

3583 include_unmanaged=include_unmanaged, 

3584 ) 

3585 

3586 

3587def _report_orphan_rows(orphans: list[install.OrphanFileStatus]) -> None: 

3588 for row in orphans: 

3589 print(f" {row.category:<16} [{row.surface}] {row.name} {row.path}{row.reason}") 

3590 

3591 

3592#: PyPI JSON metadata endpoint for the published distribution. 

3593_PYPI_LATEST_URL = "https://pypi.org/pypi/keel-workflow/json" 

3594 

3595 

3596def _fetch_latest_pypi_version( 

3597 *, url: str = _PYPI_LATEST_URL, timeout: float = 3.0, _open=None 

3598) -> str | None: 

3599 """Fetch the latest ``keel-workflow`` version from PyPI. Thin, fail-soft I/O. 

3600 

3601 Returns the version string, or ``None`` on any failure (offline, timeout, 

3602 HTTP error, malformed JSON) so ``keel doctor`` degrades to ``latest: unknown`` 

3603 rather than crashing or blocking. The network seam (``_open``) is injectable so 

3604 the parsing is unit-tested offline; the live ``urlopen`` boundary is excluded. 

3605 """ 

3606 if not url.startswith(("http://", "https://")): 

3607 return None # restrict to http(s): no file://, ftp://, or custom schemes 

3608 

3609 if _open is None: # pragma: no cover - live network boundary 

3610 from urllib.request import urlopen 

3611 _open = lambda u, t: urlopen(u, timeout=t) # noqa: E731 

3612 try: 

3613 with _open(url, timeout) as response: 

3614 payload = json.loads(response.read().decode("utf-8")) 

3615 version = payload["info"]["version"] 

3616 return version if isinstance(version, str) else None 

3617 except Exception: 

3618 return None 

3619 

3620 

3621def _doctor_state_paths(root: str, config: cfg.ProjectConfig) -> list[dict[str, object]]: 

3622 """Resolve the configured ledger + checkpoint paths and probe their existence.""" 

3623 entries: list[dict[str, object]] = [] 

3624 for label, resolver, error in ( 

3625 ("ledger", ledger.resolve_path, ledger.LedgerError), 

3626 ("checkpoint", checkpoint.resolve_path, checkpoint.CheckpointError), 

3627 ): 

3628 try: 

3629 path = resolver(root, config) 

3630 except error as exc: 

3631 entries.append({"label": label, "path": None, "status": "invalid", 

3632 "reason": str(exc)}) 

3633 continue 

3634 entries.append({ 

3635 "label": label, 

3636 "path": str(path), 

3637 "status": "present" if path.exists() else "missing", 

3638 "reason": "", 

3639 }) 

3640 return entries 

3641 

3642 

3643def _cmd_doctor(args: argparse.Namespace) -> int: 

3644 config = None 

3645 core_version = None 

3646 state_paths: list[dict[str, object]] = [] 

3647 if args.path: 

3648 try: 

3649 config = cfg.load_config(args.path) 

3650 except FileNotFoundError: 

3651 print(f"no such config: {args.path}", file=sys.stderr) 

3652 return 1 

3653 except cfg.ConfigError as exc: 

3654 print(str(exc), file=sys.stderr) 

3655 return 1 

3656 core_version = config.core_version 

3657 state_paths = _doctor_state_paths(args.root, config) 

3658 

3659 latest = None if args.offline else _fetch_latest_pypi_version() 

3660 report = doctor.run_doctor( 

3661 installed_version=__version__, 

3662 latest_version=latest, 

3663 adapter_markers=install.scan_adapter_markers(args.root), 

3664 orphans=[o.as_dict() for o in _scan_orphans(args.root, include_unmanaged=False)], 

3665 core_version=core_version, 

3666 state_paths=state_paths, 

3667 ) 

3668 if args.json: 

3669 print(json.dumps(report, indent=2, sort_keys=True)) 

3670 else: 

3671 print(doctor.render_report(report)) 

3672 if args.strict and report["status"] == "fail": 

3673 return 1 

3674 return 0 

3675 

3676 

3677def _cmd_install_adapter(args: argparse.Namespace) -> int: 

3678 if args.agent == "plugin": 

3679 installed, skipped = install.install_plugin(args.root, force=args.force) 

3680 _report_install("plugin", installed, skipped) 

3681 print(f"{len(installed)} plugin command file(s) written under commands/ — " 

3682 "install via /plugin marketplace add berkayturanci/keel; /plugin install keel") 

3683 return 0 

3684 if args.agent == "all": 

3685 results = install.install_all(args.root, force=args.force) 

3686 elif args.agent in install.TARGETS: 

3687 results = {args.agent: install.install(args.agent, args.root, force=args.force)} 

3688 else: 

3689 print(f"unknown target {args.agent!r}; valid: all, plugin, " 

3690 f"{', '.join(install.TARGETS)}", 

3691 file=sys.stderr) 

3692 return 1 

3693 total = 0 

3694 for surface, (installed, skipped) in results.items(): 

3695 _report_install(surface, installed, skipped) 

3696 total += len(installed) 

3697 print(f"{total} adapter(s) installed — Claude: /keel:<command>; " 

3698 f"other agents: keel-<command> skill (.agents/skills/)") 

3699 return 0 

3700 

3701 

3702def _cmd_setup(args: argparse.Namespace) -> int: 

3703 root = Path(args.root) 

3704 target = root / ".keel" / "project.yaml" 

3705 print(f"keel setup — {root}") 

3706 

3707 if target.exists() and not args.force: 

3708 print(f" config : using existing {target}") 

3709 print(" extensions : preserved (setup never deletes .keel/extensions/)") 

3710 else: 

3711 existed = target.exists() 

3712 if existed: 

3713 print( 

3714 "warning: --force will replace .keel/project.yaml; " 

3715 ".keel/extensions/ will not be touched", 

3716 file=sys.stderr, 

3717 ) 

3718 try: 

3719 text, stack = _render_scaffolded_config(root, wizard=args.wizard) 

3720 except ValueError as exc: 

3721 print(f" config : failed ({exc})", file=sys.stderr) 

3722 return 1 

3723 target.parent.mkdir(parents=True, exist_ok=True) 

3724 target.write_text(text, encoding="utf-8") 

3725 action = "overwrote" if existed else "wrote" 

3726 print(f" config : {action} {target} (detected stack: {stack})") 

3727 print(" extensions : preserved (setup never deletes .keel/extensions/)") 

3728 

3729 agent = args.adapter_target 

3730 if agent == "all": 

3731 results = install.install_all(root, force=args.force) 

3732 else: 

3733 results = {agent: install.install(agent, root, force=args.force)} 

3734 total = 0 

3735 for surface, (installed, skipped) in results.items(): 

3736 _report_install(surface, installed, skipped) 

3737 total += len(installed) 

3738 print(f" adapters : {total} installed, " 

3739 f"{sum(len(skipped) for _, skipped in results.values())} skipped") 

3740 

3741 try: 

3742 config = cfg.load_config(target) 

3743 loaded, _ = load_extensions(config, root, strict=True) 

3744 plan = orch.build_plan(config, loaded) 

3745 except (cfg.ConfigError, ExtensionError, gates.GateError) as exc: 

3746 print(f" validate : failed ({exc})", file=sys.stderr) 

3747 return 1 

3748 

3749 print(f" validate : OK ({config.repo or '-'}, base {config.base_branch})") 

3750 print(" plan :") 

3751 rendered = orch.render_plan(config, plan) 

3752 for line in rendered.splitlines(): 

3753 print(f" {line}") 

3754 print(" next : run /keel:ship <issue> or the matching keel-<command> skill.") 

3755 return 0 

3756 

3757 

3758def _cmd_adapter_status(args: argparse.Namespace) -> int: 

3759 try: 

3760 rows = install.adapter_status(args.agent, args.root) 

3761 except KeyError: 

3762 print(f"unknown target {args.agent!r}; valid: all, {', '.join(install.STATUS_TARGETS)}", 

3763 file=sys.stderr) 

3764 return 1 

3765 orphans = _scan_orphans(args.root, include_unmanaged=args.include_unmanaged) 

3766 if args.json: 

3767 payload = { 

3768 "adapters": { 

3769 surface: [ 

3770 {"surface": r.surface, "name": r.name, "path": r.path, 

3771 "status": r.status, "detail": r.detail} 

3772 for r in statuses 

3773 ] 

3774 for surface, statuses in rows.items() 

3775 }, 

3776 "orphans": [o.as_dict() for o in orphans], 

3777 } 

3778 print(json.dumps(payload, indent=2, sort_keys=True)) 

3779 return 0 

3780 _report_adapter_rows(rows) 

3781 _report_orphan_rows(orphans) 

3782 if orphans: 

3783 stale = sum(1 for o in orphans if o.category == install.ORPHAN_STALE_MARKER) 

3784 unmanaged = len(orphans) - stale 

3785 print(f" {len(orphans)} unmanaged keel-like file(s): " 

3786 f"{stale} orphan (stale-marker), {unmanaged} unmanaged (no-marker) — advisory only") 

3787 if not args.include_unmanaged: 

3788 print(" note: pass --include-unmanaged to also scan for marker-less command surfaces") 

3789 return 0 

3790 

3791 

3792def _cmd_update_adapter(args: argparse.Namespace) -> int: 

3793 try: 

3794 rows = install.update_adapters(args.agent, args.root, dry_run=args.dry_run) 

3795 except KeyError: 

3796 print(f"unknown target {args.agent!r}; valid: all, {', '.join(install.TARGETS)}", 

3797 file=sys.stderr) 

3798 return 1 

3799 _report_adapter_rows(rows) 

3800 if args.dry_run: 

3801 print("dry-run: no adapter files were written") 

3802 return 0 

3803 

3804 

3805def _cmd_sync(args: argparse.Namespace) -> int: 

3806 args.agent = args.target 

3807 print(f"keel sync — installed keel {__version__}") 

3808 print(" package : not upgraded by sync; upgrade keel-workflow with pip/pipx first") 

3809 rc = _cmd_update_adapter(args) 

3810 if rc == 0: 

3811 orphans = _scan_orphans(args.root, include_unmanaged=False) 

3812 if orphans: 

3813 print(f" orphans : {len(orphans)} unmanaged keel-like file(s) found — " 

3814 "run keel adapter-status for details") 

3815 print(" next : run keel validate .keel/project.yaml --root .") 

3816 print(" next : run keel plan .keel/project.yaml --root .") 

3817 return rc 

3818 

3819 

3820def _parse_legacy_mapping(raw: str) -> tuple[str, str]: 

3821 if "=" not in raw: 

3822 raise argparse.ArgumentTypeError("use LEGACY=KEEL, for example ship=ship") 

3823 legacy, command = (part.strip() for part in raw.split("=", 1)) 

3824 if not legacy or not command: 

3825 raise argparse.ArgumentTypeError("legacy and keel command names must be non-empty") 

3826 return legacy, command 

3827 

3828 

3829def _cmd_install_legacy_wrappers(args: argparse.Namespace) -> int: 

3830 matrix = Path(args.parity_matrix) 

3831 if not matrix.exists(): 

3832 print( 

3833 f"parity matrix not found: {matrix}; pass --parity-matrix after verifying rows", 

3834 file=sys.stderr, 

3835 ) 

3836 return 1 

3837 ready_commands = install.parity_ready_commands(matrix.read_text(encoding="utf-8")) 

3838 mappings = dict(args.command) if args.command else { 

3839 command: command for command in sorted(ready_commands) 

3840 } 

3841 try: 

3842 if args.agent == "all": 

3843 results = install.install_all_legacy_wrappers( 

3844 args.root, 

3845 mappings=mappings, 

3846 ready_commands=ready_commands, 

3847 force=args.force, 

3848 ) 

3849 elif args.agent in install.LEGACY_TARGETS: 

3850 results = { 

3851 args.agent: install.install_legacy_wrappers( 

3852 args.agent, 

3853 args.root, 

3854 mappings=mappings, 

3855 ready_commands=ready_commands, 

3856 force=args.force, 

3857 ) 

3858 } 

3859 else: 

3860 print( 

3861 f"unknown target {args.agent!r}; valid: all, " 

3862 f"{', '.join(install.LEGACY_TARGETS)}", 

3863 file=sys.stderr, 

3864 ) 

3865 return 1 

3866 except ValueError as exc: 

3867 print(str(exc), file=sys.stderr) 

3868 return 1 

3869 total = 0 

3870 for surface, (installed, skipped) in results.items(): 

3871 _report_install(f"legacy-{surface}", installed, skipped) 

3872 total += len(installed) 

3873 print(f"{total} legacy wrapper(s) installed — legacy commands now delegate to /keel:<command>") 

3874 return 0 

3875 

3876 

3877def _capability_requirement( 

3878 command: str, 

3879 config: cfg.ProjectConfig, 

3880 loaded: dict[str, list], 

3881 *, 

3882 pr: int | None = None, 

3883) -> runtime.CapabilityRequirement: 

3884 req = runtime.CapabilityRequirement( 

3885 required=config.knobs.required_capabilities, 

3886 optional=config.knobs.optional_capabilities, 

3887 ) 

3888 try: 

3889 specs = gates.plan_gates(config, loaded) 

3890 except gates.GateError: 

3891 return req 

3892 if project_command := project_commands.get_project_command(config, command): 

3893 req = req.merged(runtime.CapabilityRequirement( 

3894 required=project_command.required_capabilities, 

3895 optional=project_command.optional_capabilities, 

3896 )) 

3897 

3898 command_gate_commands = { 

3899 "run-gates", "ship", "pr-loop", "wrap", "work-block", "overnight", 

3900 "implement", "coverage", "deps-audit", "flake-audit", 

3901 } 

3902 if command in command_gate_commands and any(s.kind == "command" for s in specs): 

3903 req = req.merged(runtime.CapabilityRequirement(required=("shell",))) 

3904 worktree_commands = { 

3905 "ship", "pr-loop", "wrap", "work-block", "overnight", "implement" 

3906 } 

3907 github_read_commands = { 

3908 "morning", "review-cycle", "triage", "stale-prs", "regression", "review-all-day", 

3909 "coverage", "deps-audit", "flake-audit", "ci-check", 

3910 } 

3911 if command in worktree_commands: 

3912 req = req.merged(runtime.CapabilityRequirement(required=("git", "worktree"), 

3913 optional=("gh", "gh-auth"))) 

3914 elif command in github_read_commands: 

3915 req = req.merged(runtime.CapabilityRequirement(optional=("gh", "gh-auth"))) 

3916 for spec in specs: 

3917 if spec.required_capabilities or spec.optional_capabilities: 

3918 req = req.merged(runtime.CapabilityRequirement( 

3919 required=spec.required_capabilities, 

3920 optional=spec.optional_capabilities, 

3921 )) 

3922 return req 

3923 

3924 

3925def build_parser() -> argparse.ArgumentParser: 

3926 parser = argparse.ArgumentParser(prog="keel", description="keel — workflow core") 

3927 parser.add_argument("--version", action="version", version=f"keel {__version__}") 

3928 sub = parser.add_subparsers(dest="command", metavar="<command>") 

3929 

3930 p_version = sub.add_parser("version", help="print the keel version") 

3931 p_version.set_defaults(func=_cmd_version) 

3932 

3933 p_validate = sub.add_parser("validate", help="validate project config(s) against the schema") 

3934 p_validate.add_argument("paths", nargs="+", help="path(s) to project.yaml") 

3935 p_validate.add_argument("--root", default=None, 

3936 help="repo root; if set, also strict-validate extensions") 

3937 p_validate.set_defaults(func=_cmd_validate) 

3938 

3939 p_plan = sub.add_parser("plan", help="render the backbone plan for a project") 

3940 p_plan.add_argument("path", help="path to project.yaml") 

3941 p_plan.add_argument("--root", default=".", help="repo root for resolving extensions") 

3942 p_plan.add_argument("--command", dest="command_contract", default="ship", 

3943 help="adapter command contract to include in JSON output") 

3944 p_plan.add_argument("--profile", choices=("standard", "compound"), default="standard", 

3945 help="workflow profile for ship command contracts") 

3946 p_plan.add_argument("--live", action="store_true", 

3947 help="render a live preflight contract and fail if consent is missing") 

3948 p_plan.add_argument("--approve-scope", action="append", default=[], 

3949 help="approve a consent scope for this run; repeat or comma-separate") 

3950 p_plan.add_argument("--operator", default=None, 

3951 help="operator identifier to include in an approved consent record") 

3952 p_plan.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None, 

3953 help="operator consent mode: explicit, standing, or agent") 

3954 p_plan.add_argument("--target", default=None, 

3955 help="task target to include in the consent prompt and record") 

3956 p_plan.add_argument("--issue-title", default=None, 

3957 help="issue title to include in the intake/readiness contract") 

3958 p_plan.add_argument("--issue-body", default=None, 

3959 help="issue body markdown to include in the intake/readiness contract") 

3960 p_plan.add_argument("--issue-label", action="append", default=[], 

3961 help="issue label for intake/readiness; repeat or comma-separate") 

3962 p_plan.add_argument("--review-comments", choices=("inline", "summary"), default="inline", 

3963 help="review posting mode for ship-like command contracts") 

3964 p_plan.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None, 

3965 help="override the resolved reviewer count") 

3966 p_plan.add_argument("--jury", action="store_true", 

3967 help="enable the cross-vendor jury contract") 

3968 p_plan.add_argument("--no-jury", action="store_true", 

3969 help="disable the cross-vendor jury contract") 

3970 p_plan.add_argument("--jury-advisory", action="store_true", 

3971 help="run jury in advisory mode when enabled") 

3972 p_plan.add_argument("--run-id", default=None, 

3973 help="stamp the activity record for this run id (board visibility)") 

3974 p_plan.add_argument("--issue", type=_positive_int, default=None, 

3975 help="issue number to record on the activity stamp") 

3976 p_plan.add_argument("--pull-request", type=_positive_int, default=None, 

3977 help="pull request number to record on the activity stamp") 

3978 p_plan.add_argument("--json", action="store_true", help="emit structured JSON") 

3979 p_plan.set_defaults(func=_cmd_plan) 

3980 

3981 p_run = sub.add_parser("run-gates", help="run a project's command gates") 

3982 p_run.add_argument("path", help="path to project.yaml") 

3983 p_run.add_argument("--root", default=".", help="repo root for commands + extensions") 

3984 p_run.add_argument("--run-id", default=None, 

3985 help="stamp the activity record for this run id (board visibility)") 

3986 p_run.add_argument("--command", dest="gate_command", default="ship", 

3987 help="command flow the gates belong to (for the activity stamp)") 

3988 p_run.add_argument("--phase", dest="gate_phase", default="s8", 

3989 help="flow phase to stamp when the gates run (default the test step)") 

3990 p_run.add_argument("--issue", type=_positive_int, default=None, 

3991 help="issue number to record on the activity stamp") 

3992 p_run.add_argument("--pull-request", type=_positive_int, default=None, 

3993 help="pull request number to record on the activity stamp") 

3994 p_run.set_defaults(func=_cmd_run_gates) 

3995 

3996 p_window = sub.add_parser("window", help="is the merge window open now?") 

3997 p_window.add_argument("path", help="path to project.yaml") 

3998 p_window.set_defaults(func=_cmd_window) 

3999 

4000 p_claim = sub.add_parser("claim", help="claim a single-host keel resource") 

4001 p_claim.add_argument("--root", default=".", help="repo root for the claim store") 

4002 p_claim.add_argument("--owner", required=True, help="claim owner id") 

4003 p_claim.add_argument("--json", action="store_true", help="emit structured JSON") 

4004 p_claim.add_argument("resource", help="resource name, e.g. merge") 

4005 p_claim.set_defaults(func=_cmd_claim) 

4006 

4007 p_release = sub.add_parser("release", help="release a single-host keel resource") 

4008 p_release.add_argument("--root", default=".", help="repo root for the claim store") 

4009 p_release.add_argument("--owner", default=None, help="claim owner id; omit to release any") 

4010 p_release.add_argument("--json", action="store_true", help="emit structured JSON") 

4011 p_release.add_argument("resource", help="resource name, e.g. merge") 

4012 p_release.set_defaults(func=_cmd_release) 

4013 

4014 p_guard = sub.add_parser( 

4015 "guard", help="evaluate an issue against the deterministic blocker ruleset" 

4016 ) 

4017 p_guard.add_argument("path", help="path to project.yaml") 

4018 p_guard.add_argument("--root", default=".", help="repo root for live gh issue fetch") 

4019 p_guard.add_argument("--issue", type=_positive_int, default=None, 

4020 help="issue number to fetch title/labels live (offline: use the flags)") 

4021 p_guard.add_argument("--issue-title", default=None, help="issue title for offline evaluation") 

4022 p_guard.add_argument("--issue-labels", default=None, 

4023 help="comma-separated issue labels for offline evaluation") 

4024 p_guard.add_argument("--json", action="store_true", help="emit structured JSON") 

4025 p_guard.set_defaults(func=_cmd_guard) 

4026 

4027 p_merge = sub.add_parser("merge", help="perform the fail-closed core-owned PR merge") 

4028 p_merge.add_argument("path", help="path to project.yaml") 

4029 p_merge.add_argument("--root", default=".", help="repo root for git/GitHub operations") 

4030 p_merge.add_argument("--pr", type=_positive_int, required=True, help="pull request number") 

4031 p_merge.add_argument("--issue", type=_positive_int, default=None, 

4032 help="linked issue number for evidence verification") 

4033 p_merge.add_argument("--method", choices=("squash", "merge", "rebase"), default="squash", 

4034 help="GitHub merge method") 

4035 p_merge.add_argument("--owner", default=None, help="resource claim owner id") 

4036 p_merge.add_argument("--hotfix", action="store_true", 

4037 help="bypass the merge window with a recorded justification") 

4038 p_merge.add_argument("--blocker-rule", default=None, 

4039 help="keel guard rule id justifying a --hotfix bypass") 

4040 p_merge.add_argument("--operator-override", action="store_true", 

4041 help="authorize a --hotfix bypass as a named operator (audited)") 

4042 p_merge.add_argument("--run-id", default=None, 

4043 help="run id for the checkpoint gate (defaults to the gates-pass run id)") 

4044 p_merge.add_argument("--no-checkpoint-gate", action="store_true", 

4045 help="bypass the checkpoint gate as a named operator (audited)") 

4046 p_merge.add_argument("--issue-title", default=None, 

4047 help="issue title for offline blocker-rule validation") 

4048 p_merge.add_argument("--issue-labels", default=None, 

4049 help="comma-separated issue labels for offline blocker-rule validation") 

4050 p_merge.add_argument("--dry-run", action="store_true", help="verify only; do not merge") 

4051 p_merge.add_argument("--approve-scope", action="append", default=[], 

4052 help="approve a consent scope for this merge") 

4053 p_merge.add_argument("--operator", default=None, 

4054 help="operator identifier for consent evidence") 

4055 p_merge.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None, 

4056 help="operator consent mode") 

4057 p_merge.add_argument("--risk-tier", choices=consent.RISK_TIERS, default="tier-1", 

4058 help="risk tier for deterministic escalation evaluation") 

4059 p_merge.add_argument("--trust-signal", choices=consent.TRUST_SIGNALS, default="medium", 

4060 help="trust signal for deterministic escalation evaluation") 

4061 p_merge.add_argument("--retry-count", type=int, default=0, 

4062 help="retry count for deterministic escalation evaluation") 

4063 p_merge.add_argument("--conflicting-sources", action="store_true", 

4064 help="mark conflicting sources for escalation evaluation") 

4065 p_merge.add_argument("--changed-lines", type=int, default=0, 

4066 help="changed-line count for escalation evaluation") 

4067 p_merge.add_argument("--escalation-side-effect", action="append", default=[], 

4068 help="additional side-effect signal for escalation evaluation") 

4069 p_merge.add_argument("--review-comments", choices=("inline", "summary"), default="inline", 

4070 help="review posting mode for evidence verification") 

4071 p_merge.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None, 

4072 help="override required reviewer count") 

4073 p_merge.add_argument("--jury", action="store_true", help="enable jury evidence") 

4074 p_merge.add_argument("--no-jury", action="store_true", help="disable jury evidence") 

4075 p_merge.add_argument("--jury-advisory", action="store_true", 

4076 help="make jury advisory for evidence verification") 

4077 p_merge.add_argument("--gate-label", default=None, 

4078 help="override evidence gate label") 

4079 p_merge.add_argument("--json", action="store_true", help="emit structured JSON") 

4080 p_merge.set_defaults(func=_cmd_merge) 

4081 

4082 p_wr = sub.add_parser("worktree-remove", help="safely remove a registered nested worktree") 

4083 p_wr.add_argument("--root", default=".", help="repo root") 

4084 p_wr.add_argument("--json", action="store_true", help="emit structured JSON") 

4085 p_wr.add_argument("worktree", help="worktree path to remove") 

4086 p_wr.set_defaults(func=_cmd_worktree_remove) 

4087 

4088 p_ledger = sub.add_parser("ledger", help="read the structured run ledger offline") 

4089 p_ledger.add_argument("path", help="path to project.yaml") 

4090 p_ledger.add_argument("--root", default=".", help="repo root for resolving the ledger path") 

4091 p_ledger.add_argument("--limit", type=_positive_int, default=None, 

4092 help="return only the newest N records") 

4093 p_ledger.add_argument("--json", action="store_true", help="emit structured JSON") 

4094 p_ledger.set_defaults(func=_cmd_ledger) 

4095 

4096 p_capture = sub.add_parser( 

4097 "capture-verify", 

4098 help="verify post-merge capture markers for merged PRs", 

4099 ) 

4100 p_capture.add_argument("path", help="path to project.yaml") 

4101 p_capture.add_argument("--root", default=".", 

4102 help="repo root for resolving the ledger path") 

4103 p_capture.add_argument("--merged-pr", type=_positive_int, action="append", 

4104 help="merged PR number expected to have a capture marker " 

4105 "(explicit override; always added to the derived set)") 

4106 p_capture.add_argument("--from-transport", action="store_true", 

4107 help="derive the merged-PR set from the transport instead of " 

4108 "trusting --merged-pr (also runs reconcile cross-checks)") 

4109 p_capture.add_argument("--merged-since", default=None, 

4110 help="with --from-transport, only PRs merged on/after this date " 

4111 "(YYYY-MM-DD)") 

4112 p_capture.add_argument("--merged-prs-json", default=None, 

4113 help="offline transport fixture: JSON array of {\"number\": N}") 

4114 p_capture.add_argument("--pr-reviews-json", default=None, 

4115 help="offline fixture path; presence activates reconcile checks") 

4116 p_capture.add_argument("--verdict-count", type=_verdict_count_arg, action="append", 

4117 default=None, metavar="PR=N", 

4118 help="evidence-side review-verdict count for a PR (offline fixture)") 

4119 p_capture.add_argument("--json", action="store_true", help="emit structured JSON") 

4120 p_capture.set_defaults(func=_cmd_capture_verify) 

4121 

4122 p_consent = sub.add_parser( 

4123 "consent-verify", 

4124 help="reconcile observed PR side effects against approved consent scopes", 

4125 ) 

4126 p_consent.add_argument("path", help="path to project.yaml") 

4127 p_consent.add_argument("--root", default=".", 

4128 help="repo root for live gh observation and the ledger path") 

4129 p_consent.add_argument("--pr", type=_positive_int, required=True, 

4130 help="pull request number to reconcile") 

4131 p_consent.add_argument("--ledger-jsonl", default=None, 

4132 help="offline run-ledger JSONL fixture; otherwise the configured " 

4133 "ledger under --root is read for the consent record") 

4134 p_consent.add_argument("--offline", action="store_true", 

4135 help="use only the supplied observed-effect flags; make no gh calls") 

4136 p_consent.add_argument("--pr-exists", action="store_true", 

4137 help="offline: the PR exists (implies git push + gh pr create)") 

4138 p_consent.add_argument("--commented", action="store_true", 

4139 help="offline: comments were posted on the PR") 

4140 p_consent.add_argument("--merged", action="store_true", 

4141 help="offline: the PR was merged") 

4142 p_consent.add_argument("--labeled", action="store_true", 

4143 help="offline: labels were written on the PR") 

4144 p_consent.add_argument("--json", action="store_true", help="emit structured JSON") 

4145 p_consent.set_defaults(func=_cmd_consent_verify) 

4146 

4147 p_close = sub.add_parser( 

4148 "close-reconcile", 

4149 help="flag issues closed or status-done without a merge-attesting ledger record", 

4150 ) 

4151 p_close.add_argument("path", help="path to project.yaml") 

4152 p_close.add_argument("--root", default=".", 

4153 help="repo root for live gh observation and the ledger path") 

4154 p_close.add_argument("--issue", type=_positive_int, action="append", required=True, 

4155 help="issue number to reconcile (repeat for several)") 

4156 p_close.add_argument("--ledger-jsonl", default=None, 

4157 help="offline run-ledger JSONL fixture; otherwise the configured " 

4158 "ledger under --root is read for the ship_run records") 

4159 p_close.add_argument("--offline", action="store_true", 

4160 help="use only the supplied lifecycle flags; make no gh calls " 

4161 "(test/back-compat; live mode reads host-authoritative state)") 

4162 p_close.add_argument("--closed", action="store_true", 

4163 help="offline only: treat every --issue as closed (ignored when live)") 

4164 p_close.add_argument("--status-done", action="store_true", 

4165 help="offline only: treat every --issue as carrying the done label " 

4166 "(ignored when live)") 

4167 p_close.add_argument("--json", action="store_true", help="emit structured JSON") 

4168 p_close.set_defaults(func=_cmd_close_reconcile) 

4169 

4170 p_dryrun = sub.add_parser( 

4171 "dryrun-verify", 

4172 help="assert a dry run left no new ledger record, branch, or PR (post-hoc)", 

4173 ) 

4174 p_dryrun.add_argument("path", help="path to project.yaml") 

4175 p_dryrun.add_argument("--root", default=".", 

4176 help="repo root for the ledger, git, and gh observation") 

4177 p_dryrun.add_argument("--run-id", required=True, 

4178 help="the rehearsed dry-run id whose leaks to detect") 

4179 p_dryrun.add_argument("--issue", type=_positive_int, required=True, 

4180 help="the issue the dry run rehearsed (scopes branch/PR attribution)") 

4181 p_dryrun.add_argument("--before-json", required=True, 

4182 help="JSON snapshot {ledger_run_ids, branches, pr_numbers} " 

4183 "captured before the dry run") 

4184 p_dryrun.add_argument("--after-json", default=None, 

4185 help="offline after-snapshot JSON; otherwise gathered live " 

4186 "from the ledger, git, and gh") 

4187 p_dryrun.add_argument("--json", action="store_true", help="emit structured JSON") 

4188 p_dryrun.set_defaults(func=_cmd_dryrun_verify) 

4189 

4190 p_reconcile = sub.add_parser( 

4191 "capture-reconcile", 

4192 help="plan idempotent post-merge capture reconciliation actions", 

4193 ) 

4194 p_reconcile.add_argument("path", help="path to project.yaml") 

4195 p_reconcile.add_argument("--root", default=".", 

4196 help="repo root for resolving the ledger path") 

4197 p_reconcile.add_argument("--merged-pr", type=_positive_int, action="append", required=True, 

4198 help="merged PR number to reconcile") 

4199 p_reconcile.add_argument( 

4200 "--linked-issue", 

4201 type=_parse_pr_issue_mapping, 

4202 action="append", 

4203 default=[], 

4204 help="unambiguous PR-to-issue mapping as PR=ISSUE; repeat for multiple issues", 

4205 ) 

4206 p_reconcile.add_argument( 

4207 "--capture-capability", 

4208 choices=("available", "unavailable"), 

4209 default="unavailable", 

4210 help="whether the capture extension capability is currently available", 

4211 ) 

4212 p_reconcile.add_argument("--live", action="store_true", 

4213 help="label the output as a live reconciliation plan") 

4214 p_reconcile.add_argument("--json", action="store_true", help="emit structured JSON") 

4215 p_reconcile.set_defaults(func=_cmd_capture_reconcile) 

4216 

4217 p_step = sub.add_parser( 

4218 "step-verify", 

4219 help="verify a persisted step handoff against the evidence report", 

4220 ) 

4221 p_step.add_argument("--step", required=True, help="backbone step id, e.g. s7") 

4222 p_step.add_argument("--handoff-file", required=True, help="JSON step handoff file") 

4223 p_step.add_argument("--evidence-report", required=True, 

4224 help="JSON evidence verification report or verification block") 

4225 p_step.add_argument("--review-comments", choices=("inline", "summary"), 

4226 default="inline", help="review posting mode in the ship contract") 

4227 p_step.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None, 

4228 help="override the required reviewer verdict count") 

4229 p_step.add_argument("--jury", action="store_true", 

4230 help="enable the cross-vendor jury requirement") 

4231 p_step.add_argument("--no-jury", action="store_true", 

4232 help="disable the cross-vendor jury requirement") 

4233 p_step.add_argument("--jury-advisory", action="store_true", 

4234 help="make an enabled jury advisory instead of required") 

4235 p_step.add_argument("--dry-run", action="store_true", 

4236 help="verify with dry-run evidence requirements") 

4237 p_step.add_argument("--not-enforced", action="store_true", 

4238 help="verify with evidence requirements disabled") 

4239 p_step.add_argument("--json", action="store_true", help="emit structured JSON") 

4240 p_step.set_defaults(func=_cmd_step_verify) 

4241 

4242 p_rc = sub.add_parser( 

4243 "runcontrols", 

4244 help="append/evaluate deterministic run-control events", 

4245 ) 

4246 p_rc.add_argument("events_file", help="JSON array run-events file") 

4247 p_rc.add_argument("--event-json", default=None, help="single event JSON object to append") 

4248 p_rc.add_argument("--step", default=None, help="event step id") 

4249 p_rc.add_argument("--slot", default=None, help="event slot name") 

4250 p_rc.add_argument("--action", default=None, help="event action") 

4251 p_rc.add_argument("--output-fingerprint", default=None, help="event output fingerprint") 

4252 p_rc.add_argument("--diff-fingerprint", default=None, help="event diff fingerprint") 

4253 p_rc.add_argument("--work-units", type=int, default=None, help="event work-unit count") 

4254 p_rc.add_argument("--soft-failure", action="store_true", help="mark event as soft failure") 

4255 p_rc.add_argument("--max-work-units", type=int, default=runcontrols.DEFAULT_RUN_BUDGET, 

4256 help="run budget hard cap") 

4257 p_rc.add_argument("--default-step-cap", type=int, default=runcontrols.DEFAULT_STEP_CAP, 

4258 help="default per-step/slot iteration cap") 

4259 p_rc.add_argument("--step-cap", action="append", default=[], 

4260 help="override per-slot cap as SLOT=N; repeatable") 

4261 p_rc.add_argument("--identical-action-threshold", type=int, 

4262 default=runcontrols.DEFAULT_IDENTICAL_THRESHOLD, 

4263 help="oscillation threshold for repeated identical actions") 

4264 p_rc.add_argument("--alternating-diff-window", type=int, 

4265 default=runcontrols.DEFAULT_ALTERNATION_WINDOW, 

4266 help="oscillation window for alternating diff fingerprints") 

4267 p_rc.add_argument("--dry-run", action="store_true", 

4268 help="evaluate without appending the event") 

4269 p_rc.add_argument("--json", action="store_true", help="emit structured JSON") 

4270 p_rc.set_defaults(func=_cmd_runcontrols) 

4271 

4272 p_post = sub.add_parser( 

4273 "post-comment", 

4274 help="post or update a deterministic GitHub issue/PR artifact comment", 

4275 ) 

4276 p_post.add_argument("path", help="path to project.yaml") 

4277 p_post.add_argument("--root", default=".", help="repo root for GitHub operations") 

4278 p_post.add_argument("--target", required=True, help="comment target as issue:N or pr:N") 

4279 p_post.add_argument( 

4280 "--artifact", 

4281 required=True, 

4282 choices=( 

4283 "closure-comment", 

4284 "issue-update", 

4285 "review-verdict", 

4286 "jury-verdict", 

4287 "extension-result", 

4288 "step-handoff", 

4289 "run-control-halt", 

4290 ), 

4291 help="artifact contract expected in --body-file", 

4292 ) 

4293 p_post.add_argument("--body-file", required=True, help="rendered markdown body to post") 

4294 p_post.add_argument( 

4295 "--run-id", 

4296 default=None, 

4297 help="update the existing same-marker comment for this run id when present", 

4298 ) 

4299 p_post.add_argument("--dry-run", action="store_true", help="plan only; do not mutate GitHub") 

4300 p_post.add_argument("--json", action="store_true", help="emit structured JSON") 

4301 p_post.set_defaults(func=_cmd_post_comment) 

4302 

4303 p_review = sub.add_parser( 

4304 "review", 

4305 help="orchestrate a supplied review evidence bundle: render, post, re-verify", 

4306 ) 

4307 p_review.add_argument("path", help="path to project.yaml") 

4308 p_review.add_argument("--root", default=".", help="repo root for GitHub operations") 

4309 p_review.add_argument("--pr", type=_positive_int, required=True, 

4310 help="pull request number to attach verdicts to") 

4311 p_review.add_argument("--reviews", required=True, 

4312 help="JSON array of reviewer verdict objects supplied by the host") 

4313 p_review.add_argument("--issue", type=_positive_int, default=None, 

4314 help="linked issue number; otherwise inferred from PR body") 

4315 p_review.add_argument("--closure", default=None, 

4316 help="optional ship_run-shaped JSON record to post as the closure") 

4317 p_review.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None, 

4318 help="override the required reviewer verdict count") 

4319 p_review.add_argument("--head-sha", default=None, 

4320 help="offline PR head SHA used to pin verdict evidence") 

4321 p_review.add_argument("--changed-file", action="append", default=[], 

4322 help="offline changed file path; repeat to derive tier from fixtures") 

4323 p_review.add_argument("--run-id", default="run", 

4324 help="run id; per-reviewer sub-keys bind idempotent comments") 

4325 p_review.add_argument("--verify", action="store_true", 

4326 help="run evidence-verify after posting and include the outcome") 

4327 p_review.add_argument("--dry-run", action="store_true", 

4328 help="render and print what would post; do not mutate GitHub") 

4329 p_review.add_argument("--live", action="store_true", 

4330 help="actually post the rendered bundle (consent-gated)") 

4331 p_review.add_argument("--approve-scope", action="append", default=[], 

4332 help="approve a consent scope for the live run; repeatable") 

4333 p_review.add_argument("--operator", default=None, 

4334 help="operator identity recorded with an approved live run") 

4335 p_review.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None, 

4336 help="override the project consent mode for this run") 

4337 p_review.add_argument("--json", action="store_true", help="emit structured JSON") 

4338 p_review.set_defaults(func=_cmd_review) 

4339 

4340 p_evidence = sub.add_parser( 

4341 "evidence-verify", 

4342 help="verify required pre-merge ship evidence artifacts", 

4343 ) 

4344 p_evidence.add_argument("path", help="path to project.yaml") 

4345 p_evidence.add_argument("--root", default=".", help="repo root for live gh fetches") 

4346 p_evidence.add_argument("--pr", type=_positive_int, required=True, 

4347 help="pull request number to verify") 

4348 p_evidence.add_argument("--issue", type=_positive_int, default=None, 

4349 help="linked issue number; otherwise inferred from PR body") 

4350 p_evidence.add_argument("--review-comments", choices=("inline", "summary"), 

4351 default="inline", help="review posting mode in the ship contract") 

4352 p_evidence.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None, 

4353 help="override the required reviewer verdict count") 

4354 p_evidence.add_argument("--jury", action="store_true", 

4355 help="enable the cross-vendor jury requirement") 

4356 p_evidence.add_argument("--no-jury", action="store_true", 

4357 help="disable the cross-vendor jury requirement") 

4358 p_evidence.add_argument("--jury-advisory", action="store_true", 

4359 help="make an enabled jury advisory instead of required") 

4360 p_evidence.add_argument("--require-distinct-vendors", action="store_true", 

4361 help="require each review verdict to carry a distinct vendor " 

4362 "(overrides the project knob; off by default)") 

4363 p_evidence.add_argument("--dry-run", action="store_true", 

4364 help="emit the contract without requiring evidence") 

4365 p_evidence.add_argument("--deferral", action="append", default=[], 

4366 help="explicitly defer an evidence id, kind, or all") 

4367 p_evidence.add_argument("--pr-comments-json", default=None, 

4368 help="offline PR issue-comments JSON fixture") 

4369 p_evidence.add_argument("--issue-comments-json", default=None, 

4370 help="offline linked-issue comments JSON fixture") 

4371 p_evidence.add_argument("--pr-reviews-json", default=None, 

4372 help="offline PR reviews JSON fixture") 

4373 p_evidence.add_argument("--pr-body-file", default=None, 

4374 help="offline PR body fixture, used only to infer linked issue") 

4375 p_evidence.add_argument("--ledger-jsonl", default=None, 

4376 help="offline run-ledger JSONL fixture; otherwise the configured " 

4377 "ledger under --root is read to enforce closure fidelity") 

4378 p_evidence.add_argument("--changed-file", action="append", default=[], 

4379 help="offline changed file path; repeat to derive tier from fixtures") 

4380 p_evidence.add_argument("--head-sha", default=None, 

4381 help="offline PR head SHA used to bind verdict evidence") 

4382 p_evidence.add_argument("--head-ref", default=None, 

4383 help="offline PR head branch used to detect ship provenance") 

4384 p_evidence.add_argument("--pr-label", action="append", default=[], 

4385 help="inject a PR label name (repeatable); merged with live labels. " 

4386 "A live PR fetch still runs unless an offline fixture flag is " 

4387 "also supplied") 

4388 p_evidence.add_argument("--gate-label", default=None, 

4389 help="override the legacy evidence arming label") 

4390 p_evidence.add_argument("--waiver-label", default=None, 

4391 help="override the operator-applied evidence waiver label") 

4392 p_evidence.add_argument("--json", action="store_true", help="emit structured JSON") 

4393 p_evidence.set_defaults(func=_cmd_evidence_verify) 

4394 

4395 p_scope = sub.add_parser( 

4396 "scope-verify", 

4397 help="compare the implementer's declared files against the live PR diff", 

4398 ) 

4399 p_scope.add_argument("path", help="path to project.yaml") 

4400 p_scope.add_argument("--root", default=".", help="repo root for live gh fetches") 

4401 p_scope.add_argument("--pr", type=_positive_int, required=True, 

4402 help="pull request number to verify") 

4403 p_scope.add_argument("--issue", type=_positive_int, default=None, 

4404 help="linked issue number; otherwise inferred from PR body") 

4405 p_scope.add_argument("--deferral", action="append", default=[], 

4406 help="operator escape hatch; pass 'scope-waived' (or 'all') to " 

4407 "accept scope creep for this run") 

4408 p_scope.add_argument("--ledger-jsonl", default=None, 

4409 help="offline run-ledger JSONL fixture; otherwise the configured " 

4410 "ledger under --root is read for the declared scope") 

4411 p_scope.add_argument("--changed-file", action="append", default=[], 

4412 help="offline changed file path; repeat to supply the diff offline") 

4413 p_scope.add_argument("--head-sha", default=None, 

4414 help="offline PR head SHA recorded in the report") 

4415 p_scope.add_argument("--head-ref", default=None, 

4416 help="offline PR head branch") 

4417 p_scope.add_argument("--pr-body-file", default=None, 

4418 help="offline PR body fixture, used only to infer the linked issue") 

4419 p_scope.add_argument("--pr-comments-json", default=None, 

4420 help="offline PR issue-comments JSON fixture") 

4421 p_scope.add_argument("--issue-comments-json", default=None, 

4422 help="offline linked-issue comments JSON fixture") 

4423 p_scope.add_argument("--pr-reviews-json", default=None, 

4424 help="offline PR reviews JSON fixture") 

4425 p_scope.add_argument("--pr-label", action="append", default=[], 

4426 help="inject a PR label name (repeatable)") 

4427 p_scope.add_argument("--dry-run", action="store_true", 

4428 help="use offline inputs without a live gh fetch") 

4429 p_scope.add_argument("--json", action="store_true", help="emit structured JSON") 

4430 p_scope.set_defaults(func=_cmd_scope_verify) 

4431 

4432 p_verify_branch = sub.add_parser( 

4433 "verify-branch", 

4434 help="verify the s2 branch contract: up-to-date base + worktree isolation", 

4435 ) 

4436 p_verify_branch.add_argument("path", help="path to project.yaml") 

4437 p_verify_branch.add_argument("--root", default=".", 

4438 help="repo root for live git/gh fact gathering") 

4439 p_verify_branch.add_argument("--pr", type=_positive_int, required=True, 

4440 help="pull request number to verify") 

4441 p_verify_branch.add_argument( 

4442 "--tolerance", type=_nonnegative_int, default=branchscope.DEFAULT_BASE_DISTANCE, 

4443 help="commits the merge-base may sit behind the base tip before stale " 

4444 f"(default {branchscope.DEFAULT_BASE_DISTANCE}; 0 is strict)") 

4445 p_verify_branch.add_argument( 

4446 "--allow-stale-base", action="store_true", 

4447 help="operator escape (consent scope git): downgrade a stale base from a " 

4448 "failure to an advisory note, recorded on the report") 

4449 p_verify_branch.add_argument("--offline", action="store_true", 

4450 help="use only supplied facts; make no git/gh calls") 

4451 p_verify_branch.add_argument("--head-sha", default=None, 

4452 help="offline PR head SHA (otherwise fetched via gh)") 

4453 p_verify_branch.add_argument("--head-ref", default=None, 

4454 help="offline PR head branch (otherwise fetched via gh)") 

4455 p_verify_branch.add_argument("--base-tip-sha", default=None, 

4456 help="offline current origin/<base> tip SHA") 

4457 p_verify_branch.add_argument("--merge-base-sha", default=None, 

4458 help="offline merge-base of head and the base tip") 

4459 p_verify_branch.add_argument("--base-distance", type=_nonnegative_int, default=None, 

4460 help="offline commit distance merge-base..base-tip") 

4461 p_verify_branch.add_argument("--worktree-path", default=None, 

4462 help="offline working-tree path for the head branch") 

4463 p_verify_branch.add_argument("--repo-root", default=None, 

4464 help="offline repo root (primary checkout) path") 

4465 p_verify_branch.add_argument("--linked-worktree", choices=("true", "false"), default=None, 

4466 help="offline: is the head branch in a linked worktree?") 

4467 p_verify_branch.add_argument("--json", action="store_true", help="emit structured JSON") 

4468 p_verify_branch.set_defaults(func=_cmd_verify_branch) 

4469 

4470 p_status = sub.add_parser("status", help="show active/recent run progress") 

4471 p_status.add_argument("path", help="path to project.yaml") 

4472 p_status.add_argument("--root", default=".", 

4473 help="repo root for resolving checkpoint and ledger paths") 

4474 p_status.add_argument("--live-branch", action="append", default=[], 

4475 help="live branch name for orphan detection (repeatable)") 

4476 p_status.add_argument("--live-pr", action="append", type=_positive_int, default=[], 

4477 help="live pull-request number for orphan detection (repeatable)") 

4478 p_status.add_argument("--json", action="store_true", help="emit structured JSON") 

4479 p_status.set_defaults(func=_cmd_status) 

4480 

4481 p_checkpoint = sub.add_parser("checkpoint", help="read or write the resumable checkpoint") 

4482 p_checkpoint.add_argument("path", help="path to project.yaml") 

4483 p_checkpoint.add_argument("--root", default=".", 

4484 help="repo root for resolving the checkpoint path") 

4485 p_checkpoint.add_argument("--write", action="store_true", 

4486 help="write a checkpoint record instead of reading it") 

4487 p_checkpoint.add_argument("--run-id", default="run", 

4488 help="run id for --write") 

4489 p_checkpoint.add_argument("--checkpoint-command", dest="checkpoint_command_name", 

4490 choices=checkpoint.COMMANDS, default="ship", 

4491 help="workflow command being checkpointed") 

4492 p_checkpoint.add_argument("--step", choices=checkpoint.STEP_IDS, 

4493 default="s0", help="current backbone step for --write") 

4494 p_checkpoint.add_argument("--target", default=None, 

4495 help="target text to store in the checkpoint") 

4496 p_checkpoint.add_argument("--issue-queue", type=_positive_int, action="append", default=[], 

4497 help="queued issue number; repeatable") 

4498 p_checkpoint.add_argument("--active-issue", type=_positive_int, default=None, 

4499 help="active issue number") 

4500 p_checkpoint.add_argument("--branch", default=None, help="recorded branch") 

4501 p_checkpoint.add_argument("--worktree", default=None, help="recorded worktree path") 

4502 p_checkpoint.add_argument("--pull-request", type=_positive_int, default=None, 

4503 help="recorded pull request number") 

4504 p_checkpoint.add_argument("--head-sha", default=None, help="recorded head SHA") 

4505 p_checkpoint.add_argument("--completed-step", choices=checkpoint.STEP_IDS, 

4506 action="append", default=[], 

4507 help="completed backbone step; repeatable") 

4508 p_checkpoint.add_argument("--last-gate", default=None, help="last completed gate id") 

4509 p_checkpoint.add_argument("--last-review", default=None, 

4510 help="last completed review marker") 

4511 p_checkpoint.add_argument("--last-check", default=None, 

4512 help="last completed CI/check marker") 

4513 p_checkpoint.add_argument("--jury-mode", default=None, 

4514 help="resolved jury mode at this step (off/advisory/gating); " 

4515 "lets a live consumer show jury status before run end") 

4516 p_checkpoint.add_argument("--merge-state", choices=checkpoint.MERGE_STATES, 

4517 default="not-started") 

4518 p_checkpoint.add_argument("--capture-state", choices=checkpoint.CAPTURE_STATES, 

4519 default="not-started") 

4520 p_checkpoint.add_argument("--close-state", choices=checkpoint.CLOSE_STATES, 

4521 default="not-started") 

4522 p_checkpoint.add_argument("--stop-reason", default=None, 

4523 help="why the run stopped at this checkpoint") 

4524 p_checkpoint.add_argument("--json", action="store_true", help="emit structured JSON") 

4525 p_checkpoint.set_defaults(func=_cmd_checkpoint) 

4526 

4527 p_activity = sub.add_parser( 

4528 "activity", 

4529 help="read/write the additive command-activity records (live board for " 

4530 "non-ship commands)") 

4531 p_activity.add_argument("path", help="path to project.yaml") 

4532 p_activity.add_argument("--root", default=".", 

4533 help="repo root for resolving the activity dir") 

4534 g_act = p_activity.add_mutually_exclusive_group() 

4535 g_act.add_argument("--write", action="store_true", 

4536 help="write/update an activity record for --run-id") 

4537 g_act.add_argument("--done", action="store_true", 

4538 help="mark --run-id's activity record finished") 

4539 g_act.add_argument("--clear", action="store_true", 

4540 help="remove --run-id's activity record") 

4541 p_activity.add_argument("--command", dest="activity_command_name", 

4542 choices=flows.command_names(), default="ship", 

4543 help="workflow command the activity belongs to") 

4544 p_activity.add_argument("--run-id", default="run", 

4545 help="run id keying the record (one file each)") 

4546 p_activity.add_argument("--phase", default=None, 

4547 help="current flow phase id for the command (--write)") 

4548 p_activity.add_argument("--status", choices=activity.STATUSES, default="running", 

4549 help="activity status for --write") 

4550 p_activity.add_argument("--issue", type=_positive_int, default=None, 

4551 help="issue number to record") 

4552 p_activity.add_argument("--pull-request", type=_positive_int, default=None, 

4553 help="pull request number to record") 

4554 p_activity.add_argument("--note", default=None, help="optional free-text note") 

4555 p_activity.add_argument("--json", action="store_true", help="emit structured JSON") 

4556 p_activity.set_defaults(func=_cmd_activity) 

4557 

4558 p_resume = sub.add_parser("resume", help="render a dry-run resume plan") 

4559 p_resume.add_argument("path", help="path to project.yaml") 

4560 p_resume.add_argument("--root", default=".", 

4561 help="repo root for resolving the checkpoint path") 

4562 p_resume.add_argument("--live-pr-state", choices=checkpoint.LIVE_PR_STATES, 

4563 default="unknown", 

4564 help="adapter-supplied live PR state for dry-run reconcile") 

4565 p_resume.add_argument("--live-worktree-state", choices=checkpoint.LIVE_WORKTREE_STATES, 

4566 default="unknown", 

4567 help="adapter-supplied live worktree state for dry-run reconcile") 

4568 p_resume.add_argument("--json", action="store_true", help="emit structured JSON") 

4569 p_resume.set_defaults(func=_cmd_resume) 

4570 

4571 _add_ship_parser( 

4572 sub.add_parser("ship", help="dry ship assessment (tier, window, gates, decision)"), 

4573 command="ship", 

4574 ) 

4575 

4576 p_implement = sub.add_parser( 

4577 "implement", 

4578 help="standalone implement-step preflight contract", 

4579 ) 

4580 p_implement.add_argument("path", help="path to project.yaml") 

4581 p_implement.add_argument("issue", type=_positive_int, help="issue number to implement") 

4582 p_implement.add_argument("--root", default=".", help="repo root for git and extensions") 

4583 p_implement.add_argument("--delegate", default=None, 

4584 help="explicit implementer delegate override") 

4585 p_implement.add_argument("--dry-run", action="store_true", 

4586 help="explicitly mark the assessment as non-mutating") 

4587 p_implement.add_argument("--live", action="store_true", 

4588 help="render a live preflight and fail if consent is missing") 

4589 p_implement.add_argument("--approve-scope", action="append", default=[], 

4590 help="approve a consent scope for this run; repeat or comma-separate") 

4591 p_implement.add_argument("--operator", default=None, 

4592 help="operator identifier to include in an approved consent record") 

4593 p_implement.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None, 

4594 help="operator consent mode: explicit, standing, or agent") 

4595 p_implement.add_argument("--target", default=None, 

4596 help="additional target text for the consent prompt") 

4597 p_implement.add_argument("--issue-title", default=None, 

4598 help="issue title to include in the intake/readiness contract") 

4599 p_implement.add_argument("--issue-body", default=None, 

4600 help="issue body markdown to include in the intake/readiness contract") 

4601 p_implement.add_argument("--issue-label", action="append", default=[], 

4602 help="issue label for intake/readiness; repeat or comma-separate") 

4603 p_implement.add_argument("--json", action="store_true", help="emit structured JSON") 

4604 p_implement.set_defaults(func=_cmd_standalone, standalone_command="implement") 

4605 

4606 p_ci = sub.add_parser( 

4607 "ci-check", 

4608 help="standalone read-only CI diagnostic preflight contract", 

4609 ) 

4610 p_ci.add_argument("path", help="path to project.yaml") 

4611 p_ci.add_argument("--root", default=".", help="repo root for capability checks") 

4612 p_ci.add_argument("--pr", type=_positive_int, default=None, 

4613 help="PR number whose latest checks should be diagnosed") 

4614 p_ci.add_argument("--target", default=None, 

4615 help="target text to include in the diagnostic contract") 

4616 p_ci.add_argument("--json", action="store_true", help="emit structured JSON") 

4617 p_ci.set_defaults(func=_cmd_standalone, standalone_command="ci-check") 

4618 

4619 p_morning = sub.add_parser( 

4620 "morning", 

4621 help="standalone daily-brief preflight contract", 

4622 ) 

4623 p_morning.add_argument("path", help="path to project.yaml") 

4624 p_morning.add_argument("--root", default=".", help="repo root for capability checks") 

4625 p_morning.add_argument("--since", default=None, 

4626 help="optional brief window start label or timestamp") 

4627 p_morning.add_argument("--target", default=None, 

4628 help="target text to include in the morning contract") 

4629 p_morning.add_argument("--dry-run", action="store_true", 

4630 help="explicitly mark the assessment as non-mutating") 

4631 p_morning.add_argument("--live", action="store_true", 

4632 help="render a live preflight and fail if consent is missing") 

4633 p_morning.add_argument("--approve-scope", action="append", default=[], 

4634 help="approve a consent scope for this run; repeat or comma-separate") 

4635 p_morning.add_argument("--operator", default=None, 

4636 help="operator identifier to include in an approved consent record") 

4637 p_morning.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None, 

4638 help="operator consent mode: explicit, standing, or agent") 

4639 p_morning.add_argument("--json", action="store_true", help="emit structured JSON") 

4640 p_morning.set_defaults(func=_cmd_standalone, standalone_command="morning") 

4641 

4642 p_wrap = sub.add_parser( 

4643 "wrap", 

4644 help="standalone session-wrap preflight contract", 

4645 ) 

4646 p_wrap.add_argument("path", help="path to project.yaml") 

4647 p_wrap.add_argument("title", nargs="?", default=None, 

4648 help="optional PR title override to include in the contract") 

4649 p_wrap.add_argument("--root", default=".", help="repo root for git and capability checks") 

4650 p_wrap.add_argument("--since", default=None, 

4651 help="optional session start label or timestamp") 

4652 p_wrap.add_argument("--target", default=None, 

4653 help="target text to include in the wrap contract") 

4654 p_wrap.add_argument("--dry-run", action="store_true", 

4655 help="explicitly mark the assessment as non-mutating") 

4656 p_wrap.add_argument("--live", action="store_true", 

4657 help="render a live preflight and fail if consent is missing") 

4658 p_wrap.add_argument("--approve-scope", action="append", default=[], 

4659 help="approve a consent scope for this run; repeat or comma-separate") 

4660 p_wrap.add_argument("--operator", default=None, 

4661 help="operator identifier to include in an approved consent record") 

4662 p_wrap.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None, 

4663 help="operator consent mode: explicit, standing, or agent") 

4664 p_wrap.add_argument("--json", action="store_true", help="emit structured JSON") 

4665 p_wrap.set_defaults(func=_cmd_standalone, standalone_command="wrap") 

4666 

4667 p_work_block = sub.add_parser( 

4668 "work-block", 

4669 help="standalone daytime multi-issue work-block preflight contract", 

4670 ) 

4671 p_work_block.add_argument("path", help="path to project.yaml") 

4672 p_work_block.add_argument("issues", nargs="*", type=_positive_int, 

4673 help="explicit issue numbers to process in order") 

4674 p_work_block.add_argument("--root", default=".", 

4675 help="repo root for git and capability checks") 

4676 p_work_block.add_argument("--queue", default=None, 

4677 help=( 

4678 "project queue selector to use when no explicit issues " 

4679 "are given" 

4680 )) 

4681 p_work_block.add_argument("--max", dest="max_items", type=_positive_int, default=None, 

4682 help="maximum issues to attempt in this work block") 

4683 p_work_block.add_argument("--hours", type=float, default=None, 

4684 help="optional time budget in hours") 

4685 p_work_block.add_argument("--review-comments", choices=("inline", "summary"), 

4686 default="inline", 

4687 help="review posting mode to pass through ship handoffs") 

4688 p_work_block.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None, 

4689 help="reviewer override for ship handoff contracts") 

4690 p_work_block.add_argument("--target", default=None, 

4691 help="target text to include in the work-block contract") 

4692 p_work_block.add_argument("--dry-run", action="store_true", 

4693 help="explicitly mark the assessment as non-mutating") 

4694 p_work_block.add_argument("--live", action="store_true", 

4695 help="render a live preflight and fail if consent is missing") 

4696 p_work_block.add_argument("--approve-scope", action="append", default=[], 

4697 help="approve a consent scope for this run; repeat or comma-separate") 

4698 p_work_block.add_argument("--operator", default=None, 

4699 help="operator identifier to include in an approved consent record") 

4700 p_work_block.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None, 

4701 help="operator consent mode: explicit, standing, or agent") 

4702 p_work_block.add_argument("--json", action="store_true", help="emit structured JSON") 

4703 p_work_block.set_defaults(func=_cmd_standalone, standalone_command="work-block") 

4704 

4705 p_overnight = sub.add_parser( 

4706 "overnight", 

4707 help="standalone overnight-session preflight contract", 

4708 ) 

4709 p_overnight.add_argument("path", help="path to project.yaml") 

4710 p_overnight.add_argument("hours", nargs="?", type=float, default=None, 

4711 help="optional time budget in hours") 

4712 p_overnight.add_argument("--root", default=".", help="repo root for git and capability checks") 

4713 p_overnight.add_argument("--max", dest="max_items", type=_positive_int, default=None, 

4714 help="maximum issues to attempt in this session") 

4715 p_overnight.add_argument("--review-comments", choices=("inline", "summary"), default="inline", 

4716 help="review posting mode to pass through ship handoffs") 

4717 p_overnight.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None, 

4718 help="reviewer override for ship handoff contracts") 

4719 p_overnight.add_argument("--target", default=None, 

4720 help="target text to include in the overnight contract") 

4721 p_overnight.add_argument("--dry-run", action="store_true", 

4722 help="explicitly mark the assessment as non-mutating") 

4723 p_overnight.add_argument("--live", action="store_true", 

4724 help="render a live preflight and fail if consent is missing") 

4725 p_overnight.add_argument("--approve-scope", action="append", default=[], 

4726 help="approve a consent scope for this run; repeat or comma-separate") 

4727 p_overnight.add_argument("--operator", default=None, 

4728 help="operator identifier to include in an approved consent record") 

4729 p_overnight.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None, 

4730 help="operator consent mode: explicit, standing, or agent") 

4731 p_overnight.add_argument("--json", action="store_true", help="emit structured JSON") 

4732 p_overnight.set_defaults(func=_cmd_standalone, standalone_command="overnight") 

4733 

4734 p_regression = sub.add_parser( 

4735 "regression", 

4736 help="standalone scan-and-file regression preflight contract", 

4737 ) 

4738 p_regression.add_argument("path", help="path to project.yaml") 

4739 p_regression.add_argument("--root", default=".", help="repo root for git/capability checks") 

4740 p_regression.add_argument("--scope", choices=("full", "changed", "since"), default="full", 

4741 help="scan scope to include in the preflight target") 

4742 p_regression.add_argument("--since", default=None, 

4743 help="optional ref or timestamp when --scope since is used") 

4744 p_regression.add_argument("--target", default=None, 

4745 help="target text to include in the regression contract") 

4746 p_regression.add_argument("--dry-run", action="store_true", 

4747 help="explicitly mark the assessment as non-mutating") 

4748 p_regression.add_argument("--live", action="store_true", 

4749 help="render a live preflight and fail if consent is missing") 

4750 p_regression.add_argument("--approve-scope", action="append", default=[], 

4751 help="approve a consent scope for this run; repeat or comma-separate") 

4752 p_regression.add_argument("--operator", default=None, 

4753 help="operator identifier to include in an approved consent record") 

4754 p_regression.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None, 

4755 help="operator consent mode: explicit, standing, or agent") 

4756 p_regression.add_argument("--json", action="store_true", help="emit structured JSON") 

4757 p_regression.set_defaults(func=_cmd_standalone, standalone_command="regression") 

4758 

4759 p_review_all_day = sub.add_parser( 

4760 "review-all-day", 

4761 help="standalone time-window scan-and-file preflight contract", 

4762 ) 

4763 p_review_all_day.add_argument("path", help="path to project.yaml") 

4764 p_review_all_day.add_argument("days", nargs="?", type=_positive_int, default=1, 

4765 help="number of merge-window days to scan") 

4766 p_review_all_day.add_argument("--root", default=".", help="repo root for git/capability checks") 

4767 p_review_all_day.add_argument("--target", default=None, 

4768 help="target text to include in the review-all-day contract") 

4769 p_review_all_day.add_argument("--dry-run", action="store_true", 

4770 help="explicitly mark the assessment as non-mutating") 

4771 p_review_all_day.add_argument("--live", action="store_true", 

4772 help="render a live preflight and fail if consent is missing") 

4773 p_review_all_day.add_argument("--approve-scope", action="append", default=[], 

4774 help=("approve a consent scope for this run; repeat or " 

4775 "comma-separate")) 

4776 p_review_all_day.add_argument("--operator", default=None, 

4777 help=("operator identifier to include in an approved " 

4778 "consent record")) 

4779 p_review_all_day.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None, 

4780 help="operator consent mode: explicit, standing, or agent") 

4781 p_review_all_day.add_argument("--json", action="store_true", help="emit structured JSON") 

4782 p_review_all_day.set_defaults(func=_cmd_standalone, standalone_command="review-all-day") 

4783 

4784 p_caps = sub.add_parser("capabilities", help="print runtime capability report") 

4785 p_caps.add_argument("--root", default=".", help="repo root for capability checks") 

4786 p_caps.add_argument("--project", dest="path", default=None, 

4787 help="optional project.yaml to evaluate requirements") 

4788 p_caps.add_argument("--for", dest="for_command", default="ship", 

4789 help="command requirement to evaluate when --project is set") 

4790 p_caps.add_argument("--pr", type=int, default=None, 

4791 help="PR number for ship capability requirements") 

4792 p_caps.add_argument("--json", action="store_true", help="emit structured JSON") 

4793 p_caps.set_defaults(func=_cmd_capabilities) 

4794 

4795 p_doctor = sub.add_parser( 

4796 "doctor", 

4797 help="read-only diagnostics: CLI/adapter version drift, orphans, core_version, state", 

4798 ) 

4799 p_doctor.add_argument("path", nargs="?", default=None, 

4800 help="optional project.yaml (enables core_version + state checks)") 

4801 p_doctor.add_argument("--root", default=".", help="project root to inspect") 

4802 p_doctor.add_argument("--offline", action="store_true", 

4803 help="skip the PyPI latest-version check (report latest as unknown)") 

4804 p_doctor.add_argument("--strict", action="store_true", 

4805 help="exit non-zero when any check fails (default: advisory, exit 0)") 

4806 p_doctor.add_argument("--json", action="store_true", help="emit structured JSON") 

4807 p_doctor.set_defaults(func=_cmd_doctor) 

4808 

4809 p_proj = sub.add_parser("project-commands", 

4810 help="list project-provided commands declared by policy") 

4811 p_proj.add_argument("path", help="path to project.yaml") 

4812 p_proj.add_argument("--json", action="store_true", help="emit structured JSON") 

4813 p_proj.set_defaults(func=_cmd_project_commands) 

4814 

4815 p_init = sub.add_parser("init", help="scaffold a default .keel/project.yaml for this repo") 

4816 p_init.add_argument("--root", default=".", help="repo root to scaffold into") 

4817 p_init.add_argument("--force", action="store_true", help="overwrite an existing config") 

4818 p_init.add_argument("--wizard", action="store_true", help="prompt for values interactively") 

4819 p_init.set_defaults(func=_cmd_init) 

4820 

4821 p_setup = sub.add_parser( 

4822 "setup", 

4823 help="scaffold config, install adapters, validate, and render the plan", 

4824 ) 

4825 p_setup.add_argument("--root", default=".", help="project root to set up") 

4826 p_setup.add_argument( 

4827 "--adapter-target", 

4828 choices=("all", *install.TARGETS), 

4829 default="all", 

4830 help="adapter surface to install (default: all)", 

4831 ) 

4832 p_setup.add_argument( 

4833 "--force", 

4834 action="store_true", 

4835 help="overwrite existing config and generated adapters", 

4836 ) 

4837 p_setup.add_argument("--wizard", action="store_true", help="prompt for config values") 

4838 p_setup.set_defaults(func=_cmd_setup) 

4839 

4840 p_ia = sub.add_parser("install-adapter", help="install the /keel:<command> adapters") 

4841 p_ia.add_argument("agent", 

4842 help=f"'all', 'plugin', or one of: {', '.join(install.TARGETS)}") 

4843 p_ia.add_argument("--root", default=".", help="project root to install into") 

4844 p_ia.add_argument("--force", action="store_true", help="overwrite existing adapters") 

4845 p_ia.set_defaults(func=_cmd_install_adapter) 

4846 

4847 p_as = sub.add_parser("adapter-status", help="report generated adapter freshness") 

4848 p_as.add_argument("agent", nargs="?", default="all", 

4849 help=f"'all' or one of: {', '.join(install.STATUS_TARGETS)}") 

4850 p_as.add_argument("--root", default=".", help="project root to inspect") 

4851 p_as.add_argument("--include-unmanaged", action="store_true", 

4852 help="also report marker-less command-like surfaces (heuristic, opt-in)") 

4853 p_as.add_argument("--json", action="store_true", 

4854 help="emit adapter freshness + orphan/unmanaged findings as JSON") 

4855 p_as.set_defaults(func=_cmd_adapter_status) 

4856 

4857 p_ua = sub.add_parser("update-adapter", help="safely update generated adapters") 

4858 p_ua.add_argument("agent", nargs="?", default="all", 

4859 help=f"'all' or one of: {', '.join(install.TARGETS)}") 

4860 p_ua.add_argument("--root", default=".", help="project root to update") 

4861 p_ua.add_argument("--dry-run", action="store_true", help="show planned updates only") 

4862 p_ua.set_defaults(func=_cmd_update_adapter) 

4863 

4864 p_sync = sub.add_parser("sync", help="sync generated adapters with the installed keel package") 

4865 p_sync.add_argument("--root", default=".", help="project root to update") 

4866 p_sync.add_argument( 

4867 "--target", 

4868 choices=("all", *install.TARGETS), 

4869 default="all", 

4870 help="adapter surface to sync (default: all)", 

4871 ) 

4872 p_sync.add_argument("--dry-run", action="store_true", help="show planned updates only") 

4873 p_sync.set_defaults(func=_cmd_sync) 

4874 

4875 p_lw = sub.add_parser( 

4876 "install-legacy-wrappers", 

4877 help="install thin legacy command wrappers that delegate to /keel:<command>", 

4878 ) 

4879 p_lw.add_argument("agent", help=f"'all' or one of: {', '.join(install.LEGACY_TARGETS)}") 

4880 p_lw.add_argument("--root", default=".", help="project root to install into") 

4881 p_lw.add_argument("--force", action="store_true", help="overwrite existing wrappers") 

4882 p_lw.add_argument( 

4883 "--parity-matrix", 

4884 default="docs/keel/parity-matrix.md", 

4885 help="markdown parity matrix whose ready rows allow wrapper generation", 

4886 ) 

4887 p_lw.add_argument( 

4888 "--command", 

4889 action="append", 

4890 type=_parse_legacy_mapping, 

4891 default=[], 

4892 metavar="LEGACY=KEEL", 

4893 help="install one wrapper mapping; repeat for multiple commands", 

4894 ) 

4895 p_lw.set_defaults(func=_cmd_install_legacy_wrappers) 

4896 

4897 return parser 

4898 

4899 

4900def _add_ship_parser(parser: argparse.ArgumentParser, *, command: str) -> None: 

4901 parser.add_argument("path", help="path to project.yaml") 

4902 parser.add_argument("--root", default=".", help="repo root for git, gates + extensions") 

4903 parser.add_argument("--pr", type=int, default=None, help="PR number for CI status (gh)") 

4904 parser.add_argument("--hotfix", action="store_true", help="emergency: bypass the merge window") 

4905 parser.add_argument("--dry-run", action="store_true", 

4906 help="explicitly mark the assessment as non-mutating") 

4907 parser.add_argument("--live", action="store_true", 

4908 help=("run the live preflight gate and fail before gates " 

4909 "if consent is missing")) 

4910 parser.add_argument("--approve-scope", action="append", default=[], 

4911 help="approve a consent scope for this run; repeat or comma-separate") 

4912 parser.add_argument("--operator", default=None, 

4913 help="operator identifier to include in an approved consent record") 

4914 parser.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None, 

4915 help="operator consent mode: explicit, standing, or agent") 

4916 parser.add_argument("--target", default=None, 

4917 help="task target to include in the consent prompt and record") 

4918 parser.add_argument("--append-ledger", action="store_true", 

4919 help="append the structured ship run record when --live succeeds") 

4920 parser.add_argument("--run-id", default=None, 

4921 help="operator/session run id to store in the run ledger record") 

4922 parser.add_argument("--run-events-file", default=None, 

4923 help="JSON run-events file to evaluate and stamp into the ledger record") 

4924 parser.add_argument("--max-rounds", type=_positive_int, default=None, 

4925 help="explicit run-control work-unit budget override") 

4926 parser.add_argument("--issue", type=_positive_int, default=None, 

4927 help="issue number to store in the run ledger record") 

4928 parser.add_argument("--pull-request", dest="ledger_pr", type=_positive_int, default=None, 

4929 help="PR number to store in the run ledger record without CI lookup") 

4930 parser.add_argument("--branch", default=None, 

4931 help="branch name to store in the run ledger record") 

4932 parser.add_argument("--head-sha", default=None, 

4933 help="head commit SHA to store in the run ledger record") 

4934 parser.add_argument("--declared-file", action="append", default=None, 

4935 help="implementer's declared in-scope file path (repeatable); " 

4936 "recorded for keel scope-verify branch-contamination checks") 

4937 parser.add_argument("--capture-status", type=_capture_status_arg, default=None, 

4938 help="capture outcome to store in the run ledger record") 

4939 parser.add_argument("--capture-reason", default=None, 

4940 help="capture outcome reason to store in the run ledger record") 

4941 parser.add_argument("--capture-artifact", default=None, 

4942 help="durable capture artifact reference (path or content hash) " 

4943 "proving an applied capture; required for clean reconcile") 

4944 parser.add_argument("--implementer", default=None, 

4945 help="effective implementer codename or vendor/model label") 

4946 parser.add_argument("--reviewer-agent", action="append", default=[], 

4947 help="effective reviewer codename or vendor/model label; repeatable") 

4948 parser.add_argument("--tester", default=None, 

4949 help="effective tester codename or vendor/model label") 

4950 parser.add_argument("--host-agent", default=None, 

4951 help="host agent codename (e.g. claude/codex/agy) for the run context") 

4952 parser.add_argument("--transport", choices=("gh", "mcp"), default=None, 

4953 help="detected GitHub transport for the run context; " 

4954 "defaults to the resolved transport when omitted") 

4955 parser.add_argument("--strict-run-context", action="store_true", 

4956 help="block live ledger append when required run-context fields " 

4957 "would degrade") 

4958 parser.add_argument("--issue-title", default=None, 

4959 help="issue title to include in the intake/readiness contract") 

4960 parser.add_argument("--issue-body", default=None, 

4961 help="issue body markdown to include in the intake/readiness contract") 

4962 parser.add_argument("--issue-label", action="append", default=[], 

4963 help="issue label for intake/readiness; repeat or comma-separate") 

4964 parser.add_argument("--review-comments", choices=("inline", "summary"), default="inline", 

4965 help="review posting mode for the resolved ship contract") 

4966 parser.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None, 

4967 help="override the risk-derived reviewer count") 

4968 parser.add_argument("--jury", action="store_true", 

4969 help="enable the cross-vendor jury gate") 

4970 parser.add_argument("--no-jury", action="store_true", 

4971 help="disable the cross-vendor jury gate") 

4972 parser.add_argument("--jury-advisory", action="store_true", 

4973 help="make an enabled jury advisory instead of merge-gating") 

4974 parser.add_argument("--profile", choices=("standard", "compound"), default="standard", 

4975 help="workflow profile: standard (default) or compound") 

4976 parser.add_argument("--compound", action="store_true", 

4977 help="select the compound workflow profile (alias for --profile compound)") 

4978 parser.add_argument("--json", action="store_true", help="emit structured JSON") 

4979 parser.set_defaults(func=_cmd_ship, ship_command=command) 

4980 

4981 

4982def _positive_int(value: str) -> int: 

4983 parsed = int(value) 

4984 if parsed <= 0: 

4985 raise argparse.ArgumentTypeError("must be a positive integer") 

4986 return parsed 

4987 

4988 

4989def _nonnegative_int(value: str) -> int: 

4990 parsed = int(value) 

4991 if parsed < 0: 

4992 raise argparse.ArgumentTypeError("must be a non-negative integer") 

4993 return parsed 

4994 

4995 

4996def _parse_pr_issue_mapping(value: str) -> tuple[int, int]: 

4997 if "=" not in value: 

4998 raise argparse.ArgumentTypeError("linked issue mapping must be PR=ISSUE") 

4999 raw_pr, raw_issue = value.split("=", 1) 

5000 try: 

5001 pr = _positive_int(raw_pr) 

5002 issue = _positive_int(raw_issue) 

5003 except (argparse.ArgumentTypeError, ValueError) as exc: 

5004 raise argparse.ArgumentTypeError("linked issue mapping must be PR=ISSUE") from exc 

5005 return pr, issue 

5006 

5007 

5008def _capture_status_arg(value: str) -> str: 

5009 if value == "skipped": 

5010 return value 

5011 try: 

5012 capture.normalize_status(value) 

5013 except capture.CaptureError as exc: 

5014 raise argparse.ArgumentTypeError(str(exc)) from exc 

5015 return value 

5016 

5017 

5018def main(argv: list[str] | None = None) -> int: 

5019 parser = build_parser() 

5020 args = parser.parse_args(argv) 

5021 func = getattr(args, "func", None) 

5022 if func is None: 

5023 parser.print_help() 

5024 return 2 

5025 try: 

5026 return func(args) 

5027 except ledger.LedgerError as exc: 

5028 print(f"invalid ledger path: {exc}", file=sys.stderr) 

5029 return 1 

5030 except checkpoint.CheckpointError as exc: 

5031 print(f"invalid checkpoint path: {exc}", file=sys.stderr) 

5032 return 1