Coverage for src/keel/cli.py: 100%
3089 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
1"""The ``keel`` command-line interface (thin; logic lives in the pure modules).
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"""
10from __future__ import annotations
12import argparse
13import json
14import os
15import re
16import sys
17from pathlib import Path
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 mergeverify,
47 project_commands,
48 review,
49 runcontrols,
50 runtime,
51 scaffold,
52 scope,
53 ship,
54 standalone,
55 status,
56 stepverifier,
57 swarm,
58 window,
59 workspace,
60)
61from . import config as cfg
62from . import findings as fnd
63from . import orchestrator as orch
64from .extensions import ExtensionError, load_extensions
65from .gates import GateSpec
66from .model import DEFAULT_GATE_TIMEOUT_S, DEFAULT_JURY_TIMEOUT_S
67from .runner import command_gate_runner, run_argv
70def _gate_status(outcome) -> str:
71 """Operator-facing label for one gate outcome.
73 `NOT-RUN` is deliberately not `ok`: an agentic gate the command-only runner never
74 dispatched has produced no result, and showing it as a pass is what let a blocking
75 review gate nobody executed read as green.
76 """
77 if outcome.not_run:
78 return "NOT-RUN"
79 if outcome.ok:
80 return "ok"
81 return "TIMEOUT" if outcome.timed_out else "FAIL"
84def _gate_runner(root: str, diff_text: str, *, jury_mode: str = "gating",
85 timeout: int = DEFAULT_GATE_TIMEOUT_S):
86 """A gate runner that handles command gates plus the ``jury`` built-in (on the diff).
88 ``timeout`` is the project's ``knobs.gate_timeout_s``; it covers any command spec
89 that reached the runner without a resolved per-gate limit. The jury builtin reads
90 its own budget off ``spec.timeout``, which ``plan_gates`` resolves from
91 ``knobs.jury_timeout_s``.
92 """
93 commands = command_gate_runner(root, timeout=timeout)
95 def run(spec: GateSpec):
96 if spec.kind == "builtin" and spec.id == "jury":
97 jury_limit = spec.timeout if spec.timeout is not None else DEFAULT_JURY_TIMEOUT_S
98 return jury.run_gate(diff_text, cwd=root, mode=jury_mode, timeout=jury_limit)
99 return commands(spec)
101 return run
104def _cmd_version(args: argparse.Namespace) -> int:
105 print(f"keel {__version__}")
106 return 0
109def _cmd_validate(args: argparse.Namespace) -> int:
110 rc = 0
111 for path in args.paths:
112 try:
113 config = cfg.load_config(path)
114 except FileNotFoundError:
115 print(f"MISSING {path}")
116 rc = 1
117 continue
118 except cfg.ConfigError as exc:
119 print(f"INVALID {path}")
120 print(f" {exc}".replace("\n", "\n "))
121 rc = 1
122 continue
124 if args.root is not None:
125 try:
126 load_extensions(config, args.root, strict=True)
127 except ExtensionError as exc:
128 print(f"INVALID {path} (extensions)")
129 print(f" {exc}".replace("\n", "\n "))
130 rc = 1
131 continue
132 print(f"OK {path} ({config.repo or '-'}, base {config.base_branch})")
133 return rc
136def _autostamp(config: cfg.ProjectConfig, root: str, command: str, run_id: str | None,
137 phase: str, *, status: str = "running", verdict: str | None = None,
138 issue: int | None = None, pr: int | None = None) -> None:
139 """Record a run on keel-visual's board at ``phase`` from a deterministic core call.
141 The agent orchestrates the backbone but reliably **skips** the per-phase
142 ``keel activity`` calls, so runs (real ``keel ship`` included) went invisible.
143 Instead, the commands the backbone *always* runs do the stamping: ``keel plan``
144 (Step 0 → first phase), ``keel run-gates`` (s8 test), and ``keel merge`` (s10,
145 stamped ``merged`` — a real merge landed, distinct from a soft ``done``).
146 A run therefore shows up **and advances** — start → test → merged — independent of
147 agent discipline. Opt-in via ``--run-id``. Fail-soft: no run-id / unknown command /
148 unknown phase / write error / pre-activity core is a no-op, never an aborted command.
149 Never moves a run backward (a re-run of an earlier step won't undo later progress);
150 ``merged`` is terminal and is never overwritten by a later stamp.
152 ``verdict`` records whether the phase was *passed*. The never-regress rule is about
153 position, so it stays keyed on phase alone — but position was all that was recorded,
154 which is why a red gate looked exactly like an in-progress one (#636).
155 """
156 if not run_id or not flows.is_known(command):
157 return
158 order = [p.id for p in flows.flow_for(command)]
159 if phase not in order:
160 return
161 try:
162 path = activity.record_path(root, config, run_id)
163 existing = activity.read_activity(path)
164 if existing and existing.get("status") == "merged":
165 return # merged is terminal; never overwrite a landed run
166 if (status == "running" and existing and existing.get("status") == "running"
167 and existing.get("phase") in order
168 and order.index(existing["phase"]) > order.index(phase)):
169 return # don't regress a more-advanced still-running run
170 record = activity.build_activity_record(
171 command=command, run_id=run_id, phase=phase, status=status,
172 verdict=verdict, issue=issue, pr=pr)
173 activity.write_activity(path, record)
174 except (activity.ActivityError, OSError):
175 return
178def _plan_stamp_activity(args: argparse.Namespace, config: cfg.ProjectConfig) -> None:
179 """Stamp the run from the Step 0 ``keel plan`` call (first phase). See :func:`_autostamp`."""
180 command = args.command_contract
181 if not flows.is_known(command):
182 return
183 _autostamp(config, args.root, command, getattr(args, "run_id", None),
184 flows.flow_for(command)[0].id,
185 issue=getattr(args, "issue", None), pr=getattr(args, "pull_request", None))
188def _cmd_plan(args: argparse.Namespace) -> int:
189 try:
190 config = cfg.load_config(args.path)
191 except FileNotFoundError:
192 print(f"no such config: {args.path}", file=sys.stderr)
193 return 1
194 except cfg.ConfigError as exc:
195 print(str(exc), file=sys.stderr)
196 return 1
198 try:
199 ledger.resolve_path(args.root, config)
200 checkpoint.resolve_path(args.root, config)
201 except ledger.LedgerError as exc:
202 print(f"invalid ledger path: {exc}", file=sys.stderr)
203 return 1
204 except checkpoint.CheckpointError as exc:
205 print(f"invalid checkpoint path: {exc}", file=sys.stderr)
206 return 1
208 loaded, problems = load_extensions(config, args.root, strict=False)
209 try:
210 plan = orch.build_plan(config, loaded)
211 except gates.GateError as exc:
212 print(str(exc), file=sys.stderr)
213 return 1
214 requirement = (
215 runtime.scan_capability_requirement(args.command_contract, config)
216 if args.command_contract in {"regression", "review-all-day"}
217 else _capability_requirement(args.command_contract, config, loaded)
218 )
219 report = runtime.detect(args.root)
220 evaluation = runtime.evaluate(requirement, report)
221 transport = github_transport.resolve(report)
222 try:
223 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent(
224 args,
225 config,
226 _has_live_consent_scope(
227 args, args.command_contract, config, requirement, loaded
228 ),
229 )
230 except ValueError as exc:
231 print(str(exc), file=sys.stderr)
232 return 1
233 contract = contracts.build_command_contract(
234 command=args.command_contract,
235 profile=args.profile,
236 config=config,
237 loaded=loaded,
238 plan=plan,
239 requirement=requirement,
240 evaluation=evaluation,
241 transport=transport,
242 extension_problems=tuple(problems),
243 dry_run=not args.live,
244 approved_consent_scopes=approved_scopes,
245 consent_approval_source=approval_source,
246 consent_mode=consent_mode,
247 operator=approval_operator,
248 target=args.target,
249 reviewer_override=args.reviewers,
250 review_comments=args.review_comments,
251 jury=args.jury,
252 no_jury=args.no_jury,
253 jury_advisory=args.jury_advisory,
254 issue_title=args.issue_title,
255 issue_body=args.issue_body,
256 issue_labels=_issue_labels(args),
257 )
258 consent_ok, consent_message = consent.assert_operator_consent(contract["operator_consent"])
259 if args.json:
260 print(json.dumps({
261 "contract": contract,
262 "plan": orch.plan_as_dict(plan),
263 "capabilities": evaluation.as_dict(),
264 "github_transport": transport.as_dict(),
265 }, indent=2, sort_keys=True))
266 else:
267 print(orch.render_plan(config, plan))
268 print(evaluation.render())
269 print(f"operator consent: {contract['operator_consent']['status']}")
270 print(f" {contract['operator_consent']['consent_prompt']}")
271 for prob in problems:
272 print(f" ! extension not loaded: {prob}", file=sys.stderr)
273 if not consent_ok:
274 print(consent_message, file=sys.stderr)
275 return 1
276 _plan_stamp_activity(args, config) # surface the run on the board from Step 0
277 return 0
280def _cmd_run_gates(args: argparse.Namespace) -> int:
281 try:
282 config = cfg.load_config(args.path)
283 except FileNotFoundError:
284 print(f"no such config: {args.path}", file=sys.stderr)
285 return 1
286 except cfg.ConfigError as exc:
287 print(str(exc), file=sys.stderr)
288 return 1
290 loaded, problems = load_extensions(config, args.root, strict=False)
291 for prob in problems:
292 print(f" ! extension not loaded: {prob}", file=sys.stderr)
294 try:
295 specs = gates.plan_gates(config, loaded)
296 except gates.GateError as exc:
297 print(str(exc), file=sys.stderr)
298 return 1
300 requirement = _capability_requirement("run-gates", config, loaded)
301 report = runtime.detect(args.root)
302 evaluation = runtime.evaluate(requirement, report)
303 if not evaluation.ok:
304 print(evaluation.render(), file=sys.stderr)
305 return 1
306 if evaluation.missing_optional:
307 print(evaluation.render(), file=sys.stderr)
309 diff_text = git.diff(config.base_branch, "HEAD", cwd=args.root)
310 outcomes = gates.run_gates(specs, _gate_runner(args.root, diff_text, jury_mode="gating",
311 timeout=config.knobs.gate_timeout_s))
312 verdict = fnd.summarize(gates.collect_findings(outcomes))
313 # Stamp *after* the verdict exists, and carry it. Stamping on reach alone recorded
314 # a red gate as `phase: s8, status: running` with no failure signal, so the board
315 # painted a run that failed its gates as one still working through them (#636).
316 _autostamp(config, args.root, args.gate_command, getattr(args, "run_id", None),
317 args.gate_phase, verdict="blocked" if verdict.blocked else "pass",
318 issue=getattr(args, "issue", None),
319 pr=getattr(args, "pull_request", None)) # the run reached the test gate (s8)
320 for o in outcomes:
321 # A timeout still blocks; it is labelled apart so a slow host does not read
322 # as a broken test (and a hanging command still reads as red).
323 status = _gate_status(o)
324 print(f" {status:>7} {o.gate}")
326 for f in verdict.findings:
327 print(f" [{f.severity}] {f.source}: {f.message.splitlines()[0]}")
328 if verdict.blocked:
329 print("BLOCKED — merge is gated by the findings above")
330 return 1
331 return 0
334def _cmd_window(args: argparse.Namespace) -> int:
335 try:
336 config = cfg.load_config(args.path)
337 except FileNotFoundError:
338 print(f"no such config: {args.path}", file=sys.stderr)
339 return 1
340 except cfg.ConfigError as exc:
341 print(str(exc), file=sys.stderr)
342 return 1
344 if not config.timezone or not config.merge_window:
345 print("no merge window configured (needs timezone + merge_window)")
346 return 0
347 is_open = window.is_merge_open(config.timezone, config.merge_window)
348 state = "OPEN" if is_open else "CLOSED (night no-merge)"
349 print(f"merge window {state} [{config.timezone} {config.merge_window}]")
350 return 0
353def _cmd_claim(args: argparse.Namespace) -> int:
354 result = lock.claim_resource(_lock_root(args.root), args.resource, owner=args.owner)
355 if args.json:
356 print(json.dumps(result.as_dict(), indent=2, sort_keys=True))
357 else:
358 print(f"keel claim — {result.status} {result.resource}")
359 print(f" owner : {result.owner}")
360 print(f" path : {result.path}")
361 if result.holder:
362 print(f" holder: {result.holder}")
363 return 0 if result.granted else 1
366def _cmd_release(args: argparse.Namespace) -> int:
367 result = lock.release_resource(_lock_root(args.root), args.resource, owner=args.owner)
368 if args.json:
369 print(json.dumps(result.as_dict(), indent=2, sort_keys=True))
370 else:
371 print(f"keel release — {result.status} {result.resource}")
372 print(f" owner : {result.owner}")
373 print(f" path : {result.path}")
374 if result.holder:
375 print(f" holder: {result.holder}")
376 return 0 if result.status in {"released", "missing"} else 1
379def _cmd_worktree_remove(args: argparse.Namespace) -> int:
380 try:
381 worktree_path = _validated_worktree_path(args.root, args.worktree)
382 except ValueError as exc:
383 print(str(exc), file=sys.stderr)
384 return 1
385 result = git.worktree_remove(str(worktree_path), cwd=args.root)
386 if args.json:
387 print(json.dumps({
388 "worktree": str(worktree_path),
389 "removed": result.ok,
390 "code": result.code,
391 "output": result.output,
392 }, indent=2, sort_keys=True))
393 else:
394 print(f"keel worktree-remove — {'removed' if result.ok else 'failed'} {worktree_path}")
395 if result.output.strip():
396 print(result.output.strip())
397 return 0 if result.ok else 1
400def _parse_labels(raw: str | None) -> tuple[str, ...]:
401 """Split a comma-separated ``--issue-labels`` value into clean labels."""
402 if not raw:
403 return ()
404 return tuple(part.strip() for part in raw.split(",") if part.strip())
407def _gather_issue_facts(args: argparse.Namespace) -> tuple[str, tuple[str, ...], bool]:
408 """Resolve the issue title + labels for blocker evaluation.
410 Offline path: take ``--issue-title`` / ``--issue-labels`` verbatim. Live
411 path: when ``--issue`` is given, fetch the authoritative title/labels from
412 the host via ``gh`` (fail-soft — a failed fetch falls back to the args).
414 Returns ``(title, labels, authoritative)``. ``authoritative`` is True ONLY
415 when ``--issue N`` was given AND the live ``gh`` fetch succeeded and parsed
416 into a real dict carrying the host's title/labels. When ``--issue`` is
417 absent, or the fetch failed / fell back to the agent-supplied args, it is
418 False — the facts are agent-supplied and must not self-justify a window
419 bypass (audit GAP-11).
420 """
421 title = args.issue_title or ""
422 labels = _parse_labels(args.issue_labels)
423 authoritative = False
424 issue = getattr(args, "issue", None)
425 if issue is not None:
426 result = github.issue_facts(issue, cwd=args.root)
427 if result.ok:
428 try:
429 data = json.loads(result.stdout)
430 except json.JSONDecodeError:
431 data = None
432 if isinstance(data, dict):
433 authoritative = True
434 if isinstance(data.get("title"), str):
435 title = data["title"]
436 raw_labels = data.get("labels")
437 if isinstance(raw_labels, list):
438 labels = tuple(
439 str(item.get("name"))
440 for item in raw_labels
441 if isinstance(item, dict) and isinstance(item.get("name"), str)
442 )
443 return title, labels, authoritative
446def _cmd_guard(args: argparse.Namespace) -> int:
447 try:
448 config = cfg.load_config(args.path)
449 except FileNotFoundError:
450 print(f"no such config: {args.path}", file=sys.stderr)
451 return 1
452 except cfg.ConfigError as exc:
453 print(str(exc), file=sys.stderr)
454 return 1
456 try:
457 rules = guard.resolve_rules(config)
458 except guard.GuardError as exc:
459 print(f"invalid blocker rules: {exc}", file=sys.stderr)
460 return 1
462 title, labels, _authoritative = _gather_issue_facts(args)
463 result = guard.evaluate(title, labels, rules=rules)
464 if args.json:
465 print(json.dumps(result.as_dict(), indent=2, sort_keys=True))
466 else:
467 verdict = "BLOCKER" if result.is_blocker else "not a blocker"
468 print(f"keel guard — {verdict}")
469 print(f" title : {title}")
470 print(f" labels : {', '.join(labels) or '(none)'}")
471 if result.matched:
472 print(f" matched: {', '.join(result.matched)}")
473 else:
474 print(" matched: (none)")
475 return 0
478def _hotfix_justification(
479 args: argparse.Namespace, config: cfg.ProjectConfig, operator: str | None
480) -> tuple[dict[str, object] | None, str | None]:
481 """Resolve the justification required for a ``--hotfix`` window bypass.
483 Returns ``(justification, None)`` on success or ``(None, error)`` when the
484 hotfix is refused. A hotfix must carry one of two justifications, recorded
485 in the ledger:
487 * ``matched-rule`` — ``--blocker-rule <id>`` names a rule that actually fires
488 for this issue under :mod:`keel.guard`; or
489 * ``operator-override`` — an explicit ``--operator-override`` paired with a
490 named ``--operator`` (the audited human override).
492 With neither, the bypass is refused (closing audit GAP-11: an agent can no
493 longer flip ``--hotfix`` on the flag alone).
495 The ``matched-rule`` path requires **host-authoritative** issue facts: it
496 refuses unless ``--issue N`` was given and the live ``gh`` fetch succeeded,
497 so an agent cannot self-justify a window bypass with a fabricated
498 ``--issue-title``. The ``operator-override`` path (the audited human escape)
499 is unaffected.
500 """
501 if args.blocker_rule:
502 try:
503 rules = guard.resolve_rules(config)
504 except guard.GuardError as exc:
505 return None, f"invalid blocker rules: {exc}"
506 title, labels, authoritative = _gather_issue_facts(args)
507 if not authoritative:
508 return None, (
509 "hotfix matched-rule justification requires --issue <N> "
510 "(host-authoritative title/labels); agent-supplied --issue-title "
511 "is not accepted for a window bypass — use --operator-override instead"
512 )
513 result = guard.evaluate(title, labels, rules=rules)
514 if args.blocker_rule not in result.rule_ids:
515 return None, f"unknown blocker rule {args.blocker_rule!r}"
516 if args.blocker_rule not in result.matched:
517 return None, (
518 f"blocker rule {args.blocker_rule!r} did not match the issue "
519 "(title/labels do not satisfy the rule)"
520 )
521 return {
522 "kind": "matched-rule",
523 "rule_id": args.blocker_rule,
524 "matched": list(result.matched),
525 }, None
526 if args.operator_override:
527 if not operator:
528 return None, "--operator-override requires a named --operator for the audit trail"
529 return {"kind": "operator-override", "operator": operator}, None
530 return None, (
531 "hotfix requires a justification: pass --blocker-rule <id> matching a "
532 "keel guard rule for the issue, or --operator-override with a named --operator"
533 )
536CHECKPOINT_MERGE_STEP = "s10"
539def _checkpoint_gate(
540 args: argparse.Namespace,
541 config: cfg.ProjectConfig,
542 *,
543 run_id: str | None,
544 operator: str | None,
545) -> tuple[dict[str, object], str | None]:
546 """Gate the merge on a covering checkpoint for the run at step s10.
548 Returns ``(payload, None)`` when the merge may proceed or ``(payload, error)``
549 when it must be refused. The gate is enforced only when checkpointing is
550 *configured* for the project (``policy_pack.reports.checkpoint``); when it is
551 absent the gate degrades to advisory (back-compat — flows that never wrote a
552 checkpoint still merge). The ``--no-checkpoint-gate`` escape requires a named
553 ``--operator`` (mirroring ``--operator-override``) and records the bypass in
554 the merge payload audit trail.
556 This is the thin I/O layer: it reads the checkpoint file and delegates the
557 decision to :func:`keel.checkpoint.covering_checkpoint` (pure).
558 """
559 _, path_source = checkpoint.configured_checkpoint_path(config)
560 configured = path_source == "policy_pack.reports.checkpoint"
562 if args.no_checkpoint_gate:
563 if not operator:
564 return (
565 {
566 "enforced": configured,
567 "status": "bypass-refused",
568 "bypassed": False,
569 "reason": "--no-checkpoint-gate requires a named --operator",
570 },
571 "--no-checkpoint-gate requires a named --operator for the audit trail",
572 )
573 return (
574 {
575 "enforced": configured,
576 "status": "bypassed",
577 "bypassed": True,
578 "operator": operator,
579 "expected_step": CHECKPOINT_MERGE_STEP,
580 "reason": "checkpoint gate bypassed by operator",
581 },
582 None,
583 )
585 if not configured:
586 return (
587 {
588 "enforced": False,
589 "status": "advisory-skip",
590 "bypassed": False,
591 "reason": "no checkpoint configured (policy_pack.reports.checkpoint); advisory",
592 },
593 None,
594 )
596 if not run_id:
597 return (
598 {
599 "enforced": True,
600 "status": "missing",
601 "bypassed": False,
602 "expected_step": CHECKPOINT_MERGE_STEP,
603 "reason": (
604 "checkpointing is configured but no run-id is available "
605 "(pass --run-id or record a gates-pass for this head)"
606 ),
607 },
608 (
609 "checkpointing is configured but no run-id is available for the "
610 "checkpoint gate; pass --run-id or use --no-checkpoint-gate"
611 ),
612 )
614 try:
615 path = checkpoint.resolve_path(args.root, config)
616 record = checkpoint.read_checkpoint(path)
617 except checkpoint.CheckpointError as exc:
618 return (
619 {"enforced": True, "status": "invalid", "bypassed": False, "reason": str(exc)},
620 f"invalid checkpoint: {exc}",
621 )
622 coverage = checkpoint.covering_checkpoint(record, run_id, CHECKPOINT_MERGE_STEP)
623 gate_payload = {
624 "enforced": True,
625 "status": coverage["status"],
626 "bypassed": False,
627 "expected_step": CHECKPOINT_MERGE_STEP,
628 "run_id": run_id,
629 "checkpoint_step": coverage["checkpoint_step"],
630 "reason": coverage["reason"],
631 }
632 if coverage["covered"]:
633 return gate_payload, None
634 return gate_payload, coverage["reason"]
637def _cmd_merge(args: argparse.Namespace) -> int:
638 args.live = True
639 try:
640 config = cfg.load_config(args.path)
641 except FileNotFoundError:
642 print(f"no such config: {args.path}", file=sys.stderr)
643 return 1
644 except cfg.ConfigError as exc:
645 print(str(exc), file=sys.stderr)
646 return 1
648 loaded, problems = load_extensions(config, args.root, strict=False)
649 for prob in problems:
650 print(f" ! extension not loaded: {prob}", file=sys.stderr)
651 requirement = runtime.CapabilityRequirement(required=("git",), optional=("gh", "gh-auth"))
652 report = runtime.detect(args.root)
653 evaluation = runtime.evaluate(requirement, report)
654 transport = github_transport.resolve(report)
655 if not evaluation.ok or not transport.supports("pr_merge"):
656 print(evaluation.render(), file=sys.stderr)
657 print(transport.render(), file=sys.stderr)
658 return 1
659 try:
660 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent(
661 args, config, True
662 )
663 except ValueError as exc:
664 print(str(exc), file=sys.stderr)
665 return 1
666 operator_consent = consent.build_consent_contract(
667 command="merge",
668 side_effects=("git_worktree", "merge"),
669 dry_run=False,
670 approved_scopes=approved_scopes,
671 approval_source=approval_source,
672 mode=consent_mode,
673 operator=approval_operator,
674 target=f"PR #{args.pr}",
675 )
676 consent_ok, consent_message = consent.assert_operator_consent(operator_consent)
677 if not consent_ok:
678 print(consent_message, file=sys.stderr)
679 return 1
680 escalation = consent.evaluate_escalation(
681 risk_tier=args.risk_tier,
682 trust_signal=args.trust_signal,
683 side_effects=("git_worktree", "merge", *args.escalation_side_effect),
684 retry_count=args.retry_count,
685 conflicting_sources=args.conflicting_sources,
686 changed_lines=args.changed_lines,
687 )
688 missing_escalation_scope = [
689 scope for scope in escalation["consent_scope"] if scope not in approved_scopes
690 ]
691 if escalation["operator_required"] and missing_escalation_scope:
692 payload = {
693 "schema_version": "keel.merge.v1",
694 "pull_request": args.pr,
695 "status": "fail",
696 "reason": "operator escalation required",
697 "escalation": escalation,
698 "missing_scope": missing_escalation_scope,
699 }
700 if args.json:
701 print(json.dumps(payload, indent=2, sort_keys=True))
702 else:
703 print(
704 "operator escalation required: missing approved scope "
705 f"{', '.join(missing_escalation_scope)}",
706 file=sys.stderr,
707 )
708 return 1
710 hotfix_justification: dict[str, object] | None = None
711 if args.hotfix:
712 hotfix_justification, hotfix_error = _hotfix_justification(
713 args, config, approval_operator
714 )
715 if hotfix_justification is None:
716 payload = {
717 "schema_version": "keel.merge.v1",
718 "pull_request": args.pr,
719 "status": "fail",
720 "reason": "hotfix justification required",
721 "hotfix_justification": None,
722 }
723 if args.json:
724 print(json.dumps(payload, indent=2, sort_keys=True))
725 else:
726 print(hotfix_error, file=sys.stderr)
727 return 1
729 owner = args.owner or f"keel-merge-pr-{args.pr}"
730 claim = lock.claim_resource(_lock_root(args.root), "merge", owner=owner)
731 payload: dict[str, object] = {
732 "schema_version": "keel.merge.v1",
733 "pull_request": args.pr,
734 "lock": claim.as_dict(),
735 "window": None,
736 "ci": None,
737 "evidence": None,
738 "gates_sha": None,
739 "escalation": escalation,
740 "hotfix_justification": hotfix_justification,
741 "checkpoint_gate": None,
742 "merged": False,
743 }
744 if not claim.granted:
745 return _finish_merge(args, payload, "resource lock is already held", code=1)
746 try:
747 if not args.hotfix and config.timezone and config.merge_window:
748 open_now = window.is_merge_open(config.timezone, config.merge_window)
749 payload["window"] = {
750 "open": open_now,
751 "timezone": config.timezone,
752 "merge_window": config.merge_window,
753 }
754 if not open_now:
755 return _finish_merge(args, payload, "merge window is closed", code=1)
756 elif args.hotfix:
757 payload["window"] = {"bypassed": True, "reason": "hotfix"}
759 try:
760 snapshot = _merge_snapshot(args.pr, cwd=args.root)
761 except ValueError as exc:
762 return _finish_merge(args, payload, str(exc), code=1)
763 payload["ci"] = snapshot["ci"]
764 if snapshot["merge_state"] not in {"CLEAN", "HAS_HOOKS", "UNKNOWN"}:
765 return _finish_merge(
766 args, payload, f"PR merge state is {snapshot['merge_state']}", code=1
767 )
768 ci_state = snapshot["ci"]["state"]
769 if ci_state not in ("pass", "no-checks"):
770 return _finish_merge(args, payload, f"CI is {ci_state}", code=1)
772 evidence_payload = _verify_merge_evidence(args, config)
773 payload["evidence"] = evidence_payload
774 if ci_state == "no-checks":
775 # ship.md's rule, now enforced in core rather than by adapter prose: an
776 # empty check set is acceptable only when every changed path is a docs
777 # path. Checked after the evidence load because that is what supplies the
778 # changed-file list, and only on this branch so a red CI still short-
779 # circuits before any extra API work.
780 docs_only = evidence_payload.get("docs_only") is True
781 payload["ci_no_checks_docs_only"] = docs_only
782 if not docs_only:
783 return _finish_merge(
784 args, payload,
785 "CI did not run on a non-docs PR (empty check set)", code=1)
786 if not evidence_payload["enforced"]:
787 return _finish_merge(args, payload, "evidence gate is not enforced", code=1)
788 if evidence_payload["verification"]["status"] != "pass":
789 missing = ", ".join(evidence_payload["verification"]["missing"])
790 return _finish_merge(args, payload, f"missing evidence: {missing}", code=1)
792 head_sha = snapshot["head_sha"]
793 gates_run_id: str | None = None
794 if args.hotfix:
795 payload["gates_sha"] = {"bypassed": True, "reason": "hotfix", "head_sha": head_sha}
796 else:
797 try:
798 gates_records = ledger.read_records(ledger.resolve_path(args.root, config))
799 except ledger.LedgerError as exc:
800 return _finish_merge(args, payload, f"invalid run ledger: {exc}", code=1)
801 matched, record = ledger.gates_pass_for_head(
802 gates_records, args.pr, head_sha if isinstance(head_sha, str) else ""
803 )
804 gates_run_id = record.get("run_id") if record else None
805 payload["gates_sha"] = {
806 "bypassed": False,
807 "head_sha": head_sha,
808 "matched": matched,
809 "run_id": gates_run_id,
810 }
811 if not matched:
812 return _finish_merge(
813 args,
814 payload,
815 f"no gates-pass recorded for the current head {head_sha}",
816 code=1,
817 )
819 gate_payload, gate_error = _checkpoint_gate(
820 args, config, run_id=args.run_id or gates_run_id, operator=approval_operator
821 )
822 payload["checkpoint_gate"] = gate_payload
823 if gate_error is not None:
824 return _finish_merge(args, payload, gate_error, code=1)
826 if args.dry_run:
827 return _finish_merge(args, payload, "dry-run: merge not performed", code=0)
828 merged = github.merge_pr(args.pr, method=args.method, cwd=args.root)
829 payload["merged"] = merged.ok
830 payload["merge_output"] = merged.output
831 if not merged.ok:
832 return _finish_merge(args, payload, "gh merge failed", code=1)
833 _autostamp(config, args.root, "ship", getattr(args, "run_id", None), "s10",
834 status="merged", # real merge landed → green "merged", not soft "done"
835 issue=getattr(args, "issue", None), pr=args.pr)
836 return _finish_merge(args, payload, "merged", code=0)
837 finally:
838 lock.release_resource(_lock_root(args.root), "merge", owner=owner, best_effort=True)
841def _cmd_ship(args: argparse.Namespace) -> int:
842 if args.dry_run and args.live:
843 print("--dry-run and --live cannot be used together", file=sys.stderr)
844 return 1
845 command = getattr(args, "ship_command", "ship")
846 profile = "compound" if args.compound or args.profile == "compound" else "standard"
848 try:
849 config = cfg.load_config(args.path)
850 except FileNotFoundError:
851 print(f"no such config: {args.path}", file=sys.stderr)
852 return 1
853 except cfg.ConfigError as exc:
854 print(str(exc), file=sys.stderr)
855 return 1
857 loaded, problems = load_extensions(config, args.root, strict=False)
858 for prob in problems:
859 print(f" ! extension not loaded: {prob}", file=sys.stderr)
861 requirement = _capability_requirement(command, config, loaded, pr=args.pr)
862 report = runtime.detect(args.root)
863 evaluation = runtime.evaluate(requirement, report)
864 if not evaluation.ok:
865 print(evaluation.render(), file=sys.stderr)
866 return 1
867 transport = github_transport.resolve(report)
868 if args.pr is not None and not transport.supports("check_runs"):
869 print(transport.render(), file=sys.stderr)
870 print("missing required GitHub transport capability: check_runs", file=sys.stderr)
871 return 1
872 try:
873 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent(
874 args, config, _has_live_consent_scope(args, command, config, requirement, loaded)
875 )
876 except ValueError as exc:
877 print(str(exc), file=sys.stderr)
878 return 1
879 try:
880 plan = orch.build_plan(config, loaded)
881 except gates.GateError as exc:
882 print(str(exc), file=sys.stderr)
883 return 1
884 contract = contracts.build_command_contract(
885 command=command,
886 profile=profile,
887 config=config,
888 loaded=loaded,
889 plan=plan,
890 requirement=requirement,
891 evaluation=evaluation,
892 transport=transport,
893 extension_problems=tuple(problems),
894 dry_run=not args.live,
895 approved_consent_scopes=approved_scopes,
896 consent_approval_source=approval_source,
897 consent_mode=consent_mode,
898 operator=approval_operator,
899 target=args.target or (f"PR #{args.pr}" if args.pr is not None else None),
900 reviewer_override=args.reviewers,
901 review_comments=args.review_comments,
902 jury=args.jury,
903 no_jury=args.no_jury,
904 jury_advisory=args.jury_advisory,
905 issue_title=args.issue_title,
906 issue_body=args.issue_body,
907 issue_labels=_issue_labels(args),
908 )
909 consent_ok, consent_message = consent.assert_operator_consent(contract["operator_consent"])
910 if not consent_ok:
911 if args.json:
912 print(json.dumps({"contract": contract}, indent=2, sort_keys=True))
913 else:
914 print(consent_message, file=sys.stderr)
915 return 1
916 intake_record = contract["issue_intake"]
917 if args.live and _issue_context_provided(args) and intake_record:
918 if not intake_record["can_mutate_code"]:
919 if args.json:
920 print(json.dumps({"contract": contract}, indent=2, sort_keys=True))
921 else:
922 print(f"issue intake: {intake_record['status']} — {intake_record['reason']}",
923 file=sys.stderr)
924 for question in intake_record["questions"]:
925 print(f" question: {question}", file=sys.stderr)
926 return 1
927 if args.live and args.append_ledger and args.capture_status is None:
928 message = "--capture-status is required when --live --append-ledger is used"
929 if args.json:
930 print(json.dumps({"contract": contract, "error": message}, indent=2,
931 sort_keys=True))
932 else:
933 print(message, file=sys.stderr)
934 return 1
935 run_context_warnings = _run_context_warnings(args)
936 if args.live and args.append_ledger and args.strict_run_context and run_context_warnings:
937 message = "; ".join(run_context_warnings)
938 if args.json:
939 print(json.dumps({"contract": contract, "error": message}, indent=2,
940 sort_keys=True))
941 else:
942 print(message, file=sys.stderr)
943 return 1
945 changed_read = git.changed_files(config.base_branch, "HEAD", cwd=args.root)
946 # None means git could not be read. Classify fail-closed to the strictest tier
947 # rather than letting an unreadable diff look like an empty one — an empty list
948 # classifies as TIER-2 and would silently drop a reviewer and the gating jury.
949 changed_unreadable = changed_read is None
950 changed = changed_read or []
951 # Same source as `changed`, so the tier is decided from one view of the change:
952 # an unreadable diff yields {} and every path keeps the tier it already had.
953 artifacts_patches = classify.split_unified_diff(
954 git.diff(config.base_branch, "HEAD", cwd=args.root)
955 )
956 tier = (
957 classify.UNKNOWN_TIER
958 if changed_unreadable
959 else classify.tier_for_files(
960 changed,
961 tier3_globs=config.knobs.tier3_globs,
962 docs_globs=config.knobs.docs_gate_paths,
963 allowlist_globs=config.knobs.docs_only_allowlist,
964 patches=artifacts_patches,
965 )
966 )
967 review_contract = ship.resolve_review_contract(
968 tier=tier,
969 reviewer_override=args.reviewers,
970 review_comments=args.review_comments,
971 gates=config.gates,
972 policy_pack=config.policy_pack,
973 jury=args.jury,
974 no_jury=args.no_jury,
975 jury_advisory=args.jury_advisory,
976 )
977 # unreachable: orch.build_plan() above already calls plan_gates and surfaces GateError.
978 try:
979 specs = gates.plan_gates(config, loaded)
980 except gates.GateError as exc: # pragma: no cover - defensive duplicate of the build_plan guard
981 print(str(exc), file=sys.stderr)
982 return 1
983 diff_text = git.diff(config.base_branch, "HEAD", cwd=args.root)
984 outcomes = gates.run_gates(
985 specs,
986 _gate_runner(args.root, diff_text, jury_mode=review_contract["jury"]["mode"],
987 timeout=config.knobs.gate_timeout_s),
988 )
989 recorded_results = dict(getattr(args, "gate_result", None) or ())
990 planned = {spec.id for spec in specs}
991 unknown = sorted(set(recorded_results) - planned)
992 if unknown:
993 print(f"--gate-result names no planned gate: {', '.join(unknown)}", file=sys.stderr)
994 return 1
995 outcomes, rejected = gates.apply_recorded_results(outcomes, recorded_results)
996 if rejected:
997 # Refuse loudly: keel ran these and has a measured verdict. Silently discarding
998 # the flag would leave the operator believing they recorded something.
999 print(f"--gate-result cannot override a gate keel executed: {', '.join(rejected)}",
1000 file=sys.stderr)
1001 return 1
1002 verdict = fnd.summarize(gates.collect_findings(outcomes))
1003 read_ci = bool(args.pr) and transport.name == "gh"
1004 ci_conclusion = github.ci_conclusion(args.pr, cwd=args.root) if read_ci else None
1005 # Read the *names* too, not just the conclusions: "everything passed" and "the
1006 # workflows this project declares actually ran" are different questions, and
1007 # only the second one can be answered against knobs.ci_workflows (#675).
1008 ci_names = github.ci_check_names(args.pr, cwd=args.root) if read_ci else None
1009 # Workflow names, not job names: knobs.ci_workflows is keyed "CI" while the
1010 # rollup reports "test (py3.13 / ubuntu-latest)".
1011 ci_wf_names = github.ci_workflow_names(args.pr, cwd=args.root) if read_ci else None
1013 a = ship.assess(
1014 # None-preserving on purpose: assess classifies an unreadable diff fail-closed,
1015 # and collapsing it to [] here is what made this whole guard inert.
1016 changed_files=changed_read,
1017 gate_verdict=verdict,
1018 tier3_globs=config.knobs.tier3_globs,
1019 docs_globs=config.knobs.docs_gate_paths,
1020 allowlist_globs=config.knobs.docs_only_allowlist,
1021 timezone=config.timezone,
1022 merge_window=config.merge_window,
1023 merge_window_mode=config.merge_window_mode,
1024 ci_conclusion=ci_conclusion,
1025 ci_check_names=ci_names,
1026 ci_workflow_names=ci_wf_names,
1027 ci_workflows=config.knobs.ci_workflows,
1028 is_blocker=args.hotfix,
1029 # A required gate nobody dispatched has produced no verdict, so the assessment
1030 # must not report "clear to merge": `keel merge` will refuse the record, and
1031 # without this the operator is told the run is clean and given no reason why.
1032 unrun_blocking_gates=gates.unrun_blocking(outcomes),
1033 reviewer_override=args.reviewers,
1034 review_comments=args.review_comments,
1035 gates=config.gates,
1036 policy_pack=config.policy_pack,
1037 jury=args.jury,
1038 no_jury=args.no_jury,
1039 jury_advisory=args.jury_advisory,
1040 )
1041 contract["review_merge_contract"] = a.review_contract
1042 ledger_path = ledger.resolve_path(args.root, config)
1043 try:
1044 existing_ledger_records = ledger.read_records(ledger_path)
1045 except ledger.LedgerError as exc:
1046 print(f"invalid ledger {ledger_path}: {exc}", file=sys.stderr)
1047 return 1
1048 try:
1049 run_control_events = (
1050 _read_json_list(args.run_events_file, missing_ok=True)
1051 if args.run_events_file else []
1052 )
1053 except ValueError as exc:
1054 print(str(exc), file=sys.stderr)
1055 return 1
1056 run_control_report = runcontrols.evaluate_run_controls(
1057 run_control_events,
1058 max_work_units=args.max_rounds or runcontrols.DEFAULT_RUN_BUDGET,
1059 )
1060 ledger_record = ledger.build_ship_run_record(
1061 command=command,
1062 base_branch=config.base_branch,
1063 # None-preserving, as into `assess`: the record must not claim an empty diff on
1064 # a run that could not read one.
1065 changed_files=changed_read,
1066 declared_files=args.declared_file,
1067 outcomes=outcomes,
1068 verdict=verdict,
1069 assessment=a,
1070 issue_intake=contract.get("issue_intake"),
1071 target=args.target or (f"PR #{args.pr}" if args.pr is not None else None),
1072 run_id=args.run_id,
1073 issue_number=args.issue,
1074 pr_number=args.ledger_pr or args.pr,
1075 branch=args.branch,
1076 head_sha=args.head_sha,
1077 capture_status=args.capture_status,
1078 capture_reason=args.capture_reason,
1079 capture_artifact=args.capture_artifact,
1080 issue_title=args.issue_title,
1081 issue_labels=_issue_labels(args),
1082 existing_records=existing_ledger_records,
1083 config=config,
1084 implementer=args.implementer,
1085 reviewer_agents=args.reviewer_agent,
1086 tester=args.tester,
1087 host_agent=args.host_agent,
1088 transport=args.transport or transport.name,
1089 profile=profile,
1090 jury_mode=a.review_contract["jury"]["mode"],
1091 consent_status=contract["operator_consent"]["status"],
1092 consent_scopes=contract["operator_consent"]["effective_approved_scope"],
1093 run_controls=run_control_report,
1094 )
1095 try:
1096 ledger_record = ledger.sanitize_record(ledger_record, config)
1097 except ledger.redaction.RedactionError as exc:
1098 message = f"capture redaction policy invalid; skipping ledger append: {exc}"
1099 if args.append_ledger and args.live:
1100 if args.json:
1101 print(json.dumps({"contract": contract, "error": message}, indent=2,
1102 sort_keys=True))
1103 else:
1104 print(message, file=sys.stderr)
1105 return 1
1106 fallback = ledger.redaction.sanitize(
1107 ledger_record,
1108 ledger.redaction.policy_from_config(config, strict=False),
1109 )
1110 ledger_record = dict(fallback.value)
1111 ledger_record["redaction"] = {
1112 "schema_version": ledger.redaction.REDACTION_SCHEMA_VERSION,
1113 "status": "partial",
1114 "reason": "invalid-policy",
1115 "rules": fallback.audit["rules"],
1116 "redaction_count": fallback.audit["redaction_count"],
1117 }
1118 ledger_result = {
1119 "contract": contract["run_ledger"],
1120 "path": str(ledger_path),
1121 "appended": False,
1122 "record": ledger_record,
1123 "warnings": run_context_warnings,
1124 }
1125 if args.append_ledger and args.live:
1126 clash = ledger.existing_capture_marker(existing_ledger_records, ledger_record)
1127 if clash is None:
1128 ledger.append_record(ledger_path, ledger_record)
1129 ledger_result["appended"] = True
1130 else:
1131 # A second marker for this PR would make capture-verify refuse the session
1132 # and capture-reconcile refuse to repair it — recoverable only by hand. The
1133 # append is the natural retry after a crash mid-s11, so it no-ops instead.
1134 ledger_result["skipped"] = "duplicate-capture-marker"
1135 ledger_result["existing_run_id"] = clash.get("run_id")
1137 if args.json:
1138 print(json.dumps({
1139 "contract": contract,
1140 "result": contracts.ship_result_as_dict(
1141 changed_files=changed_read,
1142 outcomes=outcomes,
1143 verdict=verdict,
1144 assessment=a,
1145 issue_intake=contract.get("issue_intake"),
1146 run_ledger=ledger_result,
1147 ),
1148 }, indent=2, sort_keys=True))
1149 if run_control_report["hard_halt"]:
1150 return 1
1151 return 0 if a.merge.action != "block" else 1
1153 name = config.repo or config.extends
1154 print(f"keel {command} — {name} (base {config.base_branch})")
1155 if changed_unreadable:
1156 # Never let this be silent: the tier below was forced, not measured.
1157 print(" changed files : UNREADABLE (git diff failed) — "
1158 "classified TIER-3 fail-closed")
1159 else:
1160 print(f" changed files : {len(changed)}")
1161 print(f" profile : {contract['workflow_profile']['profile']}")
1162 print(f" risk tier : TIER-{a.tier} → {a.reviewers} reviewer(s)")
1163 jury_state = a.review_contract["jury"]["mode"]
1164 print(f" review posts : {a.review_contract['posting']['mode']}")
1165 print(f" jury : {jury_state} ({a.review_contract['jury']['reason']})")
1166 window = "OPEN" if a.window_open else f"CLOSED ({config.merge_window_mode}, night no-merge)"
1167 print(f" merge window : {window}")
1168 if a.ci_ran is False:
1169 # Print the count rather than a bare word: "0 checks" is the fact an
1170 # operator needs, and it used to be indistinguishable from "passing".
1171 ci_str = "NO CHECKS RAN (0 reported) — nothing verified this commit"
1172 elif a.ci_ok is None:
1173 ci_str = "unknown"
1174 else:
1175 ci_str = "passing" if a.ci_ok else "FAILING"
1176 if ci_names is not None:
1177 ci_str += f" ({len(ci_names)} check{'' if len(ci_names) == 1 else 's'})"
1178 print(f" ci : {ci_str}")
1179 if a.missing_workflows:
1180 print(f" ci MISSING : {', '.join(a.missing_workflows)} (declared, never ran)")
1181 print(f" github : {transport.name}")
1182 print(f" consent : {contract['operator_consent']['status']}")
1183 print(f" intake : {intake_record['status']}")
1184 print(f" run ledger : {ledger_result['path']}")
1185 print(f" run controls : {run_control_report['status']}")
1186 if args.append_ledger:
1187 if ledger_result.get("skipped") == "duplicate-capture-marker":
1188 print(" ledger append : skipped — PR already has a capture marker "
1189 f"(run {ledger_result['existing_run_id']}); a second one would block "
1190 "capture-verify with no automated repair")
1191 else:
1192 print(
1193 f" ledger append : {'yes' if ledger_result['appended'] else 'dry-run/no-live'}"
1194 )
1195 for warning in run_context_warnings:
1196 print(f" run context : warning: {warning}")
1197 if intake_record["questions"]:
1198 print(f" questions : {len(intake_record['questions'])}")
1199 if transport.degraded:
1200 print(f" github degraded: {', '.join(transport.degraded)}")
1201 for o in outcomes:
1202 print(f" gate {o.gate:<14} {_gate_status(o)}")
1203 if evaluation.missing_optional:
1204 print(f" degraded opt. : {', '.join(evaluation.missing_optional)}")
1205 if a.halted: # pragma: no cover - display only; logic covered in ship.assess tests
1206 print(" pipeline : HALTED (merge window paused)")
1207 if a.bypassed_window: # pragma: no cover - display only; logic covered in ship.assess
1208 print(" audit : hotfix bypassed the merge window")
1209 print(f" decision : {a.merge.action.upper()} — {a.merge.reason}")
1210 print(" note: dry assessment; live merge (s10) needs a configured runner (git + gh auth).")
1211 if run_control_report["hard_halt"]:
1212 return 1
1213 return 0 if a.merge.action != "block" else 1
1216def _cmd_ledger(args: argparse.Namespace) -> int:
1217 try:
1218 config = cfg.load_config(args.path)
1219 except FileNotFoundError:
1220 print(f"no such config: {args.path}", file=sys.stderr)
1221 return 1
1222 except cfg.ConfigError as exc:
1223 print(str(exc), file=sys.stderr)
1224 return 1
1226 contract = ledger.ledger_contract_as_dict(config)
1227 path = ledger.resolve_path(args.root, config)
1228 try:
1229 records = ledger.read_records(path)
1230 except ledger.LedgerError as exc:
1231 print(f"invalid ledger {path}: {exc}", file=sys.stderr)
1232 return 1
1233 if args.limit is not None:
1234 records = records[-args.limit:]
1235 payload = {
1236 "contract": contract,
1237 "path": str(path),
1238 "status": "present" if path.exists() else "missing",
1239 "records": records,
1240 "record_count": len(records),
1241 "capture_health": ledger.capture_health_summary(records),
1242 }
1243 if args.json:
1244 print(json.dumps(payload, indent=2, sort_keys=True))
1245 else:
1246 print(f"keel ledger — {payload['status']} {path}")
1247 print(f" schema : {contract['schema_version']}")
1248 print(f" records : {payload['record_count']}")
1249 print(f" missing : {contract['missing_handling']}")
1250 print(f" capture : {payload['capture_health']['status']}")
1251 print(f" capture gaps : {payload['capture_health']['counts']['needs_reconcile']}")
1252 return 0
1255def _cmd_capture_verify(args: argparse.Namespace) -> int:
1256 try:
1257 config = cfg.load_config(args.path)
1258 except FileNotFoundError:
1259 print(f"no such config: {args.path}", file=sys.stderr)
1260 return 1
1261 except cfg.ConfigError as exc:
1262 print(str(exc), file=sys.stderr)
1263 return 1
1265 ledger_path = ledger.resolve_path(args.root, config)
1266 try:
1267 records = ledger.read_records(ledger_path)
1268 except ledger.LedgerError as exc:
1269 print(f"invalid ledger {ledger_path}: {exc}", file=sys.stderr)
1270 return 1
1271 try:
1272 derived_prs, derivation = _capture_verify_merged_prs(args, config)
1273 except ValueError as exc:
1274 print(str(exc), file=sys.stderr)
1275 return 1
1276 report = capture.verify_session(records, derived_prs)
1278 reconcile_active = derivation["source"] != "args" or _reconcile_inputs_active(args)
1279 reconcile_report = None
1280 if reconcile_active:
1281 verdict_counts = _capture_verify_verdict_counts(args, config, derived_prs)
1282 reconcile_report = captureverify.reconcile(
1283 records, derived_prs, verdict_counts=verdict_counts
1284 )
1286 payload = {
1287 "contract": capture.contract_as_dict(config),
1288 "ledger_path": str(ledger_path),
1289 "merged_pr_source": derivation,
1290 "verification": report,
1291 }
1292 if reconcile_report is not None:
1293 payload["reconcile"] = reconcile_report
1295 base_ok = report["status"] == "complete"
1296 reconcile_ok = reconcile_report is None or reconcile_report["ok"]
1297 # A failed transport empties the *derived* merged-PR set, so the union degenerates to
1298 # exactly the list the agent supplied — and the anti-shrink defence this command
1299 # exists to provide silently evaporates, taking any un-captured PR with it. An audit
1300 # that could not observe must say "cannot certify", never "clean" (#630).
1301 transport_failed = derivation.get("transport_failed") is True
1302 status = "transport-unavailable" if transport_failed else report["status"]
1303 payload["status"] = status
1304 payload["certified"] = base_ok and reconcile_ok and not transport_failed
1306 if args.json:
1307 print(json.dumps(payload, indent=2, sort_keys=True))
1308 else:
1309 print(f"keel capture-verify — {status} {ledger_path}")
1310 print(f" merged-PR source: {derivation['source']} ({len(derived_prs)} PR(s))")
1311 if transport_failed:
1312 print(" transport : FAILED — the derived merged-PR set is unobservable, "
1313 "so this audit cannot certify that every merged PR was captured")
1314 for result in report["results"]:
1315 state = "ok" if result["ok"] else "FAIL"
1316 print(
1317 f" {state:>4} PR #{result['pr']} "
1318 f"{result['status']} {result['reason'] or '-'}"
1319 )
1320 if reconcile_report is not None:
1321 for finding in reconcile_report["findings"]:
1322 print(f" FAIL reconcile PR #{finding['pr']} "
1323 f"{finding['type']} {finding['reason']}")
1324 if reconcile_report["ok"]:
1325 print(" reconcile: ok")
1326 return 0 if payload["certified"] else 1
1329def _cmd_consent_verify(args: argparse.Namespace) -> int:
1330 try:
1331 config = cfg.load_config(args.path)
1332 except FileNotFoundError:
1333 print(f"no such config: {args.path}", file=sys.stderr)
1334 return 1
1335 except cfg.ConfigError as exc:
1336 print(str(exc), file=sys.stderr)
1337 return 1
1339 try:
1340 record = _consent_ledger_record(args, config)
1341 except ledger.LedgerError as exc:
1342 print(f"invalid run ledger: {exc}", file=sys.stderr)
1343 return 1
1344 try:
1345 observed = _consent_observed_effects(args, config)
1346 except ValueError as exc:
1347 print(str(exc), file=sys.stderr)
1348 return 1
1350 has_record, approved_scopes = consentverify.consent_record_from_ledger(record)
1351 report = consentverify.reconcile(
1352 observed, approved_scopes, has_consent_record=has_record
1353 )
1354 payload = {
1355 "schema_version": consentverify.SCHEMA_VERSION,
1356 "pull_request": args.pr,
1357 "scope_effect_table": consentverify.scope_effect_table(),
1358 "reconcile": report,
1359 }
1360 if args.json:
1361 print(json.dumps(payload, indent=2, sort_keys=True))
1362 else:
1363 print(f"keel consent-verify — {report['verdict']} PR #{args.pr}")
1364 print(f" consent record : {'present' if has_record else 'absent (advisory)'}")
1365 print(f" approved scopes: {', '.join(report['approved_scopes']) or 'none'}")
1366 print(f" observed : {', '.join(report['observed_effects']) or 'none'}")
1367 for finding in report["uncovered"]:
1368 print(f" FAIL {finding['message']}")
1369 if report["verdict"] == consentverify.VERDICT_PASS:
1370 print(" all observed mutations covered by approved scopes")
1371 return 0 if report["ok"] else 1
1374def _consent_ledger_record(
1375 args: argparse.Namespace,
1376 config: cfg.ProjectConfig,
1377) -> dict[str, object] | None:
1378 """Load the latest ship_run ledger record for the PR under consent-verify.
1380 Reads the run ledger (offline fixture via ``--ledger-jsonl`` or the configured
1381 path under ``--root``) and returns the most recent matching ship_run record,
1382 or ``None`` when no record matches — the advisory back-compat path.
1383 """
1384 fixture = getattr(args, "ledger_jsonl", None)
1385 if fixture is not None:
1386 records = ledger.parse_records(Path(fixture).read_text(encoding="utf-8"))
1387 else:
1388 records = ledger.read_records(ledger.resolve_path(args.root, config))
1389 return ledger.latest_ship_run_for_pr(records, args.pr)
1392def _consent_observed_effects(
1393 args: argparse.Namespace,
1394 config: cfg.ProjectConfig,
1395) -> consentverify.ObservedEffects:
1396 """Observe the mutating side effects on the PR for consent reconciliation.
1398 Offline (``--offline``): the effect flags are taken straight from the
1399 ``--pr-exists``/``--commented``/``--merged``/``--labeled`` switches, so tests
1400 are deterministic with no transport. Live: the PR state is read via the thin
1401 ``gh`` wrappers, fail-soft — a transport failure raises so the operator sees
1402 the fetch error rather than a silently-empty observation.
1403 """
1404 if args.offline:
1405 return consentverify.ObservedEffects(
1406 pr_exists=args.pr_exists,
1407 commented=args.commented,
1408 merged=args.merged,
1409 labeled=args.labeled,
1410 )
1411 owner_repo = _owner_repo(config)
1412 pr = _gh_json(["repos", owner_repo, "pulls", str(args.pr)], cwd=args.root)
1413 comments = _gh_json_list(
1414 ["repos", owner_repo, "issues", str(args.pr), "comments"], cwd=args.root
1415 )
1416 return consentverify.ObservedEffects(
1417 pr_exists=True,
1418 commented=bool(comments),
1419 merged=pr.get("merged") is True,
1420 labeled=bool(_label_names(pr.get("labels"))),
1421 )
1424def _cmd_close_reconcile(args: argparse.Namespace) -> int:
1425 try:
1426 config = cfg.load_config(args.path)
1427 except FileNotFoundError:
1428 print(f"no such config: {args.path}", file=sys.stderr)
1429 return 1
1430 except cfg.ConfigError as exc:
1431 print(str(exc), file=sys.stderr)
1432 return 1
1434 done_label = _done_label(config)
1435 try:
1436 records = _close_ledger_records(args, config)
1437 except ledger.LedgerError as exc:
1438 print(f"invalid run ledger: {exc}", file=sys.stderr)
1439 return 1
1440 try:
1441 observed = _close_observed_issues(args, config, records, done_label)
1442 except ValueError as exc:
1443 print(str(exc), file=sys.stderr)
1444 return 1
1446 report = closeorder.reconcile(observed, done_label=done_label)
1447 payload = {
1448 "schema_version": closeorder.SCHEMA_VERSION,
1449 "done_label": done_label,
1450 "reconcile": report,
1451 }
1452 if args.json:
1453 print(json.dumps(payload, indent=2, sort_keys=True))
1454 else:
1455 observed_count = report["summary"]["observed"]
1456 print(f"keel close-reconcile — {report['verdict']} ({observed_count} issue(s))")
1457 for finding in report["findings"]:
1458 print(f" FLAG {finding['message']}")
1459 if report["ok"]:
1460 print(" all observed issues consistent with the ledger")
1461 return 0 if report["ok"] else 1
1464def _done_label(config: cfg.ProjectConfig) -> str:
1465 """Resolve the done-marking label from ``policy_pack.status_transitions.done``.
1467 Falls back to :data:`keel.closeorder.DEFAULT_DONE_LABEL` when a project does
1468 not configure a done transition, so the reconcile still has a label to check.
1469 """
1470 policy = config.policy_pack if isinstance(config.policy_pack, dict) else {}
1471 transitions = policy.get("status_transitions")
1472 done = transitions.get("done") if isinstance(transitions, dict) else None
1473 return done if isinstance(done, str) and done.strip() else closeorder.DEFAULT_DONE_LABEL
1476def _close_ledger_records(
1477 args: argparse.Namespace,
1478 config: cfg.ProjectConfig,
1479) -> list[dict[str, object]]:
1480 """Load the ship_run ledger records for close-ordering reconciliation.
1482 Reads the run ledger (offline fixture via ``--ledger-jsonl`` or the configured
1483 path under ``--root``) and returns every record; the per-issue lookup is done
1484 by :func:`keel.closeorder.latest_record_for_issue`.
1485 """
1486 fixture = getattr(args, "ledger_jsonl", None)
1487 if fixture is not None:
1488 return ledger.parse_records(Path(fixture).read_text(encoding="utf-8"))
1489 return ledger.read_records(ledger.resolve_path(args.root, config))
1492def _close_observed_issues(
1493 args: argparse.Namespace,
1494 config: cfg.ProjectConfig,
1495 records: list[dict[str, object]],
1496 done_label: str,
1497) -> list[closeorder.ObservedIssue]:
1498 """Observe each issue's lifecycle state for close-ordering reconciliation.
1500 Offline (``--offline``): the ``--closed``/``--status-done`` switches apply to
1501 every ``--issue`` so tests are deterministic with no transport. Live: each
1502 issue's state and labels are read via ``gh``; a transport failure raises so
1503 the operator sees the fetch error rather than a silently-empty observation.
1504 """
1505 observed: list[closeorder.ObservedIssue] = []
1506 for number in args.issue:
1507 record = closeorder.latest_record_for_issue(records, number)
1508 if args.offline:
1509 labels = (done_label,) if args.status_done else ()
1510 observed.append(closeorder.ObservedIssue(
1511 number=number, closed=args.closed, labels=labels, record=record,
1512 ))
1513 continue
1514 owner_repo = _owner_repo(config)
1515 issue = _gh_json(["repos", owner_repo, "issues", str(number)], cwd=args.root)
1516 observed.append(closeorder.ObservedIssue(
1517 number=number,
1518 closed=issue.get("state") == "closed",
1519 labels=tuple(_label_names(issue.get("labels"))),
1520 record=record,
1521 ))
1522 return observed
1525def _cmd_dryrun_verify(args: argparse.Namespace) -> int:
1526 try:
1527 config = cfg.load_config(args.path)
1528 except FileNotFoundError:
1529 print(f"no such config: {args.path}", file=sys.stderr)
1530 return 1
1531 except cfg.ConfigError as exc:
1532 print(str(exc), file=sys.stderr)
1533 return 1
1535 try:
1536 before = _dryrun_snapshot_from_json(args.before_json)
1537 except (OSError, ValueError) as exc:
1538 print(f"invalid before snapshot: {exc}", file=sys.stderr)
1539 return 1
1540 try:
1541 after = _dryrun_after_snapshot(args, config)
1542 except (OSError, ValueError, ledger.LedgerError) as exc:
1543 print(f"invalid after snapshot: {exc}", file=sys.stderr)
1544 return 1
1546 report = dryrunverify.reconcile(before, after, run_id=args.run_id, issue=args.issue)
1547 payload = {"schema_version": dryrunverify.SCHEMA_VERSION, "reconcile": report}
1548 if args.json:
1549 print(json.dumps(payload, indent=2, sort_keys=True))
1550 else:
1551 print(f"keel dryrun-verify — {report['verdict']} run {args.run_id!r} issue #{args.issue}")
1552 for finding in report["findings"]:
1553 print(f" LEAK {finding['message']}")
1554 if report["ok"]:
1555 print(" dry run left no new ledger record, branch, or PR")
1556 return 0 if report["ok"] else 1
1559def _dryrun_snapshot_from_json(path: str) -> dryrunverify.ArtifactSnapshot:
1560 """Parse an artifact snapshot from a JSON file ``{ledger_run_ids, branches, pr_numbers}``."""
1561 data = json.loads(Path(path).read_text(encoding="utf-8"))
1562 if not isinstance(data, dict):
1563 raise ValueError("snapshot must be a JSON object")
1564 return dryrunverify.ArtifactSnapshot(
1565 ledger_run_ids=tuple(str(rid) for rid in data.get("ledger_run_ids", ())),
1566 branches=tuple(str(name) for name in data.get("branches", ())),
1567 pr_numbers=tuple(int(num) for num in data.get("pr_numbers", ())),
1568 )
1571def _dryrun_after_snapshot(
1572 args: argparse.Namespace,
1573 config: cfg.ProjectConfig,
1574) -> dryrunverify.ArtifactSnapshot:
1575 """Gather the post-dry-run artifact snapshot.
1577 Offline (``--after-json``): the snapshot is read straight from the fixture so
1578 tests are deterministic. Live: ledger run_ids come from the configured ledger,
1579 branches from ``git for-each-ref``, and PRs from ``gh pr list`` scoped to the
1580 run's issue ship-branch pattern.
1582 The after snapshot is read **fail-closed**: a corrupt ledger, a failed
1583 ``git``/``gh`` read, all raise. An integrity detector that cannot observe the
1584 after state must say "cannot certify", never silently report a clean diff —
1585 an empty-on-error snapshot would mask a real leak (``after − before = ∅``).
1586 """
1587 if args.after_json is not None:
1588 return _dryrun_snapshot_from_json(args.after_json)
1589 records = ledger.read_records(ledger.resolve_path(args.root, config))
1590 run_ids = tuple(
1591 str(record["run_id"])
1592 for record in records
1593 if record.get("record_type") == ledger.RECORD_TYPE_SHIP_RUN
1594 and isinstance(record.get("run_id"), str)
1595 )
1596 branches_result = git.list_branches(cwd=args.root)
1597 if not branches_result.ok:
1598 raise ValueError(
1599 "after snapshot incomplete: git branch listing failed; cannot certify dry run"
1600 )
1601 branches = tuple(
1602 line.strip() for line in branches_result.stdout.splitlines() if line.strip()
1603 )
1604 pattern = dryrunverify.issue_branch_pattern(args.issue)
1605 pr_numbers = tuple(
1606 number
1607 for number, head in _dryrun_live_prs(args.root)
1608 if pattern.search(head)
1609 )
1610 return dryrunverify.ArtifactSnapshot(
1611 ledger_run_ids=run_ids, branches=branches, pr_numbers=pr_numbers,
1612 )
1615def _dryrun_live_prs(root: str) -> list[tuple[int, str]]:
1616 """Return ``(number, headRefName)`` for the repo's PRs.
1618 Fail-closed: a ``gh`` transport failure raises ``ValueError`` rather than
1619 degrading to an empty list, so an unobservable PR set can never masquerade
1620 as "no new PRs" and hide a leaked PR. Malformed entries are skipped (a
1621 well-formed-but-empty list is a legitimate observation).
1622 """
1623 result = github.list_prs(cwd=root)
1624 if not result.ok:
1625 raise ValueError(
1626 "after snapshot incomplete: gh PR listing failed; cannot certify dry run"
1627 )
1628 entries = json.loads(result.stdout or "[]")
1629 pairs: list[tuple[int, str]] = []
1630 for entry in entries if isinstance(entries, list) else ():
1631 if not isinstance(entry, dict):
1632 continue
1633 number = entry.get("number")
1634 head = entry.get("headRefName")
1635 if isinstance(number, int) and isinstance(head, str):
1636 pairs.append((number, head))
1637 return pairs
1640def _reconcile_inputs_active(args: argparse.Namespace) -> bool:
1641 return bool(
1642 getattr(args, "from_transport", False)
1643 or getattr(args, "merged_prs_json", None)
1644 or getattr(args, "verdict_count", None)
1645 or getattr(args, "pr_reviews_json", None)
1646 )
1649def _capture_verify_merged_prs(
1650 args: argparse.Namespace, config: cfg.ProjectConfig
1651) -> tuple[list[int], dict[str, object]]:
1652 """Resolve the authoritative merged-PR set for capture reconciliation.
1654 Priority: an explicit transport fixture (``--merged-prs-json``), then a live
1655 transport derivation (``--from-transport``), then the explicit ``--merged-pr``
1656 override. ``--merged-pr`` always augments the derived set so an agent cannot
1657 *shrink* the merged set by omitting PRs (the union is verified).
1658 """
1659 explicit = list(args.merged_pr or ())
1660 fixture = getattr(args, "merged_prs_json", None)
1661 if fixture is not None:
1662 derived = _merged_prs_from_json(fixture)
1663 merged = _dedupe_ints([*derived, *explicit])
1664 return merged, {"source": "transport-fixture", "transport_failed": False}
1665 if getattr(args, "from_transport", False):
1666 derived, failed = _merged_prs_from_transport(args)
1667 merged = _dedupe_ints([*derived, *explicit])
1668 if not merged:
1669 raise ValueError(
1670 "no merged PRs derived from transport and none passed via --merged-pr"
1671 )
1672 return merged, {"source": "transport", "transport_failed": failed}
1673 if not explicit:
1674 raise ValueError("provide --merged-pr or --from-transport")
1675 return _dedupe_ints(explicit), {"source": "args", "transport_failed": False}
1678def _merged_prs_from_json(path: str) -> list[int]:
1679 items = _read_json_list(path)
1680 numbers: list[int] = []
1681 for item in items:
1682 number = item.get("number")
1683 if isinstance(number, int) and number > 0:
1684 numbers.append(number)
1685 return numbers
1688def _merged_prs_from_transport(args: argparse.Namespace) -> tuple[list[int], bool]:
1689 search = getattr(args, "merged_since", None)
1690 search_arg = f"merged:>={search}" if search else None
1691 result = github.merged_prs(search=search_arg, cwd=args.root)
1692 if not result.ok:
1693 return [], True
1694 try:
1695 items = json.loads(result.stdout or "[]")
1696 except json.JSONDecodeError:
1697 return [], True
1698 if not isinstance(items, list):
1699 return [], True
1700 numbers = [
1701 item["number"]
1702 for item in items
1703 if isinstance(item, dict) and isinstance(item.get("number"), int)
1704 ]
1705 return numbers, False
1708def _capture_verify_verdict_counts(
1709 args: argparse.Namespace, config: cfg.ProjectConfig, merged_prs: list[int]
1710) -> dict[int, int]:
1711 """Evidence-side review-verdict counts per PR for the reviewer cross-check.
1713 Offline: ``--verdict-count PR=N`` fixtures. Live: counts are read from the
1714 transport per PR via the shared evidence counter; a fetch failure simply
1715 omits that PR (the cross-check degrades to advisory rather than failing).
1716 """
1717 explicit = dict(getattr(args, "verdict_count", None) or ())
1718 if explicit:
1719 return explicit
1720 if not getattr(args, "from_transport", False):
1721 return {}
1722 try:
1723 owner_repo = _owner_repo(config)
1724 except ValueError:
1725 # No owner/repo configured: the reviewer cross-check degrades to advisory.
1726 return {}
1727 counts: dict[int, int] = {}
1728 for pr in merged_prs:
1729 try:
1730 pr_comments = _gh_json_list(
1731 ["repos", owner_repo, "issues", str(pr), "comments"], cwd=args.root
1732 )
1733 pr_reviews = _gh_json_list(
1734 ["repos", owner_repo, "pulls", str(pr), "reviews"], cwd=args.root
1735 )
1736 except ValueError:
1737 continue
1738 counts[pr] = evidence.count_review_verdicts(
1739 pr_comments, pr_reviews, enforced=False
1740 )
1741 return counts
1744def _cmd_capture_reconcile(args: argparse.Namespace) -> int:
1745 try:
1746 config = cfg.load_config(args.path)
1747 except FileNotFoundError:
1748 print(f"no such config: {args.path}", file=sys.stderr)
1749 return 1
1750 except cfg.ConfigError as exc:
1751 print(str(exc), file=sys.stderr)
1752 return 1
1754 ledger_path = ledger.resolve_path(args.root, config)
1755 try:
1756 records = ledger.read_records(ledger_path)
1757 except ledger.LedgerError as exc:
1758 print(f"invalid ledger {ledger_path}: {exc}", file=sys.stderr)
1759 return 1
1760 linked_issues: dict[int, list[int]] = {}
1761 for pr, issue in args.linked_issue:
1762 linked_issues.setdefault(pr, []).append(issue)
1763 merged_prs = [
1764 {"number": pr, "issue_numbers": linked_issues.get(pr, [])}
1765 for pr in args.merged_pr
1766 ]
1767 try:
1768 plan = capture.reconcile_session(
1769 records,
1770 merged_prs,
1771 config=config,
1772 capture_capability_available=args.capture_capability == "available",
1773 )
1774 except capture.CaptureError as exc:
1775 print(str(exc), file=sys.stderr)
1776 return 1
1777 payload = {
1778 "contract": capture.contract_as_dict(config)["reconcile"],
1779 "mode": "live-plan" if args.live else "dry-run",
1780 "no_mutations": True,
1781 "ledger_path": str(ledger_path),
1782 "reconcile": plan,
1783 }
1784 if args.json:
1785 print(json.dumps(payload, indent=2, sort_keys=True))
1786 else:
1787 print(f"keel capture-reconcile — {plan['status']} {ledger_path}")
1788 for result in plan["results"]:
1789 print(f" PR #{result['pr']} {result['status']} {result['reason']}")
1790 for action in result["actions"]:
1791 print(f" DRY-RUN: {action['type']} {action['idempotency_key']}")
1792 return 0 if plan["status"] != "blocked" else 1
1795def _cmd_step_verify(args: argparse.Namespace) -> int:
1796 try:
1797 handoff = _read_json_object(args.handoff_file)
1798 evidence_report = _read_json_object(args.evidence_report)
1799 except ValueError as exc:
1800 print(str(exc), file=sys.stderr)
1801 return 1
1802 review_contract = ship.resolve_review_contract(
1803 tier=None,
1804 reviewer_override=args.reviewers,
1805 review_comments=args.review_comments,
1806 gates=(),
1807 policy_pack={},
1808 jury=args.jury,
1809 no_jury=args.no_jury,
1810 jury_advisory=args.jury_advisory,
1811 )
1812 try:
1813 report = stepverifier.verify_step_completion(
1814 step_id=args.step,
1815 handoff=handoff,
1816 evidence_report=evidence_report,
1817 review_contract=review_contract,
1818 dry_run=args.dry_run,
1819 enforced=not args.not_enforced,
1820 )
1821 except KeyError as exc:
1822 print(str(exc), file=sys.stderr)
1823 return 1
1824 payload = {
1825 "contract": stepverifier.contract_as_dict(
1826 review_contract,
1827 dry_run=args.dry_run,
1828 enforced=not args.not_enforced,
1829 ),
1830 "verification": report,
1831 }
1832 if args.json:
1833 print(json.dumps(payload, indent=2, sort_keys=True))
1834 else:
1835 print(f"keel step-verify — {report['status']} {args.step}")
1836 if report["missing"]:
1837 print(f" missing : {', '.join(report['missing'])}")
1838 print(f" required : {len(report['required_evidence'])}")
1839 return 0 if report["status"] == "pass" else 1
1842def _cmd_runcontrols(args: argparse.Namespace) -> int:
1843 try:
1844 events = _read_json_list(args.events_file, missing_ok=True)
1845 event = _event_from_args(args)
1846 step_caps = _step_caps_from_args(args.step_cap)
1847 except ValueError as exc:
1848 print(str(exc), file=sys.stderr)
1849 return 1
1850 if event:
1851 events.append(event)
1852 if not args.dry_run:
1853 _write_json_list(args.events_file, events)
1854 report = runcontrols.evaluate_run_controls(
1855 events,
1856 max_work_units=args.max_work_units,
1857 default_step_cap=args.default_step_cap,
1858 step_caps=step_caps,
1859 identical_action_threshold=args.identical_action_threshold,
1860 alternating_diff_window=args.alternating_diff_window,
1861 )
1862 payload = {
1863 "contract": runcontrols.contract_as_dict(),
1864 "path": args.events_file,
1865 "appended": bool(event) and not args.dry_run,
1866 "event": event,
1867 "run_controls": report,
1868 }
1869 if args.json:
1870 print(json.dumps(payload, indent=2, sort_keys=True))
1871 else:
1872 print(f"keel runcontrols — {report['status']} {args.events_file}")
1873 print(f" events : {report['summary']['event_count']}")
1874 print(f" work units : {report['summary']['work_units']}")
1875 if report["reason"]:
1876 reason = report["reason"]
1877 print(f" halt : {reason['reason']} ({reason['scope']})")
1878 return 0 if report["status"] == "pass" else 1
1881def _cmd_review(args: argparse.Namespace) -> int:
1882 if args.dry_run and args.live:
1883 print("--dry-run and --live cannot be used together", file=sys.stderr)
1884 return 1
1885 dry_run = not args.live
1886 try:
1887 config = cfg.load_config(args.path)
1888 except FileNotFoundError:
1889 print(f"no such config: {args.path}", file=sys.stderr)
1890 return 1
1891 except cfg.ConfigError as exc:
1892 print(str(exc), file=sys.stderr)
1893 return 1
1895 report = runtime.detect(args.root)
1896 evaluation = runtime.evaluate(
1897 runtime.CapabilityRequirement(required=("gh", "gh-auth")), report
1898 )
1899 transport = github_transport.resolve(report)
1900 if not evaluation.ok or not transport.supports("comments"):
1901 print(evaluation.render(), file=sys.stderr)
1902 print(transport.render(), file=sys.stderr)
1903 return 1
1905 try:
1906 raw_reviews = json.loads(Path(args.reviews).read_text(encoding="utf-8"))
1907 reviews = review.parse_reviews(raw_reviews)
1908 except OSError as exc:
1909 print(f"cannot read --reviews {args.reviews}: {exc}", file=sys.stderr)
1910 return 1
1911 except json.JSONDecodeError as exc:
1912 print(f"--reviews {args.reviews} is not valid JSON: {exc}", file=sys.stderr)
1913 return 1
1914 except review.ReviewError as exc:
1915 print(str(exc), file=sys.stderr)
1916 return 1
1918 closure_record: dict[str, object] | None = None
1919 if args.closure is not None:
1920 try:
1921 closure_record = _read_json_object(args.closure)
1922 except OSError as exc:
1923 print(f"cannot read --closure {args.closure}: {exc}", file=sys.stderr)
1924 return 1
1925 except (json.JSONDecodeError, ValueError) as exc:
1926 print(f"--closure {args.closure} must be a JSON object: {exc}", file=sys.stderr)
1927 return 1
1929 try:
1930 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent(
1931 args, config, True
1932 )
1933 except ValueError as exc:
1934 print(str(exc), file=sys.stderr)
1935 return 1
1936 operator_consent = consent.build_consent_contract(
1937 command="review",
1938 side_effects=("comments",),
1939 dry_run=dry_run,
1940 approved_scopes=approved_scopes,
1941 approval_source=approval_source,
1942 mode=consent_mode,
1943 operator=approval_operator,
1944 target=f"PR #{args.pr}",
1945 )
1946 consent_ok, consent_message = consent.assert_operator_consent(operator_consent)
1947 if not consent_ok:
1948 print(consent_message, file=sys.stderr)
1949 return 1
1951 try:
1952 owner_repo = _owner_repo(config)
1953 except ValueError as exc:
1954 print(str(exc), file=sys.stderr)
1955 return 1
1957 evidence_args = argparse.Namespace(
1958 pr=args.pr,
1959 issue=args.issue,
1960 pr_body_file=None,
1961 pr_comments_json=None,
1962 issue_comments_json=None,
1963 pr_reviews_json=None,
1964 changed_file=tuple(args.changed_file or ()),
1965 head_sha=args.head_sha,
1966 head_ref=None,
1967 pr_label=(),
1968 dry_run=dry_run,
1969 root=args.root,
1970 )
1971 try:
1972 artifacts_ctx = _load_evidence_artifacts(evidence_args, config)
1973 except ValueError as exc:
1974 print(str(exc), file=sys.stderr)
1975 return 1
1976 changed_files = artifacts_ctx["changed_files"]
1977 # Absent from a hand-built artifacts mapping = no evidence, so the path decides.
1978 artifacts_patches = artifacts_ctx.get("patches")
1979 tier = (
1980 classify.tier_for_files(
1981 changed_files,
1982 tier3_globs=config.knobs.tier3_globs,
1983 docs_globs=config.knobs.docs_gate_paths,
1984 allowlist_globs=config.knobs.docs_only_allowlist,
1985 patches=artifacts_patches,
1986 )
1987 if changed_files else None
1988 )
1989 review_contract = ship.resolve_review_contract(
1990 tier=tier,
1991 reviewer_override=args.reviewers,
1992 gates=config.gates,
1993 policy_pack=config.policy_pack,
1994 )
1995 required_count = review_contract["reviewers"]["count"]
1997 try:
1998 plan = review.build_review_plan(
1999 reviews,
2000 required_count=required_count,
2001 head_sha=artifacts_ctx["head_sha"],
2002 pull_request=args.pr,
2003 issue=artifacts_ctx["issue"],
2004 run_id=args.run_id,
2005 tier=tier,
2006 closure_record=closure_record,
2007 )
2008 except review.ReviewError as exc:
2009 print(str(exc), file=sys.stderr)
2010 return 1
2012 posted: list[dict[str, object]] = []
2013 for post in plan.posts:
2014 if dry_run:
2015 posted.append({
2016 "artifact": post.artifact,
2017 "target": {"kind": post.target_kind, "number": post.target_number},
2018 "run_id": post.run_id,
2019 "action": "dry-run",
2020 })
2021 if not args.json:
2022 print(f"DRY-RUN: would post {post.artifact} to "
2023 f"{post.target_kind}:{post.target_number} (run-id {post.run_id})")
2024 continue
2025 try:
2026 result, error = _post_artifact_comment(
2027 owner_repo,
2028 target_kind=post.target_kind,
2029 target_number=post.target_number,
2030 artifact=post.artifact,
2031 marker=post.marker,
2032 body=post.body,
2033 run_id=post.run_id,
2034 transport_name=transport.name,
2035 dry_run=False,
2036 cwd=args.root,
2037 )
2038 except ValueError as exc:
2039 print(str(exc), file=sys.stderr)
2040 return 1
2041 if error is not None:
2042 print(error, file=sys.stderr)
2043 return 1
2044 posted.append(result)
2046 verification: dict[str, object] | None = None
2047 if args.verify and not dry_run:
2048 verification = _verify_merge_evidence(
2049 argparse.Namespace(
2050 pr=args.pr,
2051 issue=args.issue,
2052 root=args.root,
2053 reviewers=args.reviewers,
2054 review_comments="inline",
2055 jury=False,
2056 no_jury=False,
2057 jury_advisory=False,
2058 gate_label=None,
2059 waiver_label=None,
2060 ),
2061 config,
2062 )
2064 result_payload = {
2065 "schema_version": review.SCHEMA_VERSION,
2066 "plan": plan.as_dict(),
2067 "dry_run": dry_run,
2068 "transport": transport.name,
2069 "posted": posted,
2070 "consent": operator_consent["status"],
2071 "verification": verification,
2072 }
2073 if args.json:
2074 print(json.dumps(result_payload, indent=2, sort_keys=True))
2075 else:
2076 mode = "dry-run" if dry_run else "live"
2077 print(f"keel review — {mode} PR #{args.pr}")
2078 print(f" tier : {tier if tier is not None else 'unresolved'}")
2079 print(f" required : {required_count}")
2080 print(f" supplied : {plan.supplied_count}")
2081 print(f" posts : {len(plan.posts)}")
2082 if verification is not None:
2083 print(f" verify : {verification['verification']['status']}")
2084 if verification is not None and verification["verification"]["status"] != "pass":
2085 return 1
2086 return 0
2089def _cmd_review_cycle_summary(args: argparse.Namespace) -> int:
2090 try:
2091 raw = json.loads(Path(args.findings).read_text(encoding="utf-8"))
2092 except OSError as exc:
2093 print(f"cannot read --findings {args.findings}: {exc}", file=sys.stderr)
2094 return 1
2095 except json.JSONDecodeError as exc:
2096 print(f"--findings {args.findings} is not valid JSON: {exc}", file=sys.stderr)
2097 return 1
2098 try:
2099 reviewers = review.parse_cycle_reviewers(raw)
2100 except review.ReviewError as exc:
2101 print(str(exc), file=sys.stderr)
2102 return 1
2103 body = artifacts.render_review_cycle_summary(
2104 reviewers=list(reviewers),
2105 head_sha=args.head_sha,
2106 run_id=args.run_id,
2107 )
2108 if args.json:
2109 print(json.dumps(
2110 {
2111 "schema_version": artifacts.SCHEMA_VERSION,
2112 "marker": artifacts.REVIEW_CYCLE_SUMMARY_MARKER,
2113 "reviewers": len(reviewers),
2114 "body": body,
2115 },
2116 indent=2,
2117 sort_keys=True,
2118 ))
2119 else:
2120 print(body, end="")
2121 return 0
2124_REPORT_RENDERERS = {
2125 "coverage": artifacts.render_coverage_delta,
2126 "deps-audit": artifacts.render_deps_audit,
2127 "flake-audit": artifacts.render_flake_audit,
2128 "scan-finding": artifacts.render_scan_finding_issue,
2129 "triage-audit": artifacts.render_triage_audit,
2130}
2132_REPORT_MARKERS = {
2133 "coverage": artifacts.COVERAGE_DELTA_MARKER,
2134 "deps-audit": artifacts.DEPS_AUDIT_MARKER,
2135 "flake-audit": artifacts.FLAKE_AUDIT_MARKER,
2136 "scan-finding": artifacts.SCAN_FINDING_MARKER,
2137 "triage-audit": artifacts.TRIAGE_AUDIT_MARKER,
2138}
2141def _cmd_render_report(args: argparse.Namespace) -> int:
2142 try:
2143 payload = json.loads(Path(args.payload).read_text(encoding="utf-8"))
2144 except OSError as exc:
2145 print(f"cannot read --payload {args.payload}: {exc}", file=sys.stderr)
2146 return 1
2147 except json.JSONDecodeError as exc:
2148 print(f"--payload {args.payload} is not valid JSON: {exc}", file=sys.stderr)
2149 return 1
2150 if not isinstance(payload, dict):
2151 print("--payload must be a JSON object of renderer fields", file=sys.stderr)
2152 return 1
2153 try:
2154 body = _REPORT_RENDERERS[args.kind](**payload)
2155 except TypeError as exc:
2156 print(f"--payload does not match the {args.kind} report fields: {exc}", file=sys.stderr)
2157 return 1
2158 if args.json:
2159 print(json.dumps(
2160 {
2161 "schema_version": artifacts.SCHEMA_VERSION,
2162 "kind": args.kind,
2163 "marker": _REPORT_MARKERS[args.kind],
2164 "body": body,
2165 },
2166 indent=2,
2167 sort_keys=True,
2168 ))
2169 else:
2170 print(body, end="")
2171 return 0
2174def _cmd_post_comment(args: argparse.Namespace) -> int:
2175 try:
2176 config = cfg.load_config(args.path)
2177 except FileNotFoundError:
2178 print(f"no such config: {args.path}", file=sys.stderr)
2179 return 1
2180 except cfg.ConfigError as exc:
2181 print(str(exc), file=sys.stderr)
2182 return 1
2184 marker = _comment_artifact_marker(args.artifact)
2185 try:
2186 target_kind, target_number = _parse_comment_target(args.target)
2187 except ValueError as exc:
2188 print(str(exc), file=sys.stderr)
2189 return 1
2190 try:
2191 body = Path(args.body_file).read_text(encoding="utf-8")
2192 except OSError as exc:
2193 print(f"cannot read --body-file {args.body_file}: {exc}", file=sys.stderr)
2194 return 1
2195 if marker not in body:
2196 print(
2197 f"body for artifact {args.artifact} must contain marker {marker}",
2198 file=sys.stderr,
2199 )
2200 return 1
2201 if _looks_like_body_file_literal(body):
2202 print(
2203 "body appears to be a literal @/path reference; pass rendered markdown, "
2204 "not a shell-expanded placeholder",
2205 file=sys.stderr,
2206 )
2207 return 1
2209 report = runtime.detect(args.root)
2210 evaluation = runtime.evaluate(
2211 runtime.CapabilityRequirement(required=("gh", "gh-auth")), report
2212 )
2213 transport = github_transport.resolve(report)
2214 if not evaluation.ok or not transport.supports("comments"):
2215 print(evaluation.render(), file=sys.stderr)
2216 print(transport.render(), file=sys.stderr)
2217 return 1
2218 try:
2219 owner_repo = _owner_repo(config)
2220 except ValueError as exc:
2221 print(str(exc), file=sys.stderr)
2222 return 1
2224 try:
2225 payload, error = _post_artifact_comment(
2226 owner_repo,
2227 target_kind=target_kind,
2228 target_number=target_number,
2229 artifact=args.artifact,
2230 marker=marker,
2231 body=body,
2232 run_id=args.run_id,
2233 transport_name=transport.name,
2234 dry_run=args.dry_run,
2235 cwd=args.root,
2236 )
2237 except ValueError as exc:
2238 print(str(exc), file=sys.stderr)
2239 return 1
2240 if error is not None:
2241 print(error, file=sys.stderr)
2242 return 1
2243 return _finish_post_comment(args, payload, code=0)
2246def _post_artifact_comment(
2247 owner_repo: str,
2248 *,
2249 target_kind: str,
2250 target_number: int,
2251 artifact: str,
2252 marker: str,
2253 body: str,
2254 run_id: str | None,
2255 transport_name: str,
2256 dry_run: bool,
2257 cwd: str,
2258) -> tuple[dict[str, object], str | None]:
2259 """Post or update one marker/run-id comment via the existing gh path.
2261 Returns a ``(payload, error)`` pair. ``error`` is ``None`` on success; on a gh
2262 mutation failure it is the message to surface. Raises ``ValueError`` only when
2263 the comment fetch itself fails. Dry runs plan the action and never mutate.
2264 """
2265 existing = _gh_json_list(
2266 ["repos", owner_repo, "issues", str(target_number), "comments"], cwd=cwd
2267 )
2268 match = _find_comment_match(existing, marker=marker, run_id=run_id)
2269 body = _with_run_id_marker(body, run_id)
2270 payload: dict[str, object] = {
2271 "schema_version": "keel.post-comment.v1",
2272 "target": {"kind": target_kind, "number": target_number},
2273 "artifact": artifact,
2274 "marker": marker,
2275 "transport": transport_name,
2276 "run_id": run_id,
2277 "dry_run": dry_run,
2278 }
2279 if dry_run:
2280 payload["action"] = "edit" if match else "post"
2281 payload["comment_id"] = match.get("id") if match else None
2282 return payload, None
2284 if match:
2285 comment_id = match.get("id")
2286 if not isinstance(comment_id, int):
2287 return payload, "matching comment is missing an integer id"
2288 result = github.edit_issue_comment(owner_repo, comment_id, body, cwd=cwd)
2289 payload["action"] = "edited"
2290 payload["comment_id"] = comment_id
2291 else:
2292 result = github.post_issue_comment(owner_repo, target_number, body, cwd=cwd)
2293 payload["action"] = "posted"
2294 if not result.ok:
2295 return payload, result.output.strip() or "gh comment mutation failed"
2296 try:
2297 response = json.loads(result.stdout or "{}")
2298 except json.JSONDecodeError:
2299 response = {}
2300 if isinstance(response, dict):
2301 payload["comment_id"] = response.get("id", payload.get("comment_id"))
2302 payload["html_url"] = response.get("html_url")
2303 return payload, None
2306def _cmd_evidence_verify(args: argparse.Namespace) -> int:
2307 try:
2308 config = cfg.load_config(args.path)
2309 except FileNotFoundError:
2310 print(f"no such config: {args.path}", file=sys.stderr)
2311 return 1
2312 except cfg.ConfigError as exc:
2313 print(str(exc), file=sys.stderr)
2314 return 1
2316 try:
2317 artifacts = _load_evidence_artifacts(args, config)
2318 except ValueError as exc:
2319 print(str(exc), file=sys.stderr)
2320 return 1
2321 changed_files = artifacts["changed_files"]
2322 artifacts_patches = artifacts.get("patches")
2323 tier = (
2324 classify.tier_for_files(
2325 changed_files,
2326 tier3_globs=config.knobs.tier3_globs,
2327 docs_globs=config.knobs.docs_gate_paths,
2328 allowlist_globs=config.knobs.docs_only_allowlist,
2329 patches=artifacts_patches,
2330 )
2331 if changed_files else None
2332 )
2333 review_contract = ship.resolve_review_contract(
2334 tier=tier,
2335 reviewer_override=args.reviewers,
2336 review_comments=args.review_comments,
2337 gates=config.gates,
2338 policy_pack=config.policy_pack,
2339 jury=args.jury,
2340 no_jury=args.no_jury,
2341 jury_advisory=args.jury_advisory,
2342 require_distinct_vendors=(
2343 args.require_distinct_vendors
2344 or config.knobs.evidence_require_distinct_vendors
2345 ),
2346 # An explicit --jury-vendors wins; otherwise take the count a posted jury
2347 # verdict declared, so the downgrade works unattended in CI where neither
2348 # the ledger nor the jury artifact under .keel/state/ is readable.
2349 jury_participating_vendors=(
2350 args.jury_vendors
2351 if args.jury_vendors is not None
2352 else evidence.jury_participating_vendors(
2353 artifacts["pr_comments"],
2354 artifacts["pr_reviews"],
2355 head_sha=artifacts["head_sha"],
2356 )
2357 ),
2358 )
2359 gate_label = args.gate_label or config.knobs.evidence_gate_label
2360 waiver_label = args.waiver_label or evidence.DEFAULT_WAIVER_LABEL
2361 gate = evidence.gate_decision(
2362 artifacts["pr_labels"],
2363 gate_label,
2364 waiver_label=waiver_label,
2365 head_ref=artifacts.get("head_ref"),
2366 pr_comments=artifacts["pr_comments"],
2367 pr_reviews=artifacts["pr_reviews"],
2368 )
2369 enforced = gate["enforced"]
2370 try:
2371 ledger_record = _evidence_ledger_record(args, config)
2372 except ledger.LedgerError as exc:
2373 print(f"invalid run ledger: {exc}", file=sys.stderr)
2374 return 1
2375 report = evidence.verify(
2376 review_contract,
2377 pr_comments=artifacts["pr_comments"],
2378 issue_comments=artifacts["issue_comments"],
2379 pr_reviews=artifacts["pr_reviews"],
2380 pr_body=artifacts["pr_body"],
2381 pr_labels=artifacts["pr_labels"],
2382 head_sha=artifacts["head_sha"],
2383 ledger_record=ledger_record,
2384 dry_run=args.dry_run,
2385 enforced=enforced,
2386 deferrals=tuple(args.deferral or ()),
2387 phase=args.phase,
2388 require_armed=args.require_armed,
2389 waived=bool(gate.get("waived")),
2390 )
2391 payload = {
2392 "contract": evidence.contract_as_dict(
2393 review_contract,
2394 dry_run=args.dry_run,
2395 enforced=enforced,
2396 deferrals=tuple(args.deferral or ()),
2397 ),
2398 "gate_label": gate_label,
2399 "waiver_label": waiver_label,
2400 "gate": gate,
2401 "enforced": enforced,
2402 "pr_labels": artifacts["pr_labels"],
2403 "pull_request": args.pr,
2404 "issue": artifacts["issue"],
2405 "head_ref": artifacts.get("head_ref"),
2406 "head_sha": artifacts["head_sha"],
2407 "changed_files": artifacts["changed_files"],
2408 "verification": report,
2409 }
2410 if args.json:
2411 print(json.dumps(payload, indent=2, sort_keys=True))
2412 else:
2413 print(f"keel evidence-verify — {report['status']} PR #{args.pr}")
2414 print(f" issue : {artifacts['issue'] or 'not resolved'}")
2415 print(f" dry-run : {str(args.dry_run).lower()}")
2416 print(f" phase : {args.phase}")
2417 if not enforced:
2418 print(f" enforced : false ({gate['reason']})")
2419 print(" required : 0")
2420 if gate.get("waived"):
2421 print(" note : evidence gate disarmed by operator waiver label")
2422 else:
2423 print(" note : evidence gate not enforced; no ship provenance detected")
2424 # An unarmed gate checked nothing, so --require-armed refuses to call that
2425 # a pass; the waiver label stays the deliberate, operator-signed way out.
2426 if report["status"] != "pass":
2427 print(" FAIL gate-unarmed")
2428 return 1
2429 return 0
2430 print(f" enforced : true ({gate['reason']})")
2431 print(f" required : {report['required_count']}")
2432 if report["missing"]:
2433 print(f" missing : {', '.join(report['missing'])}")
2434 for result in report["results"]:
2435 state = "ok" if result["ok"] else "FAIL"
2436 suffix = " (deferred)" if result["deferred"] else ""
2437 print(f" {state:>4} {result['id']}{suffix}")
2438 return 0 if report["status"] == "pass" else 1
2441def _overtaking_prs(args: argparse.Namespace, timing: dict) -> dict:
2442 """Map each path to a PR that changed it after ``args.pr`` branched (#561).
2444 The window is (branch point, this merge). A pull request merged inside it landed
2445 work the branch does not contain, so a squash of that branch can undo it — which
2446 is precisely how #543 reverted #550 while every gate stayed green.
2447 """
2448 others = github.prs_merged_between(
2449 timing["base"], timing["branched_at"], timing["merged_at"], cwd=args.root
2450 ) or []
2451 overtaken: dict = {}
2452 for number in others:
2453 if number == args.pr:
2454 continue
2455 for path in github.pr_files(number, cwd=args.root) or []:
2456 overtaken.setdefault(path, number)
2457 return overtaken
2460def _cmd_verify_merge(args: argparse.Namespace) -> int:
2461 """Did the merge apply what was reviewed? (#561)
2463 `keel merge` proves the merge succeeded. This proves it applied the reviewed
2464 diff and nothing else — the gap a squash over an `update-branch` merge commit
2465 fell through twice in one day, silently reverting unrelated merged work while
2466 the suite stayed green because the reverted state was internally consistent.
2467 """
2468 try:
2469 config = cfg.load_config(args.path)
2470 except FileNotFoundError:
2471 print(f"no such config: {args.path}", file=sys.stderr)
2472 return 1
2473 except cfg.ConfigError as exc:
2474 print(str(exc), file=sys.stderr)
2475 return 1
2476 del config # loaded to validate the project; this check reads only GitHub
2478 timing = github.pr_merge_window(args.pr, cwd=args.root)
2479 merge_sha = args.merge_sha or (timing or {}).get("merge_commit")
2480 if not merge_sha or not timing:
2481 report = mergeverify.verify_merge(None)
2482 report["reason"] = (
2483 f"pull request #{args.pr} has no merge commit yet, or gh could not be asked"
2484 )
2485 else:
2486 report = mergeverify.verify_merge(
2487 github.commit_files(merge_sha, cwd=args.root),
2488 overtaken=_overtaking_prs(args, timing),
2489 intended=github.pr_files(args.pr, cwd=args.root),
2490 )
2491 report["pull_request"] = args.pr
2492 report["merge_commit"] = merge_sha
2493 if args.json:
2494 print(json.dumps(report, indent=2, sort_keys=True))
2495 else:
2496 print(mergeverify.render(report))
2497 # Loud on drift, quiet otherwise. `incomplete` is usually an identical change
2498 # already on the base, and `unknown` is a fact about the runner — neither is
2499 # evidence that something was reverted, so neither fails the command.
2500 return 1 if mergeverify.is_drift(report) else 0
2503def _cmd_scope_verify(args: argparse.Namespace) -> int:
2504 try:
2505 config = cfg.load_config(args.path)
2506 except FileNotFoundError:
2507 print(f"no such config: {args.path}", file=sys.stderr)
2508 return 1
2509 except cfg.ConfigError as exc:
2510 print(str(exc), file=sys.stderr)
2511 return 1
2513 try:
2514 artifacts = _load_evidence_artifacts(args, config)
2515 except ValueError as exc:
2516 print(str(exc), file=sys.stderr)
2517 return 1
2518 try:
2519 record = _scope_ledger_record(args, config)
2520 except ledger.LedgerError as exc:
2521 print(f"invalid run ledger: {exc}", file=sys.stderr)
2522 return 1
2523 declared = ledger.declared_files_for_record(record) if record is not None else None
2524 report = scope.verify(
2525 declared,
2526 list(artifacts["changed_files"]),
2527 docs_globs=config.knobs.docs_gate_paths,
2528 deferrals=tuple(args.deferral or ()),
2529 )
2530 payload = {
2531 "schema_version": scope.SCHEMA_VERSION,
2532 "pull_request": args.pr,
2533 "head_sha": artifacts["head_sha"],
2534 "changed_files": artifacts["changed_files"],
2535 "deferrals": list(args.deferral or ()),
2536 "verification": report,
2537 }
2538 if args.json:
2539 print(json.dumps(payload, indent=2, sort_keys=True))
2540 else:
2541 print(f"keel scope-verify — {report['status']} PR #{args.pr}")
2542 if report["advisory"]:
2543 print(f" note : {report['note']}")
2544 return 0
2545 print(f" declared : {len(report['declared'])} file(s)")
2546 print(f" in-scope : {len(report['in_scope'])} file(s)")
2547 if report["docs_exempt"]:
2548 print(f" docs-exempt : {', '.join(report['docs_exempt'])}")
2549 if report["scope_creep"]:
2550 print(f" scope-creep : {', '.join(report['scope_creep'])}")
2551 if report["note"]:
2552 print(f" note : {report['note']}")
2553 return 0 if report["status"] == "pass" else 1
2556def _cmd_verify_branch(args: argparse.Namespace) -> int:
2557 try:
2558 config = cfg.load_config(args.path)
2559 except FileNotFoundError:
2560 print(f"no such config: {args.path}", file=sys.stderr)
2561 return 1
2562 except cfg.ConfigError as exc:
2563 print(str(exc), file=sys.stderr)
2564 return 1
2566 base_branch = config.base_branch
2567 try:
2568 facts = _gather_branch_facts(args, base_branch)
2569 except ValueError as exc:
2570 print(str(exc), file=sys.stderr)
2571 return 1
2572 report = branchscope.verify(
2573 base_branch=base_branch,
2574 head_sha=facts["head_sha"],
2575 merge_base_sha=facts["merge_base_sha"],
2576 base_tip_sha=facts["base_tip_sha"],
2577 base_distance=facts["base_distance"],
2578 worktree_path=facts["worktree_path"],
2579 repo_root=facts["repo_root"],
2580 is_linked_worktree=facts["is_linked_worktree"],
2581 tolerance=args.tolerance,
2582 allow_stale_base=args.allow_stale_base,
2583 )
2584 payload = {
2585 "schema_version": branchscope.SCHEMA_VERSION,
2586 "pull_request": args.pr,
2587 "head_ref": facts["head_ref"],
2588 "verification": report,
2589 }
2590 if args.json:
2591 print(json.dumps(payload, indent=2, sort_keys=True))
2592 else:
2593 print(f"keel verify-branch — {report['status']} PR #{args.pr}")
2594 print(f" base : origin/{base_branch}")
2595 print(f" verdict : {report['verdict']}")
2596 ancestry = report["ancestry"]
2597 if ancestry["base_distance"] is not None:
2598 print(
2599 f" base-distance : {ancestry['base_distance']} "
2600 f"(tolerance {report['tolerance']})"
2601 )
2602 isolation = report["isolation"]
2603 print(f" isolation : {isolation['verdict']}")
2604 if report["note"]:
2605 print(f" note : {report['note']}")
2606 return 0 if report["status"] == "pass" else 1
2609def _gather_branch_facts(args: argparse.Namespace, base_branch: str) -> dict[str, object]:
2610 """Collect the git/gh facts the pure branch verdict needs.
2612 Offline fixtures (``--head-sha``, ``--base-tip-sha``, ``--merge-base-sha``,
2613 ``--base-distance``, ``--worktree-path``/``--repo-root``/``--linked-worktree``)
2614 short-circuit every live call so the gather path is deterministic in tests; a
2615 live run resolves the PR head via ``gh`` and the ancestry/worktree facts via
2616 the thin ``git`` wrappers, fail-soft (a missing fact becomes ``None`` and the
2617 pure layer skips that check rather than hard-blocking).
2618 """
2619 head_sha = args.head_sha
2620 head_ref = args.head_ref
2621 base_ref = f"origin/{base_branch}"
2622 if head_sha is None and not args.offline:
2623 owner_repo = _owner_repo_from_args(args)
2624 pr = _gh_json(["repos", owner_repo, "pulls", str(args.pr)], cwd=args.root)
2625 head = pr.get("head") if isinstance(pr.get("head"), dict) else {}
2626 head_sha = head.get("sha") if isinstance(head.get("sha"), str) else None
2627 head_ref = head.get("ref") if isinstance(head.get("ref"), str) else head_ref
2629 base_tip_sha = args.base_tip_sha
2630 merge_base_sha = args.merge_base_sha
2631 base_distance = args.base_distance
2632 if not args.offline:
2633 if base_tip_sha is None:
2634 base_tip_sha = git.rev_parse(base_ref, cwd=args.root)
2635 if merge_base_sha is None and head_sha is not None and base_tip_sha is not None:
2636 merge_base_sha = git.merge_base(head_sha, base_tip_sha, cwd=args.root)
2637 if (
2638 base_distance is None
2639 and merge_base_sha is not None
2640 and base_tip_sha is not None
2641 ):
2642 base_distance = git.rev_count(merge_base_sha, base_tip_sha, cwd=args.root)
2644 worktree_path = args.worktree_path
2645 repo_root = args.repo_root
2646 is_linked_worktree = _linked_flag(args.linked_worktree)
2647 if not args.offline and head_ref is not None:
2648 local = _local_worktree_facts(head_ref, cwd=args.root)
2649 if local is not None:
2650 worktree_path = worktree_path or local["worktree_path"]
2651 repo_root = repo_root or local["repo_root"]
2652 if is_linked_worktree is None:
2653 is_linked_worktree = local["is_linked_worktree"]
2654 return {
2655 "head_sha": head_sha,
2656 "head_ref": head_ref,
2657 "base_tip_sha": base_tip_sha,
2658 "merge_base_sha": merge_base_sha,
2659 "base_distance": base_distance,
2660 "worktree_path": worktree_path,
2661 "repo_root": repo_root,
2662 "is_linked_worktree": is_linked_worktree,
2663 }
2666def _owner_repo_from_args(args: argparse.Namespace) -> str:
2667 config = cfg.load_config(args.path)
2668 return _owner_repo(config)
2671def _linked_flag(value: str | None) -> bool | None:
2672 if value is None:
2673 return None
2674 return value == "true"
2677def _local_worktree_facts(branch: str, *, cwd: str) -> dict[str, object] | None:
2678 """Locate ``branch``'s checkout in ``git worktree list --porcelain`` (fail-soft).
2680 Returns the worktree path, the repo root (the first/primary worktree), and
2681 whether the branch lives in a *linked* (non-primary) worktree — or ``None``
2682 when the listing fails or the branch is not checked out locally (CI/PR-only).
2683 """
2684 listed = git.worktree_list(cwd=cwd)
2685 if not listed.ok:
2686 return None
2687 entries = _parse_worktree_porcelain(listed.stdout)
2688 if not entries:
2689 return None
2690 repo_root = entries[0]["path"]
2691 for index, entry in enumerate(entries):
2692 if entry["branch"] == branch:
2693 return {
2694 "worktree_path": entry["path"],
2695 "repo_root": repo_root,
2696 "is_linked_worktree": index > 0,
2697 }
2698 return None
2701def _parse_worktree_porcelain(output: str) -> list[dict[str, str | None]]:
2702 """Parse ``git worktree list --porcelain`` into ``{path, branch}`` blocks."""
2703 entries: list[dict[str, str | None]] = []
2704 current: dict[str, str | None] | None = None
2705 for line in output.splitlines():
2706 if line.startswith("worktree "):
2707 current = {"path": line[len("worktree ") :], "branch": None}
2708 entries.append(current)
2709 elif line.startswith("branch ") and current is not None:
2710 ref = line[len("branch ") :]
2711 current["branch"] = ref[len("refs/heads/") :] if ref.startswith("refs/heads/") else ref
2712 return entries
2715def _scope_ledger_record(
2716 args: argparse.Namespace,
2717 config: cfg.ProjectConfig,
2718) -> dict[str, object] | None:
2719 """Load the latest ship_run ledger record for the PR under scope-verify.
2721 Reads the run ledger (offline fixture via ``--ledger-jsonl`` or the configured
2722 path under ``--root``) and returns the most recent matching ship_run record,
2723 or ``None`` when no record matches — the advisory back-compat path.
2724 """
2725 fixture = getattr(args, "ledger_jsonl", None)
2726 if fixture is not None:
2727 records = ledger.parse_records(Path(fixture).read_text(encoding="utf-8"))
2728 else:
2729 records = ledger.read_records(ledger.resolve_path(args.root, config))
2730 return ledger.latest_ship_run_for_pr(records, args.pr)
2733def _cmd_status(args: argparse.Namespace) -> int:
2734 try:
2735 config = cfg.load_config(args.path)
2736 except FileNotFoundError:
2737 print(f"no such config: {args.path}", file=sys.stderr)
2738 return 1
2739 except cfg.ConfigError as exc:
2740 print(str(exc), file=sys.stderr)
2741 return 1
2743 checkpoint_path = checkpoint.resolve_path(args.root, config)
2744 ledger_path = ledger.resolve_path(args.root, config)
2745 try:
2746 checkpoint_record = checkpoint.read_checkpoint(checkpoint_path)
2747 except checkpoint.CheckpointError as exc:
2748 print(f"invalid checkpoint {checkpoint_path}: {exc}", file=sys.stderr)
2749 return 1
2750 try:
2751 ledger_records = ledger.read_records(ledger_path)
2752 except ledger.LedgerError as exc:
2753 print(f"invalid ledger {ledger_path}: {exc}", file=sys.stderr)
2754 return 1
2755 snapshot = status.build_status_snapshot(
2756 config=config,
2757 checkpoint_record=checkpoint_record,
2758 ledger_records=ledger_records,
2759 live_branches=list(args.live_branch),
2760 live_pull_requests=list(args.live_pr),
2761 )
2762 payload = {
2763 "contract": status.status_contract_as_dict(config),
2764 "checkpoint_path": str(checkpoint_path),
2765 "ledger_path": str(ledger_path),
2766 "snapshot": snapshot,
2767 }
2768 if args.json:
2769 print(json.dumps(payload, indent=2, sort_keys=True))
2770 else:
2771 print(status.render_status(snapshot))
2772 return 0
2775def _cmd_checkpoint(args: argparse.Namespace) -> int:
2776 try:
2777 config = cfg.load_config(args.path)
2778 except FileNotFoundError:
2779 print(f"no such config: {args.path}", file=sys.stderr)
2780 return 1
2781 except cfg.ConfigError as exc:
2782 print(str(exc), file=sys.stderr)
2783 return 1
2785 contract = checkpoint.checkpoint_contract_as_dict(config)
2786 path = checkpoint.resolve_path(args.root, config)
2787 if args.write:
2788 try:
2789 record = checkpoint.build_checkpoint_record(
2790 run_id=args.run_id,
2791 command=args.checkpoint_command_name,
2792 current_step=args.step,
2793 base_branch=config.base_branch,
2794 target=args.target,
2795 issue_queue=args.issue_queue,
2796 active_issue=args.active_issue,
2797 branch=args.branch,
2798 worktree=args.worktree,
2799 pull_request=args.pull_request,
2800 head_sha=args.head_sha,
2801 completed_steps=args.completed_step,
2802 last_gate=args.last_gate,
2803 last_review=args.last_review,
2804 last_check=args.last_check,
2805 jury_mode=args.jury_mode,
2806 merge_state=args.merge_state,
2807 capture_state=args.capture_state,
2808 close_state=args.close_state,
2809 stop_reason=args.stop_reason,
2810 )
2811 checkpoint.write_checkpoint(path, record)
2812 except checkpoint.CheckpointError as exc:
2813 print(str(exc), file=sys.stderr)
2814 return 1
2815 else:
2816 try:
2817 record = checkpoint.read_checkpoint(path)
2818 except checkpoint.CheckpointError as exc:
2819 print(f"invalid checkpoint {path}: {exc}", file=sys.stderr)
2820 return 1
2821 payload = {
2822 "contract": contract,
2823 "path": str(path),
2824 "status": "present" if record is not None else "missing",
2825 "checkpoint": record,
2826 }
2827 if args.json:
2828 print(json.dumps(payload, indent=2, sort_keys=True))
2829 else:
2830 print(f"keel checkpoint — {payload['status']} {path}")
2831 print(f" schema : {contract['schema_version']}")
2832 if record:
2833 print(f" run : {record['run_id']}")
2834 print(f" step : {record['position']['current_step']}")
2835 print(f" safe boundary : {record['resume']['safe_boundary']}")
2836 return 0
2839def _cmd_activity(args: argparse.Namespace) -> int:
2840 """Read / write / finish / clear an additive command-activity record."""
2841 try:
2842 config = cfg.load_config(args.path)
2843 except FileNotFoundError:
2844 print(f"no such config: {args.path}", file=sys.stderr)
2845 return 1
2846 except cfg.ConfigError as exc:
2847 print(str(exc), file=sys.stderr)
2848 return 1
2850 try:
2851 if args.write:
2852 path = activity.record_path(args.root, config, args.run_id)
2853 record = activity.build_activity_record(
2854 command=args.activity_command_name,
2855 run_id=args.run_id,
2856 phase=args.phase,
2857 status=args.status,
2858 issue=args.issue,
2859 pr=args.pull_request,
2860 note=args.note,
2861 )
2862 activity.write_activity(path, record)
2863 _emit_activity(args, [record], path=str(path))
2864 return 0
2865 if args.done:
2866 path = activity.record_path(args.root, config, args.run_id)
2867 record = activity.read_activity(path)
2868 if record is None:
2869 print(f"no activity record for run {args.run_id}", file=sys.stderr)
2870 return 1
2871 record["status"] = "done"
2872 activity.write_activity(path, record)
2873 _emit_activity(args, [record], path=str(path))
2874 return 0
2875 if args.clear:
2876 path = activity.record_path(args.root, config, args.run_id)
2877 removed = activity.remove_activity(path)
2878 _emit_activity(args, [], path=str(path), removed=removed)
2879 return 0
2880 records = activity.read_all_activity(activity.resolve_dir(args.root, config))
2881 _emit_activity(args, records, path=str(activity.resolve_dir(args.root, config)))
2882 return 0
2883 except activity.ActivityError as exc:
2884 print(str(exc), file=sys.stderr)
2885 return 1
2888def _emit_activity(args, records, *, path, removed=None):
2889 """Render activity output (JSON or a short line)."""
2890 if args.json:
2891 print(json.dumps(
2892 {"contract": activity.activity_contract_as_dict(), "path": path,
2893 "removed": removed, "activity": records},
2894 indent=2, sort_keys=True))
2895 return
2896 if removed is not None:
2897 print(f"keel activity — {'cleared' if removed else 'nothing to clear'} {path}")
2898 return
2899 print(f"keel activity — {len(records)} record(s) {path}")
2900 for record in records:
2901 print(f" {record['command']:14} {record['run_id']:18} "
2902 f"{record['phase']:12} {record['status']}")
2905def _cmd_scratch_dir(args: argparse.Namespace) -> int:
2906 """Print the keel-owned scratch dir, creating it (and the gitignore) by default.
2908 Adapters wire this as ``SCRATCH=$(keel scratch-dir)`` so every transient
2909 artifact (PR diffs, issue dumps, draft prose) lands under ``.keel/scratch``
2910 instead of the consumer's checkout.
2911 """
2912 scratch = workspace.scratch_dir(args.root, create=args.create)
2913 print(scratch)
2914 return 0
2917# Default activity records kept by `keel gc` — enough recent runs for the live
2918# board to stay useful while bounding unbounded growth.
2919DEFAULT_GC_KEEP_ACTIVITY = 50
2922def _cmd_gc(args: argparse.Namespace) -> int:
2923 """Reclaim disposable runtime artifacts: empty scratch, prune old activity.
2925 Single, auditable entry point for taking out keel's own trash. The run
2926 ledger, checkpoint, and locks are durable / self-bounded and are never
2927 touched. Fail-soft: a failure on one tree degrades to a no-op and the other
2928 still runs; the command never aborts a caller.
2929 """
2930 try:
2931 config = cfg.load_config(args.path)
2932 except FileNotFoundError:
2933 print(f"no such config: {args.path}", file=sys.stderr)
2934 return 1
2935 except cfg.ConfigError as exc:
2936 print(str(exc), file=sys.stderr)
2937 return 1
2939 scratch_removed: list[str] = []
2940 activity_removed: list[str] = []
2941 degraded: list[str] = []
2943 if args.scratch:
2944 try:
2945 if args.dry_run:
2946 scratch_removed = workspace.scratch_entries(args.root)
2947 else:
2948 scratch_removed = workspace.clean_scratch(args.root)
2949 except OSError as exc: # fail-soft: never abort the caller
2950 degraded.append(f"scratch: {exc}")
2952 if args.activity:
2953 try:
2954 activity_dir = activity.resolve_dir(args.root, config)
2955 if args.dry_run:
2956 activity_removed = workspace.activity_prune_plan(
2957 activity_dir, keep_last=args.keep_activity)
2958 else:
2959 activity_removed = workspace.prune_activity(
2960 activity_dir, keep_last=args.keep_activity)
2961 except (OSError, activity.ActivityError) as exc: # fail-soft
2962 degraded.append(f"activity: {exc}")
2964 if args.json:
2965 print(json.dumps({
2966 "dry_run": args.dry_run,
2967 "scratch_removed": scratch_removed,
2968 "activity_removed": activity_removed,
2969 "keep_activity": args.keep_activity,
2970 "degraded": degraded,
2971 }, indent=2, sort_keys=True))
2972 return 0
2974 verb = "would remove" if args.dry_run else "removed"
2975 print(f"keel gc — {args.path}")
2976 if args.scratch:
2977 plural = "y" if len(scratch_removed) == 1 else "ies"
2978 print(f" scratch : {verb} {len(scratch_removed)} entr{plural}")
2979 if args.activity:
2980 print(f" activity : {verb} {len(activity_removed)} record(s) "
2981 f"(kept newest {args.keep_activity})")
2982 for note in degraded:
2983 print(f" ! degraded: {note}", file=sys.stderr)
2984 return 0
2987def _observe_live_state(
2988 args: argparse.Namespace, record: dict | None
2989) -> dict:
2990 """Resolve the live PR / worktree / head state ``resume`` reconciles against.
2992 Core used to be *told* this and never looked (#635): the flags defaulted to
2993 ``unknown``, so every ambiguous outcome required the agent to volunteer the damning
2994 state, and a checkpoint pointing at a deleted PR resumed as ``pr-open``. Now keel
2995 observes by default and the flags become an explicit override for offline and
2996 fixture use, with ``--no-observe`` to opt out entirely.
2998 Unreadable is **unknown**, never ``missing``: failing to reach ``gh`` is a fact
2999 about the runner, and reading it as a fact about the PR is the confusion #675 fixed
3000 in the CI gate. The worktree probe is a local ``isdir`` and has no such ambiguity.
3001 """
3002 supplied_pr = getattr(args, "live_pr_state", None)
3003 supplied_wt = getattr(args, "live_worktree_state", None)
3004 observed: dict = {
3005 "pr": supplied_pr or "unknown",
3006 "worktree": supplied_wt or "unknown",
3007 "head_sha": None,
3008 }
3009 if getattr(args, "no_observe", False) or not isinstance(record, dict):
3010 return observed
3011 identifiers = record.get("identifiers")
3012 identifiers = identifiers if isinstance(identifiers, dict) else {}
3014 if supplied_pr is None and identifiers.get("pull_request"):
3015 state = github.pr_state(identifiers["pull_request"], cwd=args.root)
3016 observed["pr"] = state or "unknown"
3017 if supplied_wt is None and identifiers.get("worktree"):
3018 observed["worktree"] = (
3019 "present" if os.path.isdir(str(identifiers["worktree"])) else "missing"
3020 )
3021 if identifiers.get("branch"):
3022 # Read the head from the recorded worktree when it still exists — a resume run
3023 # from the main checkout would otherwise compare against the wrong branch.
3024 cwd = str(identifiers["worktree"]) if observed["worktree"] == "present" \
3025 else args.root
3026 observed["head_sha"] = git.rev_parse(str(identifiers["branch"]), cwd=cwd)
3027 return observed
3030def _cmd_resume(args: argparse.Namespace) -> int:
3031 try:
3032 config = cfg.load_config(args.path)
3033 except FileNotFoundError:
3034 print(f"no such config: {args.path}", file=sys.stderr)
3035 return 1
3036 except cfg.ConfigError as exc:
3037 print(str(exc), file=sys.stderr)
3038 return 1
3040 contract = checkpoint.checkpoint_contract_as_dict(config)
3041 path = checkpoint.resolve_path(args.root, config)
3042 try:
3043 record = checkpoint.read_checkpoint(path)
3044 observed = _observe_live_state(args, record)
3045 plan = checkpoint.resume_plan_as_dict(
3046 record,
3047 live_pr_state=observed["pr"],
3048 live_worktree_state=observed["worktree"],
3049 live_head_sha=observed["head_sha"],
3050 )
3051 except checkpoint.CheckpointError as exc:
3052 print(f"invalid checkpoint {path}: {exc}", file=sys.stderr)
3053 return 1
3054 payload = {
3055 "contract": contract,
3056 "path": str(path),
3057 "resume_plan": plan,
3058 }
3059 if args.json:
3060 print(json.dumps(payload, indent=2, sort_keys=True))
3061 else:
3062 print(f"keel resume — {plan['status']} {path}")
3063 print(f" can resume : {str(plan['can_resume']).lower()}")
3064 print(f" next step : {plan['next_step'] or '-'}")
3065 print(f" action : {plan['resume_action'] or '-'}")
3066 print(f" reason : {plan['reason']}")
3067 for warning in plan["warnings"]:
3068 print(f" warning : {warning}")
3069 return 0 if plan["status"] != "ambiguous" else 1
3072def _cmd_standalone(args: argparse.Namespace) -> int:
3073 return standalone.cmd_standalone(args)
3076def _standalone_target(args: argparse.Namespace) -> str | None:
3077 return standalone._standalone_target(args)
3080def _issue_labels(args: argparse.Namespace) -> tuple[str, ...]:
3081 return standalone._issue_labels(args)
3085def _lock_root(root: str | Path) -> Path:
3086 return Path(root) / ".keel" / "state" / "locks"
3089def _finish_merge(args: argparse.Namespace, payload: dict[str, object], reason: str, *,
3090 code: int) -> int:
3091 payload["reason"] = reason
3092 payload["status"] = "pass" if code == 0 else "fail"
3093 if args.json:
3094 print(json.dumps(payload, indent=2, sort_keys=True))
3095 else:
3096 print(f"keel merge — {payload['status']} PR #{args.pr}")
3097 print(f" reason : {reason}")
3098 lock_payload = payload.get("lock")
3099 if isinstance(lock_payload, dict):
3100 print(f" lock : {lock_payload.get('status')}")
3101 ci_payload = payload.get("ci")
3102 if isinstance(ci_payload, dict):
3103 print(f" ci : {ci_payload.get('state')}")
3104 evidence_payload = payload.get("evidence")
3105 if isinstance(evidence_payload, dict):
3106 verification = evidence_payload.get("verification")
3107 if isinstance(verification, dict):
3108 print(f" evidence: {verification.get('status')}")
3109 gates_sha = payload.get("gates_sha")
3110 if isinstance(gates_sha, dict):
3111 if gates_sha.get("bypassed"):
3112 print(" gates-sha: bypassed (hotfix)")
3113 else:
3114 print(f" gates-sha: {'matched' if gates_sha.get('matched') else 'no-match'}")
3115 justification = payload.get("hotfix_justification")
3116 if isinstance(justification, dict):
3117 detail = justification.get("rule_id") or justification.get("operator") or ""
3118 print(f" hotfix : {justification.get('kind')} {detail}".rstrip())
3119 checkpoint_gate = payload.get("checkpoint_gate")
3120 if isinstance(checkpoint_gate, dict):
3121 detail = checkpoint_gate.get("operator") or checkpoint_gate.get("checkpoint_step") or ""
3122 print(f" checkpoint: {checkpoint_gate.get('status')} {detail}".rstrip())
3123 return code
3126def _merge_snapshot(pr: int, *, cwd: str) -> dict[str, object]:
3127 result = github.pr_merge_snapshot(pr, cwd=cwd)
3128 if not result.ok:
3129 raise ValueError(f"unable to read PR merge snapshot: {result.output.strip()}")
3130 try:
3131 payload = json.loads(result.stdout or "{}")
3132 except json.JSONDecodeError as exc:
3133 raise ValueError("PR merge snapshot was not JSON") from exc
3134 rollup = payload.get("statusCheckRollup")
3135 rollup = rollup if isinstance(rollup, list) else []
3136 return {
3137 "head_sha": payload.get("headRefOid"),
3138 "merge_state": payload.get("mergeStateStatus") or "UNKNOWN",
3139 "ci": _ci_rollup_state(rollup),
3140 }
3143_PENDING_CHECK_STATES = {"EXPECTED", "PENDING", "QUEUED", "REQUESTED", "WAITING", "IN_PROGRESS"}
3146def _rollup_recency(entry: dict) -> tuple[bool, str]:
3147 """Sort key: a check genuinely still in flight is always a more recent
3148 attempt than any concluded entry for the same check — a new run cannot be
3149 queued before the previous one has concluded. Within the same
3150 pending-ness, compare by ``completedAt``/``startedAt``.
3152 "In flight" requires the entry's own ``status`` to be a recognized
3153 pending state, not merely an absent ``conclusion`` — a malformed or
3154 unexpected payload shape (no conclusion, no recognized status) falls
3155 back to plain timestamp comparison instead of unconditionally
3156 outranking a concluded entry, so a genuine stale failure can never be
3157 masked by an unrecognized shape.
3158 """
3159 status_value = entry.get("status")
3160 status_value = status_value.upper() if isinstance(status_value, str) else ""
3161 pending = not entry.get("conclusion") and status_value in _PENDING_CHECK_STATES
3162 stamp = entry.get("completedAt") or entry.get("startedAt") or ""
3163 return (pending, stamp)
3166def _dedupe_rollup(rollup: list[object]) -> list[dict]:
3167 """Keep only the most recent entry per check identity (``context``/``name``).
3169 GitHub's ``statusCheckRollup`` retains every historical run of a check, not
3170 just the latest — a check that failed once and was later rerun to green
3171 still carries its old FAILURE conclusion in the list, and a freshly
3172 requeued rerun may carry no timestamp at all yet. Evaluating the raw list
3173 would treat a superseded failure as still-current, or a stale completed
3174 entry as more current than an in-flight rerun — either way blocking (or
3175 wrongly clearing) the merge. See :func:`_rollup_recency` for the ordering.
3176 """
3177 latest: dict[str, dict] = {}
3178 order: list[str] = []
3179 for item in rollup:
3180 if not isinstance(item, dict):
3181 continue
3182 key = item.get("context") or item.get("name") or ""
3183 if key not in latest:
3184 order.append(key)
3185 latest[key] = item
3186 elif _rollup_recency(item) >= _rollup_recency(latest[key]):
3187 latest[key] = item
3188 return [latest[key] for key in order]
3191def _ci_rollup_state(rollup: list[object]) -> dict[str, object]:
3192 failures = {
3193 "ACTION_REQUIRED", "CANCELLED", "ERROR", "FAILURE",
3194 "STARTUP_FAILURE", "STALE", "TIMED_OUT",
3195 }
3196 pending_states = _PENDING_CHECK_STATES
3197 saw_pending = False
3198 saw_check = False
3199 for item in _dedupe_rollup(rollup):
3200 saw_check = True
3201 conclusion = item.get("conclusion")
3202 conclusion = conclusion.upper() if isinstance(conclusion, str) else ""
3203 status_value = item.get("status")
3204 status_value = status_value.upper() if isinstance(status_value, str) else ""
3205 if conclusion in failures:
3206 return {"state": "fail", "reason": conclusion}
3207 if not conclusion and status_value in pending_states:
3208 saw_pending = True
3209 if saw_pending:
3210 return {"state": "pending", "reason": "check-pending"}
3211 if saw_check:
3212 return {"state": "pass", "reason": "all-checks-passing"}
3213 # An empty rollup is not a pass: no check has reported for this head. Kept as its
3214 # own state so the merge gate can apply the documented docs-only carve-out instead
3215 # of treating "CI has not run" as "CI is green".
3216 return {"state": "no-checks", "reason": "no-checks"}
3219def _verify_merge_evidence(
3220 args: argparse.Namespace,
3221 config: cfg.ProjectConfig,
3222) -> dict[str, object]:
3223 evidence_args = argparse.Namespace(
3224 pr=args.pr,
3225 issue=args.issue,
3226 pr_body_file=None,
3227 pr_comments_json=None,
3228 issue_comments_json=None,
3229 pr_reviews_json=None,
3230 changed_file=(),
3231 head_sha=None,
3232 pr_label=(),
3233 dry_run=False,
3234 root=args.root,
3235 )
3236 artifacts = _load_evidence_artifacts(evidence_args, config)
3237 changed_files = artifacts["changed_files"]
3238 artifacts_patches = artifacts.get("patches")
3239 tier = (
3240 classify.tier_for_files(
3241 changed_files,
3242 tier3_globs=config.knobs.tier3_globs,
3243 docs_globs=config.knobs.docs_gate_paths,
3244 allowlist_globs=config.knobs.docs_only_allowlist,
3245 patches=artifacts_patches,
3246 )
3247 if changed_files else None
3248 )
3249 # Asked directly, not inferred from `tier == 1`: `docs_only_allowlist` can keep a
3250 # change at TIER-1 without making it docs-*only*, and a generated site file riding
3251 # along with a docs edit is exactly when a workflow should have run. An unreadable
3252 # or empty file list is deliberately not docs-only either — this carve-out is the
3253 # one place an empty CI check set is tolerated, so it must fail closed.
3254 docs_only = classify.is_docs_only(changed_files, config.knobs.docs_gate_paths)
3255 review_contract = ship.resolve_review_contract(
3256 tier=tier,
3257 reviewer_override=args.reviewers,
3258 review_comments=args.review_comments,
3259 gates=config.gates,
3260 policy_pack=config.policy_pack,
3261 jury=args.jury,
3262 no_jury=args.no_jury,
3263 jury_advisory=args.jury_advisory,
3264 require_distinct_vendors=config.knobs.evidence_require_distinct_vendors,
3265 )
3266 gate_label = args.gate_label or config.knobs.evidence_gate_label
3267 waiver_label = getattr(args, "waiver_label", None) or evidence.DEFAULT_WAIVER_LABEL
3268 gate = evidence.gate_decision(
3269 artifacts["pr_labels"],
3270 gate_label,
3271 waiver_label=waiver_label,
3272 head_ref=artifacts.get("head_ref"),
3273 pr_comments=artifacts["pr_comments"],
3274 pr_reviews=artifacts["pr_reviews"],
3275 )
3276 enforced = gate["enforced"]
3277 report = evidence.verify(
3278 review_contract,
3279 pr_comments=artifacts["pr_comments"],
3280 issue_comments=artifacts["issue_comments"],
3281 pr_reviews=artifacts["pr_reviews"],
3282 pr_body=artifacts["pr_body"],
3283 pr_labels=artifacts["pr_labels"],
3284 head_sha=artifacts["head_sha"],
3285 enforced=enforced,
3286 )
3287 return {
3288 "gate_label": gate_label,
3289 "waiver_label": waiver_label,
3290 "gate": gate,
3291 "enforced": enforced,
3292 "verification": report,
3293 "head_sha": artifacts["head_sha"],
3294 "head_ref": artifacts.get("head_ref"),
3295 "changed_files": changed_files,
3296 "docs_only": docs_only,
3297 }
3300def _validated_worktree_path(root: str | Path, worktree: str) -> Path:
3301 root_path = Path(root).resolve()
3302 raw = Path(worktree)
3303 candidate = (root_path / raw if not raw.is_absolute() else raw).resolve()
3304 if candidate == root_path or root_path not in candidate.parents:
3305 raise ValueError("worktree path must be nested under the repository root")
3306 listed = git.worktree_list(cwd=str(root_path))
3307 if not listed.ok:
3308 raise ValueError(f"unable to list registered worktrees: {listed.output.strip()}")
3309 registered = {
3310 Path(line.split(" ", 1)[1]).resolve()
3311 for line in listed.stdout.splitlines()
3312 if line.startswith("worktree ")
3313 }
3314 if candidate not in registered:
3315 raise ValueError("worktree path is not a registered git worktree")
3316 return candidate
3319def _issue_context_provided(args: argparse.Namespace) -> bool:
3320 return bool(
3321 (getattr(args, "issue_title", None) or "").strip()
3322 or (getattr(args, "issue_body", None) or "").strip()
3323 or _issue_labels(args)
3324 )
3327def _load_evidence_artifacts(
3328 args: argparse.Namespace,
3329 config: cfg.ProjectConfig,
3330) -> dict[str, object]:
3331 pr_body = _read_optional_text(args.pr_body_file)
3332 pr_comments = _read_optional_json_list(args.pr_comments_json)
3333 issue_comments = _read_optional_json_list(args.issue_comments_json)
3334 pr_reviews = _read_optional_json_list(args.pr_reviews_json)
3335 changed_files = list(getattr(args, "changed_file", ()) or ())
3336 # No diffs from fixtures or --changed-file: the classifier then falls back to
3337 # the path, which is the behaviour that existed before #794.
3338 patches: dict[str, str] = {}
3339 head_sha = args.head_sha
3340 head_ref = getattr(args, "head_ref", None)
3341 issue_number = args.issue
3342 injected_labels = list(args.pr_label or ())
3343 pr_labels: list[str] = []
3344 using_fixtures = any(
3345 path is not None for path in (
3346 args.pr_body_file,
3347 args.pr_comments_json,
3348 args.issue_comments_json,
3349 args.pr_reviews_json,
3350 )
3351 )
3352 if args.dry_run:
3353 return {
3354 "pr_body": pr_body,
3355 "pr_comments": [],
3356 "issue_comments": [],
3357 "pr_reviews": [],
3358 "issue": issue_number,
3359 "head_sha": head_sha,
3360 "head_ref": head_ref,
3361 "changed_files": changed_files,
3362 "patches": {},
3363 "pr_labels": _dedupe_preserve(injected_labels),
3364 }
3365 if not using_fixtures:
3366 owner_repo = _owner_repo(config)
3367 pr = _gh_json(["repos", owner_repo, "pulls", str(args.pr)], cwd=args.root)
3368 pr_body = pr.get("body") if isinstance(pr.get("body"), str) else ""
3369 head = pr.get("head") if isinstance(pr.get("head"), dict) else {}
3370 head_sha = head.get("sha") if isinstance(head.get("sha"), str) else None
3371 head_ref = head.get("ref") if isinstance(head.get("ref"), str) else None
3372 pr_labels = _label_names(pr.get("labels"))
3373 changed_files = _pr_changed_files(owner_repo, args.pr, cwd=args.root)
3374 patches = _pr_patches(owner_repo, args.pr, cwd=args.root)
3375 pr_comments = _gh_json_list(
3376 ["repos", owner_repo, "issues", str(args.pr), "comments"], cwd=args.root
3377 )
3378 pr_reviews = _gh_json_list(
3379 ["repos", owner_repo, "pulls", str(args.pr), "reviews"], cwd=args.root
3380 )
3381 if issue_number is None:
3382 issue_number = _linked_issue_from_body(pr_body)
3383 if issue_number is not None:
3384 issue_comments = _gh_json_list(
3385 ["repos", owner_repo, "issues", str(issue_number), "comments"], cwd=args.root
3386 )
3387 elif issue_number is None:
3388 issue_number = _linked_issue_from_body(pr_body)
3389 return {
3390 "pr_body": pr_body,
3391 "pr_comments": pr_comments,
3392 "issue_comments": issue_comments,
3393 "pr_reviews": pr_reviews,
3394 "issue": issue_number,
3395 "head_sha": head_sha,
3396 "head_ref": head_ref,
3397 "changed_files": changed_files,
3398 "patches": patches,
3399 "pr_labels": _dedupe_preserve([*pr_labels, *injected_labels]),
3400 }
3403def _evidence_ledger_record(
3404 args: argparse.Namespace,
3405 config: cfg.ProjectConfig,
3406) -> dict[str, object] | None:
3407 """Load the ship_run ledger record for the PR under verification.
3409 Reads the run ledger (offline fixture via ``--ledger-jsonl`` or the configured
3410 path under ``--root``) and returns the latest matching ship_run record, or
3411 ``None`` when no record matches — preserving marker-only closure behavior.
3412 """
3413 fixture = getattr(args, "ledger_jsonl", None)
3414 if fixture is not None:
3415 records = ledger.parse_records(Path(fixture).read_text(encoding="utf-8"))
3416 else:
3417 records = ledger.read_records(ledger.resolve_path(args.root, config))
3418 return ledger.latest_ship_run_for_pr(records, args.pr)
3421def _label_names(labels: object) -> list[str]:
3422 if not isinstance(labels, list):
3423 return []
3424 return [
3425 label["name"]
3426 for label in labels
3427 if isinstance(label, dict) and isinstance(label.get("name"), str)
3428 ]
3431def _dedupe_preserve(values: list[str]) -> list[str]:
3432 return list(dict.fromkeys(values))
3435def _owner_repo(config: cfg.ProjectConfig) -> str:
3436 if not config.owner or not config.repo:
3437 raise ValueError("project config must define owner and repo for live evidence fetch")
3438 return f"{config.owner}/{config.repo}"
3441def _run_context_warnings(args: argparse.Namespace) -> list[str]:
3442 if not getattr(args, "live", False) or not getattr(args, "append_ledger", False):
3443 return []
3444 warnings = []
3445 if not _nonblank(getattr(args, "host_agent", None)):
3446 warnings.append("missing host_agent in live run context")
3447 return warnings
3450def _nonblank(value: object) -> bool:
3451 return isinstance(value, str) and bool(value.strip())
3454def _comment_artifact_marker(artifact: str) -> str:
3455 markers = {
3456 "closure-comment": closure.COMMENT_MARKER,
3457 "issue-update": artifacts.ISSUE_UPDATE_MARKER,
3458 "review-verdict": evidence.REVIEW_VERDICT_MARKER,
3459 "jury-verdict": evidence.JURY_VERDICT_MARKER,
3460 "review-cycle-summary": artifacts.REVIEW_CYCLE_SUMMARY_MARKER,
3461 "extension-result": artifacts.EXTENSION_RESULT_MARKER,
3462 "step-handoff": artifacts.STEP_HANDOFF_MARKER,
3463 "run-control-halt": artifacts.RUN_CONTROL_HALT_MARKER,
3464 }
3465 return markers[artifact]
3468def _parse_comment_target(raw: str) -> tuple[str, int]:
3469 match = re.fullmatch(r"(?P<kind>issue|pr):(?P<number>[1-9]\d*)", raw.strip())
3470 if not match:
3471 raise ValueError("--target must use issue:<number> or pr:<number>")
3472 return match.group("kind"), int(match.group("number"))
3475def _looks_like_body_file_literal(body: str) -> bool:
3476 stripped = body.strip()
3477 return bool(re.fullmatch(r"@(?:/|~|\.\.?/).+", stripped))
3480def _find_comment_match(
3481 comments: list[dict[str, object]],
3482 *,
3483 marker: str,
3484 run_id: str | None,
3485) -> dict[str, object] | None:
3486 if run_id is None:
3487 return None
3488 matches: list[dict[str, object]] = []
3489 for comment in comments:
3490 body = comment.get("body")
3491 if not isinstance(body, str) or marker not in body:
3492 continue
3493 if not _comment_has_run_id(body, run_id):
3494 continue
3495 matches.append(comment)
3496 return matches[-1] if matches else None
3499def _with_run_id_marker(body: str, run_id: str | None) -> str:
3500 """Append the ``keel.run-id`` marker so a re-post can find and edit *this* comment.
3502 Idempotency is matched on marker **and** run-id (:func:`_find_comment_match`), but
3503 no ship renderer emitted a run-id in a form :func:`_comment_has_run_id` recognises —
3504 the closure comment writes ``- **Run id:** <id>``, and the review/jury verdicts write
3505 none at all — so every resume re-posted instead of editing. Stamping it here rather
3506 than in the renderers keeps it out of the *content*: closure fidelity compares the
3507 posted body against the canonical render, and `evidence` strips this line before
3508 comparing, so a body can be both idempotent and verbatim.
3509 """
3510 if not run_id or _comment_has_run_id(body, run_id):
3511 return body
3512 return f"{body.rstrip()}\n\n<!-- keel.run-id: {run_id} -->\n"
3515def _comment_has_run_id(body: str, run_id: str) -> bool:
3516 run_id_escaped = re.escape(run_id)
3517 combined_pattern = (
3518 rf"^\s*(?:run[-_ ]?id)\s*:\s*{run_id_escaped}\s*$|"
3519 rf"<!--\s*keel\.run-id:\s*{run_id_escaped}\s*-->"
3520 )
3521 return bool(re.search(combined_pattern, body, re.IGNORECASE | re.MULTILINE))
3524def _finish_post_comment(args: argparse.Namespace, payload: dict[str, object], *, code: int) -> int:
3525 if args.json:
3526 print(json.dumps(payload, indent=2, sort_keys=True))
3527 else:
3528 target = payload["target"]
3529 if isinstance(target, dict):
3530 rendered_target = f"{target.get('kind')}:{target.get('number')}"
3531 else:
3532 rendered_target = str(target)
3533 print(f"keel post-comment — {payload.get('action')} {rendered_target}")
3534 print(f" artifact : {payload.get('artifact')}")
3535 print(f" transport : {payload.get('transport')}")
3536 if payload.get("comment_id") is not None:
3537 print(f" comment : {payload.get('comment_id')}")
3538 return code
3541def _read_optional_text(path: str | None) -> str:
3542 return Path(path).read_text(encoding="utf-8") if path else ""
3545def _read_json_object(path: str) -> dict[str, object]:
3546 value = json.loads(Path(path).read_text(encoding="utf-8"))
3547 if not isinstance(value, dict):
3548 raise ValueError(f"{path} must contain a JSON object")
3549 return value
3552def _dedupe_ints(values: list[int]) -> list[int]:
3553 """Return a new list with duplicates removed, preserving order."""
3554 return list(dict.fromkeys(values))
3557def _read_json_list(path: str, *, missing_ok: bool = False) -> list[dict[str, object]]:
3558 p = Path(path)
3559 if missing_ok and not p.exists():
3560 return []
3561 value = json.loads(p.read_text(encoding="utf-8"))
3562 if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
3563 raise ValueError(f"{path} must contain a JSON array of objects")
3564 return value
3567def _write_json_list(path: str, value: list[dict[str, object]]) -> None:
3568 p = Path(path)
3569 p.parent.mkdir(parents=True, exist_ok=True)
3570 p.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
3573def _read_optional_json_list(path: str | None) -> list[dict[str, object]]:
3574 if path is None:
3575 return []
3576 return _read_json_list(path)
3579def _event_from_args(args: argparse.Namespace) -> dict[str, object] | None:
3580 if args.event_json:
3581 event = _read_json_object(args.event_json)
3582 else:
3583 fields = {
3584 "step_id": args.step,
3585 "slot": args.slot,
3586 "action": args.action,
3587 "output_fingerprint": args.output_fingerprint,
3588 "diff_fingerprint": args.diff_fingerprint,
3589 "work_units": args.work_units,
3590 }
3591 event = {key: value for key, value in fields.items() if value not in (None, "")}
3592 if args.soft_failure:
3593 event["soft_failure"] = True
3594 return event or None
3597def _step_caps_from_args(values: list[str]) -> dict[str, int]:
3598 caps: dict[str, int] = {}
3599 for raw in values:
3600 if "=" not in raw:
3601 raise ValueError("--step-cap must use SLOT=N")
3602 slot, value = raw.split("=", 1)
3603 slot = slot.strip()
3604 try:
3605 parsed = int(value)
3606 except ValueError as exc:
3607 raise ValueError("--step-cap value must be a positive integer") from exc
3608 if not slot or parsed <= 0:
3609 raise ValueError("--step-cap must use SLOT=N with N > 0")
3610 caps[slot] = parsed
3611 return caps
3614def _verdict_count_arg(value: str) -> tuple[int, int]:
3615 if "=" not in value:
3616 raise argparse.ArgumentTypeError("--verdict-count must use PR=N")
3617 pr_raw, count_raw = value.split("=", 1)
3618 try:
3619 pr = int(pr_raw)
3620 count = int(count_raw)
3621 except ValueError as exc:
3622 raise argparse.ArgumentTypeError("--verdict-count must use PR=N with integers") from exc
3623 if pr <= 0 or count < 0:
3624 raise argparse.ArgumentTypeError("--verdict-count requires PR>0 and N>=0")
3625 return pr, count
3628GATE_RESULTS = ("pass", "fail")
3631def _gate_result_arg(value: str) -> tuple[str, str]:
3632 """Parse ``--gate-result ID=pass|fail``."""
3633 if "=" not in value:
3634 raise argparse.ArgumentTypeError("--gate-result must use ID=pass|fail")
3635 gate_id, _, verdict = value.partition("=")
3636 gate_id, verdict = gate_id.strip(), verdict.strip().lower()
3637 if not gate_id:
3638 raise argparse.ArgumentTypeError("--gate-result requires a gate id")
3639 if verdict not in GATE_RESULTS:
3640 raise argparse.ArgumentTypeError(
3641 f"--gate-result verdict must be one of {', '.join(GATE_RESULTS)}")
3642 return gate_id, verdict
3645def _gh_json(args: list[str], *, cwd: str) -> dict[str, object]:
3646 endpoint = "/".join(args)
3647 result = run_argv(["gh", "api", endpoint], cwd=cwd)
3648 if not result.ok:
3649 raise ValueError(f"gh api {endpoint} failed: {result.output.strip()}")
3650 value = json.loads(result.stdout or "{}")
3651 if not isinstance(value, dict):
3652 raise ValueError(f"gh api {endpoint} did not return a JSON object")
3653 return value
3656def _gh_json_list(args: list[str], *, cwd: str) -> list[dict[str, object]]:
3657 endpoint = "/".join(args)
3658 result = run_argv(["gh", "api", "--paginate", "--slurp", endpoint], cwd=cwd)
3659 if not result.ok:
3660 raise ValueError(f"gh api {endpoint} failed: {result.output.strip()}")
3661 value = json.loads(result.stdout or "[]")
3662 if value and all(isinstance(item, list) for item in value):
3663 value = [entry for page in value for entry in page]
3664 if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
3665 raise ValueError(f"gh api {endpoint} did not return a JSON array")
3666 return value
3669def _pr_changed_files(owner_repo: str, pr: int, *, cwd: str) -> list[str]:
3670 files = _gh_json_list(["repos", owner_repo, "pulls", str(pr), "files"], cwd=cwd)
3671 return [item["filename"] for item in files if isinstance(item.get("filename"), str)]
3674def _pr_patches(owner_repo: str, pr: int, *, cwd: str) -> dict[str, str]:
3675 """Per-file diff hunks, keyed by path — the evidence :mod:`keel.classify` needs
3676 to tell "added a CI job" from "changed what the workflow can reach" (#794).
3678 Read from the response ``_pr_changed_files`` already fetches: ``pulls/{n}/files``
3679 carries ``patch`` alongside ``filename``, so this costs no extra API call.
3681 A file GitHub omits a patch for — binaries, anything past its per-file size cap
3682 — is simply absent, which lands it in the classifier's no-evidence case and
3683 leaves the path deciding its tier, exactly as before this existed.
3684 """
3685 files = _gh_json_list(["repos", owner_repo, "pulls", str(pr), "files"], cwd=cwd)
3686 return {
3687 item["filename"]: item["patch"]
3688 for item in files
3689 if isinstance(item.get("filename"), str) and isinstance(item.get("patch"), str)
3690 }
3693def _linked_issue_from_body(body: str) -> int | None:
3694 match = re.search(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(?P<n>[1-9]\d*)",
3695 body, re.IGNORECASE)
3696 return int(match.group("n")) if match else None
3699def _approved_consent(
3700 args: argparse.Namespace,
3701 config: cfg.ProjectConfig,
3702 has_standing_scope: bool,
3703) -> tuple[tuple[str, ...], str, str | None, str]:
3704 mode = _consent_mode(args, config)
3705 return consent.resolve_approved_consent(
3706 mode=mode,
3707 explicit_scopes=tuple(getattr(args, "approve_scope", ()) or ()),
3708 operator=getattr(args, "operator", None),
3709 is_live=getattr(args, "live", False),
3710 has_standing_scope=has_standing_scope,
3711 env_scopes=os.environ.get("KEEL_APPROVE_SCOPE"),
3712 env_operator=os.environ.get("KEEL_OPERATOR"),
3713 config_approved_scopes=config.automation.approved_scopes,
3714 config_operator=config.automation.operator,
3715 )
3718def _consent_mode(args: argparse.Namespace, config: cfg.ProjectConfig) -> str:
3719 return consent.resolve_consent_mode(
3720 getattr(args, "consent_mode", None),
3721 config.consent_mode,
3722 env_mode=os.environ.get("KEEL_CONSENT_MODE"),
3723 )
3726def _has_live_consent_scope(
3727 args: argparse.Namespace,
3728 command: str,
3729 config: cfg.ProjectConfig,
3730 requirement: runtime.CapabilityRequirement,
3731 loaded: dict,
3732) -> bool:
3733 return standalone._has_live_consent_scope(args, command, config, requirement, loaded)
3741def _cmd_capabilities(args: argparse.Namespace) -> int:
3742 report = runtime.detect(args.root)
3743 transport = github_transport.resolve(report)
3744 requirement = runtime.CapabilityRequirement()
3745 problems: list[str] = []
3746 if args.path:
3747 try:
3748 config = cfg.load_config(args.path)
3749 except FileNotFoundError:
3750 print(f"no such config: {args.path}", file=sys.stderr)
3751 return 1
3752 except cfg.ConfigError as exc:
3753 print(str(exc), file=sys.stderr)
3754 return 1
3755 loaded, problems = load_extensions(config, args.root, strict=False)
3756 requirement = _capability_requirement(args.for_command, config, loaded, pr=args.pr)
3757 evaluation = runtime.evaluate(requirement, report)
3758 if args.json:
3759 print(json.dumps({
3760 "report": report.as_dict(),
3761 "github_transport": transport.as_dict(),
3762 "evaluation": evaluation.as_dict(),
3763 "extension_problems": problems,
3764 }, indent=2, sort_keys=True))
3765 else:
3766 print(report.render())
3767 print(transport.render())
3768 if args.path:
3769 print(evaluation.render())
3770 for prob in problems:
3771 print(f" ! extension not loaded: {prob}", file=sys.stderr)
3772 return 0 if evaluation.ok else 1
3775def _cmd_project_commands(args: argparse.Namespace) -> int:
3776 try:
3777 config = cfg.load_config(args.path)
3778 except FileNotFoundError:
3779 print(f"no such config: {args.path}", file=sys.stderr)
3780 return 1
3781 except cfg.ConfigError as exc:
3782 print(str(exc), file=sys.stderr)
3783 return 1
3785 commands = project_commands.list_project_commands(config)
3786 if args.json:
3787 print(json.dumps({"project_commands": [command.as_dict() for command in commands]},
3788 indent=2, sort_keys=True))
3789 else:
3790 if not commands:
3791 print("project commands: none")
3792 return 0
3793 print("project commands:")
3794 for command in commands:
3795 caps = []
3796 if command.required_capabilities:
3797 caps.append("required=" + ",".join(command.required_capabilities))
3798 if command.optional_capabilities:
3799 caps.append("optional=" + ",".join(command.optional_capabilities))
3800 cap_text = f" ({'; '.join(caps)})" if caps else ""
3801 runner = f" -> {command.command}" if command.command else ""
3802 print(f" {command.name}{runner}{cap_text}")
3803 return 0
3806def _ask(prompt: str, default: str) -> str: # pragma: no cover - interactive I/O
3807 raw = input(f"{prompt} [{default}]: " if default else f"{prompt}: ").strip()
3808 return raw or default
3811def _cmd_init(args: argparse.Namespace) -> int:
3812 root = Path(args.root)
3813 target = root / ".keel" / "project.yaml"
3814 if target.exists() and not args.force:
3815 print(
3816 f"{target} already exists; refusing to overwrite project config "
3817 "(use --force only if you intentionally want to replace it). "
3818 "Project extensions are not touched.",
3819 file=sys.stderr,
3820 )
3821 return 1
3822 repo = root.resolve().name
3823 try:
3824 if getattr(args, "auto", False):
3825 text, meta = scaffold.auto_detect_config(root, repo=repo)
3826 stack = meta["stack"]
3827 print("keel init --auto")
3828 print(f" stack : {meta['stack']} ({meta['platform']})")
3829 print(f" base branch : {meta['base_branch']}")
3830 print(f" build gate : {meta['build_cmd']}")
3831 if meta.get("lint_cmd"):
3832 print(f" lint gate : {meta['lint_cmd']}")
3833 elif args.wizard:
3834 stack = scaffold.detect_stack(root)
3835 print(f"keel init wizard — detected stack: {stack} (Enter accepts each default)")
3836 text = scaffold.wizard(stack, _ask, repo=repo)
3837 else:
3838 stack = scaffold.detect_stack(root)
3839 text = scaffold.default_config(stack, repo=repo)
3840 except ValueError as exc:
3841 print(str(exc), file=sys.stderr)
3842 return 1
3843 target.parent.mkdir(parents=True, exist_ok=True)
3844 target.write_text(text, encoding="utf-8")
3845 workspace.ensure_runtime_gitignore(target.parent)
3846 if args.force:
3847 print(
3848 "warning: --force replaced .keel/project.yaml; .keel/extensions/ was not touched",
3849 file=sys.stderr,
3850 )
3851 print(f"wrote {target} (detected stack: {stack})")
3852 return 0
3855def _render_scaffolded_config(root: Path, *, wizard: bool) -> tuple[str, str]:
3856 stack = scaffold.detect_stack(root)
3857 repo = root.resolve().name
3858 if wizard:
3859 print(f"keel setup wizard — detected stack: {stack} (Enter accepts each default)")
3860 return scaffold.wizard(stack, _ask, repo=repo), stack
3861 return scaffold.default_config(stack, repo=repo), stack
3864def _report_install(surface: str, installed: list[str], skipped: list[str]) -> None:
3865 for name in installed:
3866 print(f" installed [{surface}] {name}")
3867 for name in skipped:
3868 print(f" skipped [{surface}] {name} (exists; --force to overwrite)")
3871def _report_adapter_rows(rows: dict[str, list[install.AdapterFileStatus]]) -> None:
3872 for surface, statuses in rows.items():
3873 for row in statuses:
3874 detail = f" — {row.detail}" if row.detail else ""
3875 print(f" {row.status:<16} [{surface}] {row.name} {row.path}{detail}")
3878def _project_only_commands(root: str | Path) -> set[str]:
3879 """Command names the project declares as project-only (never flagged as orphan).
3881 Reads ``.keel/project.yaml`` from ``root`` if present; absent or invalid config yields an
3882 empty set so the scan stays fail-soft and consumer-neutral.
3883 """
3884 path = Path(root) / ".keel" / "project.yaml"
3885 if not path.exists():
3886 return set()
3887 try:
3888 config = cfg.load_config(path)
3889 except cfg.ConfigError:
3890 return set()
3891 return {cmd.name for cmd in project_commands.list_project_commands(config)}
3894def _scan_orphans(root: str | Path, *, include_unmanaged: bool) -> list[install.OrphanFileStatus]:
3895 """Run the pure orphan/unmanaged scan with the default known-command set."""
3896 return install.scan_surface_orphans(
3897 root,
3898 known_commands=install.default_known_commands(),
3899 project_only=_project_only_commands(root),
3900 include_unmanaged=include_unmanaged,
3901 )
3904def _report_orphan_rows(orphans: list[install.OrphanFileStatus]) -> None:
3905 for row in orphans:
3906 print(f" {row.category:<16} [{row.surface}] {row.name} {row.path} — {row.reason}")
3909#: PyPI JSON metadata endpoint for the published distribution.
3910_PYPI_LATEST_URL = "https://pypi.org/pypi/keel-workflow/json"
3913def _fetch_latest_pypi_version(
3914 *, url: str = _PYPI_LATEST_URL, timeout: float = 3.0, _open=None
3915) -> str | None:
3916 """Fetch the latest ``keel-workflow`` version from PyPI. Thin, fail-soft I/O.
3918 Returns the version string, or ``None`` on any failure (offline, timeout,
3919 HTTP error, malformed JSON) so ``keel doctor`` degrades to ``latest: unknown``
3920 rather than crashing or blocking. The network seam (``_open``) is injectable so
3921 the parsing is unit-tested offline; the live ``urlopen`` boundary is excluded.
3922 """
3923 from urllib.parse import urlparse
3924 if urlparse(url).scheme.lower() not in ("http", "https"):
3925 return None # restrict to http(s): no file://, ftp://, or custom schemes
3927 if _open is None: # pragma: no cover - live network boundary
3928 from urllib.request import urlopen
3929 _open = lambda u, t: urlopen(u, timeout=t) # noqa: E731 # nosec B310
3930 try:
3931 with _open(url, timeout) as response:
3932 payload = json.loads(response.read(50 * 1024 * 1024).decode("utf-8"))
3933 version = payload["info"]["version"]
3934 return version if isinstance(version, str) else None
3935 except Exception:
3936 return None
3939def _doctor_state_paths(root: str, config: cfg.ProjectConfig) -> list[dict[str, object]]:
3940 """Resolve the configured ledger + checkpoint paths and probe their existence."""
3941 entries: list[dict[str, object]] = []
3942 for label, resolver, error in (
3943 ("ledger", ledger.resolve_path, ledger.LedgerError),
3944 ("checkpoint", checkpoint.resolve_path, checkpoint.CheckpointError),
3945 ):
3946 try:
3947 path = resolver(root, config)
3948 except error as exc:
3949 entries.append({"label": label, "path": None, "status": "invalid",
3950 "reason": str(exc)})
3951 continue
3952 entries.append({
3953 "label": label,
3954 "path": str(path),
3955 "status": "present" if path.exists() else "missing",
3956 "reason": "",
3957 })
3958 return entries
3961def _cmd_doctor(args: argparse.Namespace) -> int:
3962 config = None
3963 core_version = None
3964 state_paths: list[dict[str, object]] = []
3965 if args.path:
3966 try:
3967 config = cfg.load_config(args.path)
3968 except FileNotFoundError:
3969 print(f"no such config: {args.path}", file=sys.stderr)
3970 return 1
3971 except cfg.ConfigError as exc:
3972 print(str(exc), file=sys.stderr)
3973 return 1
3974 core_version = config.core_version
3975 state_paths = _doctor_state_paths(args.root, config)
3977 latest = None if args.offline else _fetch_latest_pypi_version()
3978 report = doctor.run_doctor(
3979 installed_version=__version__,
3980 latest_version=latest,
3981 adapter_markers=install.scan_adapter_markers(args.root),
3982 orphans=[o.as_dict() for o in _scan_orphans(args.root, include_unmanaged=False)],
3983 core_version=core_version,
3984 state_paths=state_paths,
3985 )
3986 if args.json:
3987 print(json.dumps(report, indent=2, sort_keys=True))
3988 else:
3989 print(doctor.render_report(report))
3990 if args.strict and report["status"] == "fail":
3991 return 1
3992 return 0
3995def _cmd_install_adapter(args: argparse.Namespace) -> int:
3996 if args.agent == "plugin":
3997 installed, skipped = install.install_plugin(args.root, force=args.force)
3998 _report_install("plugin", installed, skipped)
3999 print(f"{len(installed)} plugin command file(s) written under commands/ — "
4000 "install via /plugin marketplace add berkayturanci/keel; /plugin install keel")
4001 return 0
4002 if args.agent == "all":
4003 results = install.install_all(args.root, force=args.force)
4004 elif args.agent in install.TARGETS:
4005 results = {args.agent: install.install(args.agent, args.root, force=args.force)}
4006 else:
4007 print(f"unknown target {args.agent!r}; valid: all, plugin, "
4008 f"{', '.join(install.TARGETS)}",
4009 file=sys.stderr)
4010 return 1
4011 total = 0
4012 for surface, (installed, skipped) in results.items():
4013 _report_install(surface, installed, skipped)
4014 total += len(installed)
4015 print(f"{total} adapter(s) installed — Claude: /keel:<command>; "
4016 f"other agents: keel-<command> skill (.agents/skills/)")
4017 return 0
4020def _cmd_setup(args: argparse.Namespace) -> int:
4021 root = Path(args.root)
4022 target = root / ".keel" / "project.yaml"
4023 print(f"keel setup — {root}")
4025 if target.exists() and not args.force:
4026 print(f" config : using existing {target}")
4027 print(" extensions : preserved (setup never deletes .keel/extensions/)")
4028 else:
4029 existed = target.exists()
4030 if existed:
4031 print(
4032 "warning: --force will replace .keel/project.yaml; "
4033 ".keel/extensions/ will not be touched",
4034 file=sys.stderr,
4035 )
4036 try:
4037 text, stack = _render_scaffolded_config(root, wizard=args.wizard)
4038 except ValueError as exc:
4039 print(f" config : failed ({exc})", file=sys.stderr)
4040 return 1
4041 target.parent.mkdir(parents=True, exist_ok=True)
4042 target.write_text(text, encoding="utf-8")
4043 action = "overwrote" if existed else "wrote"
4044 print(f" config : {action} {target} (detected stack: {stack})")
4045 print(" extensions : preserved (setup never deletes .keel/extensions/)")
4047 if workspace.ensure_runtime_gitignore(target.parent):
4048 print(f" gitignore : scaffolded {target.parent / workspace.GITIGNORE_NAME} "
4049 "(keeps runtime state out of git)")
4051 agent = args.adapter_target
4052 if agent == "all":
4053 results = install.install_all(root, force=args.force)
4054 else:
4055 results = {agent: install.install(agent, root, force=args.force)}
4056 total = 0
4057 for surface, (installed, skipped) in results.items():
4058 _report_install(surface, installed, skipped)
4059 total += len(installed)
4060 print(f" adapters : {total} installed, "
4061 f"{sum(len(skipped) for _, skipped in results.values())} skipped")
4063 try:
4064 config = cfg.load_config(target)
4065 loaded, _ = load_extensions(config, root, strict=True)
4066 plan = orch.build_plan(config, loaded)
4067 except (cfg.ConfigError, ExtensionError, gates.GateError) as exc:
4068 print(f" validate : failed ({exc})", file=sys.stderr)
4069 return 1
4071 print(f" validate : OK ({config.repo or '-'}, base {config.base_branch})")
4072 print(" plan :")
4073 rendered = orch.render_plan(config, plan)
4074 for line in rendered.splitlines():
4075 print(f" {line}")
4076 print(" next : run /keel:ship <issue> or the matching keel-<command> skill.")
4077 return 0
4080def _cmd_adapter_status(args: argparse.Namespace) -> int:
4081 try:
4082 rows = install.adapter_status(args.agent, args.root)
4083 except KeyError:
4084 print(f"unknown target {args.agent!r}; valid: all, {', '.join(install.STATUS_TARGETS)}",
4085 file=sys.stderr)
4086 return 1
4087 orphans = _scan_orphans(args.root, include_unmanaged=args.include_unmanaged)
4088 if args.json:
4089 payload = {
4090 "adapters": {
4091 surface: [
4092 {"surface": r.surface, "name": r.name, "path": r.path,
4093 "status": r.status, "detail": r.detail}
4094 for r in statuses
4095 ]
4096 for surface, statuses in rows.items()
4097 },
4098 "orphans": [o.as_dict() for o in orphans],
4099 }
4100 print(json.dumps(payload, indent=2, sort_keys=True))
4101 return 0
4102 _report_adapter_rows(rows)
4103 _report_orphan_rows(orphans)
4104 if orphans:
4105 stale = sum(1 for o in orphans if o.category == install.ORPHAN_STALE_MARKER)
4106 unmanaged = len(orphans) - stale
4107 print(f" {len(orphans)} unmanaged keel-like file(s): "
4108 f"{stale} orphan (stale-marker), {unmanaged} unmanaged (no-marker) — advisory only")
4109 if not args.include_unmanaged:
4110 print(" note: pass --include-unmanaged to also scan for marker-less command surfaces")
4111 return 0
4114def _cmd_update_adapter(args: argparse.Namespace) -> int:
4115 try:
4116 rows = install.update_adapters(args.agent, args.root, dry_run=args.dry_run)
4117 except KeyError:
4118 print(f"unknown target {args.agent!r}; valid: all, {', '.join(install.TARGETS)}",
4119 file=sys.stderr)
4120 return 1
4121 _report_adapter_rows(rows)
4122 if args.dry_run:
4123 print("dry-run: no adapter files were written")
4124 return 0
4127def _cmd_sync(args: argparse.Namespace) -> int:
4128 args.agent = args.target
4129 print(f"keel sync — installed keel {__version__}")
4130 print(" package : not upgraded by sync; upgrade keel-workflow with pip/pipx first")
4131 rc = _cmd_update_adapter(args)
4132 if rc == 0:
4133 orphans = _scan_orphans(args.root, include_unmanaged=False)
4134 if orphans:
4135 print(f" orphans : {len(orphans)} unmanaged keel-like file(s) found — "
4136 "run keel adapter-status for details")
4137 print(" next : run keel validate .keel/project.yaml --root .")
4138 print(" next : run keel plan .keel/project.yaml --root .")
4139 return rc
4142def _parse_legacy_mapping(raw: str) -> tuple[str, str]:
4143 if "=" not in raw:
4144 raise argparse.ArgumentTypeError("use LEGACY=KEEL, for example ship=ship")
4145 legacy, command = (part.strip() for part in raw.split("=", 1))
4146 if not legacy or not command:
4147 raise argparse.ArgumentTypeError("legacy and keel command names must be non-empty")
4148 return legacy, command
4151def _cmd_install_legacy_wrappers(args: argparse.Namespace) -> int:
4152 matrix = Path(args.parity_matrix)
4153 if not matrix.exists():
4154 print(
4155 f"parity matrix not found: {matrix}; pass --parity-matrix after verifying rows",
4156 file=sys.stderr,
4157 )
4158 return 1
4159 ready_commands = install.parity_ready_commands(matrix.read_text(encoding="utf-8"))
4160 mappings = dict(args.command) if args.command else {
4161 command: command for command in sorted(ready_commands)
4162 }
4163 try:
4164 if args.agent == "all":
4165 results = install.install_all_legacy_wrappers(
4166 args.root,
4167 mappings=mappings,
4168 ready_commands=ready_commands,
4169 force=args.force,
4170 )
4171 elif args.agent in install.LEGACY_TARGETS:
4172 results = {
4173 args.agent: install.install_legacy_wrappers(
4174 args.agent,
4175 args.root,
4176 mappings=mappings,
4177 ready_commands=ready_commands,
4178 force=args.force,
4179 )
4180 }
4181 else:
4182 print(
4183 f"unknown target {args.agent!r}; valid: all, "
4184 f"{', '.join(install.LEGACY_TARGETS)}",
4185 file=sys.stderr,
4186 )
4187 return 1
4188 except ValueError as exc:
4189 print(str(exc), file=sys.stderr)
4190 return 1
4191 total = 0
4192 for surface, (installed, skipped) in results.items():
4193 _report_install(f"legacy-{surface}", installed, skipped)
4194 total += len(installed)
4195 print(f"{total} legacy wrapper(s) installed — legacy commands now delegate to /keel:<command>")
4196 return 0
4199def _capability_requirement(
4200 command: str,
4201 config: cfg.ProjectConfig,
4202 loaded: dict[str, list],
4203 *,
4204 pr: int | None = None,
4205) -> runtime.CapabilityRequirement:
4206 return runtime.build_capability_requirement(command, config, loaded, pr=pr)
4209def _cmd_swarm_plan(args: argparse.Namespace) -> int:
4210 try:
4211 config = cfg.load_config(args.path)
4212 except FileNotFoundError:
4213 print(f"no such config: {args.path}", file=sys.stderr)
4214 return 1
4215 except cfg.ConfigError as exc:
4216 print(str(exc), file=sys.stderr)
4217 return 1
4219 issue_nums: list[int] = []
4220 if args.issues:
4221 for part in args.issues.split(","):
4222 part = part.strip().lstrip("#")
4223 if part.isdigit():
4224 issue_nums.append(int(part))
4225 if args.issue:
4226 issue_nums.extend(args.issue)
4228 seen: set[int] = set()
4229 unique_issues: list[int] = []
4230 for num in issue_nums:
4231 if num not in seen:
4232 seen.add(num)
4233 unique_issues.append(num)
4235 labels = _issue_labels(args)
4236 scopes: list[swarm.IssueScope] = []
4238 if unique_issues:
4239 for num in unique_issues:
4240 scopes.append(
4241 swarm.extract_issue_scope(
4242 num,
4243 title=args.issue_title or "",
4244 body=args.issue_body or "",
4245 labels=labels,
4246 declared_files=args.declared_file,
4247 config=config,
4248 )
4249 )
4250 elif args.issue_title or args.issue_body or args.declared_file or labels:
4251 scopes.append(
4252 swarm.extract_issue_scope(
4253 1,
4254 title=args.issue_title or "",
4255 body=args.issue_body or "",
4256 labels=labels,
4257 declared_files=args.declared_file,
4258 config=config,
4259 )
4260 )
4262 plan = swarm.build_swarm_plan(scopes, swarm_id=args.swarm_id, config=config)
4264 if args.json:
4265 print(json.dumps(plan.to_dict(), indent=2))
4266 elif args.tree:
4267 print(swarm.render_swarm_plan_tree(plan))
4268 else:
4269 print(swarm.render_swarm_plan_text(plan))
4270 return 0
4273def _cmd_swarm_status(args: argparse.Namespace) -> int:
4274 try:
4275 cfg.load_config(args.path)
4276 except FileNotFoundError:
4277 print(f"no such config: {args.path}", file=sys.stderr)
4278 return 1
4279 except cfg.ConfigError as exc:
4280 print(str(exc), file=sys.stderr)
4281 return 1
4283 swarm_id = args.swarm_id
4284 if not swarm_id:
4285 state_dir = Path(args.root) / ".keel" / "state" / "swarm"
4286 if state_dir.exists():
4287 files = sorted(
4288 state_dir.glob("*.json"),
4289 key=lambda p: p.stat().st_mtime,
4290 reverse=True,
4291 )
4292 if files:
4293 swarm_id = files[0].stem
4295 state = swarm.load_swarm_state(swarm_id, root=args.root) if swarm_id else None
4297 if args.json:
4298 print(json.dumps(state.to_dict() if state else {}, indent=2))
4299 else:
4300 print(swarm.render_swarm_status_dashboard(state))
4301 return 0
4304def _cmd_swarm_run(args: argparse.Namespace) -> int:
4305 try:
4306 config = cfg.load_config(args.path)
4307 except FileNotFoundError:
4308 print(f"no such config: {args.path}", file=sys.stderr)
4309 return 1
4310 except cfg.ConfigError as exc:
4311 print(str(exc), file=sys.stderr)
4312 return 1
4314 issue_nums: list[int] = []
4315 if args.issues:
4316 for part in args.issues.split(","):
4317 part = part.strip().lstrip("#")
4318 if part.isdigit():
4319 issue_nums.append(int(part))
4320 if args.issue:
4321 issue_nums.extend(args.issue)
4323 seen: set[int] = set()
4324 unique_issues: list[int] = []
4325 for num in issue_nums:
4326 if num not in seen:
4327 seen.add(num)
4328 unique_issues.append(num)
4330 labels = _issue_labels(args)
4331 scopes: list[swarm.IssueScope] = []
4333 if unique_issues:
4334 for num in unique_issues:
4335 scopes.append(
4336 swarm.extract_issue_scope(
4337 num,
4338 title=args.issue_title or "",
4339 body=args.issue_body or "",
4340 labels=labels,
4341 declared_files=args.declared_file,
4342 config=config,
4343 )
4344 )
4345 elif args.issue_title or args.issue_body or args.declared_file or labels:
4346 scopes.append(
4347 swarm.extract_issue_scope(
4348 1,
4349 title=args.issue_title or "",
4350 body=args.issue_body or "",
4351 labels=labels,
4352 declared_files=args.declared_file,
4353 config=config,
4354 )
4355 )
4357 plan = swarm.build_swarm_plan(scopes, swarm_id=args.swarm_id, config=config)
4359 from . import swarm_runtime
4361 result = swarm_runtime.run_swarm_orchestration(
4362 plan,
4363 project_yaml=args.path,
4364 root=args.root,
4365 dry_run=not args.live,
4366 max_workers=args.max_workers,
4367 )
4369 if args.json:
4370 print(json.dumps(result.to_dict(), indent=2))
4371 elif args.tree:
4372 print(swarm.render_swarm_plan_tree(plan))
4373 print("")
4374 print(swarm.render_swarm_run_result(result))
4375 else:
4376 print(swarm.render_swarm_run_result(result))
4378 return 0 if result.status == "success" else (1 if result.status == "failed" else 0)
4381def _cmd_swarm_land(args: argparse.Namespace) -> int:
4382 try:
4383 config = cfg.load_config(args.path)
4384 except FileNotFoundError:
4385 print(f"no such config: {args.path}", file=sys.stderr)
4386 return 1
4387 except cfg.ConfigError as exc:
4388 print(str(exc), file=sys.stderr)
4389 return 1
4391 swarm_id = args.swarm_id
4392 if not swarm_id:
4393 state_dir = Path(args.root) / ".keel" / "state" / "swarm"
4394 if state_dir.exists():
4395 files = sorted(
4396 state_dir.glob("*.json"),
4397 key=lambda p: p.stat().st_mtime,
4398 reverse=True,
4399 )
4400 if files:
4401 swarm_id = files[0].stem
4403 issue_nums: list[int] = []
4404 if args.issues:
4405 for part in args.issues.split(","):
4406 part = part.strip().lstrip("#")
4407 if part.isdigit():
4408 issue_nums.append(int(part))
4409 if args.issue:
4410 issue_nums.extend(args.issue)
4412 seen: set[int] = set()
4413 unique_issues: list[int] = []
4414 for num in issue_nums:
4415 if num not in seen:
4416 seen.add(num)
4417 unique_issues.append(num)
4419 labels = _issue_labels(args)
4420 scopes: list[swarm.IssueScope] = []
4422 if unique_issues:
4423 for num in unique_issues:
4424 scopes.append(
4425 swarm.extract_issue_scope(
4426 num,
4427 title=args.issue_title or "",
4428 body=args.issue_body or "",
4429 labels=labels,
4430 declared_files=args.declared_file,
4431 config=config,
4432 )
4433 )
4434 elif args.issue_title or args.issue_body or args.declared_file or labels:
4435 scopes.append(
4436 swarm.extract_issue_scope(
4437 1,
4438 title=args.issue_title or "",
4439 body=args.issue_body or "",
4440 labels=labels,
4441 declared_files=args.declared_file,
4442 config=config,
4443 )
4444 )
4446 plan = swarm.build_swarm_plan(scopes, swarm_id=swarm_id, config=config)
4448 from . import swarm_landing
4450 result = swarm_landing.land_wave_clusters(
4451 plan,
4452 wave_index=args.wave,
4453 project_yaml=args.path,
4454 root=args.root,
4455 dry_run=not args.live,
4456 )
4458 if args.json:
4459 print(json.dumps(result.to_dict(), indent=2))
4460 else:
4461 print(swarm.render_swarm_landing_result(result))
4463 return 0 if result.status == "success" else (1 if result.status == "failed" else 0)
4466def _cmd_canary(args: argparse.Namespace) -> int:
4467 try:
4468 cfg.load_config(args.path)
4469 except (cfg.ConfigError, OSError) as exc:
4470 print(f"keel canary: {exc}", file=sys.stderr)
4471 return 1
4473 from . import canary
4475 result = canary.run_canary_guard(
4476 args.path,
4477 pr_number=args.pr,
4478 commit_sha=args.commit,
4479 root=args.root,
4480 duration_m=args.duration,
4481 health_cmd=args.health_cmd,
4482 auto_revert=args.auto_revert,
4483 )
4485 if args.json:
4486 print(json.dumps(result.to_dict(), indent=2))
4487 else:
4488 print(canary.render_canary_result(result))
4490 return 0 if result.passed else 1
4493def _cmd_rollback(args: argparse.Namespace) -> int:
4494 from . import canary
4496 result = canary.execute_rollback(
4497 args.commit,
4498 root=args.root,
4499 )
4501 if args.json:
4502 print(json.dumps(result.to_dict(), indent=2))
4503 else:
4504 print(canary.render_rollback_result(result))
4506 return 0 if result.success else 1
4509def _cmd_cost_report(args: argparse.Namespace) -> int:
4510 from . import cost
4512 report = cost.generate_cost_report(root=args.root)
4514 if args.json:
4515 print(json.dumps(report.to_dict(), indent=2))
4516 else:
4517 print(cost.render_cost_report(report))
4519 return 0
4522def build_parser() -> argparse.ArgumentParser:
4523 parser = argparse.ArgumentParser(prog="keel", description="keel — workflow core")
4524 parser.add_argument("--version", action="version", version=f"keel {__version__}")
4525 sub = parser.add_subparsers(dest="command", metavar="<command>")
4527 p_version = sub.add_parser("version", help="print the keel version")
4528 p_version.set_defaults(func=_cmd_version)
4530 p_validate = sub.add_parser("validate", help="validate project config(s) against the schema")
4531 p_validate.add_argument("paths", nargs="+", help="path(s) to project.yaml")
4532 p_validate.add_argument("--root", default=None,
4533 help="repo root; if set, also strict-validate extensions")
4534 p_validate.set_defaults(func=_cmd_validate)
4536 p_plan = sub.add_parser("plan", help="render the backbone plan for a project")
4537 p_plan.add_argument("path", help="path to project.yaml")
4538 p_plan.add_argument("--root", default=".", help="repo root for resolving extensions")
4539 p_plan.add_argument("--command", dest="command_contract", default="ship",
4540 help="adapter command contract to include in JSON output")
4541 p_plan.add_argument("--profile", choices=("standard", "compound"), default="standard",
4542 help="workflow profile for ship command contracts")
4543 p_plan.add_argument("--live", action="store_true",
4544 help="render a live preflight contract and fail if consent is missing")
4545 p_plan.add_argument("--approve-scope", action="append", default=[],
4546 help="approve a consent scope for this run; repeat or comma-separate")
4547 p_plan.add_argument("--operator", default=None,
4548 help="operator identifier to include in an approved consent record")
4549 p_plan.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4550 help="operator consent mode: explicit, standing, or agent")
4551 p_plan.add_argument("--target", default=None,
4552 help="task target to include in the consent prompt and record")
4553 p_plan.add_argument("--issue-title", default=None,
4554 help="issue title to include in the intake/readiness contract")
4555 p_plan.add_argument("--issue-body", default=None,
4556 help="issue body markdown to include in the intake/readiness contract")
4557 p_plan.add_argument("--issue-label", action="append", default=[],
4558 help="issue label for intake/readiness; repeat or comma-separate")
4559 p_plan.add_argument("--review-comments", choices=("inline", "summary"), default="inline",
4560 help="review posting mode for ship-like command contracts")
4561 p_plan.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
4562 help="override the resolved reviewer count")
4563 p_plan.add_argument("--jury", action="store_true",
4564 help="enable the cross-vendor jury contract")
4565 p_plan.add_argument("--no-jury", action="store_true",
4566 help="disable the cross-vendor jury contract")
4567 p_plan.add_argument("--jury-advisory", action="store_true",
4568 help="run jury in advisory mode when enabled")
4569 p_plan.add_argument("--run-id", default=None,
4570 help="stamp the activity record for this run id (board visibility)")
4571 p_plan.add_argument("--issue", type=_positive_int, default=None,
4572 help="issue number to record on the activity stamp")
4573 p_plan.add_argument("--pull-request", type=_positive_int, default=None,
4574 help="pull request number to record on the activity stamp")
4575 p_plan.add_argument("--json", action="store_true", help="emit structured JSON")
4576 p_plan.set_defaults(func=_cmd_plan)
4578 p_run = sub.add_parser("run-gates", help="run a project's command gates")
4579 p_run.add_argument("path", help="path to project.yaml")
4580 p_run.add_argument("--root", default=".", help="repo root for commands + extensions")
4581 p_run.add_argument("--run-id", default=None,
4582 help="stamp the activity record for this run id (board visibility)")
4583 p_run.add_argument("--command", dest="gate_command", default="ship",
4584 help="command flow the gates belong to (for the activity stamp)")
4585 p_run.add_argument("--phase", dest="gate_phase", default="s8",
4586 help="flow phase to stamp when the gates run (default the test step)")
4587 p_run.add_argument("--issue", type=_positive_int, default=None,
4588 help="issue number to record on the activity stamp")
4589 p_run.add_argument("--pull-request", type=_positive_int, default=None,
4590 help="pull request number to record on the activity stamp")
4591 p_run.set_defaults(func=_cmd_run_gates)
4593 p_window = sub.add_parser("window", help="is the merge window open now?")
4594 p_window.add_argument("path", help="path to project.yaml")
4595 p_window.set_defaults(func=_cmd_window)
4597 p_claim = sub.add_parser("claim", help="claim a single-host keel resource")
4598 p_claim.add_argument("--root", default=".", help="repo root for the claim store")
4599 p_claim.add_argument("--owner", required=True, help="claim owner id")
4600 p_claim.add_argument("--json", action="store_true", help="emit structured JSON")
4601 p_claim.add_argument("resource", help="resource name, e.g. merge")
4602 p_claim.set_defaults(func=_cmd_claim)
4604 p_release = sub.add_parser("release", help="release a single-host keel resource")
4605 p_release.add_argument("--root", default=".", help="repo root for the claim store")
4606 p_release.add_argument("--owner", default=None, help="claim owner id; omit to release any")
4607 p_release.add_argument("--json", action="store_true", help="emit structured JSON")
4608 p_release.add_argument("resource", help="resource name, e.g. merge")
4609 p_release.set_defaults(func=_cmd_release)
4611 p_guard = sub.add_parser(
4612 "guard", help="evaluate an issue against the deterministic blocker ruleset"
4613 )
4614 p_guard.add_argument("path", help="path to project.yaml")
4615 p_guard.add_argument("--root", default=".", help="repo root for live gh issue fetch")
4616 p_guard.add_argument("--issue", type=_positive_int, default=None,
4617 help="issue number to fetch title/labels live (offline: use the flags)")
4618 p_guard.add_argument("--issue-title", default=None, help="issue title for offline evaluation")
4619 p_guard.add_argument("--issue-labels", default=None,
4620 help="comma-separated issue labels for offline evaluation")
4621 p_guard.add_argument("--json", action="store_true", help="emit structured JSON")
4622 p_guard.set_defaults(func=_cmd_guard)
4624 p_merge = sub.add_parser("merge", help="perform the fail-closed core-owned PR merge")
4625 p_merge.add_argument("path", help="path to project.yaml")
4626 p_merge.add_argument("--root", default=".", help="repo root for git/GitHub operations")
4627 p_merge.add_argument("--pr", type=_positive_int, required=True, help="pull request number")
4628 p_merge.add_argument("--issue", type=_positive_int, default=None,
4629 help="linked issue number for evidence verification")
4630 p_merge.add_argument("--method", choices=("squash", "merge", "rebase"), default="squash",
4631 help="GitHub merge method")
4632 p_merge.add_argument("--owner", default=None, help="resource claim owner id")
4633 p_merge.add_argument("--hotfix", action="store_true",
4634 help="bypass the merge window with a recorded justification")
4635 p_merge.add_argument("--blocker-rule", default=None,
4636 help="keel guard rule id justifying a --hotfix bypass")
4637 p_merge.add_argument("--operator-override", action="store_true",
4638 help="authorize a --hotfix bypass as a named operator (audited)")
4639 p_merge.add_argument("--run-id", default=None,
4640 help="run id for the checkpoint gate (defaults to the gates-pass run id)")
4641 p_merge.add_argument("--no-checkpoint-gate", action="store_true",
4642 help="bypass the checkpoint gate as a named operator (audited)")
4643 p_merge.add_argument("--issue-title", default=None,
4644 help="issue title for offline blocker-rule validation")
4645 p_merge.add_argument("--issue-labels", default=None,
4646 help="comma-separated issue labels for offline blocker-rule validation")
4647 p_merge.add_argument("--dry-run", action="store_true", help="verify only; do not merge")
4648 p_merge.add_argument("--approve-scope", action="append", default=[],
4649 help="approve a consent scope for this merge")
4650 p_merge.add_argument("--operator", default=None,
4651 help="operator identifier for consent evidence")
4652 p_merge.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4653 help="operator consent mode")
4654 p_merge.add_argument("--risk-tier", choices=consent.RISK_TIERS, default="tier-1",
4655 help="risk tier for deterministic escalation evaluation")
4656 p_merge.add_argument("--trust-signal", choices=consent.TRUST_SIGNALS, default="medium",
4657 help="trust signal for deterministic escalation evaluation")
4658 p_merge.add_argument("--retry-count", type=int, default=0,
4659 help="retry count for deterministic escalation evaluation")
4660 p_merge.add_argument("--conflicting-sources", action="store_true",
4661 help="mark conflicting sources for escalation evaluation")
4662 p_merge.add_argument("--changed-lines", type=int, default=0,
4663 help="changed-line count for escalation evaluation")
4664 p_merge.add_argument("--escalation-side-effect", action="append", default=[],
4665 help="additional side-effect signal for escalation evaluation")
4666 p_merge.add_argument("--review-comments", choices=("inline", "summary"), default="inline",
4667 help="review posting mode for evidence verification")
4668 p_merge.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
4669 help="override required reviewer count")
4670 p_merge.add_argument("--jury", action="store_true", help="enable jury evidence")
4671 p_merge.add_argument("--no-jury", action="store_true", help="disable jury evidence")
4672 p_merge.add_argument("--jury-advisory", action="store_true",
4673 help="make jury advisory for evidence verification")
4674 p_merge.add_argument("--gate-label", default=None,
4675 help="override evidence gate label")
4676 p_merge.add_argument("--json", action="store_true", help="emit structured JSON")
4677 p_merge.set_defaults(func=_cmd_merge)
4679 p_wr = sub.add_parser("worktree-remove", help="safely remove a registered nested worktree")
4680 p_wr.add_argument("--root", default=".", help="repo root")
4681 p_wr.add_argument("--json", action="store_true", help="emit structured JSON")
4682 p_wr.add_argument("worktree", help="worktree path to remove")
4683 p_wr.set_defaults(func=_cmd_worktree_remove)
4685 p_ledger = sub.add_parser("ledger", help="read the structured run ledger offline")
4686 p_ledger.add_argument("path", help="path to project.yaml")
4687 p_ledger.add_argument("--root", default=".", help="repo root for resolving the ledger path")
4688 p_ledger.add_argument("--limit", type=_positive_int, default=None,
4689 help="return only the newest N records")
4690 p_ledger.add_argument("--json", action="store_true", help="emit structured JSON")
4691 p_ledger.set_defaults(func=_cmd_ledger)
4693 p_capture = sub.add_parser(
4694 "capture-verify",
4695 help="verify post-merge capture markers for merged PRs",
4696 )
4697 p_capture.add_argument("path", help="path to project.yaml")
4698 p_capture.add_argument("--root", default=".",
4699 help="repo root for resolving the ledger path")
4700 p_capture.add_argument("--merged-pr", type=_positive_int, action="append",
4701 help="merged PR number expected to have a capture marker "
4702 "(explicit override; always added to the derived set)")
4703 p_capture.add_argument("--from-transport", action="store_true",
4704 help="derive the merged-PR set from the transport instead of "
4705 "trusting --merged-pr (also runs reconcile cross-checks)")
4706 p_capture.add_argument("--merged-since", default=None,
4707 help="with --from-transport, only PRs merged on/after this date "
4708 "(YYYY-MM-DD)")
4709 p_capture.add_argument("--merged-prs-json", default=None,
4710 help="offline transport fixture: JSON array of {\"number\": N}")
4711 p_capture.add_argument("--pr-reviews-json", default=None,
4712 help="offline fixture path; presence activates reconcile checks")
4713 p_capture.add_argument("--verdict-count", type=_verdict_count_arg, action="append",
4714 default=None, metavar="PR=N",
4715 help="evidence-side review-verdict count for a PR (offline fixture)")
4716 p_capture.add_argument("--json", action="store_true", help="emit structured JSON")
4717 p_capture.set_defaults(func=_cmd_capture_verify)
4719 p_consent = sub.add_parser(
4720 "consent-verify",
4721 help="reconcile observed PR side effects against approved consent scopes",
4722 )
4723 p_consent.add_argument("path", help="path to project.yaml")
4724 p_consent.add_argument("--root", default=".",
4725 help="repo root for live gh observation and the ledger path")
4726 p_consent.add_argument("--pr", type=_positive_int, required=True,
4727 help="pull request number to reconcile")
4728 p_consent.add_argument("--ledger-jsonl", default=None,
4729 help="offline run-ledger JSONL fixture; otherwise the configured "
4730 "ledger under --root is read for the consent record")
4731 p_consent.add_argument("--offline", action="store_true",
4732 help="use only the supplied observed-effect flags; make no gh calls")
4733 p_consent.add_argument("--pr-exists", action="store_true",
4734 help="offline: the PR exists (implies git push + gh pr create)")
4735 p_consent.add_argument("--commented", action="store_true",
4736 help="offline: comments were posted on the PR")
4737 p_consent.add_argument("--merged", action="store_true",
4738 help="offline: the PR was merged")
4739 p_consent.add_argument("--labeled", action="store_true",
4740 help="offline: labels were written on the PR")
4741 p_consent.add_argument("--json", action="store_true", help="emit structured JSON")
4742 p_consent.set_defaults(func=_cmd_consent_verify)
4744 p_close = sub.add_parser(
4745 "close-reconcile",
4746 help="flag issues closed or status-done without a merge-attesting ledger record",
4747 )
4748 p_close.add_argument("path", help="path to project.yaml")
4749 p_close.add_argument("--root", default=".",
4750 help="repo root for live gh observation and the ledger path")
4751 p_close.add_argument("--issue", type=_positive_int, action="append", required=True,
4752 help="issue number to reconcile (repeat for several)")
4753 p_close.add_argument("--ledger-jsonl", default=None,
4754 help="offline run-ledger JSONL fixture; otherwise the configured "
4755 "ledger under --root is read for the ship_run records")
4756 p_close.add_argument("--offline", action="store_true",
4757 help="use only the supplied lifecycle flags; make no gh calls "
4758 "(test/back-compat; live mode reads host-authoritative state)")
4759 p_close.add_argument("--closed", action="store_true",
4760 help="offline only: treat every --issue as closed (ignored when live)")
4761 p_close.add_argument("--status-done", action="store_true",
4762 help="offline only: treat every --issue as carrying the done label "
4763 "(ignored when live)")
4764 p_close.add_argument("--json", action="store_true", help="emit structured JSON")
4765 p_close.set_defaults(func=_cmd_close_reconcile)
4767 p_dryrun = sub.add_parser(
4768 "dryrun-verify",
4769 help="assert a dry run left no new ledger record, branch, or PR (post-hoc)",
4770 )
4771 p_dryrun.add_argument("path", help="path to project.yaml")
4772 p_dryrun.add_argument("--root", default=".",
4773 help="repo root for the ledger, git, and gh observation")
4774 p_dryrun.add_argument("--run-id", required=True,
4775 help="the rehearsed dry-run id whose leaks to detect")
4776 p_dryrun.add_argument("--issue", type=_positive_int, required=True,
4777 help="the issue the dry run rehearsed (scopes branch/PR attribution)")
4778 p_dryrun.add_argument("--before-json", required=True,
4779 help="JSON snapshot {ledger_run_ids, branches, pr_numbers} "
4780 "captured before the dry run")
4781 p_dryrun.add_argument("--after-json", default=None,
4782 help="offline after-snapshot JSON; otherwise gathered live "
4783 "from the ledger, git, and gh")
4784 p_dryrun.add_argument("--json", action="store_true", help="emit structured JSON")
4785 p_dryrun.set_defaults(func=_cmd_dryrun_verify)
4787 p_reconcile = sub.add_parser(
4788 "capture-reconcile",
4789 help="plan idempotent post-merge capture reconciliation actions",
4790 )
4791 p_reconcile.add_argument("path", help="path to project.yaml")
4792 p_reconcile.add_argument("--root", default=".",
4793 help="repo root for resolving the ledger path")
4794 p_reconcile.add_argument("--merged-pr", type=_positive_int, action="append", required=True,
4795 help="merged PR number to reconcile")
4796 p_reconcile.add_argument(
4797 "--linked-issue",
4798 type=_parse_pr_issue_mapping,
4799 action="append",
4800 default=[],
4801 help="unambiguous PR-to-issue mapping as PR=ISSUE; repeat for multiple issues",
4802 )
4803 p_reconcile.add_argument(
4804 "--capture-capability",
4805 choices=("available", "unavailable"),
4806 default="unavailable",
4807 help="whether the capture extension capability is currently available",
4808 )
4809 p_reconcile.add_argument("--live", action="store_true",
4810 help="label the output as a live reconciliation plan")
4811 p_reconcile.add_argument("--json", action="store_true", help="emit structured JSON")
4812 p_reconcile.set_defaults(func=_cmd_capture_reconcile)
4814 p_step = sub.add_parser(
4815 "step-verify",
4816 help="verify a persisted step handoff against the evidence report",
4817 )
4818 p_step.add_argument("--step", required=True, help="backbone step id, e.g. s7")
4819 p_step.add_argument("--handoff-file", required=True, help="JSON step handoff file")
4820 p_step.add_argument("--evidence-report", required=True,
4821 help="JSON evidence verification report or verification block")
4822 p_step.add_argument("--review-comments", choices=("inline", "summary"),
4823 default="inline", help="review posting mode in the ship contract")
4824 p_step.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
4825 help="override the required reviewer verdict count")
4826 p_step.add_argument("--jury", action="store_true",
4827 help="enable the cross-vendor jury requirement")
4828 p_step.add_argument("--no-jury", action="store_true",
4829 help="disable the cross-vendor jury requirement")
4830 p_step.add_argument("--jury-advisory", action="store_true",
4831 help="make an enabled jury advisory instead of required")
4832 p_step.add_argument("--dry-run", action="store_true",
4833 help="verify with dry-run evidence requirements")
4834 p_step.add_argument("--not-enforced", action="store_true",
4835 help="verify with evidence requirements disabled")
4836 p_step.add_argument("--json", action="store_true", help="emit structured JSON")
4837 p_step.set_defaults(func=_cmd_step_verify)
4839 p_rc = sub.add_parser(
4840 "runcontrols",
4841 help="append/evaluate deterministic run-control events",
4842 )
4843 p_rc.add_argument("events_file", help="JSON array run-events file")
4844 p_rc.add_argument("--event-json", default=None, help="single event JSON object to append")
4845 p_rc.add_argument("--step", default=None, help="event step id")
4846 p_rc.add_argument("--slot", default=None, help="event slot name")
4847 p_rc.add_argument("--action", default=None, help="event action")
4848 p_rc.add_argument("--output-fingerprint", default=None, help="event output fingerprint")
4849 p_rc.add_argument("--diff-fingerprint", default=None, help="event diff fingerprint")
4850 p_rc.add_argument("--work-units", type=int, default=None, help="event work-unit count")
4851 p_rc.add_argument("--soft-failure", action="store_true", help="mark event as soft failure")
4852 p_rc.add_argument("--max-work-units", type=int, default=runcontrols.DEFAULT_RUN_BUDGET,
4853 help="run budget hard cap")
4854 p_rc.add_argument("--default-step-cap", type=int, default=runcontrols.DEFAULT_STEP_CAP,
4855 help="default per-step/slot iteration cap")
4856 p_rc.add_argument("--step-cap", action="append", default=[],
4857 help="override per-slot cap as SLOT=N; repeatable")
4858 p_rc.add_argument("--identical-action-threshold", type=int,
4859 default=runcontrols.DEFAULT_IDENTICAL_THRESHOLD,
4860 help="oscillation threshold for repeated identical actions")
4861 p_rc.add_argument("--alternating-diff-window", type=int,
4862 default=runcontrols.DEFAULT_ALTERNATION_WINDOW,
4863 help="oscillation window for alternating diff fingerprints")
4864 p_rc.add_argument("--dry-run", action="store_true",
4865 help="evaluate without appending the event")
4866 p_rc.add_argument("--json", action="store_true", help="emit structured JSON")
4867 p_rc.set_defaults(func=_cmd_runcontrols)
4869 p_post = sub.add_parser(
4870 "post-comment",
4871 help="post or update a deterministic GitHub issue/PR artifact comment",
4872 )
4873 p_post.add_argument("path", help="path to project.yaml")
4874 p_post.add_argument("--root", default=".", help="repo root for GitHub operations")
4875 p_post.add_argument("--target", required=True, help="comment target as issue:N or pr:N")
4876 p_post.add_argument(
4877 "--artifact",
4878 required=True,
4879 choices=(
4880 "closure-comment",
4881 "issue-update",
4882 "review-verdict",
4883 "jury-verdict",
4884 "review-cycle-summary",
4885 "extension-result",
4886 "step-handoff",
4887 "run-control-halt",
4888 ),
4889 help="artifact contract expected in --body-file",
4890 )
4891 p_post.add_argument("--body-file", required=True, help="rendered markdown body to post")
4892 p_post.add_argument(
4893 "--run-id",
4894 default=None,
4895 help="update the existing same-marker comment for this run id when present",
4896 )
4897 p_post.add_argument("--dry-run", action="store_true", help="plan only; do not mutate GitHub")
4898 p_post.add_argument("--json", action="store_true", help="emit structured JSON")
4899 p_post.set_defaults(func=_cmd_post_comment)
4901 p_review = sub.add_parser(
4902 "review",
4903 help="orchestrate a supplied review evidence bundle: render, post, re-verify",
4904 )
4905 p_review.add_argument("path", help="path to project.yaml")
4906 p_review.add_argument("--root", default=".", help="repo root for GitHub operations")
4907 p_review.add_argument("--pr", type=_positive_int, required=True,
4908 help="pull request number to attach verdicts to")
4909 p_review.add_argument("--reviews", required=True,
4910 help="JSON array of reviewer verdict objects supplied by the host")
4911 p_review.add_argument("--issue", type=_positive_int, default=None,
4912 help="linked issue number; otherwise inferred from PR body")
4913 p_review.add_argument("--closure", default=None,
4914 help="optional ship_run-shaped JSON record to post as the closure")
4915 p_review.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
4916 help="override the required reviewer verdict count")
4917 p_review.add_argument("--head-sha", default=None,
4918 help="offline PR head SHA used to pin verdict evidence")
4919 p_review.add_argument("--changed-file", action="append", default=[],
4920 help="offline changed file path; repeat to derive tier from fixtures")
4921 p_review.add_argument("--run-id", default="run",
4922 help="run id; per-reviewer sub-keys bind idempotent comments")
4923 p_review.add_argument("--verify", action="store_true",
4924 help="run evidence-verify after posting and include the outcome")
4925 p_review.add_argument("--dry-run", action="store_true",
4926 help="render and print what would post; do not mutate GitHub")
4927 p_review.add_argument("--live", action="store_true",
4928 help="actually post the rendered bundle (consent-gated)")
4929 p_review.add_argument("--approve-scope", action="append", default=[],
4930 help="approve a consent scope for the live run; repeatable")
4931 p_review.add_argument("--operator", default=None,
4932 help="operator identity recorded with an approved live run")
4933 p_review.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4934 help="override the project consent mode for this run")
4935 p_review.add_argument("--json", action="store_true", help="emit structured JSON")
4936 p_review.set_defaults(func=_cmd_review)
4938 p_rcs = sub.add_parser(
4939 "review-cycle-summary",
4940 help="render the deterministic multi-reviewer review-cycle summary comment",
4941 )
4942 p_rcs.add_argument(
4943 "--findings",
4944 required=True,
4945 help="JSON array of reviewer findings blocks supplied by the host",
4946 )
4947 p_rcs.add_argument("--head-sha", default=None,
4948 help="PR head SHA to pin the summary to")
4949 p_rcs.add_argument("--run-id", default=None,
4950 help="embed a run-id marker so an idempotent re-post edits in place")
4951 p_rcs.add_argument("--json", action="store_true", help="emit structured JSON")
4952 p_rcs.set_defaults(func=_cmd_review_cycle_summary)
4954 p_report = sub.add_parser(
4955 "render-report",
4956 help="render a deterministic reporting comment (coverage / deps-audit / flake-audit)",
4957 )
4958 p_report.add_argument("--kind", required=True,
4959 choices=("coverage", "deps-audit", "flake-audit",
4960 "scan-finding", "triage-audit"),
4961 help="which reporting artifact to render")
4962 p_report.add_argument("--payload", required=True,
4963 help="JSON object of renderer fields supplied by the host")
4964 p_report.add_argument("--json", action="store_true", help="emit structured JSON")
4965 p_report.set_defaults(func=_cmd_render_report)
4967 p_evidence = sub.add_parser(
4968 "evidence-verify",
4969 help="verify required pre-merge ship evidence artifacts",
4970 )
4971 p_evidence.add_argument("path", help="path to project.yaml")
4972 p_evidence.add_argument("--root", default=".", help="repo root for live gh fetches")
4973 p_evidence.add_argument("--pr", type=_positive_int, required=True,
4974 help="pull request number to verify")
4975 p_evidence.add_argument("--issue", type=_positive_int, default=None,
4976 help="linked issue number; otherwise inferred from PR body")
4977 p_evidence.add_argument("--review-comments", choices=("inline", "summary"),
4978 default="inline", help="review posting mode in the ship contract")
4979 p_evidence.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
4980 help="override the required reviewer verdict count")
4981 p_evidence.add_argument("--jury", action="store_true",
4982 help="enable the cross-vendor jury requirement")
4983 p_evidence.add_argument("--no-jury", action="store_true",
4984 help="disable the cross-vendor jury requirement")
4985 p_evidence.add_argument("--jury-advisory", action="store_true",
4986 help="make an enabled jury advisory instead of required")
4987 p_evidence.add_argument("--jury-vendors", type=_nonnegative_int, default=None,
4988 help="distinct vendors that actually took part in the jury panel; "
4989 f"below {ship.MINIMUM_JURY_VENDORS} a gating jury is downgraded "
4990 "to advisory and no jury verdict is required. 0 covers a run "
4991 "where no agent returned output. Omit when the panel is unknown")
4992 p_evidence.add_argument("--require-distinct-vendors", action="store_true",
4993 help="require each review verdict to carry a distinct vendor "
4994 "(overrides the project knob; off by default)")
4995 p_evidence.add_argument("--phase", choices=evidence.PHASES, default=evidence.PHASE_ALL,
4996 help="which phase's evidence to require: pre-merge (review + "
4997 "gating jury, the s10 merge gate), post-merge (the s11 "
4998 "closure comments), or all (default)")
4999 p_evidence.add_argument("--require-armed", action="store_true",
5000 help="fail instead of passing when the gate is not armed, so a "
5001 "green result cannot mean 'checked nothing'; the operator "
5002 "waiver label remains the deliberate way to disarm")
5003 p_evidence.add_argument("--dry-run", action="store_true",
5004 help="emit the contract without requiring evidence")
5005 p_evidence.add_argument("--deferral", action="append", default=[],
5006 help="explicitly defer an evidence id, kind, or all")
5007 p_evidence.add_argument("--pr-comments-json", default=None,
5008 help="offline PR issue-comments JSON fixture")
5009 p_evidence.add_argument("--issue-comments-json", default=None,
5010 help="offline linked-issue comments JSON fixture")
5011 p_evidence.add_argument("--pr-reviews-json", default=None,
5012 help="offline PR reviews JSON fixture")
5013 p_evidence.add_argument("--pr-body-file", default=None,
5014 help="offline PR body fixture, used only to infer linked issue")
5015 p_evidence.add_argument("--ledger-jsonl", default=None,
5016 help="offline run-ledger JSONL fixture; otherwise the configured "
5017 "ledger under --root is read to enforce closure fidelity")
5018 p_evidence.add_argument("--changed-file", action="append", default=[],
5019 help="offline changed file path; repeat to derive tier from fixtures")
5020 p_evidence.add_argument("--head-sha", default=None,
5021 help="offline PR head SHA used to bind verdict evidence")
5022 p_evidence.add_argument("--head-ref", default=None,
5023 help="offline PR head branch used to detect ship provenance")
5024 p_evidence.add_argument("--pr-label", action="append", default=[],
5025 help="inject a PR label name (repeatable); merged with live labels. "
5026 "A live PR fetch still runs unless an offline fixture flag is "
5027 "also supplied")
5028 p_evidence.add_argument("--gate-label", default=None,
5029 help="override the legacy evidence arming label")
5030 p_evidence.add_argument("--waiver-label", default=None,
5031 help="override the operator-applied evidence waiver label")
5032 p_evidence.add_argument("--json", action="store_true", help="emit structured JSON")
5033 p_evidence.set_defaults(func=_cmd_evidence_verify)
5035 p_vm = sub.add_parser(
5036 "verify-merge",
5037 help="confirm a merged PR's diff actually landed, and nothing else did",
5038 )
5039 p_vm.add_argument("path", help="path to project.yaml")
5040 p_vm.add_argument("--root", default=".", help="repo root for live gh fetches")
5041 p_vm.add_argument("--pr", type=_positive_int, required=True,
5042 help="merged pull request number to verify")
5043 p_vm.add_argument("--merge-sha", default=None,
5044 help="merge commit SHA; read from the PR when omitted")
5045 p_vm.add_argument("--json", action="store_true", help="emit structured JSON")
5046 p_vm.set_defaults(func=_cmd_verify_merge)
5048 p_scope = sub.add_parser(
5049 "scope-verify",
5050 help="compare the implementer's declared files against the live PR diff",
5051 )
5052 p_scope.add_argument("path", help="path to project.yaml")
5053 p_scope.add_argument("--root", default=".", help="repo root for live gh fetches")
5054 p_scope.add_argument("--pr", type=_positive_int, required=True,
5055 help="pull request number to verify")
5056 p_scope.add_argument("--issue", type=_positive_int, default=None,
5057 help="linked issue number; otherwise inferred from PR body")
5058 p_scope.add_argument("--deferral", action="append", default=[],
5059 help="operator escape hatch; pass 'scope-waived' (or 'all') to "
5060 "accept scope creep for this run")
5061 p_scope.add_argument("--ledger-jsonl", default=None,
5062 help="offline run-ledger JSONL fixture; otherwise the configured "
5063 "ledger under --root is read for the declared scope")
5064 p_scope.add_argument("--changed-file", action="append", default=[],
5065 help="offline changed file path; repeat to supply the diff offline")
5066 p_scope.add_argument("--head-sha", default=None,
5067 help="offline PR head SHA recorded in the report")
5068 p_scope.add_argument("--head-ref", default=None,
5069 help="offline PR head branch")
5070 p_scope.add_argument("--pr-body-file", default=None,
5071 help="offline PR body fixture, used only to infer the linked issue")
5072 p_scope.add_argument("--pr-comments-json", default=None,
5073 help="offline PR issue-comments JSON fixture")
5074 p_scope.add_argument("--issue-comments-json", default=None,
5075 help="offline linked-issue comments JSON fixture")
5076 p_scope.add_argument("--pr-reviews-json", default=None,
5077 help="offline PR reviews JSON fixture")
5078 p_scope.add_argument("--pr-label", action="append", default=[],
5079 help="inject a PR label name (repeatable)")
5080 p_scope.add_argument("--dry-run", action="store_true",
5081 help="use offline inputs without a live gh fetch")
5082 p_scope.add_argument("--json", action="store_true", help="emit structured JSON")
5083 p_scope.set_defaults(func=_cmd_scope_verify)
5085 p_verify_branch = sub.add_parser(
5086 "verify-branch",
5087 help="verify the s2 branch contract: up-to-date base + worktree isolation",
5088 )
5089 p_verify_branch.add_argument("path", help="path to project.yaml")
5090 p_verify_branch.add_argument("--root", default=".",
5091 help="repo root for live git/gh fact gathering")
5092 p_verify_branch.add_argument("--pr", type=_positive_int, required=True,
5093 help="pull request number to verify")
5094 p_verify_branch.add_argument(
5095 "--tolerance", type=_nonnegative_int, default=branchscope.DEFAULT_BASE_DISTANCE,
5096 help="commits the merge-base may sit behind the base tip before stale "
5097 f"(default {branchscope.DEFAULT_BASE_DISTANCE}; 0 is strict)")
5098 p_verify_branch.add_argument(
5099 "--allow-stale-base", action="store_true",
5100 help="operator escape (consent scope git): downgrade a stale base from a "
5101 "failure to an advisory note, recorded on the report")
5102 p_verify_branch.add_argument("--offline", action="store_true",
5103 help="use only supplied facts; make no git/gh calls")
5104 p_verify_branch.add_argument("--head-sha", default=None,
5105 help="offline PR head SHA (otherwise fetched via gh)")
5106 p_verify_branch.add_argument("--head-ref", default=None,
5107 help="offline PR head branch (otherwise fetched via gh)")
5108 p_verify_branch.add_argument("--base-tip-sha", default=None,
5109 help="offline current origin/<base> tip SHA")
5110 p_verify_branch.add_argument("--merge-base-sha", default=None,
5111 help="offline merge-base of head and the base tip")
5112 p_verify_branch.add_argument("--base-distance", type=_nonnegative_int, default=None,
5113 help="offline commit distance merge-base..base-tip")
5114 p_verify_branch.add_argument("--worktree-path", default=None,
5115 help="offline working-tree path for the head branch")
5116 p_verify_branch.add_argument("--repo-root", default=None,
5117 help="offline repo root (primary checkout) path")
5118 p_verify_branch.add_argument("--linked-worktree", choices=("true", "false"), default=None,
5119 help="offline: is the head branch in a linked worktree?")
5120 p_verify_branch.add_argument("--json", action="store_true", help="emit structured JSON")
5121 p_verify_branch.set_defaults(func=_cmd_verify_branch)
5123 p_status = sub.add_parser("status", help="show active/recent run progress")
5124 p_status.add_argument("path", help="path to project.yaml")
5125 p_status.add_argument("--root", default=".",
5126 help="repo root for resolving checkpoint and ledger paths")
5127 p_status.add_argument("--live-branch", action="append", default=[],
5128 help="live branch name for orphan detection (repeatable)")
5129 p_status.add_argument("--live-pr", action="append", type=_positive_int, default=[],
5130 help="live pull-request number for orphan detection (repeatable)")
5131 p_status.add_argument("--json", action="store_true", help="emit structured JSON")
5132 p_status.set_defaults(func=_cmd_status)
5134 p_checkpoint = sub.add_parser("checkpoint", help="read or write the resumable checkpoint")
5135 p_checkpoint.add_argument("path", help="path to project.yaml")
5136 p_checkpoint.add_argument("--root", default=".",
5137 help="repo root for resolving the checkpoint path")
5138 p_checkpoint.add_argument("--write", action="store_true",
5139 help="write a checkpoint record instead of reading it")
5140 p_checkpoint.add_argument("--run-id", default="run",
5141 help="run id for --write")
5142 p_checkpoint.add_argument("--checkpoint-command", dest="checkpoint_command_name",
5143 choices=checkpoint.COMMANDS, default="ship",
5144 help="workflow command being checkpointed")
5145 p_checkpoint.add_argument("--step", choices=checkpoint.STEP_IDS,
5146 default="s0", help="current backbone step for --write")
5147 p_checkpoint.add_argument("--target", default=None,
5148 help="target text to store in the checkpoint")
5149 p_checkpoint.add_argument("--issue-queue", type=_positive_int, action="append", default=[],
5150 help="queued issue number; repeatable")
5151 p_checkpoint.add_argument("--active-issue", type=_positive_int, default=None,
5152 help="active issue number")
5153 p_checkpoint.add_argument("--branch", default=None, help="recorded branch")
5154 p_checkpoint.add_argument("--worktree", default=None, help="recorded worktree path")
5155 p_checkpoint.add_argument("--pull-request", type=_positive_int, default=None,
5156 help="recorded pull request number")
5157 p_checkpoint.add_argument("--head-sha", default=None, help="recorded head SHA")
5158 p_checkpoint.add_argument("--completed-step", choices=checkpoint.STEP_IDS,
5159 action="append", default=[],
5160 help="completed backbone step; repeatable")
5161 p_checkpoint.add_argument("--last-gate", default=None, help="last completed gate id")
5162 p_checkpoint.add_argument("--last-review", default=None,
5163 help="last completed review marker")
5164 p_checkpoint.add_argument("--last-check", default=None,
5165 help="last completed CI/check marker")
5166 p_checkpoint.add_argument("--jury-mode", default=None,
5167 help="resolved jury mode at this step (off/advisory/gating); "
5168 "lets a live consumer show jury status before run end")
5169 p_checkpoint.add_argument("--merge-state", choices=checkpoint.MERGE_STATES,
5170 default="not-started")
5171 p_checkpoint.add_argument("--capture-state", choices=checkpoint.CAPTURE_STATES,
5172 default="not-started")
5173 p_checkpoint.add_argument("--close-state", choices=checkpoint.CLOSE_STATES,
5174 default="not-started")
5175 p_checkpoint.add_argument("--stop-reason", default=None,
5176 help="why the run stopped at this checkpoint")
5177 p_checkpoint.add_argument("--json", action="store_true", help="emit structured JSON")
5178 p_checkpoint.set_defaults(func=_cmd_checkpoint)
5180 p_activity = sub.add_parser(
5181 "activity",
5182 help="read/write the additive command-activity records (live board for "
5183 "non-ship commands)")
5184 p_activity.add_argument("path", help="path to project.yaml")
5185 p_activity.add_argument("--root", default=".",
5186 help="repo root for resolving the activity dir")
5187 g_act = p_activity.add_mutually_exclusive_group()
5188 g_act.add_argument("--write", action="store_true",
5189 help="write/update an activity record for --run-id")
5190 g_act.add_argument("--done", action="store_true",
5191 help="mark --run-id's activity record finished")
5192 g_act.add_argument("--clear", action="store_true",
5193 help="remove --run-id's activity record")
5194 p_activity.add_argument("--command", dest="activity_command_name",
5195 choices=flows.command_names(), default="ship",
5196 help="workflow command the activity belongs to")
5197 p_activity.add_argument("--run-id", default="run",
5198 help="run id keying the record (one file each)")
5199 p_activity.add_argument("--phase", default=None,
5200 help="current flow phase id for the command (--write)")
5201 p_activity.add_argument("--status", choices=activity.STATUSES, default="running",
5202 help="activity status for --write")
5203 p_activity.add_argument("--issue", type=_positive_int, default=None,
5204 help="issue number to record")
5205 p_activity.add_argument("--pull-request", type=_positive_int, default=None,
5206 help="pull request number to record")
5207 p_activity.add_argument("--note", default=None, help="optional free-text note")
5208 p_activity.add_argument("--json", action="store_true", help="emit structured JSON")
5209 p_activity.set_defaults(func=_cmd_activity)
5211 p_scratch = sub.add_parser(
5212 "scratch-dir",
5213 help="print (and create) the keel-owned scratch dir for transient artifacts")
5214 p_scratch.add_argument("--root", default=".",
5215 help="repo root under which .keel/scratch lives")
5216 p_scratch.add_argument("--no-create", dest="create", action="store_false",
5217 help="print the path without creating it")
5218 p_scratch.set_defaults(func=_cmd_scratch_dir, create=True)
5220 p_gc = sub.add_parser(
5221 "gc",
5222 help="reclaim disposable runtime artifacts (empty scratch, prune old activity)")
5223 p_gc.add_argument("path", help="path to project.yaml")
5224 p_gc.add_argument("--root", default=".",
5225 help="repo root under which .keel runtime artifacts live")
5226 p_gc.add_argument("--keep-activity", type=_nonnegative_int,
5227 default=DEFAULT_GC_KEEP_ACTIVITY,
5228 help=f"activity records to keep, newest first "
5229 f"(default {DEFAULT_GC_KEEP_ACTIVITY})")
5230 p_gc.add_argument("--no-scratch", dest="scratch", action="store_false",
5231 help="skip emptying .keel/scratch")
5232 p_gc.add_argument("--no-activity", dest="activity", action="store_false",
5233 help="skip pruning .keel/activity")
5234 p_gc.add_argument("--dry-run", action="store_true",
5235 help="report what would be reclaimed without removing anything")
5236 p_gc.add_argument("--json", action="store_true", help="emit structured JSON")
5237 p_gc.set_defaults(func=_cmd_gc, scratch=True, activity=True)
5239 p_resume = sub.add_parser("resume", help="render a dry-run resume plan")
5240 p_resume.add_argument("path", help="path to project.yaml")
5241 p_resume.add_argument("--root", default=".",
5242 help="repo root for resolving the checkpoint path")
5243 p_resume.add_argument("--live-pr-state", choices=checkpoint.LIVE_PR_STATES,
5244 default=None,
5245 help="override the observed live PR state (offline/fixture path)")
5246 p_resume.add_argument("--live-worktree-state", choices=checkpoint.LIVE_WORKTREE_STATES,
5247 default=None,
5248 help="override the observed live worktree state "
5249 "(offline/fixture path)")
5250 p_resume.add_argument("--no-observe", action="store_true",
5251 help="do not read git/gh; treat unsupplied live state as unknown")
5252 p_resume.add_argument("--json", action="store_true", help="emit structured JSON")
5253 p_resume.set_defaults(func=_cmd_resume)
5255 _add_ship_parser(
5256 sub.add_parser("ship", help="dry ship assessment (tier, window, gates, decision)"),
5257 command="ship",
5258 )
5260 p_implement = sub.add_parser(
5261 "implement",
5262 help="standalone implement-step preflight contract",
5263 )
5264 p_implement.add_argument("path", help="path to project.yaml")
5265 p_implement.add_argument("issue", type=_positive_int, help="issue number to implement")
5266 p_implement.add_argument("--root", default=".", help="repo root for git and extensions")
5267 p_implement.add_argument("--delegate", default=None,
5268 help="explicit implementer delegate override")
5269 p_implement.add_argument("--dry-run", action="store_true",
5270 help="explicitly mark the assessment as non-mutating")
5271 p_implement.add_argument("--live", action="store_true",
5272 help="render a live preflight and fail if consent is missing")
5273 p_implement.add_argument("--approve-scope", action="append", default=[],
5274 help="approve a consent scope for this run; repeat or comma-separate")
5275 p_implement.add_argument("--operator", default=None,
5276 help="operator identifier to include in an approved consent record")
5277 p_implement.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
5278 help="operator consent mode: explicit, standing, or agent")
5279 p_implement.add_argument("--target", default=None,
5280 help="additional target text for the consent prompt")
5281 p_implement.add_argument("--issue-title", default=None,
5282 help="issue title to include in the intake/readiness contract")
5283 p_implement.add_argument("--issue-body", default=None,
5284 help="issue body markdown to include in the intake/readiness contract")
5285 p_implement.add_argument("--issue-label", action="append", default=[],
5286 help="issue label for intake/readiness; repeat or comma-separate")
5287 p_implement.add_argument("--json", action="store_true", help="emit structured JSON")
5288 p_implement.set_defaults(func=_cmd_standalone, standalone_command="implement")
5290 p_ci = sub.add_parser(
5291 "ci-check",
5292 help="standalone read-only CI diagnostic preflight contract",
5293 )
5294 p_ci.add_argument("path", help="path to project.yaml")
5295 p_ci.add_argument("--root", default=".", help="repo root for capability checks")
5296 p_ci.add_argument("--pr", type=_positive_int, default=None,
5297 help="PR number whose latest checks should be diagnosed")
5298 p_ci.add_argument("--target", default=None,
5299 help="target text to include in the diagnostic contract")
5300 p_ci.add_argument("--json", action="store_true", help="emit structured JSON")
5301 p_ci.set_defaults(func=_cmd_standalone, standalone_command="ci-check")
5303 p_morning = sub.add_parser(
5304 "morning",
5305 help="standalone daily-brief preflight contract",
5306 )
5307 p_morning.add_argument("path", help="path to project.yaml")
5308 p_morning.add_argument("--root", default=".", help="repo root for capability checks")
5309 p_morning.add_argument("--since", default=None,
5310 help="optional brief window start label or timestamp")
5311 p_morning.add_argument("--target", default=None,
5312 help="target text to include in the morning contract")
5313 p_morning.add_argument("--dry-run", action="store_true",
5314 help="explicitly mark the assessment as non-mutating")
5315 p_morning.add_argument("--live", action="store_true",
5316 help="render a live preflight and fail if consent is missing")
5317 p_morning.add_argument("--approve-scope", action="append", default=[],
5318 help="approve a consent scope for this run; repeat or comma-separate")
5319 p_morning.add_argument("--operator", default=None,
5320 help="operator identifier to include in an approved consent record")
5321 p_morning.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
5322 help="operator consent mode: explicit, standing, or agent")
5323 p_morning.add_argument("--json", action="store_true", help="emit structured JSON")
5324 p_morning.set_defaults(func=_cmd_standalone, standalone_command="morning")
5326 p_wrap = sub.add_parser(
5327 "wrap",
5328 help="standalone session-wrap preflight contract",
5329 )
5330 p_wrap.add_argument("path", help="path to project.yaml")
5331 p_wrap.add_argument("title", nargs="?", default=None,
5332 help="optional PR title override to include in the contract")
5333 p_wrap.add_argument("--root", default=".", help="repo root for git and capability checks")
5334 p_wrap.add_argument("--since", default=None,
5335 help="optional session start label or timestamp")
5336 p_wrap.add_argument("--target", default=None,
5337 help="target text to include in the wrap contract")
5338 p_wrap.add_argument("--dry-run", action="store_true",
5339 help="explicitly mark the assessment as non-mutating")
5340 p_wrap.add_argument("--live", action="store_true",
5341 help="render a live preflight and fail if consent is missing")
5342 p_wrap.add_argument("--approve-scope", action="append", default=[],
5343 help="approve a consent scope for this run; repeat or comma-separate")
5344 p_wrap.add_argument("--operator", default=None,
5345 help="operator identifier to include in an approved consent record")
5346 p_wrap.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
5347 help="operator consent mode: explicit, standing, or agent")
5348 p_wrap.add_argument("--json", action="store_true", help="emit structured JSON")
5349 p_wrap.set_defaults(func=_cmd_standalone, standalone_command="wrap")
5351 p_work_block = sub.add_parser(
5352 "work-block",
5353 help="standalone daytime multi-issue work-block preflight contract",
5354 )
5355 p_work_block.add_argument("path", help="path to project.yaml")
5356 p_work_block.add_argument("issues", nargs="*", type=_positive_int,
5357 help="explicit issue numbers to process in order")
5358 p_work_block.add_argument("--root", default=".",
5359 help="repo root for git and capability checks")
5360 p_work_block.add_argument("--queue", default=None,
5361 help=(
5362 "project queue selector to use when no explicit issues "
5363 "are given"
5364 ))
5365 p_work_block.add_argument("--max", dest="max_items", type=_positive_int, default=None,
5366 help="maximum issues to attempt in this work block")
5367 p_work_block.add_argument("--hours", type=float, default=None,
5368 help="optional time budget in hours")
5369 p_work_block.add_argument("--review-comments", choices=("inline", "summary"),
5370 default="inline",
5371 help="review posting mode to pass through ship handoffs")
5372 p_work_block.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
5373 help="reviewer override for ship handoff contracts")
5374 p_work_block.add_argument("--target", default=None,
5375 help="target text to include in the work-block contract")
5376 p_work_block.add_argument("--dry-run", action="store_true",
5377 help="explicitly mark the assessment as non-mutating")
5378 p_work_block.add_argument("--live", action="store_true",
5379 help="render a live preflight and fail if consent is missing")
5380 p_work_block.add_argument("--approve-scope", action="append", default=[],
5381 help="approve a consent scope for this run; repeat or comma-separate")
5382 p_work_block.add_argument("--operator", default=None,
5383 help="operator identifier to include in an approved consent record")
5384 p_work_block.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
5385 help="operator consent mode: explicit, standing, or agent")
5386 p_work_block.add_argument("--json", action="store_true", help="emit structured JSON")
5387 p_work_block.set_defaults(func=_cmd_standalone, standalone_command="work-block")
5389 p_overnight = sub.add_parser(
5390 "overnight",
5391 help="standalone overnight-session preflight contract",
5392 )
5393 p_overnight.add_argument("path", help="path to project.yaml")
5394 p_overnight.add_argument("hours", nargs="?", type=float, default=None,
5395 help="optional time budget in hours")
5396 p_overnight.add_argument("--root", default=".", help="repo root for git and capability checks")
5397 p_overnight.add_argument("--max", dest="max_items", type=_positive_int, default=None,
5398 help="maximum issues to attempt in this session")
5399 p_overnight.add_argument("--review-comments", choices=("inline", "summary"), default="inline",
5400 help="review posting mode to pass through ship handoffs")
5401 p_overnight.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
5402 help="reviewer override for ship handoff contracts")
5403 p_overnight.add_argument("--target", default=None,
5404 help="target text to include in the overnight contract")
5405 p_overnight.add_argument("--dry-run", action="store_true",
5406 help="explicitly mark the assessment as non-mutating")
5407 p_overnight.add_argument("--live", action="store_true",
5408 help="render a live preflight and fail if consent is missing")
5409 p_overnight.add_argument("--approve-scope", action="append", default=[],
5410 help="approve a consent scope for this run; repeat or comma-separate")
5411 p_overnight.add_argument("--operator", default=None,
5412 help="operator identifier to include in an approved consent record")
5413 p_overnight.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
5414 help="operator consent mode: explicit, standing, or agent")
5415 p_overnight.add_argument("--json", action="store_true", help="emit structured JSON")
5416 p_overnight.set_defaults(func=_cmd_standalone, standalone_command="overnight")
5418 p_regression = sub.add_parser(
5419 "regression",
5420 help="standalone scan-and-file regression preflight contract",
5421 )
5422 p_regression.add_argument("path", help="path to project.yaml")
5423 p_regression.add_argument("--root", default=".", help="repo root for git/capability checks")
5424 p_regression.add_argument("--scope", choices=("full", "changed", "since"), default="full",
5425 help="scan scope to include in the preflight target")
5426 p_regression.add_argument("--since", default=None,
5427 help="optional ref or timestamp when --scope since is used")
5428 p_regression.add_argument("--target", default=None,
5429 help="target text to include in the regression contract")
5430 p_regression.add_argument("--dry-run", action="store_true",
5431 help="explicitly mark the assessment as non-mutating")
5432 p_regression.add_argument("--live", action="store_true",
5433 help="render a live preflight and fail if consent is missing")
5434 p_regression.add_argument("--approve-scope", action="append", default=[],
5435 help="approve a consent scope for this run; repeat or comma-separate")
5436 p_regression.add_argument("--operator", default=None,
5437 help="operator identifier to include in an approved consent record")
5438 p_regression.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
5439 help="operator consent mode: explicit, standing, or agent")
5440 p_regression.add_argument("--json", action="store_true", help="emit structured JSON")
5441 p_regression.set_defaults(func=_cmd_standalone, standalone_command="regression")
5443 p_review_all_day = sub.add_parser(
5444 "review-all-day",
5445 help="standalone time-window scan-and-file preflight contract",
5446 )
5447 p_review_all_day.add_argument("path", help="path to project.yaml")
5448 p_review_all_day.add_argument("days", nargs="?", type=_positive_int, default=1,
5449 help="number of merge-window days to scan")
5450 p_review_all_day.add_argument("--root", default=".", help="repo root for git/capability checks")
5451 p_review_all_day.add_argument("--target", default=None,
5452 help="target text to include in the review-all-day contract")
5453 p_review_all_day.add_argument("--dry-run", action="store_true",
5454 help="explicitly mark the assessment as non-mutating")
5455 p_review_all_day.add_argument("--live", action="store_true",
5456 help="render a live preflight and fail if consent is missing")
5457 p_review_all_day.add_argument("--approve-scope", action="append", default=[],
5458 help=("approve a consent scope for this run; repeat or "
5459 "comma-separate"))
5460 p_review_all_day.add_argument("--operator", default=None,
5461 help=("operator identifier to include in an approved "
5462 "consent record"))
5463 p_review_all_day.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
5464 help="operator consent mode: explicit, standing, or agent")
5465 p_review_all_day.add_argument("--json", action="store_true", help="emit structured JSON")
5466 p_review_all_day.set_defaults(func=_cmd_standalone, standalone_command="review-all-day")
5468 p_caps = sub.add_parser("capabilities", help="print runtime capability report")
5469 p_caps.add_argument("--root", default=".", help="repo root for capability checks")
5470 p_caps.add_argument("--project", dest="path", default=None,
5471 help="optional project.yaml to evaluate requirements")
5472 p_caps.add_argument("--for", dest="for_command", default="ship",
5473 help="command requirement to evaluate when --project is set")
5474 p_caps.add_argument("--pr", type=int, default=None,
5475 help="PR number for ship capability requirements")
5476 p_caps.add_argument("--json", action="store_true", help="emit structured JSON")
5477 p_caps.set_defaults(func=_cmd_capabilities)
5479 p_doctor = sub.add_parser(
5480 "doctor",
5481 help="read-only diagnostics: CLI/adapter version drift, orphans, core_version, state",
5482 )
5483 p_doctor.add_argument("path", nargs="?", default=None,
5484 help="optional project.yaml (enables core_version + state checks)")
5485 p_doctor.add_argument("--root", default=".", help="project root to inspect")
5486 p_doctor.add_argument("--offline", action="store_true",
5487 help="skip the PyPI latest-version check (report latest as unknown)")
5488 p_doctor.add_argument("--strict", action="store_true",
5489 help="exit non-zero when any check fails (default: advisory, exit 0)")
5490 p_doctor.add_argument("--json", action="store_true", help="emit structured JSON")
5491 p_doctor.set_defaults(func=_cmd_doctor)
5493 p_proj = sub.add_parser("project-commands",
5494 help="list project-provided commands declared by policy")
5495 p_proj.add_argument("path", help="path to project.yaml")
5496 p_proj.add_argument("--json", action="store_true", help="emit structured JSON")
5497 p_proj.set_defaults(func=_cmd_project_commands)
5499 p_init = sub.add_parser("init", help="scaffold a default .keel/project.yaml for this repo")
5500 p_init.add_argument("--root", default=".", help="repo root to scaffold into")
5501 p_init.add_argument("--force", action="store_true", help="overwrite an existing config")
5502 p_init.add_argument("--wizard", action="store_true", help="prompt for values interactively")
5503 p_init.add_argument(
5504 "--auto", action="store_true", help="smart auto-detect stack and gates without prompts"
5505 )
5506 p_init.set_defaults(func=_cmd_init)
5508 p_setup = sub.add_parser(
5509 "setup",
5510 help="scaffold config, install adapters, validate, and render the plan",
5511 )
5512 p_setup.add_argument("--root", default=".", help="project root to set up")
5513 p_setup.add_argument(
5514 "--adapter-target",
5515 choices=("all", *install.TARGETS),
5516 default="all",
5517 help="adapter surface to install (default: all)",
5518 )
5519 p_setup.add_argument(
5520 "--force",
5521 action="store_true",
5522 help="overwrite existing config and generated adapters",
5523 )
5524 p_setup.add_argument("--wizard", action="store_true", help="prompt for config values")
5525 p_setup.set_defaults(func=_cmd_setup)
5527 p_ia = sub.add_parser("install-adapter", help="install the /keel:<command> adapters")
5528 p_ia.add_argument("agent",
5529 help=f"'all', 'plugin', or one of: {', '.join(install.TARGETS)}")
5530 p_ia.add_argument("--root", default=".", help="project root to install into")
5531 p_ia.add_argument("--force", action="store_true", help="overwrite existing adapters")
5532 p_ia.set_defaults(func=_cmd_install_adapter)
5534 p_as = sub.add_parser("adapter-status", help="report generated adapter freshness")
5535 p_as.add_argument("agent", nargs="?", default="all",
5536 help=f"'all' or one of: {', '.join(install.STATUS_TARGETS)}")
5537 p_as.add_argument("--root", default=".", help="project root to inspect")
5538 p_as.add_argument("--include-unmanaged", action="store_true",
5539 help="also report marker-less command-like surfaces (heuristic, opt-in)")
5540 p_as.add_argument("--json", action="store_true",
5541 help="emit adapter freshness + orphan/unmanaged findings as JSON")
5542 p_as.set_defaults(func=_cmd_adapter_status)
5544 p_ua = sub.add_parser("update-adapter", help="safely update generated adapters")
5545 p_ua.add_argument("agent", nargs="?", default="all",
5546 help=f"'all' or one of: {', '.join(install.TARGETS)}")
5547 p_ua.add_argument("--root", default=".", help="project root to update")
5548 p_ua.add_argument("--dry-run", action="store_true", help="show planned updates only")
5549 p_ua.set_defaults(func=_cmd_update_adapter)
5551 p_sync = sub.add_parser("sync", help="sync generated adapters with the installed keel package")
5552 p_sync.add_argument("--root", default=".", help="project root to update")
5553 p_sync.add_argument(
5554 "--target",
5555 choices=("all", *install.TARGETS),
5556 default="all",
5557 help="adapter surface to sync (default: all)",
5558 )
5559 p_sync.add_argument("--dry-run", action="store_true", help="show planned updates only")
5560 p_sync.set_defaults(func=_cmd_sync)
5562 p_lw = sub.add_parser(
5563 "install-legacy-wrappers",
5564 help="install thin legacy command wrappers that delegate to /keel:<command>",
5565 )
5566 p_lw.add_argument("agent", help=f"'all' or one of: {', '.join(install.LEGACY_TARGETS)}")
5567 p_lw.add_argument("--root", default=".", help="project root to install into")
5568 p_lw.add_argument("--force", action="store_true", help="overwrite existing wrappers")
5569 p_lw.add_argument(
5570 "--parity-matrix",
5571 default="docs/keel/parity-matrix.md",
5572 help="markdown parity matrix whose ready rows allow wrapper generation",
5573 )
5574 p_lw.add_argument(
5575 "--command",
5576 action="append",
5577 type=_parse_legacy_mapping,
5578 default=[],
5579 metavar="LEGACY=KEEL",
5580 help="install one wrapper mapping; repeat for multiple commands",
5581 )
5582 p_lw.set_defaults(func=_cmd_install_legacy_wrappers)
5584 p_sp = sub.add_parser(
5585 "swarm-plan",
5586 help="render conflict-free DAG waves and clusters for a swarm of issues",
5587 )
5588 p_sp.add_argument("path", help="path to project.yaml")
5589 p_sp.add_argument(
5590 "--issues", default=None, help="comma-separated issue numbers (e.g. 101,102,103)"
5591 )
5592 p_sp.add_argument("--issue", type=_positive_int, action="append", default=[],
5593 help="specific issue number; repeat for multiple")
5594 p_sp.add_argument("--declared-file", action="append", default=[],
5595 help="declared file path; repeat for multiple")
5596 p_sp.add_argument("--issue-title", default=None, help="issue title")
5597 p_sp.add_argument("--issue-body", default=None, help="issue body markdown")
5598 p_sp.add_argument("--issue-label", action="append", default=[],
5599 help="issue label; repeat or comma-separate")
5600 p_sp.add_argument("--swarm-id", default=None, help="custom swarm ID")
5601 p_sp.add_argument("--tree", action="store_true", help="render visual ASCII/Unicode DAG tree")
5602 p_sp.add_argument("--json", action="store_true", help="emit structured JSON")
5603 p_sp.set_defaults(func=_cmd_swarm_plan)
5605 p_ss = sub.add_parser(
5606 "swarm-status",
5607 help="display live/recent swarm execution status and cluster dashboard",
5608 )
5609 p_ss.add_argument("path", help="path to project.yaml")
5610 p_ss.add_argument("--root", default=".", help="repo root for state")
5611 p_ss.add_argument("--swarm-id", default=None, help="specific swarm execution ID")
5612 p_ss.add_argument("--json", action="store_true", help="emit structured JSON")
5613 p_ss.set_defaults(func=_cmd_swarm_status)
5615 p_sr = sub.add_parser(
5616 "swarm-run",
5617 help="execute parallel swarm workers in isolated worktrees across DAG waves",
5618 )
5619 p_sr.add_argument("path", help="path to project.yaml")
5620 p_sr.add_argument("--root", default=".", help="repo root for git, gates + extensions")
5621 p_sr.add_argument(
5622 "--issues", default=None, help="comma-separated issue numbers (e.g. 101,102,103)"
5623 )
5624 p_sr.add_argument("--issue", type=_positive_int, action="append", default=[],
5625 help="specific issue number; repeat for multiple")
5626 p_sr.add_argument("--declared-file", action="append", default=[],
5627 help="declared file path; repeat for multiple")
5628 p_sr.add_argument("--issue-title", default=None, help="issue title")
5629 p_sr.add_argument("--issue-body", default=None, help="issue body markdown")
5630 p_sr.add_argument("--issue-label", action="append", default=[],
5631 help="issue label; repeat or comma-separate")
5632 p_sr.add_argument("--swarm-id", default=None, help="custom swarm ID")
5633 p_sr.add_argument("--max-workers", type=_positive_int, default=4,
5634 help="maximum parallel workers (default: 4)")
5635 p_sr.add_argument("--live", action="store_true", help="run mutating live execution")
5636 p_sr.add_argument("--tree", action="store_true", help="render visual DAG tree")
5637 p_sr.add_argument("--json", action="store_true", help="emit structured JSON")
5638 p_sr.set_defaults(func=_cmd_swarm_run)
5640 p_sl = sub.add_parser(
5641 "swarm-land",
5642 help="land a wave of clusters via direct batch landing or sequential funneling",
5643 )
5644 p_sl.add_argument("path", help="path to project.yaml")
5645 p_sl.add_argument("--root", default=".", help="repo root for git, gates + extensions")
5646 p_sl.add_argument(
5647 "--wave", type=_positive_int, default=1, help="wave index to land (default: 1)"
5648 )
5649 p_sl.add_argument(
5650 "--issues", default=None, help="comma-separated issue numbers (e.g. 101,102,103)"
5651 )
5652 p_sl.add_argument("--issue", type=_positive_int, action="append", default=[],
5653 help="specific issue number; repeat for multiple")
5654 p_sl.add_argument("--declared-file", action="append", default=[],
5655 help="declared file path; repeat for multiple")
5656 p_sl.add_argument("--issue-title", default=None, help="issue title")
5657 p_sl.add_argument("--issue-body", default=None, help="issue body markdown")
5658 p_sl.add_argument("--issue-label", action="append", default=[],
5659 help="issue label; repeat or comma-separate")
5660 p_sl.add_argument("--swarm-id", default=None, help="custom swarm ID")
5661 p_sl.add_argument("--live", action="store_true", help="run live mutating git landing")
5662 p_sl.add_argument("--json", action="store_true", help="emit structured JSON")
5663 p_sl.set_defaults(func=_cmd_swarm_land)
5665 p_canary = sub.add_parser(
5666 "canary",
5667 help="monitor post-merge health signals and guard against regressions",
5668 )
5669 p_canary.add_argument("path", help="path to project.yaml")
5670 p_canary.add_argument("--root", default=".", help="repo root for git and gates")
5671 p_canary.add_argument("--pr", type=int, default=None, help="PR number being monitored")
5672 p_canary.add_argument("--commit", default=None, help="target merge commit SHA")
5673 p_canary.add_argument(
5674 "--duration", type=_positive_int, default=1, help="monitoring duration in minutes"
5675 )
5676 p_canary.add_argument("--health-cmd", default=None, help="custom health probe command")
5677 p_canary.add_argument(
5678 "--auto-revert", action="store_true", help="automatically revert merge commit on failure"
5679 )
5680 p_canary.add_argument("--json", action="store_true", help="emit structured JSON")
5681 p_canary.set_defaults(func=_cmd_canary)
5683 p_rb = sub.add_parser(
5684 "rollback",
5685 help="atomically revert a merge commit",
5686 )
5687 p_rb.add_argument("commit", help="merge commit SHA to revert")
5688 p_rb.add_argument("--root", default=".", help="repo root")
5689 p_rb.add_argument("--json", action="store_true", help="emit structured JSON")
5690 p_rb.set_defaults(func=_cmd_rollback)
5692 p_cost = sub.add_parser(
5693 "cost-report",
5694 help="report token consumption, estimated USD costs, and model analytics",
5695 )
5696 p_cost.add_argument("--root", default=".", help="repo root containing .keel/activity")
5697 p_cost.add_argument("--json", action="store_true", help="emit structured JSON")
5698 p_cost.set_defaults(func=_cmd_cost_report)
5700 return parser
5703def _add_ship_parser(parser: argparse.ArgumentParser, *, command: str) -> None:
5704 parser.add_argument("path", help="path to project.yaml")
5705 parser.add_argument("--root", default=".", help="repo root for git, gates + extensions")
5706 parser.add_argument("--pr", type=int, default=None, help="PR number for CI status (gh)")
5707 parser.add_argument("--hotfix", action="store_true", help="emergency: bypass the merge window")
5708 parser.add_argument("--dry-run", action="store_true",
5709 help="explicitly mark the assessment as non-mutating")
5710 parser.add_argument("--live", action="store_true",
5711 help=("run the live preflight gate and fail before gates "
5712 "if consent is missing"))
5713 parser.add_argument("--approve-scope", action="append", default=[],
5714 help="approve a consent scope for this run; repeat or comma-separate")
5715 parser.add_argument("--operator", default=None,
5716 help="operator identifier to include in an approved consent record")
5717 parser.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
5718 help="operator consent mode: explicit, standing, or agent")
5719 parser.add_argument("--target", default=None,
5720 help="task target to include in the consent prompt and record")
5721 parser.add_argument("--append-ledger", action="store_true",
5722 help="append the structured ship run record when --live succeeds")
5723 parser.add_argument("--run-id", default=None,
5724 help="operator/session run id to store in the run ledger record")
5725 parser.add_argument("--run-events-file", default=None,
5726 help="JSON run-events file to evaluate and stamp into the ledger record")
5727 parser.add_argument("--max-rounds", type=_positive_int, default=None,
5728 help="explicit run-control work-unit budget override")
5729 parser.add_argument("--issue", type=_positive_int, default=None,
5730 help="issue number to store in the run ledger record")
5731 parser.add_argument("--pull-request", dest="ledger_pr", type=_positive_int, default=None,
5732 help="PR number to store in the run ledger record without CI lookup")
5733 parser.add_argument("--branch", default=None,
5734 help="branch name to store in the run ledger record")
5735 parser.add_argument("--head-sha", default=None,
5736 help="head commit SHA to store in the run ledger record")
5737 parser.add_argument("--declared-file", action="append", default=None,
5738 help="implementer's declared in-scope file path (repeatable); "
5739 "recorded for keel scope-verify branch-contamination checks")
5740 parser.add_argument("--capture-status", type=_capture_status_arg, default=None,
5741 help="capture outcome to store in the run ledger record")
5742 parser.add_argument("--capture-reason", default=None,
5743 help="capture outcome reason to store in the run ledger record")
5744 parser.add_argument("--capture-artifact", default=None,
5745 help="durable capture artifact reference (path or content hash) "
5746 "proving an applied capture; required for clean reconcile")
5747 parser.add_argument("--gate-result", action="append", default=[],
5748 type=_gate_result_arg, metavar="ID=pass|fail",
5749 help="record the verdict of a gate this command cannot execute "
5750 "(an agentic gate, run by the dispatching agent); repeatable")
5751 parser.add_argument("--implementer", default=None,
5752 help="effective implementer codename or vendor/model label")
5753 parser.add_argument("--reviewer-agent", action="append", default=[],
5754 help="effective reviewer codename or vendor/model label; repeatable")
5755 parser.add_argument("--tester", default=None,
5756 help="effective tester codename or vendor/model label")
5757 parser.add_argument("--host-agent", default=None,
5758 help="host agent codename (e.g. claude/codex/agy) for the run context")
5759 parser.add_argument("--transport", choices=("gh", "mcp"), default=None,
5760 help="detected GitHub transport for the run context; "
5761 "defaults to the resolved transport when omitted")
5762 parser.add_argument("--strict-run-context", action="store_true",
5763 help="block live ledger append when required run-context fields "
5764 "would degrade")
5765 parser.add_argument("--issue-title", default=None,
5766 help="issue title to include in the intake/readiness contract")
5767 parser.add_argument("--issue-body", default=None,
5768 help="issue body markdown to include in the intake/readiness contract")
5769 parser.add_argument("--issue-label", action="append", default=[],
5770 help="issue label for intake/readiness; repeat or comma-separate")
5771 parser.add_argument("--review-comments", choices=("inline", "summary"), default="inline",
5772 help="review posting mode for the resolved ship contract")
5773 parser.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
5774 help="override the risk-derived reviewer count")
5775 parser.add_argument("--jury", action="store_true",
5776 help="enable the cross-vendor jury gate")
5777 parser.add_argument("--no-jury", action="store_true",
5778 help="disable the cross-vendor jury gate")
5779 parser.add_argument("--jury-advisory", action="store_true",
5780 help="make an enabled jury advisory instead of merge-gating")
5781 parser.add_argument("--profile", choices=("standard", "compound"), default="standard",
5782 help="workflow profile: standard (default) or compound")
5783 parser.add_argument("--compound", action="store_true",
5784 help="select the compound workflow profile (alias for --profile compound)")
5785 parser.add_argument("--json", action="store_true", help="emit structured JSON")
5786 parser.set_defaults(func=_cmd_ship, ship_command=command)
5789def _positive_int(value: str) -> int:
5790 parsed = int(value)
5791 if parsed <= 0:
5792 raise argparse.ArgumentTypeError("must be a positive integer")
5793 return parsed
5796def _nonnegative_int(value: str) -> int:
5797 parsed = int(value)
5798 if parsed < 0:
5799 raise argparse.ArgumentTypeError("must be a non-negative integer")
5800 return parsed
5803def _parse_pr_issue_mapping(value: str) -> tuple[int, int]:
5804 if "=" not in value:
5805 raise argparse.ArgumentTypeError("linked issue mapping must be PR=ISSUE")
5806 raw_pr, raw_issue = value.split("=", 1)
5807 try:
5808 pr = _positive_int(raw_pr)
5809 issue = _positive_int(raw_issue)
5810 except (argparse.ArgumentTypeError, ValueError) as exc:
5811 raise argparse.ArgumentTypeError("linked issue mapping must be PR=ISSUE") from exc
5812 return pr, issue
5815def _capture_status_arg(value: str) -> str:
5816 if value == "skipped":
5817 return value
5818 try:
5819 capture.normalize_status(value)
5820 except capture.CaptureError as exc:
5821 raise argparse.ArgumentTypeError(str(exc)) from exc
5822 return value
5825def main(argv: list[str] | None = None) -> int:
5826 parser = build_parser()
5827 args = parser.parse_args(argv)
5828 func = getattr(args, "func", None)
5829 if func is None:
5830 parser.print_help()
5831 return 2
5832 try:
5833 return func(args)
5834 except ledger.LedgerError as exc:
5835 print(f"invalid ledger path: {exc}", file=sys.stderr)
5836 return 1
5837 except checkpoint.CheckpointError as exc:
5838 print(f"invalid checkpoint path: {exc}", file=sys.stderr)
5839 return 1