Coverage for src/keel/cli.py: 100%
2786 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
1"""The ``keel`` command-line interface (thin; logic lives in the pure modules).
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 project_commands,
47 review,
48 runcontrols,
49 runtime,
50 scaffold,
51 scope,
52 ship,
53 status,
54 stepverifier,
55 window,
56)
57from . import config as cfg
58from . import findings as fnd
59from . import orchestrator as orch
60from .extensions import ExtensionError, load_extensions
61from .gates import GateSpec
62from .runner import command_gate_runner, run_argv
65def _gate_runner(root: str, diff_text: str, *, jury_mode: str = "gating"):
66 """A gate runner that handles command gates plus the ``jury`` built-in (on the diff)."""
67 commands = command_gate_runner(root)
69 def run(spec: GateSpec):
70 if spec.kind == "builtin" and spec.id == "jury":
71 return jury.run_gate(diff_text, cwd=root, mode=jury_mode)
72 return commands(spec)
74 return run
77def _cmd_version(args: argparse.Namespace) -> int:
78 print(f"keel {__version__}")
79 return 0
82def _cmd_validate(args: argparse.Namespace) -> int:
83 rc = 0
84 for path in args.paths:
85 try:
86 config = cfg.load_config(path)
87 except FileNotFoundError:
88 print(f"MISSING {path}")
89 rc = 1
90 continue
91 except cfg.ConfigError as exc:
92 print(f"INVALID {path}")
93 print(f" {exc}".replace("\n", "\n "))
94 rc = 1
95 continue
97 if args.root is not None:
98 try:
99 load_extensions(config, args.root, strict=True)
100 except ExtensionError as exc:
101 print(f"INVALID {path} (extensions)")
102 print(f" {exc}".replace("\n", "\n "))
103 rc = 1
104 continue
105 print(f"OK {path} ({config.repo or '-'}, base {config.base_branch})")
106 return rc
109def _autostamp(config: cfg.ProjectConfig, root: str, command: str, run_id: str | None,
110 phase: str, *, status: str = "running",
111 issue: int | None = None, pr: int | None = None) -> None:
112 """Record a run on keel-visual's board at ``phase`` from a deterministic core call.
114 The agent orchestrates the backbone but reliably **skips** the per-phase
115 ``keel activity`` calls, so runs (real ``keel ship`` included) went invisible.
116 Instead, the commands the backbone *always* runs do the stamping: ``keel plan``
117 (Step 0 → first phase), ``keel run-gates`` (s8 test), and ``keel merge`` (s10,
118 stamped ``merged`` — a real merge landed, distinct from a soft ``done``).
119 A run therefore shows up **and advances** — start → test → merged — independent of
120 agent discipline. Opt-in via ``--run-id``. Fail-soft: no run-id / unknown command /
121 unknown phase / write error / pre-activity core is a no-op, never an aborted command.
122 Never moves a run backward (a re-run of an earlier step won't undo later progress);
123 ``merged`` is terminal and is never overwritten by a later stamp.
124 """
125 if not run_id or not flows.is_known(command):
126 return
127 order = [p.id for p in flows.flow_for(command)]
128 if phase not in order:
129 return
130 try:
131 path = activity.record_path(root, config, run_id)
132 existing = activity.read_activity(path)
133 if existing and existing.get("status") == "merged":
134 return # merged is terminal; never overwrite a landed run
135 if (status == "running" and existing and existing.get("status") == "running"
136 and existing.get("phase") in order
137 and order.index(existing["phase"]) > order.index(phase)):
138 return # don't regress a more-advanced still-running run
139 record = activity.build_activity_record(
140 command=command, run_id=run_id, phase=phase, status=status,
141 issue=issue, pr=pr)
142 activity.write_activity(path, record)
143 except (activity.ActivityError, OSError):
144 return
147def _plan_stamp_activity(args: argparse.Namespace, config: cfg.ProjectConfig) -> None:
148 """Stamp the run from the Step 0 ``keel plan`` call (first phase). See :func:`_autostamp`."""
149 command = args.command_contract
150 if not flows.is_known(command):
151 return
152 _autostamp(config, args.root, command, getattr(args, "run_id", None),
153 flows.flow_for(command)[0].id,
154 issue=getattr(args, "issue", None), pr=getattr(args, "pull_request", None))
157def _cmd_plan(args: argparse.Namespace) -> int:
158 try:
159 config = cfg.load_config(args.path)
160 except FileNotFoundError:
161 print(f"no such config: {args.path}", file=sys.stderr)
162 return 1
163 except cfg.ConfigError as exc:
164 print(str(exc), file=sys.stderr)
165 return 1
167 try:
168 ledger.resolve_path(args.root, config)
169 checkpoint.resolve_path(args.root, config)
170 except ledger.LedgerError as exc:
171 print(f"invalid ledger path: {exc}", file=sys.stderr)
172 return 1
173 except checkpoint.CheckpointError as exc:
174 print(f"invalid checkpoint path: {exc}", file=sys.stderr)
175 return 1
177 loaded, problems = load_extensions(config, args.root, strict=False)
178 try:
179 plan = orch.build_plan(config, loaded)
180 except gates.GateError as exc:
181 print(str(exc), file=sys.stderr)
182 return 1
183 requirement = (
184 _scan_capability_requirement(args.command_contract, config)
185 if args.command_contract in {"regression", "review-all-day"}
186 else _capability_requirement(args.command_contract, config, loaded)
187 )
188 report = runtime.detect(args.root)
189 evaluation = runtime.evaluate(requirement, report)
190 transport = github_transport.resolve(report)
191 try:
192 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent(
193 args,
194 config,
195 _has_live_consent_scope(
196 args, args.command_contract, config, requirement, loaded
197 ),
198 )
199 except ValueError as exc:
200 print(str(exc), file=sys.stderr)
201 return 1
202 contract = contracts.build_command_contract(
203 command=args.command_contract,
204 profile=args.profile,
205 config=config,
206 loaded=loaded,
207 plan=plan,
208 requirement=requirement,
209 evaluation=evaluation,
210 transport=transport,
211 extension_problems=tuple(problems),
212 dry_run=not args.live,
213 approved_consent_scopes=approved_scopes,
214 consent_approval_source=approval_source,
215 consent_mode=consent_mode,
216 operator=approval_operator,
217 target=args.target,
218 reviewer_override=args.reviewers,
219 review_comments=args.review_comments,
220 jury=args.jury,
221 no_jury=args.no_jury,
222 jury_advisory=args.jury_advisory,
223 issue_title=args.issue_title,
224 issue_body=args.issue_body,
225 issue_labels=_issue_labels(args),
226 )
227 consent_ok, consent_message = consent.assert_operator_consent(contract["operator_consent"])
228 if args.json:
229 print(json.dumps({
230 "contract": contract,
231 "plan": orch.plan_as_dict(plan),
232 "capabilities": evaluation.as_dict(),
233 "github_transport": transport.as_dict(),
234 }, indent=2, sort_keys=True))
235 else:
236 print(orch.render_plan(config, plan))
237 print(evaluation.render())
238 print(f"operator consent: {contract['operator_consent']['status']}")
239 print(f" {contract['operator_consent']['consent_prompt']}")
240 for prob in problems:
241 print(f" ! extension not loaded: {prob}", file=sys.stderr)
242 if not consent_ok:
243 print(consent_message, file=sys.stderr)
244 return 1
245 _plan_stamp_activity(args, config) # surface the run on the board from Step 0
246 return 0
249def _cmd_run_gates(args: argparse.Namespace) -> int:
250 try:
251 config = cfg.load_config(args.path)
252 except FileNotFoundError:
253 print(f"no such config: {args.path}", file=sys.stderr)
254 return 1
255 except cfg.ConfigError as exc:
256 print(str(exc), file=sys.stderr)
257 return 1
259 loaded, problems = load_extensions(config, args.root, strict=False)
260 for prob in problems:
261 print(f" ! extension not loaded: {prob}", file=sys.stderr)
263 try:
264 specs = gates.plan_gates(config, loaded)
265 except gates.GateError as exc:
266 print(str(exc), file=sys.stderr)
267 return 1
269 requirement = _capability_requirement("run-gates", config, loaded)
270 report = runtime.detect(args.root)
271 evaluation = runtime.evaluate(requirement, report)
272 if not evaluation.ok:
273 print(evaluation.render(), file=sys.stderr)
274 return 1
275 if evaluation.missing_optional:
276 print(evaluation.render(), file=sys.stderr)
278 diff_text = git.diff(config.base_branch, "HEAD", cwd=args.root)
279 outcomes = gates.run_gates(specs, _gate_runner(args.root, diff_text, jury_mode="gating"))
280 _autostamp(config, args.root, args.gate_command, getattr(args, "run_id", None),
281 args.gate_phase, issue=getattr(args, "issue", None),
282 pr=getattr(args, "pull_request", None)) # the run reached the test gate (s8)
283 for o in outcomes:
284 status = "ok" if o.ok else "FAIL"
285 print(f" {status:>4} {o.gate}")
287 verdict = fnd.summarize(gates.collect_findings(outcomes))
288 for f in verdict.findings:
289 print(f" [{f.severity}] {f.source}: {f.message.splitlines()[0]}")
290 if verdict.blocked:
291 print("BLOCKED — merge is gated by the findings above")
292 return 1
293 return 0
296def _cmd_window(args: argparse.Namespace) -> int:
297 try:
298 config = cfg.load_config(args.path)
299 except FileNotFoundError:
300 print(f"no such config: {args.path}", file=sys.stderr)
301 return 1
302 except cfg.ConfigError as exc:
303 print(str(exc), file=sys.stderr)
304 return 1
306 if not config.timezone or not config.merge_window:
307 print("no merge window configured (needs timezone + merge_window)")
308 return 0
309 is_open = window.is_merge_open(config.timezone, config.merge_window)
310 state = "OPEN" if is_open else "CLOSED (night no-merge)"
311 print(f"merge window {state} [{config.timezone} {config.merge_window}]")
312 return 0
315def _cmd_claim(args: argparse.Namespace) -> int:
316 result = lock.claim_resource(_lock_root(args.root), args.resource, owner=args.owner)
317 if args.json:
318 print(json.dumps(result.as_dict(), indent=2, sort_keys=True))
319 else:
320 print(f"keel claim — {result.status} {result.resource}")
321 print(f" owner : {result.owner}")
322 print(f" path : {result.path}")
323 if result.holder:
324 print(f" holder: {result.holder}")
325 return 0 if result.granted else 1
328def _cmd_release(args: argparse.Namespace) -> int:
329 result = lock.release_resource(_lock_root(args.root), args.resource, owner=args.owner)
330 if args.json:
331 print(json.dumps(result.as_dict(), indent=2, sort_keys=True))
332 else:
333 print(f"keel release — {result.status} {result.resource}")
334 print(f" owner : {result.owner}")
335 print(f" path : {result.path}")
336 if result.holder:
337 print(f" holder: {result.holder}")
338 return 0 if result.status in {"released", "missing"} else 1
341def _cmd_worktree_remove(args: argparse.Namespace) -> int:
342 try:
343 worktree_path = _validated_worktree_path(args.root, args.worktree)
344 except ValueError as exc:
345 print(str(exc), file=sys.stderr)
346 return 1
347 result = git.worktree_remove(str(worktree_path), cwd=args.root)
348 if args.json:
349 print(json.dumps({
350 "worktree": str(worktree_path),
351 "removed": result.ok,
352 "code": result.code,
353 "output": result.output,
354 }, indent=2, sort_keys=True))
355 else:
356 print(f"keel worktree-remove — {'removed' if result.ok else 'failed'} {worktree_path}")
357 if result.output.strip():
358 print(result.output.strip())
359 return 0 if result.ok else 1
362def _parse_labels(raw: str | None) -> tuple[str, ...]:
363 """Split a comma-separated ``--issue-labels`` value into clean labels."""
364 if not raw:
365 return ()
366 return tuple(part.strip() for part in raw.split(",") if part.strip())
369def _gather_issue_facts(args: argparse.Namespace) -> tuple[str, tuple[str, ...], bool]:
370 """Resolve the issue title + labels for blocker evaluation.
372 Offline path: take ``--issue-title`` / ``--issue-labels`` verbatim. Live
373 path: when ``--issue`` is given, fetch the authoritative title/labels from
374 the host via ``gh`` (fail-soft — a failed fetch falls back to the args).
376 Returns ``(title, labels, authoritative)``. ``authoritative`` is True ONLY
377 when ``--issue N`` was given AND the live ``gh`` fetch succeeded and parsed
378 into a real dict carrying the host's title/labels. When ``--issue`` is
379 absent, or the fetch failed / fell back to the agent-supplied args, it is
380 False — the facts are agent-supplied and must not self-justify a window
381 bypass (audit GAP-11).
382 """
383 title = args.issue_title or ""
384 labels = _parse_labels(args.issue_labels)
385 authoritative = False
386 issue = getattr(args, "issue", None)
387 if issue is not None:
388 result = github.issue_facts(issue, cwd=args.root)
389 if result.ok:
390 try:
391 data = json.loads(result.output)
392 except json.JSONDecodeError:
393 data = None
394 if isinstance(data, dict):
395 authoritative = True
396 if isinstance(data.get("title"), str):
397 title = data["title"]
398 raw_labels = data.get("labels")
399 if isinstance(raw_labels, list):
400 labels = tuple(
401 str(item.get("name"))
402 for item in raw_labels
403 if isinstance(item, dict) and isinstance(item.get("name"), str)
404 )
405 return title, labels, authoritative
408def _cmd_guard(args: argparse.Namespace) -> int:
409 try:
410 config = cfg.load_config(args.path)
411 except FileNotFoundError:
412 print(f"no such config: {args.path}", file=sys.stderr)
413 return 1
414 except cfg.ConfigError as exc:
415 print(str(exc), file=sys.stderr)
416 return 1
418 try:
419 rules = guard.resolve_rules(config)
420 except guard.GuardError as exc:
421 print(f"invalid blocker rules: {exc}", file=sys.stderr)
422 return 1
424 title, labels, _authoritative = _gather_issue_facts(args)
425 result = guard.evaluate(title, labels, rules=rules)
426 if args.json:
427 print(json.dumps(result.as_dict(), indent=2, sort_keys=True))
428 else:
429 verdict = "BLOCKER" if result.is_blocker else "not a blocker"
430 print(f"keel guard — {verdict}")
431 print(f" title : {title}")
432 print(f" labels : {', '.join(labels) or '(none)'}")
433 if result.matched:
434 print(f" matched: {', '.join(result.matched)}")
435 else:
436 print(" matched: (none)")
437 return 0
440def _hotfix_justification(
441 args: argparse.Namespace, config: cfg.ProjectConfig, operator: str | None
442) -> tuple[dict[str, object] | None, str | None]:
443 """Resolve the justification required for a ``--hotfix`` window bypass.
445 Returns ``(justification, None)`` on success or ``(None, error)`` when the
446 hotfix is refused. A hotfix must carry one of two justifications, recorded
447 in the ledger:
449 * ``matched-rule`` — ``--blocker-rule <id>`` names a rule that actually fires
450 for this issue under :mod:`keel.guard`; or
451 * ``operator-override`` — an explicit ``--operator-override`` paired with a
452 named ``--operator`` (the audited human override).
454 With neither, the bypass is refused (closing audit GAP-11: an agent can no
455 longer flip ``--hotfix`` on the flag alone).
457 The ``matched-rule`` path requires **host-authoritative** issue facts: it
458 refuses unless ``--issue N`` was given and the live ``gh`` fetch succeeded,
459 so an agent cannot self-justify a window bypass with a fabricated
460 ``--issue-title``. The ``operator-override`` path (the audited human escape)
461 is unaffected.
462 """
463 if args.blocker_rule:
464 try:
465 rules = guard.resolve_rules(config)
466 except guard.GuardError as exc:
467 return None, f"invalid blocker rules: {exc}"
468 title, labels, authoritative = _gather_issue_facts(args)
469 if not authoritative:
470 return None, (
471 "hotfix matched-rule justification requires --issue <N> "
472 "(host-authoritative title/labels); agent-supplied --issue-title "
473 "is not accepted for a window bypass — use --operator-override instead"
474 )
475 result = guard.evaluate(title, labels, rules=rules)
476 if args.blocker_rule not in result.rule_ids:
477 return None, f"unknown blocker rule {args.blocker_rule!r}"
478 if args.blocker_rule not in result.matched:
479 return None, (
480 f"blocker rule {args.blocker_rule!r} did not match the issue "
481 "(title/labels do not satisfy the rule)"
482 )
483 return {
484 "kind": "matched-rule",
485 "rule_id": args.blocker_rule,
486 "matched": list(result.matched),
487 }, None
488 if args.operator_override:
489 if not operator:
490 return None, "--operator-override requires a named --operator for the audit trail"
491 return {"kind": "operator-override", "operator": operator}, None
492 return None, (
493 "hotfix requires a justification: pass --blocker-rule <id> matching a "
494 "keel guard rule for the issue, or --operator-override with a named --operator"
495 )
498CHECKPOINT_MERGE_STEP = "s10"
501def _checkpoint_gate(
502 args: argparse.Namespace,
503 config: cfg.ProjectConfig,
504 *,
505 run_id: str | None,
506 operator: str | None,
507) -> tuple[dict[str, object], str | None]:
508 """Gate the merge on a covering checkpoint for the run at step s10.
510 Returns ``(payload, None)`` when the merge may proceed or ``(payload, error)``
511 when it must be refused. The gate is enforced only when checkpointing is
512 *configured* for the project (``policy_pack.reports.checkpoint``); when it is
513 absent the gate degrades to advisory (back-compat — flows that never wrote a
514 checkpoint still merge). The ``--no-checkpoint-gate`` escape requires a named
515 ``--operator`` (mirroring ``--operator-override``) and records the bypass in
516 the merge payload audit trail.
518 This is the thin I/O layer: it reads the checkpoint file and delegates the
519 decision to :func:`keel.checkpoint.covering_checkpoint` (pure).
520 """
521 _, path_source = checkpoint.configured_checkpoint_path(config)
522 configured = path_source == "policy_pack.reports.checkpoint"
524 if args.no_checkpoint_gate:
525 if not operator:
526 return (
527 {
528 "enforced": configured,
529 "status": "bypass-refused",
530 "bypassed": False,
531 "reason": "--no-checkpoint-gate requires a named --operator",
532 },
533 "--no-checkpoint-gate requires a named --operator for the audit trail",
534 )
535 return (
536 {
537 "enforced": configured,
538 "status": "bypassed",
539 "bypassed": True,
540 "operator": operator,
541 "expected_step": CHECKPOINT_MERGE_STEP,
542 "reason": "checkpoint gate bypassed by operator",
543 },
544 None,
545 )
547 if not configured:
548 return (
549 {
550 "enforced": False,
551 "status": "advisory-skip",
552 "bypassed": False,
553 "reason": "no checkpoint configured (policy_pack.reports.checkpoint); advisory",
554 },
555 None,
556 )
558 if not run_id:
559 return (
560 {
561 "enforced": True,
562 "status": "missing",
563 "bypassed": False,
564 "expected_step": CHECKPOINT_MERGE_STEP,
565 "reason": (
566 "checkpointing is configured but no run-id is available "
567 "(pass --run-id or record a gates-pass for this head)"
568 ),
569 },
570 (
571 "checkpointing is configured but no run-id is available for the "
572 "checkpoint gate; pass --run-id or use --no-checkpoint-gate"
573 ),
574 )
576 try:
577 path = checkpoint.resolve_path(args.root, config)
578 record = checkpoint.read_checkpoint(path)
579 except checkpoint.CheckpointError as exc:
580 return (
581 {"enforced": True, "status": "invalid", "bypassed": False, "reason": str(exc)},
582 f"invalid checkpoint: {exc}",
583 )
584 coverage = checkpoint.covering_checkpoint(record, run_id, CHECKPOINT_MERGE_STEP)
585 gate_payload = {
586 "enforced": True,
587 "status": coverage["status"],
588 "bypassed": False,
589 "expected_step": CHECKPOINT_MERGE_STEP,
590 "run_id": run_id,
591 "checkpoint_step": coverage["checkpoint_step"],
592 "reason": coverage["reason"],
593 }
594 if coverage["covered"]:
595 return gate_payload, None
596 return gate_payload, coverage["reason"]
599def _cmd_merge(args: argparse.Namespace) -> int:
600 args.live = True
601 try:
602 config = cfg.load_config(args.path)
603 except FileNotFoundError:
604 print(f"no such config: {args.path}", file=sys.stderr)
605 return 1
606 except cfg.ConfigError as exc:
607 print(str(exc), file=sys.stderr)
608 return 1
610 loaded, problems = load_extensions(config, args.root, strict=False)
611 for prob in problems:
612 print(f" ! extension not loaded: {prob}", file=sys.stderr)
613 requirement = runtime.CapabilityRequirement(required=("git",), optional=("gh", "gh-auth"))
614 report = runtime.detect(args.root)
615 evaluation = runtime.evaluate(requirement, report)
616 transport = github_transport.resolve(report)
617 if not evaluation.ok or not transport.supports("pr_merge"):
618 print(evaluation.render(), file=sys.stderr)
619 print(transport.render(), file=sys.stderr)
620 return 1
621 try:
622 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent(
623 args, config, True
624 )
625 except ValueError as exc:
626 print(str(exc), file=sys.stderr)
627 return 1
628 operator_consent = consent.build_consent_contract(
629 command="merge",
630 side_effects=("git_worktree", "merge"),
631 dry_run=False,
632 approved_scopes=approved_scopes,
633 approval_source=approval_source,
634 mode=consent_mode,
635 operator=approval_operator,
636 target=f"PR #{args.pr}",
637 )
638 consent_ok, consent_message = consent.assert_operator_consent(operator_consent)
639 if not consent_ok:
640 print(consent_message, file=sys.stderr)
641 return 1
642 escalation = consent.evaluate_escalation(
643 risk_tier=args.risk_tier,
644 trust_signal=args.trust_signal,
645 side_effects=("git_worktree", "merge", *args.escalation_side_effect),
646 retry_count=args.retry_count,
647 conflicting_sources=args.conflicting_sources,
648 changed_lines=args.changed_lines,
649 )
650 missing_escalation_scope = [
651 scope for scope in escalation["consent_scope"] if scope not in approved_scopes
652 ]
653 if escalation["operator_required"] and missing_escalation_scope:
654 payload = {
655 "schema_version": "keel.merge.v1",
656 "pull_request": args.pr,
657 "status": "fail",
658 "reason": "operator escalation required",
659 "escalation": escalation,
660 "missing_scope": missing_escalation_scope,
661 }
662 if args.json:
663 print(json.dumps(payload, indent=2, sort_keys=True))
664 else:
665 print(
666 "operator escalation required: missing approved scope "
667 f"{', '.join(missing_escalation_scope)}",
668 file=sys.stderr,
669 )
670 return 1
672 hotfix_justification: dict[str, object] | None = None
673 if args.hotfix:
674 hotfix_justification, hotfix_error = _hotfix_justification(
675 args, config, approval_operator
676 )
677 if hotfix_justification is None:
678 payload = {
679 "schema_version": "keel.merge.v1",
680 "pull_request": args.pr,
681 "status": "fail",
682 "reason": "hotfix justification required",
683 "hotfix_justification": None,
684 }
685 if args.json:
686 print(json.dumps(payload, indent=2, sort_keys=True))
687 else:
688 print(hotfix_error, file=sys.stderr)
689 return 1
691 owner = args.owner or f"keel-merge-pr-{args.pr}"
692 claim = lock.claim_resource(_lock_root(args.root), "merge", owner=owner)
693 payload: dict[str, object] = {
694 "schema_version": "keel.merge.v1",
695 "pull_request": args.pr,
696 "lock": claim.as_dict(),
697 "window": None,
698 "ci": None,
699 "evidence": None,
700 "gates_sha": None,
701 "escalation": escalation,
702 "hotfix_justification": hotfix_justification,
703 "checkpoint_gate": None,
704 "merged": False,
705 }
706 if not claim.granted:
707 return _finish_merge(args, payload, "resource lock is already held", code=1)
708 try:
709 if not args.hotfix and config.timezone and config.merge_window:
710 open_now = window.is_merge_open(config.timezone, config.merge_window)
711 payload["window"] = {
712 "open": open_now,
713 "timezone": config.timezone,
714 "merge_window": config.merge_window,
715 }
716 if not open_now:
717 return _finish_merge(args, payload, "merge window is closed", code=1)
718 elif args.hotfix:
719 payload["window"] = {"bypassed": True, "reason": "hotfix"}
721 try:
722 snapshot = _merge_snapshot(args.pr, cwd=args.root)
723 except ValueError as exc:
724 return _finish_merge(args, payload, str(exc), code=1)
725 payload["ci"] = snapshot["ci"]
726 if snapshot["merge_state"] not in {"CLEAN", "HAS_HOOKS", "UNKNOWN"}:
727 return _finish_merge(
728 args, payload, f"PR merge state is {snapshot['merge_state']}", code=1
729 )
730 if snapshot["ci"]["state"] != "pass":
731 return _finish_merge(args, payload, f"CI is {snapshot['ci']['state']}", code=1)
733 evidence_payload = _verify_merge_evidence(args, config)
734 payload["evidence"] = evidence_payload
735 if not evidence_payload["enforced"]:
736 return _finish_merge(args, payload, "evidence gate is not enforced", code=1)
737 if evidence_payload["verification"]["status"] != "pass":
738 missing = ", ".join(evidence_payload["verification"]["missing"])
739 return _finish_merge(args, payload, f"missing evidence: {missing}", code=1)
741 head_sha = snapshot["head_sha"]
742 gates_run_id: str | None = None
743 if args.hotfix:
744 payload["gates_sha"] = {"bypassed": True, "reason": "hotfix", "head_sha": head_sha}
745 else:
746 try:
747 gates_records = ledger.read_records(ledger.resolve_path(args.root, config))
748 except ledger.LedgerError as exc:
749 return _finish_merge(args, payload, f"invalid run ledger: {exc}", code=1)
750 matched, record = ledger.gates_pass_for_head(
751 gates_records, args.pr, head_sha if isinstance(head_sha, str) else ""
752 )
753 gates_run_id = record.get("run_id") if record else None
754 payload["gates_sha"] = {
755 "bypassed": False,
756 "head_sha": head_sha,
757 "matched": matched,
758 "run_id": gates_run_id,
759 }
760 if not matched:
761 return _finish_merge(
762 args,
763 payload,
764 f"no gates-pass recorded for the current head {head_sha}",
765 code=1,
766 )
768 gate_payload, gate_error = _checkpoint_gate(
769 args, config, run_id=args.run_id or gates_run_id, operator=approval_operator
770 )
771 payload["checkpoint_gate"] = gate_payload
772 if gate_error is not None:
773 return _finish_merge(args, payload, gate_error, code=1)
775 if args.dry_run:
776 return _finish_merge(args, payload, "dry-run: merge not performed", code=0)
777 merged = github.merge_pr(args.pr, method=args.method, cwd=args.root)
778 payload["merged"] = merged.ok
779 payload["merge_output"] = merged.output
780 if not merged.ok:
781 return _finish_merge(args, payload, "gh merge failed", code=1)
782 _autostamp(config, args.root, "ship", getattr(args, "run_id", None), "s10",
783 status="merged", # real merge landed → green "merged", not soft "done"
784 issue=getattr(args, "issue", None), pr=args.pr)
785 return _finish_merge(args, payload, "merged", code=0)
786 finally:
787 lock.release_resource(_lock_root(args.root), "merge", owner=owner, best_effort=True)
790def _cmd_ship(args: argparse.Namespace) -> int:
791 if args.dry_run and args.live:
792 print("--dry-run and --live cannot be used together", file=sys.stderr)
793 return 1
794 command = getattr(args, "ship_command", "ship")
795 profile = "compound" if args.compound or args.profile == "compound" else "standard"
797 try:
798 config = cfg.load_config(args.path)
799 except FileNotFoundError:
800 print(f"no such config: {args.path}", file=sys.stderr)
801 return 1
802 except cfg.ConfigError as exc:
803 print(str(exc), file=sys.stderr)
804 return 1
806 loaded, problems = load_extensions(config, args.root, strict=False)
807 for prob in problems:
808 print(f" ! extension not loaded: {prob}", file=sys.stderr)
810 requirement = _capability_requirement(command, config, loaded, pr=args.pr)
811 report = runtime.detect(args.root)
812 evaluation = runtime.evaluate(requirement, report)
813 if not evaluation.ok:
814 print(evaluation.render(), file=sys.stderr)
815 return 1
816 transport = github_transport.resolve(report)
817 if args.pr is not None and not transport.supports("check_runs"):
818 print(transport.render(), file=sys.stderr)
819 print("missing required GitHub transport capability: check_runs", file=sys.stderr)
820 return 1
821 try:
822 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent(
823 args, config, _has_live_consent_scope(args, command, config, requirement, loaded)
824 )
825 except ValueError as exc:
826 print(str(exc), file=sys.stderr)
827 return 1
828 try:
829 plan = orch.build_plan(config, loaded)
830 except gates.GateError as exc:
831 print(str(exc), file=sys.stderr)
832 return 1
833 contract = contracts.build_command_contract(
834 command=command,
835 profile=profile,
836 config=config,
837 loaded=loaded,
838 plan=plan,
839 requirement=requirement,
840 evaluation=evaluation,
841 transport=transport,
842 extension_problems=tuple(problems),
843 dry_run=not args.live,
844 approved_consent_scopes=approved_scopes,
845 consent_approval_source=approval_source,
846 consent_mode=consent_mode,
847 operator=approval_operator,
848 target=args.target or (f"PR #{args.pr}" if args.pr is not None else None),
849 reviewer_override=args.reviewers,
850 review_comments=args.review_comments,
851 jury=args.jury,
852 no_jury=args.no_jury,
853 jury_advisory=args.jury_advisory,
854 issue_title=args.issue_title,
855 issue_body=args.issue_body,
856 issue_labels=_issue_labels(args),
857 )
858 consent_ok, consent_message = consent.assert_operator_consent(contract["operator_consent"])
859 if not consent_ok:
860 if args.json:
861 print(json.dumps({"contract": contract}, indent=2, sort_keys=True))
862 else:
863 print(consent_message, file=sys.stderr)
864 return 1
865 intake_record = contract["issue_intake"]
866 if args.live and _issue_context_provided(args) and intake_record:
867 if not intake_record["can_mutate_code"]:
868 if args.json:
869 print(json.dumps({"contract": contract}, indent=2, sort_keys=True))
870 else:
871 print(f"issue intake: {intake_record['status']} — {intake_record['reason']}",
872 file=sys.stderr)
873 for question in intake_record["questions"]:
874 print(f" question: {question}", file=sys.stderr)
875 return 1
876 if args.live and args.append_ledger and args.capture_status is None:
877 message = "--capture-status is required when --live --append-ledger is used"
878 if args.json:
879 print(json.dumps({"contract": contract, "error": message}, indent=2,
880 sort_keys=True))
881 else:
882 print(message, file=sys.stderr)
883 return 1
884 run_context_warnings = _run_context_warnings(args)
885 if args.live and args.append_ledger and args.strict_run_context and run_context_warnings:
886 message = "; ".join(run_context_warnings)
887 if args.json:
888 print(json.dumps({"contract": contract, "error": message}, indent=2,
889 sort_keys=True))
890 else:
891 print(message, file=sys.stderr)
892 return 1
894 changed = git.changed_files(config.base_branch, "HEAD", cwd=args.root)
895 tier = classify.tier_for_files(
896 changed,
897 tier3_globs=config.knobs.tier3_globs,
898 docs_globs=config.knobs.docs_gate_paths,
899 )
900 review_contract = ship.resolve_review_contract(
901 tier=tier,
902 reviewer_override=args.reviewers,
903 review_comments=args.review_comments,
904 gates=config.gates,
905 policy_pack=config.policy_pack,
906 jury=args.jury,
907 no_jury=args.no_jury,
908 jury_advisory=args.jury_advisory,
909 )
910 # unreachable: orch.build_plan() above already calls plan_gates and surfaces GateError.
911 try:
912 specs = gates.plan_gates(config, loaded)
913 except gates.GateError as exc: # pragma: no cover - defensive duplicate of the build_plan guard
914 print(str(exc), file=sys.stderr)
915 return 1
916 diff_text = git.diff(config.base_branch, "HEAD", cwd=args.root)
917 outcomes = gates.run_gates(
918 specs,
919 _gate_runner(args.root, diff_text, jury_mode=review_contract["jury"]["mode"]),
920 )
921 verdict = fnd.summarize(gates.collect_findings(outcomes))
922 ci_conclusion = (
923 github.ci_conclusion(args.pr, cwd=args.root)
924 if args.pr and transport.name == "gh"
925 else None
926 )
928 a = ship.assess(
929 changed_files=changed,
930 gate_verdict=verdict,
931 tier3_globs=config.knobs.tier3_globs,
932 docs_globs=config.knobs.docs_gate_paths,
933 timezone=config.timezone,
934 merge_window=config.merge_window,
935 merge_window_mode=config.merge_window_mode,
936 ci_conclusion=ci_conclusion,
937 is_blocker=args.hotfix,
938 reviewer_override=args.reviewers,
939 review_comments=args.review_comments,
940 gates=config.gates,
941 policy_pack=config.policy_pack,
942 jury=args.jury,
943 no_jury=args.no_jury,
944 jury_advisory=args.jury_advisory,
945 )
946 contract["review_merge_contract"] = a.review_contract
947 ledger_path = ledger.resolve_path(args.root, config)
948 try:
949 existing_ledger_records = ledger.read_records(ledger_path)
950 except ledger.LedgerError as exc:
951 print(f"invalid ledger {ledger_path}: {exc}", file=sys.stderr)
952 return 1
953 try:
954 run_control_events = (
955 _read_json_list(args.run_events_file, missing_ok=True)
956 if args.run_events_file else []
957 )
958 except ValueError as exc:
959 print(str(exc), file=sys.stderr)
960 return 1
961 run_control_report = runcontrols.evaluate_run_controls(
962 run_control_events,
963 max_work_units=args.max_rounds or runcontrols.DEFAULT_RUN_BUDGET,
964 )
965 ledger_record = ledger.build_ship_run_record(
966 command=command,
967 base_branch=config.base_branch,
968 changed_files=changed,
969 declared_files=args.declared_file,
970 outcomes=outcomes,
971 verdict=verdict,
972 assessment=a,
973 issue_intake=contract.get("issue_intake"),
974 target=args.target or (f"PR #{args.pr}" if args.pr is not None else None),
975 run_id=args.run_id,
976 issue_number=args.issue,
977 pr_number=args.ledger_pr or args.pr,
978 branch=args.branch,
979 head_sha=args.head_sha,
980 capture_status=args.capture_status,
981 capture_reason=args.capture_reason,
982 capture_artifact=args.capture_artifact,
983 issue_title=args.issue_title,
984 issue_labels=_issue_labels(args),
985 existing_records=existing_ledger_records,
986 config=config,
987 implementer=args.implementer,
988 reviewer_agents=args.reviewer_agent,
989 tester=args.tester,
990 host_agent=args.host_agent,
991 transport=args.transport or transport.name,
992 profile=profile,
993 jury_mode=a.review_contract["jury"]["mode"],
994 consent_status=contract["operator_consent"]["status"],
995 consent_scopes=contract["operator_consent"]["effective_approved_scope"],
996 run_controls=run_control_report,
997 )
998 try:
999 ledger_record = ledger.sanitize_record(ledger_record, config)
1000 except ledger.redaction.RedactionError as exc:
1001 message = f"capture redaction policy invalid; skipping ledger append: {exc}"
1002 if args.append_ledger and args.live:
1003 if args.json:
1004 print(json.dumps({"contract": contract, "error": message}, indent=2,
1005 sort_keys=True))
1006 else:
1007 print(message, file=sys.stderr)
1008 return 1
1009 fallback = ledger.redaction.sanitize(
1010 ledger_record,
1011 ledger.redaction.policy_from_config(config, strict=False),
1012 )
1013 ledger_record = dict(fallback.value)
1014 ledger_record["redaction"] = {
1015 "schema_version": ledger.redaction.REDACTION_SCHEMA_VERSION,
1016 "status": "partial",
1017 "reason": "invalid-policy",
1018 "rules": fallback.audit["rules"],
1019 "redaction_count": fallback.audit["redaction_count"],
1020 }
1021 ledger_result = {
1022 "contract": contract["run_ledger"],
1023 "path": str(ledger_path),
1024 "appended": False,
1025 "record": ledger_record,
1026 "warnings": run_context_warnings,
1027 }
1028 if args.append_ledger and args.live:
1029 ledger.append_record(ledger_path, ledger_record)
1030 ledger_result["appended"] = True
1032 if args.json:
1033 print(json.dumps({
1034 "contract": contract,
1035 "result": contracts.ship_result_as_dict(
1036 changed_files=changed,
1037 outcomes=outcomes,
1038 verdict=verdict,
1039 assessment=a,
1040 issue_intake=contract.get("issue_intake"),
1041 run_ledger=ledger_result,
1042 ),
1043 }, indent=2, sort_keys=True))
1044 if run_control_report["hard_halt"]:
1045 return 1
1046 return 0 if a.merge.action != "block" else 1
1048 name = config.repo or config.extends
1049 print(f"keel {command} — {name} (base {config.base_branch})")
1050 print(f" changed files : {len(changed)}")
1051 print(f" profile : {contract['workflow_profile']['profile']}")
1052 print(f" risk tier : TIER-{a.tier} → {a.reviewers} reviewer(s)")
1053 jury_state = a.review_contract["jury"]["mode"]
1054 print(f" review posts : {a.review_contract['posting']['mode']}")
1055 print(f" jury : {jury_state} ({a.review_contract['jury']['reason']})")
1056 window = "OPEN" if a.window_open else f"CLOSED ({config.merge_window_mode}, night no-merge)"
1057 print(f" merge window : {window}")
1058 ci_str = "unknown" if a.ci_ok is None else ("passing" if a.ci_ok else "FAILING")
1059 print(f" ci : {ci_str}")
1060 print(f" github : {transport.name}")
1061 print(f" consent : {contract['operator_consent']['status']}")
1062 print(f" intake : {intake_record['status']}")
1063 print(f" run ledger : {ledger_result['path']}")
1064 print(f" run controls : {run_control_report['status']}")
1065 if args.append_ledger:
1066 print(f" ledger append : {'yes' if ledger_result['appended'] else 'dry-run/no-live'}")
1067 for warning in run_context_warnings:
1068 print(f" run context : warning: {warning}")
1069 if intake_record["questions"]:
1070 print(f" questions : {len(intake_record['questions'])}")
1071 if transport.degraded:
1072 print(f" github degraded: {', '.join(transport.degraded)}")
1073 for o in outcomes:
1074 print(f" gate {o.gate:<14} {'ok' if o.ok else 'FAIL'}")
1075 if evaluation.missing_optional:
1076 print(f" degraded opt. : {', '.join(evaluation.missing_optional)}")
1077 if a.halted: # pragma: no cover - display only; logic covered in ship.assess tests
1078 print(" pipeline : HALTED (merge window paused)")
1079 if a.bypassed_window: # pragma: no cover - display only; logic covered in ship.assess
1080 print(" audit : hotfix bypassed the merge window")
1081 print(f" decision : {a.merge.action.upper()} — {a.merge.reason}")
1082 print(" note: dry assessment; live merge (s10) needs a configured runner (git + gh auth).")
1083 if run_control_report["hard_halt"]:
1084 return 1
1085 return 0 if a.merge.action != "block" else 1
1088def _cmd_ledger(args: argparse.Namespace) -> int:
1089 try:
1090 config = cfg.load_config(args.path)
1091 except FileNotFoundError:
1092 print(f"no such config: {args.path}", file=sys.stderr)
1093 return 1
1094 except cfg.ConfigError as exc:
1095 print(str(exc), file=sys.stderr)
1096 return 1
1098 contract = ledger.ledger_contract_as_dict(config)
1099 path = ledger.resolve_path(args.root, config)
1100 try:
1101 records = ledger.read_records(path)
1102 except ledger.LedgerError as exc:
1103 print(f"invalid ledger {path}: {exc}", file=sys.stderr)
1104 return 1
1105 if args.limit is not None:
1106 records = records[-args.limit:]
1107 payload = {
1108 "contract": contract,
1109 "path": str(path),
1110 "status": "present" if path.exists() else "missing",
1111 "records": records,
1112 "record_count": len(records),
1113 "capture_health": ledger.capture_health_summary(records),
1114 }
1115 if args.json:
1116 print(json.dumps(payload, indent=2, sort_keys=True))
1117 else:
1118 print(f"keel ledger — {payload['status']} {path}")
1119 print(f" schema : {contract['schema_version']}")
1120 print(f" records : {payload['record_count']}")
1121 print(f" missing : {contract['missing_handling']}")
1122 print(f" capture : {payload['capture_health']['status']}")
1123 print(f" capture gaps : {payload['capture_health']['counts']['needs_reconcile']}")
1124 return 0
1127def _cmd_capture_verify(args: argparse.Namespace) -> int:
1128 try:
1129 config = cfg.load_config(args.path)
1130 except FileNotFoundError:
1131 print(f"no such config: {args.path}", file=sys.stderr)
1132 return 1
1133 except cfg.ConfigError as exc:
1134 print(str(exc), file=sys.stderr)
1135 return 1
1137 ledger_path = ledger.resolve_path(args.root, config)
1138 try:
1139 records = ledger.read_records(ledger_path)
1140 except ledger.LedgerError as exc:
1141 print(f"invalid ledger {ledger_path}: {exc}", file=sys.stderr)
1142 return 1
1143 try:
1144 derived_prs, derivation = _capture_verify_merged_prs(args, config)
1145 except ValueError as exc:
1146 print(str(exc), file=sys.stderr)
1147 return 1
1148 report = capture.verify_session(records, derived_prs)
1150 reconcile_active = derivation["source"] != "args" or _reconcile_inputs_active(args)
1151 reconcile_report = None
1152 if reconcile_active:
1153 verdict_counts = _capture_verify_verdict_counts(args, config, derived_prs)
1154 reconcile_report = captureverify.reconcile(
1155 records, derived_prs, verdict_counts=verdict_counts
1156 )
1158 payload = {
1159 "contract": capture.contract_as_dict(config),
1160 "ledger_path": str(ledger_path),
1161 "merged_pr_source": derivation,
1162 "verification": report,
1163 }
1164 if reconcile_report is not None:
1165 payload["reconcile"] = reconcile_report
1167 base_ok = report["status"] == "complete"
1168 reconcile_ok = reconcile_report is None or reconcile_report["ok"]
1170 if args.json:
1171 print(json.dumps(payload, indent=2, sort_keys=True))
1172 else:
1173 print(f"keel capture-verify — {report['status']} {ledger_path}")
1174 print(f" merged-PR source: {derivation['source']} ({len(derived_prs)} PR(s))")
1175 for result in report["results"]:
1176 state = "ok" if result["ok"] else "FAIL"
1177 print(
1178 f" {state:>4} PR #{result['pr']} "
1179 f"{result['status']} {result['reason'] or '-'}"
1180 )
1181 if reconcile_report is not None:
1182 for finding in reconcile_report["findings"]:
1183 print(f" FAIL reconcile PR #{finding['pr']} "
1184 f"{finding['type']} {finding['reason']}")
1185 if reconcile_report["ok"]:
1186 print(" reconcile: ok")
1187 return 0 if base_ok and reconcile_ok else 1
1190def _cmd_consent_verify(args: argparse.Namespace) -> int:
1191 try:
1192 config = cfg.load_config(args.path)
1193 except FileNotFoundError:
1194 print(f"no such config: {args.path}", file=sys.stderr)
1195 return 1
1196 except cfg.ConfigError as exc:
1197 print(str(exc), file=sys.stderr)
1198 return 1
1200 try:
1201 record = _consent_ledger_record(args, config)
1202 except ledger.LedgerError as exc:
1203 print(f"invalid run ledger: {exc}", file=sys.stderr)
1204 return 1
1205 try:
1206 observed = _consent_observed_effects(args, config)
1207 except ValueError as exc:
1208 print(str(exc), file=sys.stderr)
1209 return 1
1211 has_record, approved_scopes = consentverify.consent_record_from_ledger(record)
1212 report = consentverify.reconcile(
1213 observed, approved_scopes, has_consent_record=has_record
1214 )
1215 payload = {
1216 "schema_version": consentverify.SCHEMA_VERSION,
1217 "pull_request": args.pr,
1218 "scope_effect_table": consentverify.scope_effect_table(),
1219 "reconcile": report,
1220 }
1221 if args.json:
1222 print(json.dumps(payload, indent=2, sort_keys=True))
1223 else:
1224 print(f"keel consent-verify — {report['verdict']} PR #{args.pr}")
1225 print(f" consent record : {'present' if has_record else 'absent (advisory)'}")
1226 print(f" approved scopes: {', '.join(report['approved_scopes']) or 'none'}")
1227 print(f" observed : {', '.join(report['observed_effects']) or 'none'}")
1228 for finding in report["uncovered"]:
1229 print(f" FAIL {finding['message']}")
1230 if report["verdict"] == consentverify.VERDICT_PASS:
1231 print(" all observed mutations covered by approved scopes")
1232 return 0 if report["ok"] else 1
1235def _consent_ledger_record(
1236 args: argparse.Namespace,
1237 config: cfg.ProjectConfig,
1238) -> dict[str, object] | None:
1239 """Load the latest ship_run ledger record for the PR under consent-verify.
1241 Reads the run ledger (offline fixture via ``--ledger-jsonl`` or the configured
1242 path under ``--root``) and returns the most recent matching ship_run record,
1243 or ``None`` when no record matches — the advisory back-compat path.
1244 """
1245 fixture = getattr(args, "ledger_jsonl", None)
1246 if fixture is not None:
1247 records = ledger.parse_records(Path(fixture).read_text(encoding="utf-8"))
1248 else:
1249 records = ledger.read_records(ledger.resolve_path(args.root, config))
1250 return ledger.latest_ship_run_for_pr(records, args.pr)
1253def _consent_observed_effects(
1254 args: argparse.Namespace,
1255 config: cfg.ProjectConfig,
1256) -> consentverify.ObservedEffects:
1257 """Observe the mutating side effects on the PR for consent reconciliation.
1259 Offline (``--offline``): the effect flags are taken straight from the
1260 ``--pr-exists``/``--commented``/``--merged``/``--labeled`` switches, so tests
1261 are deterministic with no transport. Live: the PR state is read via the thin
1262 ``gh`` wrappers, fail-soft — a transport failure raises so the operator sees
1263 the fetch error rather than a silently-empty observation.
1264 """
1265 if args.offline:
1266 return consentverify.ObservedEffects(
1267 pr_exists=args.pr_exists,
1268 commented=args.commented,
1269 merged=args.merged,
1270 labeled=args.labeled,
1271 )
1272 owner_repo = _owner_repo(config)
1273 pr = _gh_json(["repos", owner_repo, "pulls", str(args.pr)], cwd=args.root)
1274 comments = _gh_json_list(
1275 ["repos", owner_repo, "issues", str(args.pr), "comments"], cwd=args.root
1276 )
1277 return consentverify.ObservedEffects(
1278 pr_exists=True,
1279 commented=bool(comments),
1280 merged=pr.get("merged") is True,
1281 labeled=bool(_label_names(pr.get("labels"))),
1282 )
1285def _cmd_close_reconcile(args: argparse.Namespace) -> int:
1286 try:
1287 config = cfg.load_config(args.path)
1288 except FileNotFoundError:
1289 print(f"no such config: {args.path}", file=sys.stderr)
1290 return 1
1291 except cfg.ConfigError as exc:
1292 print(str(exc), file=sys.stderr)
1293 return 1
1295 done_label = _done_label(config)
1296 try:
1297 records = _close_ledger_records(args, config)
1298 except ledger.LedgerError as exc:
1299 print(f"invalid run ledger: {exc}", file=sys.stderr)
1300 return 1
1301 try:
1302 observed = _close_observed_issues(args, config, records, done_label)
1303 except ValueError as exc:
1304 print(str(exc), file=sys.stderr)
1305 return 1
1307 report = closeorder.reconcile(observed, done_label=done_label)
1308 payload = {
1309 "schema_version": closeorder.SCHEMA_VERSION,
1310 "done_label": done_label,
1311 "reconcile": report,
1312 }
1313 if args.json:
1314 print(json.dumps(payload, indent=2, sort_keys=True))
1315 else:
1316 observed_count = report["summary"]["observed"]
1317 print(f"keel close-reconcile — {report['verdict']} ({observed_count} issue(s))")
1318 for finding in report["findings"]:
1319 print(f" FLAG {finding['message']}")
1320 if report["ok"]:
1321 print(" all observed issues consistent with the ledger")
1322 return 0 if report["ok"] else 1
1325def _done_label(config: cfg.ProjectConfig) -> str:
1326 """Resolve the done-marking label from ``policy_pack.status_transitions.done``.
1328 Falls back to :data:`keel.closeorder.DEFAULT_DONE_LABEL` when a project does
1329 not configure a done transition, so the reconcile still has a label to check.
1330 """
1331 policy = config.policy_pack if isinstance(config.policy_pack, dict) else {}
1332 transitions = policy.get("status_transitions")
1333 done = transitions.get("done") if isinstance(transitions, dict) else None
1334 return done if isinstance(done, str) and done.strip() else closeorder.DEFAULT_DONE_LABEL
1337def _close_ledger_records(
1338 args: argparse.Namespace,
1339 config: cfg.ProjectConfig,
1340) -> list[dict[str, object]]:
1341 """Load the ship_run ledger records for close-ordering reconciliation.
1343 Reads the run ledger (offline fixture via ``--ledger-jsonl`` or the configured
1344 path under ``--root``) and returns every record; the per-issue lookup is done
1345 by :func:`keel.closeorder.latest_record_for_issue`.
1346 """
1347 fixture = getattr(args, "ledger_jsonl", None)
1348 if fixture is not None:
1349 return ledger.parse_records(Path(fixture).read_text(encoding="utf-8"))
1350 return ledger.read_records(ledger.resolve_path(args.root, config))
1353def _close_observed_issues(
1354 args: argparse.Namespace,
1355 config: cfg.ProjectConfig,
1356 records: list[dict[str, object]],
1357 done_label: str,
1358) -> list[closeorder.ObservedIssue]:
1359 """Observe each issue's lifecycle state for close-ordering reconciliation.
1361 Offline (``--offline``): the ``--closed``/``--status-done`` switches apply to
1362 every ``--issue`` so tests are deterministic with no transport. Live: each
1363 issue's state and labels are read via ``gh``; a transport failure raises so
1364 the operator sees the fetch error rather than a silently-empty observation.
1365 """
1366 observed: list[closeorder.ObservedIssue] = []
1367 for number in args.issue:
1368 record = closeorder.latest_record_for_issue(records, number)
1369 if args.offline:
1370 labels = (done_label,) if args.status_done else ()
1371 observed.append(closeorder.ObservedIssue(
1372 number=number, closed=args.closed, labels=labels, record=record,
1373 ))
1374 continue
1375 owner_repo = _owner_repo(config)
1376 issue = _gh_json(["repos", owner_repo, "issues", str(number)], cwd=args.root)
1377 observed.append(closeorder.ObservedIssue(
1378 number=number,
1379 closed=issue.get("state") == "closed",
1380 labels=tuple(_label_names(issue.get("labels"))),
1381 record=record,
1382 ))
1383 return observed
1386def _cmd_dryrun_verify(args: argparse.Namespace) -> int:
1387 try:
1388 config = cfg.load_config(args.path)
1389 except FileNotFoundError:
1390 print(f"no such config: {args.path}", file=sys.stderr)
1391 return 1
1392 except cfg.ConfigError as exc:
1393 print(str(exc), file=sys.stderr)
1394 return 1
1396 try:
1397 before = _dryrun_snapshot_from_json(args.before_json)
1398 except (OSError, ValueError) as exc:
1399 print(f"invalid before snapshot: {exc}", file=sys.stderr)
1400 return 1
1401 try:
1402 after = _dryrun_after_snapshot(args, config)
1403 except (OSError, ValueError, ledger.LedgerError) as exc:
1404 print(f"invalid after snapshot: {exc}", file=sys.stderr)
1405 return 1
1407 report = dryrunverify.reconcile(before, after, run_id=args.run_id, issue=args.issue)
1408 payload = {"schema_version": dryrunverify.SCHEMA_VERSION, "reconcile": report}
1409 if args.json:
1410 print(json.dumps(payload, indent=2, sort_keys=True))
1411 else:
1412 print(f"keel dryrun-verify — {report['verdict']} run {args.run_id!r} issue #{args.issue}")
1413 for finding in report["findings"]:
1414 print(f" LEAK {finding['message']}")
1415 if report["ok"]:
1416 print(" dry run left no new ledger record, branch, or PR")
1417 return 0 if report["ok"] else 1
1420def _dryrun_snapshot_from_json(path: str) -> dryrunverify.ArtifactSnapshot:
1421 """Parse an artifact snapshot from a JSON file ``{ledger_run_ids, branches, pr_numbers}``."""
1422 data = json.loads(Path(path).read_text(encoding="utf-8"))
1423 if not isinstance(data, dict):
1424 raise ValueError("snapshot must be a JSON object")
1425 return dryrunverify.ArtifactSnapshot(
1426 ledger_run_ids=tuple(str(rid) for rid in data.get("ledger_run_ids", ())),
1427 branches=tuple(str(name) for name in data.get("branches", ())),
1428 pr_numbers=tuple(int(num) for num in data.get("pr_numbers", ())),
1429 )
1432def _dryrun_after_snapshot(
1433 args: argparse.Namespace,
1434 config: cfg.ProjectConfig,
1435) -> dryrunverify.ArtifactSnapshot:
1436 """Gather the post-dry-run artifact snapshot.
1438 Offline (``--after-json``): the snapshot is read straight from the fixture so
1439 tests are deterministic. Live: ledger run_ids come from the configured ledger,
1440 branches from ``git for-each-ref``, and PRs from ``gh pr list`` scoped to the
1441 run's issue ship-branch pattern.
1443 The after snapshot is read **fail-closed**: a corrupt ledger, a failed
1444 ``git``/``gh`` read, all raise. An integrity detector that cannot observe the
1445 after state must say "cannot certify", never silently report a clean diff —
1446 an empty-on-error snapshot would mask a real leak (``after − before = ∅``).
1447 """
1448 if args.after_json is not None:
1449 return _dryrun_snapshot_from_json(args.after_json)
1450 records = ledger.read_records(ledger.resolve_path(args.root, config))
1451 run_ids = tuple(
1452 str(record["run_id"])
1453 for record in records
1454 if record.get("record_type") == ledger.RECORD_TYPE_SHIP_RUN
1455 and isinstance(record.get("run_id"), str)
1456 )
1457 branches_result = git.list_branches(cwd=args.root)
1458 if not branches_result.ok:
1459 raise ValueError(
1460 "after snapshot incomplete: git branch listing failed; cannot certify dry run"
1461 )
1462 branches = tuple(
1463 line.strip() for line in branches_result.output.splitlines() if line.strip()
1464 )
1465 pattern = dryrunverify.issue_branch_pattern(args.issue)
1466 pr_numbers = tuple(
1467 number
1468 for number, head in _dryrun_live_prs(args.root)
1469 if pattern.search(head)
1470 )
1471 return dryrunverify.ArtifactSnapshot(
1472 ledger_run_ids=run_ids, branches=branches, pr_numbers=pr_numbers,
1473 )
1476def _dryrun_live_prs(root: str) -> list[tuple[int, str]]:
1477 """Return ``(number, headRefName)`` for the repo's PRs.
1479 Fail-closed: a ``gh`` transport failure raises ``ValueError`` rather than
1480 degrading to an empty list, so an unobservable PR set can never masquerade
1481 as "no new PRs" and hide a leaked PR. Malformed entries are skipped (a
1482 well-formed-but-empty list is a legitimate observation).
1483 """
1484 result = github.list_prs(cwd=root)
1485 if not result.ok:
1486 raise ValueError(
1487 "after snapshot incomplete: gh PR listing failed; cannot certify dry run"
1488 )
1489 entries = json.loads(result.output or "[]")
1490 pairs: list[tuple[int, str]] = []
1491 for entry in entries if isinstance(entries, list) else ():
1492 if not isinstance(entry, dict):
1493 continue
1494 number = entry.get("number")
1495 head = entry.get("headRefName")
1496 if isinstance(number, int) and isinstance(head, str):
1497 pairs.append((number, head))
1498 return pairs
1501def _reconcile_inputs_active(args: argparse.Namespace) -> bool:
1502 return bool(
1503 getattr(args, "from_transport", False)
1504 or getattr(args, "merged_prs_json", None)
1505 or getattr(args, "verdict_count", None)
1506 or getattr(args, "pr_reviews_json", None)
1507 )
1510def _capture_verify_merged_prs(
1511 args: argparse.Namespace, config: cfg.ProjectConfig
1512) -> tuple[list[int], dict[str, object]]:
1513 """Resolve the authoritative merged-PR set for capture reconciliation.
1515 Priority: an explicit transport fixture (``--merged-prs-json``), then a live
1516 transport derivation (``--from-transport``), then the explicit ``--merged-pr``
1517 override. ``--merged-pr`` always augments the derived set so an agent cannot
1518 *shrink* the merged set by omitting PRs (the union is verified).
1519 """
1520 explicit = list(args.merged_pr or ())
1521 fixture = getattr(args, "merged_prs_json", None)
1522 if fixture is not None:
1523 derived = _merged_prs_from_json(fixture)
1524 merged = _dedupe_ints([*derived, *explicit])
1525 return merged, {"source": "transport-fixture", "transport_failed": False}
1526 if getattr(args, "from_transport", False):
1527 derived, failed = _merged_prs_from_transport(args)
1528 merged = _dedupe_ints([*derived, *explicit])
1529 if not merged:
1530 raise ValueError(
1531 "no merged PRs derived from transport and none passed via --merged-pr"
1532 )
1533 return merged, {"source": "transport", "transport_failed": failed}
1534 if not explicit:
1535 raise ValueError("provide --merged-pr or --from-transport")
1536 return _dedupe_ints(explicit), {"source": "args", "transport_failed": False}
1539def _merged_prs_from_json(path: str) -> list[int]:
1540 items = _read_json_list(path)
1541 numbers: list[int] = []
1542 for item in items:
1543 number = item.get("number")
1544 if isinstance(number, int) and number > 0:
1545 numbers.append(number)
1546 return numbers
1549def _merged_prs_from_transport(args: argparse.Namespace) -> tuple[list[int], bool]:
1550 search = getattr(args, "merged_since", None)
1551 search_arg = f"merged:>={search}" if search else None
1552 result = github.merged_prs(search=search_arg, cwd=args.root)
1553 if not result.ok:
1554 return [], True
1555 try:
1556 items = json.loads(result.output or "[]")
1557 except json.JSONDecodeError:
1558 return [], True
1559 if not isinstance(items, list):
1560 return [], True
1561 numbers = [
1562 item["number"]
1563 for item in items
1564 if isinstance(item, dict) and isinstance(item.get("number"), int)
1565 ]
1566 return numbers, False
1569def _capture_verify_verdict_counts(
1570 args: argparse.Namespace, config: cfg.ProjectConfig, merged_prs: list[int]
1571) -> dict[int, int]:
1572 """Evidence-side review-verdict counts per PR for the reviewer cross-check.
1574 Offline: ``--verdict-count PR=N`` fixtures. Live: counts are read from the
1575 transport per PR via the shared evidence counter; a fetch failure simply
1576 omits that PR (the cross-check degrades to advisory rather than failing).
1577 """
1578 explicit = dict(getattr(args, "verdict_count", None) or ())
1579 if explicit:
1580 return explicit
1581 if not getattr(args, "from_transport", False):
1582 return {}
1583 try:
1584 owner_repo = _owner_repo(config)
1585 except ValueError:
1586 # No owner/repo configured: the reviewer cross-check degrades to advisory.
1587 return {}
1588 counts: dict[int, int] = {}
1589 for pr in merged_prs:
1590 try:
1591 pr_comments = _gh_json_list(
1592 ["repos", owner_repo, "issues", str(pr), "comments"], cwd=args.root
1593 )
1594 pr_reviews = _gh_json_list(
1595 ["repos", owner_repo, "pulls", str(pr), "reviews"], cwd=args.root
1596 )
1597 except ValueError:
1598 continue
1599 counts[pr] = evidence.count_review_verdicts(
1600 pr_comments, pr_reviews, enforced=False
1601 )
1602 return counts
1605def _cmd_capture_reconcile(args: argparse.Namespace) -> int:
1606 try:
1607 config = cfg.load_config(args.path)
1608 except FileNotFoundError:
1609 print(f"no such config: {args.path}", file=sys.stderr)
1610 return 1
1611 except cfg.ConfigError as exc:
1612 print(str(exc), file=sys.stderr)
1613 return 1
1615 ledger_path = ledger.resolve_path(args.root, config)
1616 try:
1617 records = ledger.read_records(ledger_path)
1618 except ledger.LedgerError as exc:
1619 print(f"invalid ledger {ledger_path}: {exc}", file=sys.stderr)
1620 return 1
1621 linked_issues: dict[int, list[int]] = {}
1622 for pr, issue in args.linked_issue:
1623 linked_issues.setdefault(pr, []).append(issue)
1624 merged_prs = [
1625 {"number": pr, "issue_numbers": linked_issues.get(pr, [])}
1626 for pr in args.merged_pr
1627 ]
1628 try:
1629 plan = capture.reconcile_session(
1630 records,
1631 merged_prs,
1632 config=config,
1633 capture_capability_available=args.capture_capability == "available",
1634 )
1635 except capture.CaptureError as exc:
1636 print(str(exc), file=sys.stderr)
1637 return 1
1638 payload = {
1639 "contract": capture.contract_as_dict(config)["reconcile"],
1640 "mode": "live-plan" if args.live else "dry-run",
1641 "no_mutations": True,
1642 "ledger_path": str(ledger_path),
1643 "reconcile": plan,
1644 }
1645 if args.json:
1646 print(json.dumps(payload, indent=2, sort_keys=True))
1647 else:
1648 print(f"keel capture-reconcile — {plan['status']} {ledger_path}")
1649 for result in plan["results"]:
1650 print(f" PR #{result['pr']} {result['status']} {result['reason']}")
1651 for action in result["actions"]:
1652 print(f" DRY-RUN: {action['type']} {action['idempotency_key']}")
1653 return 0 if plan["status"] != "blocked" else 1
1656def _cmd_step_verify(args: argparse.Namespace) -> int:
1657 try:
1658 handoff = _read_json_object(args.handoff_file)
1659 evidence_report = _read_json_object(args.evidence_report)
1660 except ValueError as exc:
1661 print(str(exc), file=sys.stderr)
1662 return 1
1663 review_contract = ship.resolve_review_contract(
1664 tier=None,
1665 reviewer_override=args.reviewers,
1666 review_comments=args.review_comments,
1667 gates=(),
1668 policy_pack={},
1669 jury=args.jury,
1670 no_jury=args.no_jury,
1671 jury_advisory=args.jury_advisory,
1672 )
1673 try:
1674 report = stepverifier.verify_step_completion(
1675 step_id=args.step,
1676 handoff=handoff,
1677 evidence_report=evidence_report,
1678 review_contract=review_contract,
1679 dry_run=args.dry_run,
1680 enforced=not args.not_enforced,
1681 )
1682 except KeyError as exc:
1683 print(str(exc), file=sys.stderr)
1684 return 1
1685 payload = {
1686 "contract": stepverifier.contract_as_dict(
1687 review_contract,
1688 dry_run=args.dry_run,
1689 enforced=not args.not_enforced,
1690 ),
1691 "verification": report,
1692 }
1693 if args.json:
1694 print(json.dumps(payload, indent=2, sort_keys=True))
1695 else:
1696 print(f"keel step-verify — {report['status']} {args.step}")
1697 if report["missing"]:
1698 print(f" missing : {', '.join(report['missing'])}")
1699 print(f" required : {len(report['required_evidence'])}")
1700 return 0 if report["status"] == "pass" else 1
1703def _cmd_runcontrols(args: argparse.Namespace) -> int:
1704 try:
1705 events = _read_json_list(args.events_file, missing_ok=True)
1706 event = _event_from_args(args)
1707 step_caps = _step_caps_from_args(args.step_cap)
1708 except ValueError as exc:
1709 print(str(exc), file=sys.stderr)
1710 return 1
1711 if event:
1712 events.append(event)
1713 if not args.dry_run:
1714 _write_json_list(args.events_file, events)
1715 report = runcontrols.evaluate_run_controls(
1716 events,
1717 max_work_units=args.max_work_units,
1718 default_step_cap=args.default_step_cap,
1719 step_caps=step_caps,
1720 identical_action_threshold=args.identical_action_threshold,
1721 alternating_diff_window=args.alternating_diff_window,
1722 )
1723 payload = {
1724 "contract": runcontrols.contract_as_dict(),
1725 "path": args.events_file,
1726 "appended": bool(event) and not args.dry_run,
1727 "event": event,
1728 "run_controls": report,
1729 }
1730 if args.json:
1731 print(json.dumps(payload, indent=2, sort_keys=True))
1732 else:
1733 print(f"keel runcontrols — {report['status']} {args.events_file}")
1734 print(f" events : {report['summary']['event_count']}")
1735 print(f" work units : {report['summary']['work_units']}")
1736 if report["reason"]:
1737 reason = report["reason"]
1738 print(f" halt : {reason['reason']} ({reason['scope']})")
1739 return 0 if report["status"] == "pass" else 1
1742def _cmd_review(args: argparse.Namespace) -> int:
1743 if args.dry_run and args.live:
1744 print("--dry-run and --live cannot be used together", file=sys.stderr)
1745 return 1
1746 dry_run = not args.live
1747 try:
1748 config = cfg.load_config(args.path)
1749 except FileNotFoundError:
1750 print(f"no such config: {args.path}", file=sys.stderr)
1751 return 1
1752 except cfg.ConfigError as exc:
1753 print(str(exc), file=sys.stderr)
1754 return 1
1756 report = runtime.detect(args.root)
1757 evaluation = runtime.evaluate(
1758 runtime.CapabilityRequirement(required=("gh", "gh-auth")), report
1759 )
1760 transport = github_transport.resolve(report)
1761 if not evaluation.ok or not transport.supports("comments"):
1762 print(evaluation.render(), file=sys.stderr)
1763 print(transport.render(), file=sys.stderr)
1764 return 1
1766 try:
1767 raw_reviews = json.loads(Path(args.reviews).read_text(encoding="utf-8"))
1768 reviews = review.parse_reviews(raw_reviews)
1769 except OSError as exc:
1770 print(f"cannot read --reviews {args.reviews}: {exc}", file=sys.stderr)
1771 return 1
1772 except json.JSONDecodeError as exc:
1773 print(f"--reviews {args.reviews} is not valid JSON: {exc}", file=sys.stderr)
1774 return 1
1775 except review.ReviewError as exc:
1776 print(str(exc), file=sys.stderr)
1777 return 1
1779 closure_record: dict[str, object] | None = None
1780 if args.closure is not None:
1781 try:
1782 closure_record = _read_json_object(args.closure)
1783 except OSError as exc:
1784 print(f"cannot read --closure {args.closure}: {exc}", file=sys.stderr)
1785 return 1
1786 except (json.JSONDecodeError, ValueError) as exc:
1787 print(f"--closure {args.closure} must be a JSON object: {exc}", file=sys.stderr)
1788 return 1
1790 try:
1791 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent(
1792 args, config, True
1793 )
1794 except ValueError as exc:
1795 print(str(exc), file=sys.stderr)
1796 return 1
1797 operator_consent = consent.build_consent_contract(
1798 command="review",
1799 side_effects=("comments",),
1800 dry_run=dry_run,
1801 approved_scopes=approved_scopes,
1802 approval_source=approval_source,
1803 mode=consent_mode,
1804 operator=approval_operator,
1805 target=f"PR #{args.pr}",
1806 )
1807 consent_ok, consent_message = consent.assert_operator_consent(operator_consent)
1808 if not consent_ok:
1809 print(consent_message, file=sys.stderr)
1810 return 1
1812 try:
1813 owner_repo = _owner_repo(config)
1814 except ValueError as exc:
1815 print(str(exc), file=sys.stderr)
1816 return 1
1818 evidence_args = argparse.Namespace(
1819 pr=args.pr,
1820 issue=args.issue,
1821 pr_body_file=None,
1822 pr_comments_json=None,
1823 issue_comments_json=None,
1824 pr_reviews_json=None,
1825 changed_file=tuple(args.changed_file or ()),
1826 head_sha=args.head_sha,
1827 head_ref=None,
1828 pr_label=(),
1829 dry_run=dry_run,
1830 root=args.root,
1831 )
1832 try:
1833 artifacts_ctx = _load_evidence_artifacts(evidence_args, config)
1834 except ValueError as exc:
1835 print(str(exc), file=sys.stderr)
1836 return 1
1837 changed_files = artifacts_ctx["changed_files"]
1838 tier = (
1839 classify.tier_for_files(
1840 changed_files,
1841 tier3_globs=config.knobs.tier3_globs,
1842 docs_globs=config.knobs.docs_gate_paths,
1843 )
1844 if changed_files else None
1845 )
1846 review_contract = ship.resolve_review_contract(
1847 tier=tier,
1848 reviewer_override=args.reviewers,
1849 gates=config.gates,
1850 policy_pack=config.policy_pack,
1851 )
1852 required_count = review_contract["reviewers"]["count"]
1854 try:
1855 plan = review.build_review_plan(
1856 reviews,
1857 required_count=required_count,
1858 head_sha=artifacts_ctx["head_sha"],
1859 pull_request=args.pr,
1860 issue=artifacts_ctx["issue"],
1861 run_id=args.run_id,
1862 tier=tier,
1863 closure_record=closure_record,
1864 )
1865 except review.ReviewError as exc:
1866 print(str(exc), file=sys.stderr)
1867 return 1
1869 posted: list[dict[str, object]] = []
1870 for post in plan.posts:
1871 if dry_run:
1872 posted.append({
1873 "artifact": post.artifact,
1874 "target": {"kind": post.target_kind, "number": post.target_number},
1875 "run_id": post.run_id,
1876 "action": "dry-run",
1877 })
1878 if not args.json:
1879 print(f"DRY-RUN: would post {post.artifact} to "
1880 f"{post.target_kind}:{post.target_number} (run-id {post.run_id})")
1881 continue
1882 try:
1883 result, error = _post_artifact_comment(
1884 owner_repo,
1885 target_kind=post.target_kind,
1886 target_number=post.target_number,
1887 artifact=post.artifact,
1888 marker=post.marker,
1889 body=post.body,
1890 run_id=post.run_id,
1891 transport_name=transport.name,
1892 dry_run=False,
1893 cwd=args.root,
1894 )
1895 except ValueError as exc:
1896 print(str(exc), file=sys.stderr)
1897 return 1
1898 if error is not None:
1899 print(error, file=sys.stderr)
1900 return 1
1901 posted.append(result)
1903 verification: dict[str, object] | None = None
1904 if args.verify and not dry_run:
1905 verification = _verify_merge_evidence(
1906 argparse.Namespace(
1907 pr=args.pr,
1908 issue=args.issue,
1909 root=args.root,
1910 reviewers=args.reviewers,
1911 review_comments="inline",
1912 jury=False,
1913 no_jury=False,
1914 jury_advisory=False,
1915 gate_label=None,
1916 waiver_label=None,
1917 ),
1918 config,
1919 )
1921 result_payload = {
1922 "schema_version": review.SCHEMA_VERSION,
1923 "plan": plan.as_dict(),
1924 "dry_run": dry_run,
1925 "transport": transport.name,
1926 "posted": posted,
1927 "consent": operator_consent["status"],
1928 "verification": verification,
1929 }
1930 if args.json:
1931 print(json.dumps(result_payload, indent=2, sort_keys=True))
1932 else:
1933 mode = "dry-run" if dry_run else "live"
1934 print(f"keel review — {mode} PR #{args.pr}")
1935 print(f" tier : {tier if tier is not None else 'unresolved'}")
1936 print(f" required : {required_count}")
1937 print(f" supplied : {plan.supplied_count}")
1938 print(f" posts : {len(plan.posts)}")
1939 if verification is not None:
1940 print(f" verify : {verification['verification']['status']}")
1941 if verification is not None and verification["verification"]["status"] != "pass":
1942 return 1
1943 return 0
1946def _cmd_post_comment(args: argparse.Namespace) -> int:
1947 try:
1948 config = cfg.load_config(args.path)
1949 except FileNotFoundError:
1950 print(f"no such config: {args.path}", file=sys.stderr)
1951 return 1
1952 except cfg.ConfigError as exc:
1953 print(str(exc), file=sys.stderr)
1954 return 1
1956 marker = _comment_artifact_marker(args.artifact)
1957 try:
1958 target_kind, target_number = _parse_comment_target(args.target)
1959 except ValueError as exc:
1960 print(str(exc), file=sys.stderr)
1961 return 1
1962 try:
1963 body = Path(args.body_file).read_text(encoding="utf-8")
1964 except OSError as exc:
1965 print(f"cannot read --body-file {args.body_file}: {exc}", file=sys.stderr)
1966 return 1
1967 if marker not in body:
1968 print(
1969 f"body for artifact {args.artifact} must contain marker {marker}",
1970 file=sys.stderr,
1971 )
1972 return 1
1973 if _looks_like_body_file_literal(body):
1974 print(
1975 "body appears to be a literal @/path reference; pass rendered markdown, "
1976 "not a shell-expanded placeholder",
1977 file=sys.stderr,
1978 )
1979 return 1
1981 report = runtime.detect(args.root)
1982 evaluation = runtime.evaluate(
1983 runtime.CapabilityRequirement(required=("gh", "gh-auth")), report
1984 )
1985 transport = github_transport.resolve(report)
1986 if not evaluation.ok or not transport.supports("comments"):
1987 print(evaluation.render(), file=sys.stderr)
1988 print(transport.render(), file=sys.stderr)
1989 return 1
1990 try:
1991 owner_repo = _owner_repo(config)
1992 except ValueError as exc:
1993 print(str(exc), file=sys.stderr)
1994 return 1
1996 try:
1997 payload, error = _post_artifact_comment(
1998 owner_repo,
1999 target_kind=target_kind,
2000 target_number=target_number,
2001 artifact=args.artifact,
2002 marker=marker,
2003 body=body,
2004 run_id=args.run_id,
2005 transport_name=transport.name,
2006 dry_run=args.dry_run,
2007 cwd=args.root,
2008 )
2009 except ValueError as exc:
2010 print(str(exc), file=sys.stderr)
2011 return 1
2012 if error is not None:
2013 print(error, file=sys.stderr)
2014 return 1
2015 return _finish_post_comment(args, payload, code=0)
2018def _post_artifact_comment(
2019 owner_repo: str,
2020 *,
2021 target_kind: str,
2022 target_number: int,
2023 artifact: str,
2024 marker: str,
2025 body: str,
2026 run_id: str | None,
2027 transport_name: str,
2028 dry_run: bool,
2029 cwd: str,
2030) -> tuple[dict[str, object], str | None]:
2031 """Post or update one marker/run-id comment via the existing gh path.
2033 Returns a ``(payload, error)`` pair. ``error`` is ``None`` on success; on a gh
2034 mutation failure it is the message to surface. Raises ``ValueError`` only when
2035 the comment fetch itself fails. Dry runs plan the action and never mutate.
2036 """
2037 existing = _gh_json_list(
2038 ["repos", owner_repo, "issues", str(target_number), "comments"], cwd=cwd
2039 )
2040 match = _find_comment_match(existing, marker=marker, run_id=run_id)
2041 payload: dict[str, object] = {
2042 "schema_version": "keel.post-comment.v1",
2043 "target": {"kind": target_kind, "number": target_number},
2044 "artifact": artifact,
2045 "marker": marker,
2046 "transport": transport_name,
2047 "run_id": run_id,
2048 "dry_run": dry_run,
2049 }
2050 if dry_run:
2051 payload["action"] = "edit" if match else "post"
2052 payload["comment_id"] = match.get("id") if match else None
2053 return payload, None
2055 if match:
2056 comment_id = match.get("id")
2057 if not isinstance(comment_id, int):
2058 return payload, "matching comment is missing an integer id"
2059 result = github.edit_issue_comment(owner_repo, comment_id, body, cwd=cwd)
2060 payload["action"] = "edited"
2061 payload["comment_id"] = comment_id
2062 else:
2063 result = github.post_issue_comment(owner_repo, target_number, body, cwd=cwd)
2064 payload["action"] = "posted"
2065 if not result.ok:
2066 return payload, result.output.strip() or "gh comment mutation failed"
2067 try:
2068 response = json.loads(result.output or "{}")
2069 except json.JSONDecodeError:
2070 response = {}
2071 if isinstance(response, dict):
2072 payload["comment_id"] = response.get("id", payload.get("comment_id"))
2073 payload["html_url"] = response.get("html_url")
2074 return payload, None
2077def _cmd_evidence_verify(args: argparse.Namespace) -> int:
2078 try:
2079 config = cfg.load_config(args.path)
2080 except FileNotFoundError:
2081 print(f"no such config: {args.path}", file=sys.stderr)
2082 return 1
2083 except cfg.ConfigError as exc:
2084 print(str(exc), file=sys.stderr)
2085 return 1
2087 try:
2088 artifacts = _load_evidence_artifacts(args, config)
2089 except ValueError as exc:
2090 print(str(exc), file=sys.stderr)
2091 return 1
2092 changed_files = artifacts["changed_files"]
2093 tier = (
2094 classify.tier_for_files(
2095 changed_files,
2096 tier3_globs=config.knobs.tier3_globs,
2097 docs_globs=config.knobs.docs_gate_paths,
2098 )
2099 if changed_files else None
2100 )
2101 review_contract = ship.resolve_review_contract(
2102 tier=tier,
2103 reviewer_override=args.reviewers,
2104 review_comments=args.review_comments,
2105 gates=config.gates,
2106 policy_pack=config.policy_pack,
2107 jury=args.jury,
2108 no_jury=args.no_jury,
2109 jury_advisory=args.jury_advisory,
2110 require_distinct_vendors=(
2111 args.require_distinct_vendors
2112 or config.knobs.evidence_require_distinct_vendors
2113 ),
2114 )
2115 gate_label = args.gate_label or config.knobs.evidence_gate_label
2116 waiver_label = args.waiver_label or evidence.DEFAULT_WAIVER_LABEL
2117 gate = evidence.gate_decision(
2118 artifacts["pr_labels"],
2119 gate_label,
2120 waiver_label=waiver_label,
2121 head_ref=artifacts.get("head_ref"),
2122 pr_comments=artifacts["pr_comments"],
2123 pr_reviews=artifacts["pr_reviews"],
2124 )
2125 enforced = gate["enforced"]
2126 try:
2127 ledger_record = _evidence_ledger_record(args, config)
2128 except ledger.LedgerError as exc:
2129 print(f"invalid run ledger: {exc}", file=sys.stderr)
2130 return 1
2131 report = evidence.verify(
2132 review_contract,
2133 pr_comments=artifacts["pr_comments"],
2134 issue_comments=artifacts["issue_comments"],
2135 pr_reviews=artifacts["pr_reviews"],
2136 pr_body=artifacts["pr_body"],
2137 pr_labels=artifacts["pr_labels"],
2138 head_sha=artifacts["head_sha"],
2139 ledger_record=ledger_record,
2140 dry_run=args.dry_run,
2141 enforced=enforced,
2142 deferrals=tuple(args.deferral or ()),
2143 )
2144 payload = {
2145 "contract": evidence.contract_as_dict(
2146 review_contract,
2147 dry_run=args.dry_run,
2148 enforced=enforced,
2149 deferrals=tuple(args.deferral or ()),
2150 ),
2151 "gate_label": gate_label,
2152 "waiver_label": waiver_label,
2153 "gate": gate,
2154 "enforced": enforced,
2155 "pr_labels": artifacts["pr_labels"],
2156 "pull_request": args.pr,
2157 "issue": artifacts["issue"],
2158 "head_ref": artifacts.get("head_ref"),
2159 "head_sha": artifacts["head_sha"],
2160 "changed_files": artifacts["changed_files"],
2161 "verification": report,
2162 }
2163 if args.json:
2164 print(json.dumps(payload, indent=2, sort_keys=True))
2165 else:
2166 print(f"keel evidence-verify — {report['status']} PR #{args.pr}")
2167 print(f" issue : {artifacts['issue'] or 'not resolved'}")
2168 print(f" dry-run : {str(args.dry_run).lower()}")
2169 if not enforced:
2170 print(f" enforced : false ({gate['reason']})")
2171 print(" required : 0")
2172 if gate.get("waived"):
2173 print(" note : evidence gate disarmed by operator waiver label")
2174 else:
2175 print(" note : evidence gate not enforced; no ship provenance detected")
2176 return 0
2177 print(f" enforced : true ({gate['reason']})")
2178 print(f" required : {report['required_count']}")
2179 if report["missing"]:
2180 print(f" missing : {', '.join(report['missing'])}")
2181 for result in report["results"]:
2182 state = "ok" if result["ok"] else "FAIL"
2183 suffix = " (deferred)" if result["deferred"] else ""
2184 print(f" {state:>4} {result['id']}{suffix}")
2185 return 0 if report["status"] == "pass" else 1
2188def _cmd_scope_verify(args: argparse.Namespace) -> int:
2189 try:
2190 config = cfg.load_config(args.path)
2191 except FileNotFoundError:
2192 print(f"no such config: {args.path}", file=sys.stderr)
2193 return 1
2194 except cfg.ConfigError as exc:
2195 print(str(exc), file=sys.stderr)
2196 return 1
2198 try:
2199 artifacts = _load_evidence_artifacts(args, config)
2200 except ValueError as exc:
2201 print(str(exc), file=sys.stderr)
2202 return 1
2203 try:
2204 record = _scope_ledger_record(args, config)
2205 except ledger.LedgerError as exc:
2206 print(f"invalid run ledger: {exc}", file=sys.stderr)
2207 return 1
2208 declared = ledger.declared_files_for_record(record) if record is not None else None
2209 report = scope.verify(
2210 declared,
2211 list(artifacts["changed_files"]),
2212 docs_globs=config.knobs.docs_gate_paths,
2213 deferrals=tuple(args.deferral or ()),
2214 )
2215 payload = {
2216 "schema_version": scope.SCHEMA_VERSION,
2217 "pull_request": args.pr,
2218 "head_sha": artifacts["head_sha"],
2219 "changed_files": artifacts["changed_files"],
2220 "deferrals": list(args.deferral or ()),
2221 "verification": report,
2222 }
2223 if args.json:
2224 print(json.dumps(payload, indent=2, sort_keys=True))
2225 else:
2226 print(f"keel scope-verify — {report['status']} PR #{args.pr}")
2227 if report["advisory"]:
2228 print(f" note : {report['note']}")
2229 return 0
2230 print(f" declared : {len(report['declared'])} file(s)")
2231 print(f" in-scope : {len(report['in_scope'])} file(s)")
2232 if report["docs_exempt"]:
2233 print(f" docs-exempt : {', '.join(report['docs_exempt'])}")
2234 if report["scope_creep"]:
2235 print(f" scope-creep : {', '.join(report['scope_creep'])}")
2236 if report["note"]:
2237 print(f" note : {report['note']}")
2238 return 0 if report["status"] == "pass" else 1
2241def _cmd_verify_branch(args: argparse.Namespace) -> int:
2242 try:
2243 config = cfg.load_config(args.path)
2244 except FileNotFoundError:
2245 print(f"no such config: {args.path}", file=sys.stderr)
2246 return 1
2247 except cfg.ConfigError as exc:
2248 print(str(exc), file=sys.stderr)
2249 return 1
2251 base_branch = config.base_branch
2252 try:
2253 facts = _gather_branch_facts(args, base_branch)
2254 except ValueError as exc:
2255 print(str(exc), file=sys.stderr)
2256 return 1
2257 report = branchscope.verify(
2258 base_branch=base_branch,
2259 head_sha=facts["head_sha"],
2260 merge_base_sha=facts["merge_base_sha"],
2261 base_tip_sha=facts["base_tip_sha"],
2262 base_distance=facts["base_distance"],
2263 worktree_path=facts["worktree_path"],
2264 repo_root=facts["repo_root"],
2265 is_linked_worktree=facts["is_linked_worktree"],
2266 tolerance=args.tolerance,
2267 allow_stale_base=args.allow_stale_base,
2268 )
2269 payload = {
2270 "schema_version": branchscope.SCHEMA_VERSION,
2271 "pull_request": args.pr,
2272 "head_ref": facts["head_ref"],
2273 "verification": report,
2274 }
2275 if args.json:
2276 print(json.dumps(payload, indent=2, sort_keys=True))
2277 else:
2278 print(f"keel verify-branch — {report['status']} PR #{args.pr}")
2279 print(f" base : origin/{base_branch}")
2280 print(f" verdict : {report['verdict']}")
2281 ancestry = report["ancestry"]
2282 if ancestry["base_distance"] is not None:
2283 print(
2284 f" base-distance : {ancestry['base_distance']} "
2285 f"(tolerance {report['tolerance']})"
2286 )
2287 isolation = report["isolation"]
2288 print(f" isolation : {isolation['verdict']}")
2289 if report["note"]:
2290 print(f" note : {report['note']}")
2291 return 0 if report["status"] == "pass" else 1
2294def _gather_branch_facts(args: argparse.Namespace, base_branch: str) -> dict[str, object]:
2295 """Collect the git/gh facts the pure branch verdict needs.
2297 Offline fixtures (``--head-sha``, ``--base-tip-sha``, ``--merge-base-sha``,
2298 ``--base-distance``, ``--worktree-path``/``--repo-root``/``--linked-worktree``)
2299 short-circuit every live call so the gather path is deterministic in tests; a
2300 live run resolves the PR head via ``gh`` and the ancestry/worktree facts via
2301 the thin ``git`` wrappers, fail-soft (a missing fact becomes ``None`` and the
2302 pure layer skips that check rather than hard-blocking).
2303 """
2304 head_sha = args.head_sha
2305 head_ref = args.head_ref
2306 base_ref = f"origin/{base_branch}"
2307 if head_sha is None and not args.offline:
2308 owner_repo = _owner_repo_from_args(args)
2309 pr = _gh_json(["repos", owner_repo, "pulls", str(args.pr)], cwd=args.root)
2310 head = pr.get("head") if isinstance(pr.get("head"), dict) else {}
2311 head_sha = head.get("sha") if isinstance(head.get("sha"), str) else None
2312 head_ref = head.get("ref") if isinstance(head.get("ref"), str) else head_ref
2314 base_tip_sha = args.base_tip_sha
2315 merge_base_sha = args.merge_base_sha
2316 base_distance = args.base_distance
2317 if not args.offline:
2318 if base_tip_sha is None:
2319 base_tip_sha = git.rev_parse(base_ref, cwd=args.root)
2320 if merge_base_sha is None and head_sha is not None and base_tip_sha is not None:
2321 merge_base_sha = git.merge_base(head_sha, base_tip_sha, cwd=args.root)
2322 if (
2323 base_distance is None
2324 and merge_base_sha is not None
2325 and base_tip_sha is not None
2326 ):
2327 base_distance = git.rev_count(merge_base_sha, base_tip_sha, cwd=args.root)
2329 worktree_path = args.worktree_path
2330 repo_root = args.repo_root
2331 is_linked_worktree = _linked_flag(args.linked_worktree)
2332 if not args.offline and head_ref is not None:
2333 local = _local_worktree_facts(head_ref, cwd=args.root)
2334 if local is not None:
2335 worktree_path = worktree_path or local["worktree_path"]
2336 repo_root = repo_root or local["repo_root"]
2337 if is_linked_worktree is None:
2338 is_linked_worktree = local["is_linked_worktree"]
2339 return {
2340 "head_sha": head_sha,
2341 "head_ref": head_ref,
2342 "base_tip_sha": base_tip_sha,
2343 "merge_base_sha": merge_base_sha,
2344 "base_distance": base_distance,
2345 "worktree_path": worktree_path,
2346 "repo_root": repo_root,
2347 "is_linked_worktree": is_linked_worktree,
2348 }
2351def _owner_repo_from_args(args: argparse.Namespace) -> str:
2352 config = cfg.load_config(args.path)
2353 return _owner_repo(config)
2356def _linked_flag(value: str | None) -> bool | None:
2357 if value is None:
2358 return None
2359 return value == "true"
2362def _local_worktree_facts(branch: str, *, cwd: str) -> dict[str, object] | None:
2363 """Locate ``branch``'s checkout in ``git worktree list --porcelain`` (fail-soft).
2365 Returns the worktree path, the repo root (the first/primary worktree), and
2366 whether the branch lives in a *linked* (non-primary) worktree — or ``None``
2367 when the listing fails or the branch is not checked out locally (CI/PR-only).
2368 """
2369 listed = git.worktree_list(cwd=cwd)
2370 if not listed.ok:
2371 return None
2372 entries = _parse_worktree_porcelain(listed.output)
2373 if not entries:
2374 return None
2375 repo_root = entries[0]["path"]
2376 for index, entry in enumerate(entries):
2377 if entry["branch"] == branch:
2378 return {
2379 "worktree_path": entry["path"],
2380 "repo_root": repo_root,
2381 "is_linked_worktree": index > 0,
2382 }
2383 return None
2386def _parse_worktree_porcelain(output: str) -> list[dict[str, str | None]]:
2387 """Parse ``git worktree list --porcelain`` into ``{path, branch}`` blocks."""
2388 entries: list[dict[str, str | None]] = []
2389 current: dict[str, str | None] | None = None
2390 for line in output.splitlines():
2391 if line.startswith("worktree "):
2392 current = {"path": line[len("worktree ") :], "branch": None}
2393 entries.append(current)
2394 elif line.startswith("branch ") and current is not None:
2395 ref = line[len("branch ") :]
2396 current["branch"] = ref[len("refs/heads/") :] if ref.startswith("refs/heads/") else ref
2397 return entries
2400def _scope_ledger_record(
2401 args: argparse.Namespace,
2402 config: cfg.ProjectConfig,
2403) -> dict[str, object] | None:
2404 """Load the latest ship_run ledger record for the PR under scope-verify.
2406 Reads the run ledger (offline fixture via ``--ledger-jsonl`` or the configured
2407 path under ``--root``) and returns the most recent matching ship_run record,
2408 or ``None`` when no record matches — the advisory back-compat path.
2409 """
2410 fixture = getattr(args, "ledger_jsonl", None)
2411 if fixture is not None:
2412 records = ledger.parse_records(Path(fixture).read_text(encoding="utf-8"))
2413 else:
2414 records = ledger.read_records(ledger.resolve_path(args.root, config))
2415 return ledger.latest_ship_run_for_pr(records, args.pr)
2418def _cmd_status(args: argparse.Namespace) -> int:
2419 try:
2420 config = cfg.load_config(args.path)
2421 except FileNotFoundError:
2422 print(f"no such config: {args.path}", file=sys.stderr)
2423 return 1
2424 except cfg.ConfigError as exc:
2425 print(str(exc), file=sys.stderr)
2426 return 1
2428 checkpoint_path = checkpoint.resolve_path(args.root, config)
2429 ledger_path = ledger.resolve_path(args.root, config)
2430 try:
2431 checkpoint_record = checkpoint.read_checkpoint(checkpoint_path)
2432 except checkpoint.CheckpointError as exc:
2433 print(f"invalid checkpoint {checkpoint_path}: {exc}", file=sys.stderr)
2434 return 1
2435 try:
2436 ledger_records = ledger.read_records(ledger_path)
2437 except ledger.LedgerError as exc:
2438 print(f"invalid ledger {ledger_path}: {exc}", file=sys.stderr)
2439 return 1
2440 snapshot = status.build_status_snapshot(
2441 config=config,
2442 checkpoint_record=checkpoint_record,
2443 ledger_records=ledger_records,
2444 live_branches=list(args.live_branch),
2445 live_pull_requests=list(args.live_pr),
2446 )
2447 payload = {
2448 "contract": status.status_contract_as_dict(config),
2449 "checkpoint_path": str(checkpoint_path),
2450 "ledger_path": str(ledger_path),
2451 "snapshot": snapshot,
2452 }
2453 if args.json:
2454 print(json.dumps(payload, indent=2, sort_keys=True))
2455 else:
2456 print(status.render_status(snapshot))
2457 return 0
2460def _cmd_checkpoint(args: argparse.Namespace) -> int:
2461 try:
2462 config = cfg.load_config(args.path)
2463 except FileNotFoundError:
2464 print(f"no such config: {args.path}", file=sys.stderr)
2465 return 1
2466 except cfg.ConfigError as exc:
2467 print(str(exc), file=sys.stderr)
2468 return 1
2470 contract = checkpoint.checkpoint_contract_as_dict(config)
2471 path = checkpoint.resolve_path(args.root, config)
2472 if args.write:
2473 try:
2474 record = checkpoint.build_checkpoint_record(
2475 run_id=args.run_id,
2476 command=args.checkpoint_command_name,
2477 current_step=args.step,
2478 base_branch=config.base_branch,
2479 target=args.target,
2480 issue_queue=args.issue_queue,
2481 active_issue=args.active_issue,
2482 branch=args.branch,
2483 worktree=args.worktree,
2484 pull_request=args.pull_request,
2485 head_sha=args.head_sha,
2486 completed_steps=args.completed_step,
2487 last_gate=args.last_gate,
2488 last_review=args.last_review,
2489 last_check=args.last_check,
2490 jury_mode=args.jury_mode,
2491 merge_state=args.merge_state,
2492 capture_state=args.capture_state,
2493 close_state=args.close_state,
2494 stop_reason=args.stop_reason,
2495 )
2496 checkpoint.write_checkpoint(path, record)
2497 except checkpoint.CheckpointError as exc:
2498 print(str(exc), file=sys.stderr)
2499 return 1
2500 else:
2501 try:
2502 record = checkpoint.read_checkpoint(path)
2503 except checkpoint.CheckpointError as exc:
2504 print(f"invalid checkpoint {path}: {exc}", file=sys.stderr)
2505 return 1
2506 payload = {
2507 "contract": contract,
2508 "path": str(path),
2509 "status": "present" if record is not None else "missing",
2510 "checkpoint": record,
2511 }
2512 if args.json:
2513 print(json.dumps(payload, indent=2, sort_keys=True))
2514 else:
2515 print(f"keel checkpoint — {payload['status']} {path}")
2516 print(f" schema : {contract['schema_version']}")
2517 if record:
2518 print(f" run : {record['run_id']}")
2519 print(f" step : {record['position']['current_step']}")
2520 print(f" safe boundary : {record['resume']['safe_boundary']}")
2521 return 0
2524def _cmd_activity(args: argparse.Namespace) -> int:
2525 """Read / write / finish / clear an additive command-activity record."""
2526 try:
2527 config = cfg.load_config(args.path)
2528 except FileNotFoundError:
2529 print(f"no such config: {args.path}", file=sys.stderr)
2530 return 1
2531 except cfg.ConfigError as exc:
2532 print(str(exc), file=sys.stderr)
2533 return 1
2535 try:
2536 if args.write:
2537 path = activity.record_path(args.root, config, args.run_id)
2538 record = activity.build_activity_record(
2539 command=args.activity_command_name,
2540 run_id=args.run_id,
2541 phase=args.phase,
2542 status=args.status,
2543 issue=args.issue,
2544 pr=args.pull_request,
2545 note=args.note,
2546 )
2547 activity.write_activity(path, record)
2548 _emit_activity(args, [record], path=str(path))
2549 return 0
2550 if args.done:
2551 path = activity.record_path(args.root, config, args.run_id)
2552 record = activity.read_activity(path)
2553 if record is None:
2554 print(f"no activity record for run {args.run_id}", file=sys.stderr)
2555 return 1
2556 record["status"] = "done"
2557 activity.write_activity(path, record)
2558 _emit_activity(args, [record], path=str(path))
2559 return 0
2560 if args.clear:
2561 path = activity.record_path(args.root, config, args.run_id)
2562 removed = activity.remove_activity(path)
2563 _emit_activity(args, [], path=str(path), removed=removed)
2564 return 0
2565 records = activity.read_all_activity(activity.resolve_dir(args.root, config))
2566 _emit_activity(args, records, path=str(activity.resolve_dir(args.root, config)))
2567 return 0
2568 except activity.ActivityError as exc:
2569 print(str(exc), file=sys.stderr)
2570 return 1
2573def _emit_activity(args, records, *, path, removed=None):
2574 """Render activity output (JSON or a short line)."""
2575 if args.json:
2576 print(json.dumps(
2577 {"contract": activity.activity_contract_as_dict(), "path": path,
2578 "removed": removed, "activity": records},
2579 indent=2, sort_keys=True))
2580 return
2581 if removed is not None:
2582 print(f"keel activity — {'cleared' if removed else 'nothing to clear'} {path}")
2583 return
2584 print(f"keel activity — {len(records)} record(s) {path}")
2585 for record in records:
2586 print(f" {record['command']:14} {record['run_id']:18} "
2587 f"{record['phase']:12} {record['status']}")
2590def _cmd_resume(args: argparse.Namespace) -> int:
2591 try:
2592 config = cfg.load_config(args.path)
2593 except FileNotFoundError:
2594 print(f"no such config: {args.path}", file=sys.stderr)
2595 return 1
2596 except cfg.ConfigError as exc:
2597 print(str(exc), file=sys.stderr)
2598 return 1
2600 contract = checkpoint.checkpoint_contract_as_dict(config)
2601 path = checkpoint.resolve_path(args.root, config)
2602 try:
2603 record = checkpoint.read_checkpoint(path)
2604 plan = checkpoint.resume_plan_as_dict(
2605 record,
2606 live_pr_state=args.live_pr_state,
2607 live_worktree_state=args.live_worktree_state,
2608 )
2609 except checkpoint.CheckpointError as exc:
2610 print(f"invalid checkpoint {path}: {exc}", file=sys.stderr)
2611 return 1
2612 payload = {
2613 "contract": contract,
2614 "path": str(path),
2615 "resume_plan": plan,
2616 }
2617 if args.json:
2618 print(json.dumps(payload, indent=2, sort_keys=True))
2619 else:
2620 print(f"keel resume — {plan['status']} {path}")
2621 print(f" can resume : {str(plan['can_resume']).lower()}")
2622 print(f" next step : {plan['next_step'] or '-'}")
2623 print(f" action : {plan['resume_action'] or '-'}")
2624 print(f" reason : {plan['reason']}")
2625 for warning in plan["warnings"]:
2626 print(f" warning : {warning}")
2627 return 0 if plan["status"] != "ambiguous" else 1
2630def _cmd_standalone(args: argparse.Namespace) -> int:
2631 if getattr(args, "dry_run", False) and getattr(args, "live", False):
2632 print("--dry-run and --live cannot be used together", file=sys.stderr)
2633 return 1
2634 command = args.standalone_command
2635 try:
2636 config = cfg.load_config(args.path)
2637 except FileNotFoundError:
2638 print(f"no such config: {args.path}", file=sys.stderr)
2639 return 1
2640 except cfg.ConfigError as exc:
2641 print(str(exc), file=sys.stderr)
2642 return 1
2644 loaded, problems = load_extensions(config, args.root, strict=False)
2645 for prob in problems:
2646 print(f" ! extension not loaded: {prob}", file=sys.stderr)
2648 requirement = (
2649 _ci_check_capability_requirement(config)
2650 if command == "ci-check"
2651 else _morning_capability_requirement(config)
2652 if command == "morning"
2653 else _scan_capability_requirement(command, config)
2654 if command in {"regression", "review-all-day"}
2655 else _capability_requirement(command, config, loaded, pr=getattr(args, "pr", None))
2656 )
2657 report = runtime.detect(args.root)
2658 evaluation = runtime.evaluate(requirement, report)
2659 if not evaluation.ok:
2660 print(evaluation.render(), file=sys.stderr)
2661 return 1
2662 transport = github_transport.resolve(report)
2663 target = _standalone_target(args)
2664 try:
2665 approved_scopes, approval_source, approval_operator, consent_mode = _approved_consent(
2666 args, config, _has_live_consent_scope(args, command, config, requirement, loaded)
2667 )
2668 except ValueError as exc:
2669 print(str(exc), file=sys.stderr)
2670 return 1
2671 try:
2672 plan = orch.build_plan(config, loaded)
2673 except gates.GateError as exc:
2674 print(str(exc), file=sys.stderr)
2675 return 1
2676 contract = contracts.build_command_contract(
2677 command=command,
2678 config=config,
2679 loaded=loaded,
2680 plan=plan,
2681 requirement=requirement,
2682 evaluation=evaluation,
2683 transport=transport,
2684 extension_problems=tuple(problems),
2685 dry_run=not getattr(args, "live", False),
2686 approved_consent_scopes=approved_scopes,
2687 consent_approval_source=approval_source,
2688 consent_mode=consent_mode,
2689 operator=approval_operator,
2690 target=target,
2691 reviewer_override=getattr(args, "reviewers", None),
2692 review_comments=getattr(args, "review_comments", "inline"),
2693 issue_title=getattr(args, "issue_title", None),
2694 issue_body=getattr(args, "issue_body", None),
2695 issue_labels=_issue_labels(args),
2696 )
2697 consent_ok, consent_message = consent.assert_operator_consent(contract["operator_consent"])
2698 result = contracts.standalone_result_as_dict(
2699 command=command,
2700 config=config,
2701 target=target,
2702 delegate=getattr(args, "delegate", None),
2703 transport=transport,
2704 evaluation=evaluation,
2705 )
2706 if not consent_ok:
2707 if args.json:
2708 print(json.dumps({"contract": contract, "result": result}, indent=2,
2709 sort_keys=True))
2710 else:
2711 print(consent_message, file=sys.stderr)
2712 return 1
2713 intake_record = contract.get("issue_intake")
2714 if command == "implement" and getattr(args, "live", False) and _issue_context_provided(args):
2715 if intake_record and not intake_record["can_mutate_code"]:
2716 if args.json:
2717 print(json.dumps({"contract": contract, "result": result}, indent=2,
2718 sort_keys=True))
2719 else:
2720 print(f"issue intake: {intake_record['status']} — {intake_record['reason']}",
2721 file=sys.stderr)
2722 for question in intake_record["questions"]:
2723 print(f" question: {question}", file=sys.stderr)
2724 return 1
2725 if args.json:
2726 print(json.dumps({"contract": contract, "result": result}, indent=2, sort_keys=True))
2727 return 0
2729 name = config.repo or config.extends
2730 print(f"keel {command} — {name} (base {config.base_branch})")
2731 print(f" target : {target or 'not specified'}")
2732 print(f" profile : {contract['workflow_profile']['profile']}")
2733 print(f" github : {transport.name}")
2734 print(f" consent : {contract['operator_consent']['status']}")
2735 if evaluation.missing_optional:
2736 print(f" degraded opt. : {', '.join(evaluation.missing_optional)}")
2737 if command == "implement":
2738 print(f" worktree : {result['worktree_path_pattern']}")
2739 print(f" branch : {result['branch_pattern']}")
2740 print(" merge : never in standalone implement")
2741 if args.delegate:
2742 print(f" delegate : {args.delegate}")
2743 elif command == "ci-check":
2744 workflows = ", ".join(result["ci_workflows"]) or "not configured"
2745 print(f" workflows : {workflows}")
2746 print(" mode : read-only; propose one fix, never apply")
2747 elif command == "morning":
2748 brief = result["brief"]
2749 health = brief["health_providers"]
2750 unavailable = [p["name"] for p in health if p["status"] in {"blocked", "unavailable"}]
2751 report_names = ", ".join(brief["reports"]) or "not configured"
2752 print(f" reports : {report_names}")
2753 print(f" health : {len(health)} provider(s)")
2754 if unavailable:
2755 print(f" unavailable : {', '.join(unavailable)}")
2756 print(f" deferrals : {brief['deferral_queue']['status']}")
2757 elif command in {"wrap", "work-block", "overnight"}:
2758 session = result["session"]
2759 report_names = ", ".join(session["reports"]) or "not configured"
2760 print(f" reports : {report_names}")
2761 print(f" deferrals : {session['deferral_queue']['status']}")
2762 if command == "wrap":
2763 linked_required = (
2764 session["wrap"]["workspace_preflight"]["must_run_from_linked_worktree"]
2765 )
2766 print(f" worktree : linked required={linked_required}")
2767 print(" pr : ready PR after configured gates")
2768 elif command == "work-block":
2769 print(" mode source : keel work-block")
2770 print(" queue : explicit issues or selector")
2771 print(" handoff : ship per issue")
2772 print(" outcomes : shipped, PR-open, deferred, blocked, skipped, needs-input")
2773 else:
2774 print(f" window : {session['merge_window'] or 'not configured'}")
2775 print(f" mode source : {session['overnight']['mode_source']['command']}")
2776 print(" merge policy : ship window + no-night-merge")
2777 elif command in {"regression", "review-all-day"}:
2778 scan = result["scan"]
2779 print(f" areas : {len(scan['areas'])} configured")
2780 print(f" dedupe : similarity>={scan['dedupe']['near_text_similarity']}")
2781 print(" writes : issues only after consent; no code/PR mutation")
2782 if command == "review-all-day":
2783 print(f" title prefix : {scan['review_all_day']['issue_creation']['title_prefix']}")
2784 else:
2785 print(f" handoff : {scan['regression']['issue_creation']['route_to']}")
2786 mode = "live preflight contract" if getattr(args, "live", False) else "dry-run contract"
2787 print(f" note : {mode}; adapters perform any approved live work.")
2788 return 0
2791def _standalone_target(args: argparse.Namespace) -> str | None:
2792 if getattr(args, "issue", None) is not None:
2793 issue = f"issue #{args.issue}"
2794 extra = getattr(args, "target", None)
2795 return f"{issue} ({extra})" if extra else issue
2796 if getattr(args, "pr", None) is not None:
2797 return f"PR #{args.pr}"
2798 if getattr(args, "since", None) is not None:
2799 extra = getattr(args, "target", None)
2800 target = f"since {args.since}"
2801 return f"{target} ({extra})" if extra else target
2802 if getattr(args, "scope", None) is not None:
2803 scope = f"scope {args.scope}"
2804 extra = getattr(args, "target", None)
2805 if getattr(args, "days", None) is not None:
2806 scope = f"{args.days} day scan ({scope})"
2807 return f"{scope} ({extra})" if extra else scope
2808 if getattr(args, "days", None) is not None:
2809 return f"{args.days} day scan"
2810 if getattr(args, "issues", None):
2811 target = "issues " + ", ".join(f"#{issue}" for issue in args.issues)
2812 max_items = getattr(args, "max_items", None)
2813 extra = getattr(args, "target", None)
2814 if max_items is not None:
2815 target = f"{target} (max {max_items})"
2816 return f"{target} ({extra})" if extra else target
2817 if getattr(args, "queue", None) is not None:
2818 target = f"queue {args.queue}"
2819 max_items = getattr(args, "max_items", None)
2820 extra = getattr(args, "target", None)
2821 if max_items is not None:
2822 target = f"{target} (max {max_items})"
2823 return f"{target} ({extra})" if extra else target
2824 if getattr(args, "title", None) is not None:
2825 return args.title
2826 if getattr(args, "hours", None) is not None:
2827 target = f"{args.hours:g}h session"
2828 max_items = getattr(args, "max_items", None)
2829 return f"{target} (max {max_items})" if max_items is not None else target
2830 return getattr(args, "target", None)
2833def _issue_labels(args: argparse.Namespace) -> tuple[str, ...]:
2834 labels: list[str] = []
2835 for raw in getattr(args, "issue_label", ()) or ():
2836 labels.extend(part.strip() for part in raw.split(",") if part.strip())
2837 return tuple(dict.fromkeys(labels))
2840def _lock_root(root: str | Path) -> Path:
2841 return Path(root) / ".keel" / "state" / "locks"
2844def _finish_merge(args: argparse.Namespace, payload: dict[str, object], reason: str, *,
2845 code: int) -> int:
2846 payload["reason"] = reason
2847 payload["status"] = "pass" if code == 0 else "fail"
2848 if args.json:
2849 print(json.dumps(payload, indent=2, sort_keys=True))
2850 else:
2851 print(f"keel merge — {payload['status']} PR #{args.pr}")
2852 print(f" reason : {reason}")
2853 lock_payload = payload.get("lock")
2854 if isinstance(lock_payload, dict):
2855 print(f" lock : {lock_payload.get('status')}")
2856 ci_payload = payload.get("ci")
2857 if isinstance(ci_payload, dict):
2858 print(f" ci : {ci_payload.get('state')}")
2859 evidence_payload = payload.get("evidence")
2860 if isinstance(evidence_payload, dict):
2861 verification = evidence_payload.get("verification")
2862 if isinstance(verification, dict):
2863 print(f" evidence: {verification.get('status')}")
2864 gates_sha = payload.get("gates_sha")
2865 if isinstance(gates_sha, dict):
2866 if gates_sha.get("bypassed"):
2867 print(" gates-sha: bypassed (hotfix)")
2868 else:
2869 print(f" gates-sha: {'matched' if gates_sha.get('matched') else 'no-match'}")
2870 justification = payload.get("hotfix_justification")
2871 if isinstance(justification, dict):
2872 detail = justification.get("rule_id") or justification.get("operator") or ""
2873 print(f" hotfix : {justification.get('kind')} {detail}".rstrip())
2874 checkpoint_gate = payload.get("checkpoint_gate")
2875 if isinstance(checkpoint_gate, dict):
2876 detail = checkpoint_gate.get("operator") or checkpoint_gate.get("checkpoint_step") or ""
2877 print(f" checkpoint: {checkpoint_gate.get('status')} {detail}".rstrip())
2878 return code
2881def _merge_snapshot(pr: int, *, cwd: str) -> dict[str, object]:
2882 result = github.pr_merge_snapshot(pr, cwd=cwd)
2883 if not result.ok:
2884 raise ValueError(f"unable to read PR merge snapshot: {result.output.strip()}")
2885 try:
2886 payload = json.loads(result.output or "{}")
2887 except json.JSONDecodeError as exc:
2888 raise ValueError("PR merge snapshot was not JSON") from exc
2889 rollup = payload.get("statusCheckRollup")
2890 rollup = rollup if isinstance(rollup, list) else []
2891 return {
2892 "head_sha": payload.get("headRefOid"),
2893 "merge_state": payload.get("mergeStateStatus") or "UNKNOWN",
2894 "ci": _ci_rollup_state(rollup),
2895 }
2898def _ci_rollup_state(rollup: list[object]) -> dict[str, object]:
2899 failures = {
2900 "ACTION_REQUIRED", "CANCELLED", "ERROR", "FAILURE",
2901 "STARTUP_FAILURE", "STALE", "TIMED_OUT",
2902 }
2903 pending_states = {"EXPECTED", "PENDING", "QUEUED", "REQUESTED", "WAITING", "IN_PROGRESS"}
2904 saw_pending = False
2905 saw_check = False
2906 for item in rollup:
2907 if not isinstance(item, dict):
2908 continue
2909 saw_check = True
2910 conclusion = item.get("conclusion")
2911 conclusion = conclusion.upper() if isinstance(conclusion, str) else ""
2912 status_value = item.get("status")
2913 status_value = status_value.upper() if isinstance(status_value, str) else ""
2914 if conclusion in failures:
2915 return {"state": "fail", "reason": conclusion}
2916 if not conclusion and status_value in pending_states:
2917 saw_pending = True
2918 if saw_pending:
2919 return {"state": "pending", "reason": "check-pending"}
2920 return {"state": "pass", "reason": "all-checks-passing" if saw_check else "no-checks"}
2923def _verify_merge_evidence(
2924 args: argparse.Namespace,
2925 config: cfg.ProjectConfig,
2926) -> dict[str, object]:
2927 evidence_args = argparse.Namespace(
2928 pr=args.pr,
2929 issue=args.issue,
2930 pr_body_file=None,
2931 pr_comments_json=None,
2932 issue_comments_json=None,
2933 pr_reviews_json=None,
2934 changed_file=(),
2935 head_sha=None,
2936 pr_label=(),
2937 dry_run=False,
2938 root=args.root,
2939 )
2940 artifacts = _load_evidence_artifacts(evidence_args, config)
2941 changed_files = artifacts["changed_files"]
2942 tier = (
2943 classify.tier_for_files(
2944 changed_files,
2945 tier3_globs=config.knobs.tier3_globs,
2946 docs_globs=config.knobs.docs_gate_paths,
2947 )
2948 if changed_files else None
2949 )
2950 review_contract = ship.resolve_review_contract(
2951 tier=tier,
2952 reviewer_override=args.reviewers,
2953 review_comments=args.review_comments,
2954 gates=config.gates,
2955 policy_pack=config.policy_pack,
2956 jury=args.jury,
2957 no_jury=args.no_jury,
2958 jury_advisory=args.jury_advisory,
2959 require_distinct_vendors=config.knobs.evidence_require_distinct_vendors,
2960 )
2961 gate_label = args.gate_label or config.knobs.evidence_gate_label
2962 waiver_label = getattr(args, "waiver_label", None) or evidence.DEFAULT_WAIVER_LABEL
2963 gate = evidence.gate_decision(
2964 artifacts["pr_labels"],
2965 gate_label,
2966 waiver_label=waiver_label,
2967 head_ref=artifacts.get("head_ref"),
2968 pr_comments=artifacts["pr_comments"],
2969 pr_reviews=artifacts["pr_reviews"],
2970 )
2971 enforced = gate["enforced"]
2972 report = evidence.verify(
2973 review_contract,
2974 pr_comments=artifacts["pr_comments"],
2975 issue_comments=artifacts["issue_comments"],
2976 pr_reviews=artifacts["pr_reviews"],
2977 pr_body=artifacts["pr_body"],
2978 pr_labels=artifacts["pr_labels"],
2979 head_sha=artifacts["head_sha"],
2980 enforced=enforced,
2981 )
2982 return {
2983 "gate_label": gate_label,
2984 "waiver_label": waiver_label,
2985 "gate": gate,
2986 "enforced": enforced,
2987 "verification": report,
2988 "head_sha": artifacts["head_sha"],
2989 "head_ref": artifacts.get("head_ref"),
2990 "changed_files": changed_files,
2991 }
2994def _validated_worktree_path(root: str | Path, worktree: str) -> Path:
2995 root_path = Path(root).resolve()
2996 raw = Path(worktree)
2997 candidate = (root_path / raw if not raw.is_absolute() else raw).resolve()
2998 if candidate == root_path or root_path not in candidate.parents:
2999 raise ValueError("worktree path must be nested under the repository root")
3000 listed = git.worktree_list(cwd=str(root_path))
3001 if not listed.ok:
3002 raise ValueError(f"unable to list registered worktrees: {listed.output.strip()}")
3003 registered = {
3004 Path(line.split(" ", 1)[1]).resolve()
3005 for line in listed.output.splitlines()
3006 if line.startswith("worktree ")
3007 }
3008 if candidate not in registered:
3009 raise ValueError("worktree path is not a registered git worktree")
3010 return candidate
3013def _issue_context_provided(args: argparse.Namespace) -> bool:
3014 return bool(
3015 (getattr(args, "issue_title", None) or "").strip()
3016 or (getattr(args, "issue_body", None) or "").strip()
3017 or _issue_labels(args)
3018 )
3021def _load_evidence_artifacts(
3022 args: argparse.Namespace,
3023 config: cfg.ProjectConfig,
3024) -> dict[str, object]:
3025 pr_body = _read_optional_text(args.pr_body_file)
3026 pr_comments = _read_optional_json_list(args.pr_comments_json)
3027 issue_comments = _read_optional_json_list(args.issue_comments_json)
3028 pr_reviews = _read_optional_json_list(args.pr_reviews_json)
3029 changed_files = list(getattr(args, "changed_file", ()) or ())
3030 head_sha = args.head_sha
3031 head_ref = getattr(args, "head_ref", None)
3032 issue_number = args.issue
3033 injected_labels = list(args.pr_label or ())
3034 pr_labels: list[str] = []
3035 using_fixtures = any(
3036 path is not None for path in (
3037 args.pr_body_file,
3038 args.pr_comments_json,
3039 args.issue_comments_json,
3040 args.pr_reviews_json,
3041 )
3042 )
3043 if args.dry_run:
3044 return {
3045 "pr_body": pr_body,
3046 "pr_comments": [],
3047 "issue_comments": [],
3048 "pr_reviews": [],
3049 "issue": issue_number,
3050 "head_sha": head_sha,
3051 "head_ref": head_ref,
3052 "changed_files": changed_files,
3053 "pr_labels": _dedupe_preserve(injected_labels),
3054 }
3055 if not using_fixtures:
3056 owner_repo = _owner_repo(config)
3057 pr = _gh_json(["repos", owner_repo, "pulls", str(args.pr)], cwd=args.root)
3058 pr_body = pr.get("body") if isinstance(pr.get("body"), str) else ""
3059 head = pr.get("head") if isinstance(pr.get("head"), dict) else {}
3060 head_sha = head.get("sha") if isinstance(head.get("sha"), str) else None
3061 head_ref = head.get("ref") if isinstance(head.get("ref"), str) else None
3062 pr_labels = _label_names(pr.get("labels"))
3063 changed_files = _pr_changed_files(owner_repo, args.pr, cwd=args.root)
3064 pr_comments = _gh_json_list(
3065 ["repos", owner_repo, "issues", str(args.pr), "comments"], cwd=args.root
3066 )
3067 pr_reviews = _gh_json_list(
3068 ["repos", owner_repo, "pulls", str(args.pr), "reviews"], cwd=args.root
3069 )
3070 if issue_number is None:
3071 issue_number = _linked_issue_from_body(pr_body)
3072 if issue_number is not None:
3073 issue_comments = _gh_json_list(
3074 ["repos", owner_repo, "issues", str(issue_number), "comments"], cwd=args.root
3075 )
3076 elif issue_number is None:
3077 issue_number = _linked_issue_from_body(pr_body)
3078 return {
3079 "pr_body": pr_body,
3080 "pr_comments": pr_comments,
3081 "issue_comments": issue_comments,
3082 "pr_reviews": pr_reviews,
3083 "issue": issue_number,
3084 "head_sha": head_sha,
3085 "head_ref": head_ref,
3086 "changed_files": changed_files,
3087 "pr_labels": _dedupe_preserve([*pr_labels, *injected_labels]),
3088 }
3091def _evidence_ledger_record(
3092 args: argparse.Namespace,
3093 config: cfg.ProjectConfig,
3094) -> dict[str, object] | None:
3095 """Load the ship_run ledger record for the PR under verification.
3097 Reads the run ledger (offline fixture via ``--ledger-jsonl`` or the configured
3098 path under ``--root``) and returns the latest matching ship_run record, or
3099 ``None`` when no record matches — preserving marker-only closure behavior.
3100 """
3101 fixture = getattr(args, "ledger_jsonl", None)
3102 if fixture is not None:
3103 records = ledger.parse_records(Path(fixture).read_text(encoding="utf-8"))
3104 else:
3105 records = ledger.read_records(ledger.resolve_path(args.root, config))
3106 return ledger.latest_ship_run_for_pr(records, args.pr)
3109def _label_names(labels: object) -> list[str]:
3110 if not isinstance(labels, list):
3111 return []
3112 return [
3113 label["name"]
3114 for label in labels
3115 if isinstance(label, dict) and isinstance(label.get("name"), str)
3116 ]
3119def _dedupe_preserve(values: list[str]) -> list[str]:
3120 return list(dict.fromkeys(values))
3123def _owner_repo(config: cfg.ProjectConfig) -> str:
3124 if not config.owner or not config.repo:
3125 raise ValueError("project config must define owner and repo for live evidence fetch")
3126 return f"{config.owner}/{config.repo}"
3129def _run_context_warnings(args: argparse.Namespace) -> list[str]:
3130 if not getattr(args, "live", False) or not getattr(args, "append_ledger", False):
3131 return []
3132 warnings = []
3133 if not _nonblank(getattr(args, "host_agent", None)):
3134 warnings.append("missing host_agent in live run context")
3135 return warnings
3138def _nonblank(value: object) -> bool:
3139 return isinstance(value, str) and bool(value.strip())
3142def _comment_artifact_marker(artifact: str) -> str:
3143 markers = {
3144 "closure-comment": closure.COMMENT_MARKER,
3145 "issue-update": artifacts.ISSUE_UPDATE_MARKER,
3146 "review-verdict": evidence.REVIEW_VERDICT_MARKER,
3147 "jury-verdict": evidence.JURY_VERDICT_MARKER,
3148 "extension-result": artifacts.EXTENSION_RESULT_MARKER,
3149 "step-handoff": artifacts.STEP_HANDOFF_MARKER,
3150 "run-control-halt": artifacts.RUN_CONTROL_HALT_MARKER,
3151 }
3152 return markers[artifact]
3155def _parse_comment_target(raw: str) -> tuple[str, int]:
3156 match = re.fullmatch(r"(?P<kind>issue|pr):(?P<number>[1-9]\d*)", raw.strip())
3157 if not match:
3158 raise ValueError("--target must use issue:<number> or pr:<number>")
3159 return match.group("kind"), int(match.group("number"))
3162def _looks_like_body_file_literal(body: str) -> bool:
3163 stripped = body.strip()
3164 return bool(re.fullmatch(r"@(?:/|~|\.\.?/).+", stripped))
3167def _find_comment_match(
3168 comments: list[dict[str, object]],
3169 *,
3170 marker: str,
3171 run_id: str | None,
3172) -> dict[str, object] | None:
3173 if run_id is None:
3174 return None
3175 matches: list[dict[str, object]] = []
3176 for comment in comments:
3177 body = comment.get("body")
3178 if not isinstance(body, str) or marker not in body:
3179 continue
3180 if not _comment_has_run_id(body, run_id):
3181 continue
3182 matches.append(comment)
3183 return matches[-1] if matches else None
3186def _comment_has_run_id(body: str, run_id: str) -> bool:
3187 patterns = (
3188 rf"^\s*(?:run[-_ ]?id)\s*:\s*{re.escape(run_id)}\s*$",
3189 rf"<!--\s*keel\.run-id:\s*{re.escape(run_id)}\s*-->",
3190 )
3191 return any(re.search(pattern, body, re.IGNORECASE | re.MULTILINE) for pattern in patterns)
3194def _finish_post_comment(args: argparse.Namespace, payload: dict[str, object], *, code: int) -> int:
3195 if args.json:
3196 print(json.dumps(payload, indent=2, sort_keys=True))
3197 else:
3198 target = payload["target"]
3199 if isinstance(target, dict):
3200 rendered_target = f"{target.get('kind')}:{target.get('number')}"
3201 else:
3202 rendered_target = str(target)
3203 print(f"keel post-comment — {payload.get('action')} {rendered_target}")
3204 print(f" artifact : {payload.get('artifact')}")
3205 print(f" transport : {payload.get('transport')}")
3206 if payload.get("comment_id") is not None:
3207 print(f" comment : {payload.get('comment_id')}")
3208 return code
3211def _read_optional_text(path: str | None) -> str:
3212 return Path(path).read_text(encoding="utf-8") if path else ""
3215def _read_json_object(path: str) -> dict[str, object]:
3216 value = json.loads(Path(path).read_text(encoding="utf-8"))
3217 if not isinstance(value, dict):
3218 raise ValueError(f"{path} must contain a JSON object")
3219 return value
3222def _dedupe_ints(values: list[int]) -> list[int]:
3223 seen: set[int] = set()
3224 out: list[int] = []
3225 for value in values:
3226 if value not in seen:
3227 seen.add(value)
3228 out.append(value)
3229 return out
3232def _read_json_list(path: str, *, missing_ok: bool = False) -> list[dict[str, object]]:
3233 p = Path(path)
3234 if missing_ok and not p.exists():
3235 return []
3236 value = json.loads(p.read_text(encoding="utf-8"))
3237 if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
3238 raise ValueError(f"{path} must contain a JSON array of objects")
3239 return value
3242def _write_json_list(path: str, value: list[dict[str, object]]) -> None:
3243 p = Path(path)
3244 p.parent.mkdir(parents=True, exist_ok=True)
3245 p.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
3248def _read_optional_json_list(path: str | None) -> list[dict[str, object]]:
3249 if path is None:
3250 return []
3251 return _read_json_list(path)
3254def _event_from_args(args: argparse.Namespace) -> dict[str, object] | None:
3255 if args.event_json:
3256 event = _read_json_object(args.event_json)
3257 else:
3258 fields = {
3259 "step_id": args.step,
3260 "slot": args.slot,
3261 "action": args.action,
3262 "output_fingerprint": args.output_fingerprint,
3263 "diff_fingerprint": args.diff_fingerprint,
3264 "work_units": args.work_units,
3265 }
3266 event = {key: value for key, value in fields.items() if value not in (None, "")}
3267 if args.soft_failure:
3268 event["soft_failure"] = True
3269 return event or None
3272def _step_caps_from_args(values: list[str]) -> dict[str, int]:
3273 caps: dict[str, int] = {}
3274 for raw in values:
3275 if "=" not in raw:
3276 raise ValueError("--step-cap must use SLOT=N")
3277 slot, value = raw.split("=", 1)
3278 slot = slot.strip()
3279 try:
3280 parsed = int(value)
3281 except ValueError as exc:
3282 raise ValueError("--step-cap value must be a positive integer") from exc
3283 if not slot or parsed <= 0:
3284 raise ValueError("--step-cap must use SLOT=N with N > 0")
3285 caps[slot] = parsed
3286 return caps
3289def _verdict_count_arg(value: str) -> tuple[int, int]:
3290 if "=" not in value:
3291 raise argparse.ArgumentTypeError("--verdict-count must use PR=N")
3292 pr_raw, count_raw = value.split("=", 1)
3293 try:
3294 pr = int(pr_raw)
3295 count = int(count_raw)
3296 except ValueError as exc:
3297 raise argparse.ArgumentTypeError("--verdict-count must use PR=N with integers") from exc
3298 if pr <= 0 or count < 0:
3299 raise argparse.ArgumentTypeError("--verdict-count requires PR>0 and N>=0")
3300 return pr, count
3303def _gh_json(args: list[str], *, cwd: str) -> dict[str, object]:
3304 endpoint = "/".join(args)
3305 result = run_argv(["gh", "api", endpoint], cwd=cwd)
3306 if not result.ok:
3307 raise ValueError(f"gh api {endpoint} failed: {result.output.strip()}")
3308 value = json.loads(result.output or "{}")
3309 if not isinstance(value, dict):
3310 raise ValueError(f"gh api {endpoint} did not return a JSON object")
3311 return value
3314def _gh_json_list(args: list[str], *, cwd: str) -> list[dict[str, object]]:
3315 endpoint = "/".join(args)
3316 result = run_argv(["gh", "api", "--paginate", "--slurp", endpoint], cwd=cwd)
3317 if not result.ok:
3318 raise ValueError(f"gh api {endpoint} failed: {result.output.strip()}")
3319 value = json.loads(result.output or "[]")
3320 if value and all(isinstance(item, list) for item in value):
3321 value = [entry for page in value for entry in page]
3322 if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
3323 raise ValueError(f"gh api {endpoint} did not return a JSON array")
3324 return value
3327def _pr_changed_files(owner_repo: str, pr: int, *, cwd: str) -> list[str]:
3328 files = _gh_json_list(["repos", owner_repo, "pulls", str(pr), "files"], cwd=cwd)
3329 return [item["filename"] for item in files if isinstance(item.get("filename"), str)]
3332def _linked_issue_from_body(body: str) -> int | None:
3333 match = re.search(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(?P<n>[1-9]\d*)",
3334 body, re.IGNORECASE)
3335 return int(match.group("n")) if match else None
3338def _approved_consent(
3339 args: argparse.Namespace,
3340 config: cfg.ProjectConfig,
3341 has_standing_scope: bool,
3342) -> tuple[tuple[str, ...], str, str | None, str]:
3343 mode = _consent_mode(args, config)
3344 explicit = tuple(getattr(args, "approve_scope", ()) or ())
3345 if explicit:
3346 return consent.normalize_scopes(explicit), "flag", getattr(args, "operator", None), mode
3347 if mode == "agent" or not getattr(args, "live", False):
3348 return (), "none", getattr(args, "operator", None), mode
3349 if mode == "explicit":
3350 return (), "none", getattr(args, "operator", None), mode
3351 if not has_standing_scope:
3352 return (), "none", getattr(args, "operator", None), mode
3353 env_value = os.environ.get("KEEL_APPROVE_SCOPE")
3354 if env_value:
3355 operator = os.environ.get("KEEL_OPERATOR")
3356 if not operator:
3357 raise ValueError("KEEL_OPERATOR is required when KEEL_APPROVE_SCOPE is used")
3358 return consent.normalize_scopes((env_value,)), "env", operator, mode
3359 if config.automation.approved_scopes:
3360 if not config.automation.operator:
3361 raise ValueError(
3362 "automation.operator is required when automation.approved_scopes is used"
3363 )
3364 return (
3365 consent.normalize_scopes(config.automation.approved_scopes),
3366 "config",
3367 config.automation.operator,
3368 mode,
3369 )
3370 return (), "none", getattr(args, "operator", None), mode
3373def _consent_mode(args: argparse.Namespace, config: cfg.ProjectConfig) -> str:
3374 mode = getattr(args, "consent_mode", None) or os.environ.get("KEEL_CONSENT_MODE")
3375 mode = mode or config.consent_mode
3376 if mode not in consent.CONSENT_MODES:
3377 raise ValueError(
3378 f"unknown consent mode {mode!r}; valid: {', '.join(consent.CONSENT_MODES)}"
3379 )
3380 return mode
3383def _has_live_consent_scope(
3384 args: argparse.Namespace,
3385 command: str,
3386 config: cfg.ProjectConfig,
3387 requirement: runtime.CapabilityRequirement,
3388 loaded: dict,
3389) -> bool:
3390 if not getattr(args, "live", False):
3391 return False
3392 side_effects = contracts.command_side_effects(command, config, requirement, loaded)
3393 return bool(consent.side_effect_scopes(side_effects))
3396def _ci_check_capability_requirement(config: cfg.ProjectConfig) -> runtime.CapabilityRequirement:
3397 optional = ["gh", "gh-auth"]
3398 if config.knobs.ci_workflows:
3399 optional.append("raw-actions-logs")
3400 return runtime.CapabilityRequirement(optional=tuple(optional))
3403def _morning_capability_requirement(config: cfg.ProjectConfig) -> runtime.CapabilityRequirement:
3404 required: list[str] = []
3405 optional: list[str] = ["gh", "gh-auth"]
3406 pack = config.policy_pack or {}
3407 health = pack.get("health_providers") if isinstance(pack.get("health_providers"), dict) else {}
3408 for provider in health.values():
3409 if not isinstance(provider, dict):
3410 continue
3411 required.extend(provider.get("required_capabilities") or ())
3412 optional.extend(provider.get("optional_capabilities") or ())
3413 return runtime.CapabilityRequirement(
3414 required=tuple(dict.fromkeys(required)),
3415 optional=tuple(dict.fromkeys(optional)),
3416 )
3419def _scan_capability_requirement(
3420 command: str,
3421 config: cfg.ProjectConfig,
3422) -> runtime.CapabilityRequirement:
3423 del config
3424 if command == "regression":
3425 return runtime.CapabilityRequirement(
3426 required=("git", "worktree"),
3427 optional=("gh", "gh-auth", "github-mcp", "parallel-subagents"),
3428 )
3429 return runtime.CapabilityRequirement(
3430 required=("git",),
3431 optional=("gh", "gh-auth", "github-mcp", "parallel-subagents"),
3432 )
3435def _cmd_capabilities(args: argparse.Namespace) -> int:
3436 report = runtime.detect(args.root)
3437 transport = github_transport.resolve(report)
3438 requirement = runtime.CapabilityRequirement()
3439 problems: list[str] = []
3440 if args.path:
3441 try:
3442 config = cfg.load_config(args.path)
3443 except FileNotFoundError:
3444 print(f"no such config: {args.path}", file=sys.stderr)
3445 return 1
3446 except cfg.ConfigError as exc:
3447 print(str(exc), file=sys.stderr)
3448 return 1
3449 loaded, problems = load_extensions(config, args.root, strict=False)
3450 requirement = _capability_requirement(args.for_command, config, loaded, pr=args.pr)
3451 evaluation = runtime.evaluate(requirement, report)
3452 if args.json:
3453 print(json.dumps({
3454 "report": report.as_dict(),
3455 "github_transport": transport.as_dict(),
3456 "evaluation": evaluation.as_dict(),
3457 "extension_problems": problems,
3458 }, indent=2, sort_keys=True))
3459 else:
3460 print(report.render())
3461 print(transport.render())
3462 if args.path:
3463 print(evaluation.render())
3464 for prob in problems:
3465 print(f" ! extension not loaded: {prob}", file=sys.stderr)
3466 return 0 if evaluation.ok else 1
3469def _cmd_project_commands(args: argparse.Namespace) -> int:
3470 try:
3471 config = cfg.load_config(args.path)
3472 except FileNotFoundError:
3473 print(f"no such config: {args.path}", file=sys.stderr)
3474 return 1
3475 except cfg.ConfigError as exc:
3476 print(str(exc), file=sys.stderr)
3477 return 1
3479 commands = project_commands.list_project_commands(config)
3480 if args.json:
3481 print(json.dumps({"project_commands": [command.as_dict() for command in commands]},
3482 indent=2, sort_keys=True))
3483 else:
3484 if not commands:
3485 print("project commands: none")
3486 return 0
3487 print("project commands:")
3488 for command in commands:
3489 caps = []
3490 if command.required_capabilities:
3491 caps.append("required=" + ",".join(command.required_capabilities))
3492 if command.optional_capabilities:
3493 caps.append("optional=" + ",".join(command.optional_capabilities))
3494 cap_text = f" ({'; '.join(caps)})" if caps else ""
3495 runner = f" -> {command.command}" if command.command else ""
3496 print(f" {command.name}{runner}{cap_text}")
3497 return 0
3500def _ask(prompt: str, default: str) -> str: # pragma: no cover - interactive I/O
3501 raw = input(f"{prompt} [{default}]: " if default else f"{prompt}: ").strip()
3502 return raw or default
3505def _cmd_init(args: argparse.Namespace) -> int:
3506 root = Path(args.root)
3507 target = root / ".keel" / "project.yaml"
3508 if target.exists() and not args.force:
3509 print(
3510 f"{target} already exists; refusing to overwrite project config "
3511 "(use --force only if you intentionally want to replace it). "
3512 "Project extensions are not touched.",
3513 file=sys.stderr,
3514 )
3515 return 1
3516 stack = scaffold.detect_stack(root)
3517 repo = root.resolve().name
3518 try:
3519 if args.wizard:
3520 print(f"keel init wizard — detected stack: {stack} (Enter accepts each default)")
3521 text = scaffold.wizard(stack, _ask, repo=repo)
3522 else:
3523 text = scaffold.default_config(stack, repo=repo)
3524 except ValueError as exc:
3525 print(str(exc), file=sys.stderr)
3526 return 1
3527 target.parent.mkdir(parents=True, exist_ok=True)
3528 target.write_text(text, encoding="utf-8")
3529 if args.force:
3530 print(
3531 "warning: --force replaced .keel/project.yaml; .keel/extensions/ was not touched",
3532 file=sys.stderr,
3533 )
3534 print(f"wrote {target} (detected stack: {stack})")
3535 return 0
3538def _render_scaffolded_config(root: Path, *, wizard: bool) -> tuple[str, str]:
3539 stack = scaffold.detect_stack(root)
3540 repo = root.resolve().name
3541 if wizard:
3542 print(f"keel setup wizard — detected stack: {stack} (Enter accepts each default)")
3543 return scaffold.wizard(stack, _ask, repo=repo), stack
3544 return scaffold.default_config(stack, repo=repo), stack
3547def _report_install(surface: str, installed: list[str], skipped: list[str]) -> None:
3548 for name in installed:
3549 print(f" installed [{surface}] {name}")
3550 for name in skipped:
3551 print(f" skipped [{surface}] {name} (exists; --force to overwrite)")
3554def _report_adapter_rows(rows: dict[str, list[install.AdapterFileStatus]]) -> None:
3555 for surface, statuses in rows.items():
3556 for row in statuses:
3557 detail = f" — {row.detail}" if row.detail else ""
3558 print(f" {row.status:<16} [{surface}] {row.name} {row.path}{detail}")
3561def _project_only_commands(root: str | Path) -> set[str]:
3562 """Command names the project declares as project-only (never flagged as orphan).
3564 Reads ``.keel/project.yaml`` from ``root`` if present; absent or invalid config yields an
3565 empty set so the scan stays fail-soft and consumer-neutral.
3566 """
3567 path = Path(root) / ".keel" / "project.yaml"
3568 if not path.exists():
3569 return set()
3570 try:
3571 config = cfg.load_config(path)
3572 except cfg.ConfigError:
3573 return set()
3574 return {cmd.name for cmd in project_commands.list_project_commands(config)}
3577def _scan_orphans(root: str | Path, *, include_unmanaged: bool) -> list[install.OrphanFileStatus]:
3578 """Run the pure orphan/unmanaged scan with the default known-command set."""
3579 return install.scan_surface_orphans(
3580 root,
3581 known_commands=install.default_known_commands(),
3582 project_only=_project_only_commands(root),
3583 include_unmanaged=include_unmanaged,
3584 )
3587def _report_orphan_rows(orphans: list[install.OrphanFileStatus]) -> None:
3588 for row in orphans:
3589 print(f" {row.category:<16} [{row.surface}] {row.name} {row.path} — {row.reason}")
3592#: PyPI JSON metadata endpoint for the published distribution.
3593_PYPI_LATEST_URL = "https://pypi.org/pypi/keel-workflow/json"
3596def _fetch_latest_pypi_version(
3597 *, url: str = _PYPI_LATEST_URL, timeout: float = 3.0, _open=None
3598) -> str | None:
3599 """Fetch the latest ``keel-workflow`` version from PyPI. Thin, fail-soft I/O.
3601 Returns the version string, or ``None`` on any failure (offline, timeout,
3602 HTTP error, malformed JSON) so ``keel doctor`` degrades to ``latest: unknown``
3603 rather than crashing or blocking. The network seam (``_open``) is injectable so
3604 the parsing is unit-tested offline; the live ``urlopen`` boundary is excluded.
3605 """
3606 if not url.startswith(("http://", "https://")):
3607 return None # restrict to http(s): no file://, ftp://, or custom schemes
3609 if _open is None: # pragma: no cover - live network boundary
3610 from urllib.request import urlopen
3611 _open = lambda u, t: urlopen(u, timeout=t) # noqa: E731
3612 try:
3613 with _open(url, timeout) as response:
3614 payload = json.loads(response.read().decode("utf-8"))
3615 version = payload["info"]["version"]
3616 return version if isinstance(version, str) else None
3617 except Exception:
3618 return None
3621def _doctor_state_paths(root: str, config: cfg.ProjectConfig) -> list[dict[str, object]]:
3622 """Resolve the configured ledger + checkpoint paths and probe their existence."""
3623 entries: list[dict[str, object]] = []
3624 for label, resolver, error in (
3625 ("ledger", ledger.resolve_path, ledger.LedgerError),
3626 ("checkpoint", checkpoint.resolve_path, checkpoint.CheckpointError),
3627 ):
3628 try:
3629 path = resolver(root, config)
3630 except error as exc:
3631 entries.append({"label": label, "path": None, "status": "invalid",
3632 "reason": str(exc)})
3633 continue
3634 entries.append({
3635 "label": label,
3636 "path": str(path),
3637 "status": "present" if path.exists() else "missing",
3638 "reason": "",
3639 })
3640 return entries
3643def _cmd_doctor(args: argparse.Namespace) -> int:
3644 config = None
3645 core_version = None
3646 state_paths: list[dict[str, object]] = []
3647 if args.path:
3648 try:
3649 config = cfg.load_config(args.path)
3650 except FileNotFoundError:
3651 print(f"no such config: {args.path}", file=sys.stderr)
3652 return 1
3653 except cfg.ConfigError as exc:
3654 print(str(exc), file=sys.stderr)
3655 return 1
3656 core_version = config.core_version
3657 state_paths = _doctor_state_paths(args.root, config)
3659 latest = None if args.offline else _fetch_latest_pypi_version()
3660 report = doctor.run_doctor(
3661 installed_version=__version__,
3662 latest_version=latest,
3663 adapter_markers=install.scan_adapter_markers(args.root),
3664 orphans=[o.as_dict() for o in _scan_orphans(args.root, include_unmanaged=False)],
3665 core_version=core_version,
3666 state_paths=state_paths,
3667 )
3668 if args.json:
3669 print(json.dumps(report, indent=2, sort_keys=True))
3670 else:
3671 print(doctor.render_report(report))
3672 if args.strict and report["status"] == "fail":
3673 return 1
3674 return 0
3677def _cmd_install_adapter(args: argparse.Namespace) -> int:
3678 if args.agent == "plugin":
3679 installed, skipped = install.install_plugin(args.root, force=args.force)
3680 _report_install("plugin", installed, skipped)
3681 print(f"{len(installed)} plugin command file(s) written under commands/ — "
3682 "install via /plugin marketplace add berkayturanci/keel; /plugin install keel")
3683 return 0
3684 if args.agent == "all":
3685 results = install.install_all(args.root, force=args.force)
3686 elif args.agent in install.TARGETS:
3687 results = {args.agent: install.install(args.agent, args.root, force=args.force)}
3688 else:
3689 print(f"unknown target {args.agent!r}; valid: all, plugin, "
3690 f"{', '.join(install.TARGETS)}",
3691 file=sys.stderr)
3692 return 1
3693 total = 0
3694 for surface, (installed, skipped) in results.items():
3695 _report_install(surface, installed, skipped)
3696 total += len(installed)
3697 print(f"{total} adapter(s) installed — Claude: /keel:<command>; "
3698 f"other agents: keel-<command> skill (.agents/skills/)")
3699 return 0
3702def _cmd_setup(args: argparse.Namespace) -> int:
3703 root = Path(args.root)
3704 target = root / ".keel" / "project.yaml"
3705 print(f"keel setup — {root}")
3707 if target.exists() and not args.force:
3708 print(f" config : using existing {target}")
3709 print(" extensions : preserved (setup never deletes .keel/extensions/)")
3710 else:
3711 existed = target.exists()
3712 if existed:
3713 print(
3714 "warning: --force will replace .keel/project.yaml; "
3715 ".keel/extensions/ will not be touched",
3716 file=sys.stderr,
3717 )
3718 try:
3719 text, stack = _render_scaffolded_config(root, wizard=args.wizard)
3720 except ValueError as exc:
3721 print(f" config : failed ({exc})", file=sys.stderr)
3722 return 1
3723 target.parent.mkdir(parents=True, exist_ok=True)
3724 target.write_text(text, encoding="utf-8")
3725 action = "overwrote" if existed else "wrote"
3726 print(f" config : {action} {target} (detected stack: {stack})")
3727 print(" extensions : preserved (setup never deletes .keel/extensions/)")
3729 agent = args.adapter_target
3730 if agent == "all":
3731 results = install.install_all(root, force=args.force)
3732 else:
3733 results = {agent: install.install(agent, root, force=args.force)}
3734 total = 0
3735 for surface, (installed, skipped) in results.items():
3736 _report_install(surface, installed, skipped)
3737 total += len(installed)
3738 print(f" adapters : {total} installed, "
3739 f"{sum(len(skipped) for _, skipped in results.values())} skipped")
3741 try:
3742 config = cfg.load_config(target)
3743 loaded, _ = load_extensions(config, root, strict=True)
3744 plan = orch.build_plan(config, loaded)
3745 except (cfg.ConfigError, ExtensionError, gates.GateError) as exc:
3746 print(f" validate : failed ({exc})", file=sys.stderr)
3747 return 1
3749 print(f" validate : OK ({config.repo or '-'}, base {config.base_branch})")
3750 print(" plan :")
3751 rendered = orch.render_plan(config, plan)
3752 for line in rendered.splitlines():
3753 print(f" {line}")
3754 print(" next : run /keel:ship <issue> or the matching keel-<command> skill.")
3755 return 0
3758def _cmd_adapter_status(args: argparse.Namespace) -> int:
3759 try:
3760 rows = install.adapter_status(args.agent, args.root)
3761 except KeyError:
3762 print(f"unknown target {args.agent!r}; valid: all, {', '.join(install.STATUS_TARGETS)}",
3763 file=sys.stderr)
3764 return 1
3765 orphans = _scan_orphans(args.root, include_unmanaged=args.include_unmanaged)
3766 if args.json:
3767 payload = {
3768 "adapters": {
3769 surface: [
3770 {"surface": r.surface, "name": r.name, "path": r.path,
3771 "status": r.status, "detail": r.detail}
3772 for r in statuses
3773 ]
3774 for surface, statuses in rows.items()
3775 },
3776 "orphans": [o.as_dict() for o in orphans],
3777 }
3778 print(json.dumps(payload, indent=2, sort_keys=True))
3779 return 0
3780 _report_adapter_rows(rows)
3781 _report_orphan_rows(orphans)
3782 if orphans:
3783 stale = sum(1 for o in orphans if o.category == install.ORPHAN_STALE_MARKER)
3784 unmanaged = len(orphans) - stale
3785 print(f" {len(orphans)} unmanaged keel-like file(s): "
3786 f"{stale} orphan (stale-marker), {unmanaged} unmanaged (no-marker) — advisory only")
3787 if not args.include_unmanaged:
3788 print(" note: pass --include-unmanaged to also scan for marker-less command surfaces")
3789 return 0
3792def _cmd_update_adapter(args: argparse.Namespace) -> int:
3793 try:
3794 rows = install.update_adapters(args.agent, args.root, dry_run=args.dry_run)
3795 except KeyError:
3796 print(f"unknown target {args.agent!r}; valid: all, {', '.join(install.TARGETS)}",
3797 file=sys.stderr)
3798 return 1
3799 _report_adapter_rows(rows)
3800 if args.dry_run:
3801 print("dry-run: no adapter files were written")
3802 return 0
3805def _cmd_sync(args: argparse.Namespace) -> int:
3806 args.agent = args.target
3807 print(f"keel sync — installed keel {__version__}")
3808 print(" package : not upgraded by sync; upgrade keel-workflow with pip/pipx first")
3809 rc = _cmd_update_adapter(args)
3810 if rc == 0:
3811 orphans = _scan_orphans(args.root, include_unmanaged=False)
3812 if orphans:
3813 print(f" orphans : {len(orphans)} unmanaged keel-like file(s) found — "
3814 "run keel adapter-status for details")
3815 print(" next : run keel validate .keel/project.yaml --root .")
3816 print(" next : run keel plan .keel/project.yaml --root .")
3817 return rc
3820def _parse_legacy_mapping(raw: str) -> tuple[str, str]:
3821 if "=" not in raw:
3822 raise argparse.ArgumentTypeError("use LEGACY=KEEL, for example ship=ship")
3823 legacy, command = (part.strip() for part in raw.split("=", 1))
3824 if not legacy or not command:
3825 raise argparse.ArgumentTypeError("legacy and keel command names must be non-empty")
3826 return legacy, command
3829def _cmd_install_legacy_wrappers(args: argparse.Namespace) -> int:
3830 matrix = Path(args.parity_matrix)
3831 if not matrix.exists():
3832 print(
3833 f"parity matrix not found: {matrix}; pass --parity-matrix after verifying rows",
3834 file=sys.stderr,
3835 )
3836 return 1
3837 ready_commands = install.parity_ready_commands(matrix.read_text(encoding="utf-8"))
3838 mappings = dict(args.command) if args.command else {
3839 command: command for command in sorted(ready_commands)
3840 }
3841 try:
3842 if args.agent == "all":
3843 results = install.install_all_legacy_wrappers(
3844 args.root,
3845 mappings=mappings,
3846 ready_commands=ready_commands,
3847 force=args.force,
3848 )
3849 elif args.agent in install.LEGACY_TARGETS:
3850 results = {
3851 args.agent: install.install_legacy_wrappers(
3852 args.agent,
3853 args.root,
3854 mappings=mappings,
3855 ready_commands=ready_commands,
3856 force=args.force,
3857 )
3858 }
3859 else:
3860 print(
3861 f"unknown target {args.agent!r}; valid: all, "
3862 f"{', '.join(install.LEGACY_TARGETS)}",
3863 file=sys.stderr,
3864 )
3865 return 1
3866 except ValueError as exc:
3867 print(str(exc), file=sys.stderr)
3868 return 1
3869 total = 0
3870 for surface, (installed, skipped) in results.items():
3871 _report_install(f"legacy-{surface}", installed, skipped)
3872 total += len(installed)
3873 print(f"{total} legacy wrapper(s) installed — legacy commands now delegate to /keel:<command>")
3874 return 0
3877def _capability_requirement(
3878 command: str,
3879 config: cfg.ProjectConfig,
3880 loaded: dict[str, list],
3881 *,
3882 pr: int | None = None,
3883) -> runtime.CapabilityRequirement:
3884 req = runtime.CapabilityRequirement(
3885 required=config.knobs.required_capabilities,
3886 optional=config.knobs.optional_capabilities,
3887 )
3888 try:
3889 specs = gates.plan_gates(config, loaded)
3890 except gates.GateError:
3891 return req
3892 if project_command := project_commands.get_project_command(config, command):
3893 req = req.merged(runtime.CapabilityRequirement(
3894 required=project_command.required_capabilities,
3895 optional=project_command.optional_capabilities,
3896 ))
3898 command_gate_commands = {
3899 "run-gates", "ship", "pr-loop", "wrap", "work-block", "overnight",
3900 "implement", "coverage", "deps-audit", "flake-audit",
3901 }
3902 if command in command_gate_commands and any(s.kind == "command" for s in specs):
3903 req = req.merged(runtime.CapabilityRequirement(required=("shell",)))
3904 worktree_commands = {
3905 "ship", "pr-loop", "wrap", "work-block", "overnight", "implement"
3906 }
3907 github_read_commands = {
3908 "morning", "review-cycle", "triage", "stale-prs", "regression", "review-all-day",
3909 "coverage", "deps-audit", "flake-audit", "ci-check",
3910 }
3911 if command in worktree_commands:
3912 req = req.merged(runtime.CapabilityRequirement(required=("git", "worktree"),
3913 optional=("gh", "gh-auth")))
3914 elif command in github_read_commands:
3915 req = req.merged(runtime.CapabilityRequirement(optional=("gh", "gh-auth")))
3916 for spec in specs:
3917 if spec.required_capabilities or spec.optional_capabilities:
3918 req = req.merged(runtime.CapabilityRequirement(
3919 required=spec.required_capabilities,
3920 optional=spec.optional_capabilities,
3921 ))
3922 return req
3925def build_parser() -> argparse.ArgumentParser:
3926 parser = argparse.ArgumentParser(prog="keel", description="keel — workflow core")
3927 parser.add_argument("--version", action="version", version=f"keel {__version__}")
3928 sub = parser.add_subparsers(dest="command", metavar="<command>")
3930 p_version = sub.add_parser("version", help="print the keel version")
3931 p_version.set_defaults(func=_cmd_version)
3933 p_validate = sub.add_parser("validate", help="validate project config(s) against the schema")
3934 p_validate.add_argument("paths", nargs="+", help="path(s) to project.yaml")
3935 p_validate.add_argument("--root", default=None,
3936 help="repo root; if set, also strict-validate extensions")
3937 p_validate.set_defaults(func=_cmd_validate)
3939 p_plan = sub.add_parser("plan", help="render the backbone plan for a project")
3940 p_plan.add_argument("path", help="path to project.yaml")
3941 p_plan.add_argument("--root", default=".", help="repo root for resolving extensions")
3942 p_plan.add_argument("--command", dest="command_contract", default="ship",
3943 help="adapter command contract to include in JSON output")
3944 p_plan.add_argument("--profile", choices=("standard", "compound"), default="standard",
3945 help="workflow profile for ship command contracts")
3946 p_plan.add_argument("--live", action="store_true",
3947 help="render a live preflight contract and fail if consent is missing")
3948 p_plan.add_argument("--approve-scope", action="append", default=[],
3949 help="approve a consent scope for this run; repeat or comma-separate")
3950 p_plan.add_argument("--operator", default=None,
3951 help="operator identifier to include in an approved consent record")
3952 p_plan.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
3953 help="operator consent mode: explicit, standing, or agent")
3954 p_plan.add_argument("--target", default=None,
3955 help="task target to include in the consent prompt and record")
3956 p_plan.add_argument("--issue-title", default=None,
3957 help="issue title to include in the intake/readiness contract")
3958 p_plan.add_argument("--issue-body", default=None,
3959 help="issue body markdown to include in the intake/readiness contract")
3960 p_plan.add_argument("--issue-label", action="append", default=[],
3961 help="issue label for intake/readiness; repeat or comma-separate")
3962 p_plan.add_argument("--review-comments", choices=("inline", "summary"), default="inline",
3963 help="review posting mode for ship-like command contracts")
3964 p_plan.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
3965 help="override the resolved reviewer count")
3966 p_plan.add_argument("--jury", action="store_true",
3967 help="enable the cross-vendor jury contract")
3968 p_plan.add_argument("--no-jury", action="store_true",
3969 help="disable the cross-vendor jury contract")
3970 p_plan.add_argument("--jury-advisory", action="store_true",
3971 help="run jury in advisory mode when enabled")
3972 p_plan.add_argument("--run-id", default=None,
3973 help="stamp the activity record for this run id (board visibility)")
3974 p_plan.add_argument("--issue", type=_positive_int, default=None,
3975 help="issue number to record on the activity stamp")
3976 p_plan.add_argument("--pull-request", type=_positive_int, default=None,
3977 help="pull request number to record on the activity stamp")
3978 p_plan.add_argument("--json", action="store_true", help="emit structured JSON")
3979 p_plan.set_defaults(func=_cmd_plan)
3981 p_run = sub.add_parser("run-gates", help="run a project's command gates")
3982 p_run.add_argument("path", help="path to project.yaml")
3983 p_run.add_argument("--root", default=".", help="repo root for commands + extensions")
3984 p_run.add_argument("--run-id", default=None,
3985 help="stamp the activity record for this run id (board visibility)")
3986 p_run.add_argument("--command", dest="gate_command", default="ship",
3987 help="command flow the gates belong to (for the activity stamp)")
3988 p_run.add_argument("--phase", dest="gate_phase", default="s8",
3989 help="flow phase to stamp when the gates run (default the test step)")
3990 p_run.add_argument("--issue", type=_positive_int, default=None,
3991 help="issue number to record on the activity stamp")
3992 p_run.add_argument("--pull-request", type=_positive_int, default=None,
3993 help="pull request number to record on the activity stamp")
3994 p_run.set_defaults(func=_cmd_run_gates)
3996 p_window = sub.add_parser("window", help="is the merge window open now?")
3997 p_window.add_argument("path", help="path to project.yaml")
3998 p_window.set_defaults(func=_cmd_window)
4000 p_claim = sub.add_parser("claim", help="claim a single-host keel resource")
4001 p_claim.add_argument("--root", default=".", help="repo root for the claim store")
4002 p_claim.add_argument("--owner", required=True, help="claim owner id")
4003 p_claim.add_argument("--json", action="store_true", help="emit structured JSON")
4004 p_claim.add_argument("resource", help="resource name, e.g. merge")
4005 p_claim.set_defaults(func=_cmd_claim)
4007 p_release = sub.add_parser("release", help="release a single-host keel resource")
4008 p_release.add_argument("--root", default=".", help="repo root for the claim store")
4009 p_release.add_argument("--owner", default=None, help="claim owner id; omit to release any")
4010 p_release.add_argument("--json", action="store_true", help="emit structured JSON")
4011 p_release.add_argument("resource", help="resource name, e.g. merge")
4012 p_release.set_defaults(func=_cmd_release)
4014 p_guard = sub.add_parser(
4015 "guard", help="evaluate an issue against the deterministic blocker ruleset"
4016 )
4017 p_guard.add_argument("path", help="path to project.yaml")
4018 p_guard.add_argument("--root", default=".", help="repo root for live gh issue fetch")
4019 p_guard.add_argument("--issue", type=_positive_int, default=None,
4020 help="issue number to fetch title/labels live (offline: use the flags)")
4021 p_guard.add_argument("--issue-title", default=None, help="issue title for offline evaluation")
4022 p_guard.add_argument("--issue-labels", default=None,
4023 help="comma-separated issue labels for offline evaluation")
4024 p_guard.add_argument("--json", action="store_true", help="emit structured JSON")
4025 p_guard.set_defaults(func=_cmd_guard)
4027 p_merge = sub.add_parser("merge", help="perform the fail-closed core-owned PR merge")
4028 p_merge.add_argument("path", help="path to project.yaml")
4029 p_merge.add_argument("--root", default=".", help="repo root for git/GitHub operations")
4030 p_merge.add_argument("--pr", type=_positive_int, required=True, help="pull request number")
4031 p_merge.add_argument("--issue", type=_positive_int, default=None,
4032 help="linked issue number for evidence verification")
4033 p_merge.add_argument("--method", choices=("squash", "merge", "rebase"), default="squash",
4034 help="GitHub merge method")
4035 p_merge.add_argument("--owner", default=None, help="resource claim owner id")
4036 p_merge.add_argument("--hotfix", action="store_true",
4037 help="bypass the merge window with a recorded justification")
4038 p_merge.add_argument("--blocker-rule", default=None,
4039 help="keel guard rule id justifying a --hotfix bypass")
4040 p_merge.add_argument("--operator-override", action="store_true",
4041 help="authorize a --hotfix bypass as a named operator (audited)")
4042 p_merge.add_argument("--run-id", default=None,
4043 help="run id for the checkpoint gate (defaults to the gates-pass run id)")
4044 p_merge.add_argument("--no-checkpoint-gate", action="store_true",
4045 help="bypass the checkpoint gate as a named operator (audited)")
4046 p_merge.add_argument("--issue-title", default=None,
4047 help="issue title for offline blocker-rule validation")
4048 p_merge.add_argument("--issue-labels", default=None,
4049 help="comma-separated issue labels for offline blocker-rule validation")
4050 p_merge.add_argument("--dry-run", action="store_true", help="verify only; do not merge")
4051 p_merge.add_argument("--approve-scope", action="append", default=[],
4052 help="approve a consent scope for this merge")
4053 p_merge.add_argument("--operator", default=None,
4054 help="operator identifier for consent evidence")
4055 p_merge.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4056 help="operator consent mode")
4057 p_merge.add_argument("--risk-tier", choices=consent.RISK_TIERS, default="tier-1",
4058 help="risk tier for deterministic escalation evaluation")
4059 p_merge.add_argument("--trust-signal", choices=consent.TRUST_SIGNALS, default="medium",
4060 help="trust signal for deterministic escalation evaluation")
4061 p_merge.add_argument("--retry-count", type=int, default=0,
4062 help="retry count for deterministic escalation evaluation")
4063 p_merge.add_argument("--conflicting-sources", action="store_true",
4064 help="mark conflicting sources for escalation evaluation")
4065 p_merge.add_argument("--changed-lines", type=int, default=0,
4066 help="changed-line count for escalation evaluation")
4067 p_merge.add_argument("--escalation-side-effect", action="append", default=[],
4068 help="additional side-effect signal for escalation evaluation")
4069 p_merge.add_argument("--review-comments", choices=("inline", "summary"), default="inline",
4070 help="review posting mode for evidence verification")
4071 p_merge.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
4072 help="override required reviewer count")
4073 p_merge.add_argument("--jury", action="store_true", help="enable jury evidence")
4074 p_merge.add_argument("--no-jury", action="store_true", help="disable jury evidence")
4075 p_merge.add_argument("--jury-advisory", action="store_true",
4076 help="make jury advisory for evidence verification")
4077 p_merge.add_argument("--gate-label", default=None,
4078 help="override evidence gate label")
4079 p_merge.add_argument("--json", action="store_true", help="emit structured JSON")
4080 p_merge.set_defaults(func=_cmd_merge)
4082 p_wr = sub.add_parser("worktree-remove", help="safely remove a registered nested worktree")
4083 p_wr.add_argument("--root", default=".", help="repo root")
4084 p_wr.add_argument("--json", action="store_true", help="emit structured JSON")
4085 p_wr.add_argument("worktree", help="worktree path to remove")
4086 p_wr.set_defaults(func=_cmd_worktree_remove)
4088 p_ledger = sub.add_parser("ledger", help="read the structured run ledger offline")
4089 p_ledger.add_argument("path", help="path to project.yaml")
4090 p_ledger.add_argument("--root", default=".", help="repo root for resolving the ledger path")
4091 p_ledger.add_argument("--limit", type=_positive_int, default=None,
4092 help="return only the newest N records")
4093 p_ledger.add_argument("--json", action="store_true", help="emit structured JSON")
4094 p_ledger.set_defaults(func=_cmd_ledger)
4096 p_capture = sub.add_parser(
4097 "capture-verify",
4098 help="verify post-merge capture markers for merged PRs",
4099 )
4100 p_capture.add_argument("path", help="path to project.yaml")
4101 p_capture.add_argument("--root", default=".",
4102 help="repo root for resolving the ledger path")
4103 p_capture.add_argument("--merged-pr", type=_positive_int, action="append",
4104 help="merged PR number expected to have a capture marker "
4105 "(explicit override; always added to the derived set)")
4106 p_capture.add_argument("--from-transport", action="store_true",
4107 help="derive the merged-PR set from the transport instead of "
4108 "trusting --merged-pr (also runs reconcile cross-checks)")
4109 p_capture.add_argument("--merged-since", default=None,
4110 help="with --from-transport, only PRs merged on/after this date "
4111 "(YYYY-MM-DD)")
4112 p_capture.add_argument("--merged-prs-json", default=None,
4113 help="offline transport fixture: JSON array of {\"number\": N}")
4114 p_capture.add_argument("--pr-reviews-json", default=None,
4115 help="offline fixture path; presence activates reconcile checks")
4116 p_capture.add_argument("--verdict-count", type=_verdict_count_arg, action="append",
4117 default=None, metavar="PR=N",
4118 help="evidence-side review-verdict count for a PR (offline fixture)")
4119 p_capture.add_argument("--json", action="store_true", help="emit structured JSON")
4120 p_capture.set_defaults(func=_cmd_capture_verify)
4122 p_consent = sub.add_parser(
4123 "consent-verify",
4124 help="reconcile observed PR side effects against approved consent scopes",
4125 )
4126 p_consent.add_argument("path", help="path to project.yaml")
4127 p_consent.add_argument("--root", default=".",
4128 help="repo root for live gh observation and the ledger path")
4129 p_consent.add_argument("--pr", type=_positive_int, required=True,
4130 help="pull request number to reconcile")
4131 p_consent.add_argument("--ledger-jsonl", default=None,
4132 help="offline run-ledger JSONL fixture; otherwise the configured "
4133 "ledger under --root is read for the consent record")
4134 p_consent.add_argument("--offline", action="store_true",
4135 help="use only the supplied observed-effect flags; make no gh calls")
4136 p_consent.add_argument("--pr-exists", action="store_true",
4137 help="offline: the PR exists (implies git push + gh pr create)")
4138 p_consent.add_argument("--commented", action="store_true",
4139 help="offline: comments were posted on the PR")
4140 p_consent.add_argument("--merged", action="store_true",
4141 help="offline: the PR was merged")
4142 p_consent.add_argument("--labeled", action="store_true",
4143 help="offline: labels were written on the PR")
4144 p_consent.add_argument("--json", action="store_true", help="emit structured JSON")
4145 p_consent.set_defaults(func=_cmd_consent_verify)
4147 p_close = sub.add_parser(
4148 "close-reconcile",
4149 help="flag issues closed or status-done without a merge-attesting ledger record",
4150 )
4151 p_close.add_argument("path", help="path to project.yaml")
4152 p_close.add_argument("--root", default=".",
4153 help="repo root for live gh observation and the ledger path")
4154 p_close.add_argument("--issue", type=_positive_int, action="append", required=True,
4155 help="issue number to reconcile (repeat for several)")
4156 p_close.add_argument("--ledger-jsonl", default=None,
4157 help="offline run-ledger JSONL fixture; otherwise the configured "
4158 "ledger under --root is read for the ship_run records")
4159 p_close.add_argument("--offline", action="store_true",
4160 help="use only the supplied lifecycle flags; make no gh calls "
4161 "(test/back-compat; live mode reads host-authoritative state)")
4162 p_close.add_argument("--closed", action="store_true",
4163 help="offline only: treat every --issue as closed (ignored when live)")
4164 p_close.add_argument("--status-done", action="store_true",
4165 help="offline only: treat every --issue as carrying the done label "
4166 "(ignored when live)")
4167 p_close.add_argument("--json", action="store_true", help="emit structured JSON")
4168 p_close.set_defaults(func=_cmd_close_reconcile)
4170 p_dryrun = sub.add_parser(
4171 "dryrun-verify",
4172 help="assert a dry run left no new ledger record, branch, or PR (post-hoc)",
4173 )
4174 p_dryrun.add_argument("path", help="path to project.yaml")
4175 p_dryrun.add_argument("--root", default=".",
4176 help="repo root for the ledger, git, and gh observation")
4177 p_dryrun.add_argument("--run-id", required=True,
4178 help="the rehearsed dry-run id whose leaks to detect")
4179 p_dryrun.add_argument("--issue", type=_positive_int, required=True,
4180 help="the issue the dry run rehearsed (scopes branch/PR attribution)")
4181 p_dryrun.add_argument("--before-json", required=True,
4182 help="JSON snapshot {ledger_run_ids, branches, pr_numbers} "
4183 "captured before the dry run")
4184 p_dryrun.add_argument("--after-json", default=None,
4185 help="offline after-snapshot JSON; otherwise gathered live "
4186 "from the ledger, git, and gh")
4187 p_dryrun.add_argument("--json", action="store_true", help="emit structured JSON")
4188 p_dryrun.set_defaults(func=_cmd_dryrun_verify)
4190 p_reconcile = sub.add_parser(
4191 "capture-reconcile",
4192 help="plan idempotent post-merge capture reconciliation actions",
4193 )
4194 p_reconcile.add_argument("path", help="path to project.yaml")
4195 p_reconcile.add_argument("--root", default=".",
4196 help="repo root for resolving the ledger path")
4197 p_reconcile.add_argument("--merged-pr", type=_positive_int, action="append", required=True,
4198 help="merged PR number to reconcile")
4199 p_reconcile.add_argument(
4200 "--linked-issue",
4201 type=_parse_pr_issue_mapping,
4202 action="append",
4203 default=[],
4204 help="unambiguous PR-to-issue mapping as PR=ISSUE; repeat for multiple issues",
4205 )
4206 p_reconcile.add_argument(
4207 "--capture-capability",
4208 choices=("available", "unavailable"),
4209 default="unavailable",
4210 help="whether the capture extension capability is currently available",
4211 )
4212 p_reconcile.add_argument("--live", action="store_true",
4213 help="label the output as a live reconciliation plan")
4214 p_reconcile.add_argument("--json", action="store_true", help="emit structured JSON")
4215 p_reconcile.set_defaults(func=_cmd_capture_reconcile)
4217 p_step = sub.add_parser(
4218 "step-verify",
4219 help="verify a persisted step handoff against the evidence report",
4220 )
4221 p_step.add_argument("--step", required=True, help="backbone step id, e.g. s7")
4222 p_step.add_argument("--handoff-file", required=True, help="JSON step handoff file")
4223 p_step.add_argument("--evidence-report", required=True,
4224 help="JSON evidence verification report or verification block")
4225 p_step.add_argument("--review-comments", choices=("inline", "summary"),
4226 default="inline", help="review posting mode in the ship contract")
4227 p_step.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
4228 help="override the required reviewer verdict count")
4229 p_step.add_argument("--jury", action="store_true",
4230 help="enable the cross-vendor jury requirement")
4231 p_step.add_argument("--no-jury", action="store_true",
4232 help="disable the cross-vendor jury requirement")
4233 p_step.add_argument("--jury-advisory", action="store_true",
4234 help="make an enabled jury advisory instead of required")
4235 p_step.add_argument("--dry-run", action="store_true",
4236 help="verify with dry-run evidence requirements")
4237 p_step.add_argument("--not-enforced", action="store_true",
4238 help="verify with evidence requirements disabled")
4239 p_step.add_argument("--json", action="store_true", help="emit structured JSON")
4240 p_step.set_defaults(func=_cmd_step_verify)
4242 p_rc = sub.add_parser(
4243 "runcontrols",
4244 help="append/evaluate deterministic run-control events",
4245 )
4246 p_rc.add_argument("events_file", help="JSON array run-events file")
4247 p_rc.add_argument("--event-json", default=None, help="single event JSON object to append")
4248 p_rc.add_argument("--step", default=None, help="event step id")
4249 p_rc.add_argument("--slot", default=None, help="event slot name")
4250 p_rc.add_argument("--action", default=None, help="event action")
4251 p_rc.add_argument("--output-fingerprint", default=None, help="event output fingerprint")
4252 p_rc.add_argument("--diff-fingerprint", default=None, help="event diff fingerprint")
4253 p_rc.add_argument("--work-units", type=int, default=None, help="event work-unit count")
4254 p_rc.add_argument("--soft-failure", action="store_true", help="mark event as soft failure")
4255 p_rc.add_argument("--max-work-units", type=int, default=runcontrols.DEFAULT_RUN_BUDGET,
4256 help="run budget hard cap")
4257 p_rc.add_argument("--default-step-cap", type=int, default=runcontrols.DEFAULT_STEP_CAP,
4258 help="default per-step/slot iteration cap")
4259 p_rc.add_argument("--step-cap", action="append", default=[],
4260 help="override per-slot cap as SLOT=N; repeatable")
4261 p_rc.add_argument("--identical-action-threshold", type=int,
4262 default=runcontrols.DEFAULT_IDENTICAL_THRESHOLD,
4263 help="oscillation threshold for repeated identical actions")
4264 p_rc.add_argument("--alternating-diff-window", type=int,
4265 default=runcontrols.DEFAULT_ALTERNATION_WINDOW,
4266 help="oscillation window for alternating diff fingerprints")
4267 p_rc.add_argument("--dry-run", action="store_true",
4268 help="evaluate without appending the event")
4269 p_rc.add_argument("--json", action="store_true", help="emit structured JSON")
4270 p_rc.set_defaults(func=_cmd_runcontrols)
4272 p_post = sub.add_parser(
4273 "post-comment",
4274 help="post or update a deterministic GitHub issue/PR artifact comment",
4275 )
4276 p_post.add_argument("path", help="path to project.yaml")
4277 p_post.add_argument("--root", default=".", help="repo root for GitHub operations")
4278 p_post.add_argument("--target", required=True, help="comment target as issue:N or pr:N")
4279 p_post.add_argument(
4280 "--artifact",
4281 required=True,
4282 choices=(
4283 "closure-comment",
4284 "issue-update",
4285 "review-verdict",
4286 "jury-verdict",
4287 "extension-result",
4288 "step-handoff",
4289 "run-control-halt",
4290 ),
4291 help="artifact contract expected in --body-file",
4292 )
4293 p_post.add_argument("--body-file", required=True, help="rendered markdown body to post")
4294 p_post.add_argument(
4295 "--run-id",
4296 default=None,
4297 help="update the existing same-marker comment for this run id when present",
4298 )
4299 p_post.add_argument("--dry-run", action="store_true", help="plan only; do not mutate GitHub")
4300 p_post.add_argument("--json", action="store_true", help="emit structured JSON")
4301 p_post.set_defaults(func=_cmd_post_comment)
4303 p_review = sub.add_parser(
4304 "review",
4305 help="orchestrate a supplied review evidence bundle: render, post, re-verify",
4306 )
4307 p_review.add_argument("path", help="path to project.yaml")
4308 p_review.add_argument("--root", default=".", help="repo root for GitHub operations")
4309 p_review.add_argument("--pr", type=_positive_int, required=True,
4310 help="pull request number to attach verdicts to")
4311 p_review.add_argument("--reviews", required=True,
4312 help="JSON array of reviewer verdict objects supplied by the host")
4313 p_review.add_argument("--issue", type=_positive_int, default=None,
4314 help="linked issue number; otherwise inferred from PR body")
4315 p_review.add_argument("--closure", default=None,
4316 help="optional ship_run-shaped JSON record to post as the closure")
4317 p_review.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
4318 help="override the required reviewer verdict count")
4319 p_review.add_argument("--head-sha", default=None,
4320 help="offline PR head SHA used to pin verdict evidence")
4321 p_review.add_argument("--changed-file", action="append", default=[],
4322 help="offline changed file path; repeat to derive tier from fixtures")
4323 p_review.add_argument("--run-id", default="run",
4324 help="run id; per-reviewer sub-keys bind idempotent comments")
4325 p_review.add_argument("--verify", action="store_true",
4326 help="run evidence-verify after posting and include the outcome")
4327 p_review.add_argument("--dry-run", action="store_true",
4328 help="render and print what would post; do not mutate GitHub")
4329 p_review.add_argument("--live", action="store_true",
4330 help="actually post the rendered bundle (consent-gated)")
4331 p_review.add_argument("--approve-scope", action="append", default=[],
4332 help="approve a consent scope for the live run; repeatable")
4333 p_review.add_argument("--operator", default=None,
4334 help="operator identity recorded with an approved live run")
4335 p_review.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4336 help="override the project consent mode for this run")
4337 p_review.add_argument("--json", action="store_true", help="emit structured JSON")
4338 p_review.set_defaults(func=_cmd_review)
4340 p_evidence = sub.add_parser(
4341 "evidence-verify",
4342 help="verify required pre-merge ship evidence artifacts",
4343 )
4344 p_evidence.add_argument("path", help="path to project.yaml")
4345 p_evidence.add_argument("--root", default=".", help="repo root for live gh fetches")
4346 p_evidence.add_argument("--pr", type=_positive_int, required=True,
4347 help="pull request number to verify")
4348 p_evidence.add_argument("--issue", type=_positive_int, default=None,
4349 help="linked issue number; otherwise inferred from PR body")
4350 p_evidence.add_argument("--review-comments", choices=("inline", "summary"),
4351 default="inline", help="review posting mode in the ship contract")
4352 p_evidence.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
4353 help="override the required reviewer verdict count")
4354 p_evidence.add_argument("--jury", action="store_true",
4355 help="enable the cross-vendor jury requirement")
4356 p_evidence.add_argument("--no-jury", action="store_true",
4357 help="disable the cross-vendor jury requirement")
4358 p_evidence.add_argument("--jury-advisory", action="store_true",
4359 help="make an enabled jury advisory instead of required")
4360 p_evidence.add_argument("--require-distinct-vendors", action="store_true",
4361 help="require each review verdict to carry a distinct vendor "
4362 "(overrides the project knob; off by default)")
4363 p_evidence.add_argument("--dry-run", action="store_true",
4364 help="emit the contract without requiring evidence")
4365 p_evidence.add_argument("--deferral", action="append", default=[],
4366 help="explicitly defer an evidence id, kind, or all")
4367 p_evidence.add_argument("--pr-comments-json", default=None,
4368 help="offline PR issue-comments JSON fixture")
4369 p_evidence.add_argument("--issue-comments-json", default=None,
4370 help="offline linked-issue comments JSON fixture")
4371 p_evidence.add_argument("--pr-reviews-json", default=None,
4372 help="offline PR reviews JSON fixture")
4373 p_evidence.add_argument("--pr-body-file", default=None,
4374 help="offline PR body fixture, used only to infer linked issue")
4375 p_evidence.add_argument("--ledger-jsonl", default=None,
4376 help="offline run-ledger JSONL fixture; otherwise the configured "
4377 "ledger under --root is read to enforce closure fidelity")
4378 p_evidence.add_argument("--changed-file", action="append", default=[],
4379 help="offline changed file path; repeat to derive tier from fixtures")
4380 p_evidence.add_argument("--head-sha", default=None,
4381 help="offline PR head SHA used to bind verdict evidence")
4382 p_evidence.add_argument("--head-ref", default=None,
4383 help="offline PR head branch used to detect ship provenance")
4384 p_evidence.add_argument("--pr-label", action="append", default=[],
4385 help="inject a PR label name (repeatable); merged with live labels. "
4386 "A live PR fetch still runs unless an offline fixture flag is "
4387 "also supplied")
4388 p_evidence.add_argument("--gate-label", default=None,
4389 help="override the legacy evidence arming label")
4390 p_evidence.add_argument("--waiver-label", default=None,
4391 help="override the operator-applied evidence waiver label")
4392 p_evidence.add_argument("--json", action="store_true", help="emit structured JSON")
4393 p_evidence.set_defaults(func=_cmd_evidence_verify)
4395 p_scope = sub.add_parser(
4396 "scope-verify",
4397 help="compare the implementer's declared files against the live PR diff",
4398 )
4399 p_scope.add_argument("path", help="path to project.yaml")
4400 p_scope.add_argument("--root", default=".", help="repo root for live gh fetches")
4401 p_scope.add_argument("--pr", type=_positive_int, required=True,
4402 help="pull request number to verify")
4403 p_scope.add_argument("--issue", type=_positive_int, default=None,
4404 help="linked issue number; otherwise inferred from PR body")
4405 p_scope.add_argument("--deferral", action="append", default=[],
4406 help="operator escape hatch; pass 'scope-waived' (or 'all') to "
4407 "accept scope creep for this run")
4408 p_scope.add_argument("--ledger-jsonl", default=None,
4409 help="offline run-ledger JSONL fixture; otherwise the configured "
4410 "ledger under --root is read for the declared scope")
4411 p_scope.add_argument("--changed-file", action="append", default=[],
4412 help="offline changed file path; repeat to supply the diff offline")
4413 p_scope.add_argument("--head-sha", default=None,
4414 help="offline PR head SHA recorded in the report")
4415 p_scope.add_argument("--head-ref", default=None,
4416 help="offline PR head branch")
4417 p_scope.add_argument("--pr-body-file", default=None,
4418 help="offline PR body fixture, used only to infer the linked issue")
4419 p_scope.add_argument("--pr-comments-json", default=None,
4420 help="offline PR issue-comments JSON fixture")
4421 p_scope.add_argument("--issue-comments-json", default=None,
4422 help="offline linked-issue comments JSON fixture")
4423 p_scope.add_argument("--pr-reviews-json", default=None,
4424 help="offline PR reviews JSON fixture")
4425 p_scope.add_argument("--pr-label", action="append", default=[],
4426 help="inject a PR label name (repeatable)")
4427 p_scope.add_argument("--dry-run", action="store_true",
4428 help="use offline inputs without a live gh fetch")
4429 p_scope.add_argument("--json", action="store_true", help="emit structured JSON")
4430 p_scope.set_defaults(func=_cmd_scope_verify)
4432 p_verify_branch = sub.add_parser(
4433 "verify-branch",
4434 help="verify the s2 branch contract: up-to-date base + worktree isolation",
4435 )
4436 p_verify_branch.add_argument("path", help="path to project.yaml")
4437 p_verify_branch.add_argument("--root", default=".",
4438 help="repo root for live git/gh fact gathering")
4439 p_verify_branch.add_argument("--pr", type=_positive_int, required=True,
4440 help="pull request number to verify")
4441 p_verify_branch.add_argument(
4442 "--tolerance", type=_nonnegative_int, default=branchscope.DEFAULT_BASE_DISTANCE,
4443 help="commits the merge-base may sit behind the base tip before stale "
4444 f"(default {branchscope.DEFAULT_BASE_DISTANCE}; 0 is strict)")
4445 p_verify_branch.add_argument(
4446 "--allow-stale-base", action="store_true",
4447 help="operator escape (consent scope git): downgrade a stale base from a "
4448 "failure to an advisory note, recorded on the report")
4449 p_verify_branch.add_argument("--offline", action="store_true",
4450 help="use only supplied facts; make no git/gh calls")
4451 p_verify_branch.add_argument("--head-sha", default=None,
4452 help="offline PR head SHA (otherwise fetched via gh)")
4453 p_verify_branch.add_argument("--head-ref", default=None,
4454 help="offline PR head branch (otherwise fetched via gh)")
4455 p_verify_branch.add_argument("--base-tip-sha", default=None,
4456 help="offline current origin/<base> tip SHA")
4457 p_verify_branch.add_argument("--merge-base-sha", default=None,
4458 help="offline merge-base of head and the base tip")
4459 p_verify_branch.add_argument("--base-distance", type=_nonnegative_int, default=None,
4460 help="offline commit distance merge-base..base-tip")
4461 p_verify_branch.add_argument("--worktree-path", default=None,
4462 help="offline working-tree path for the head branch")
4463 p_verify_branch.add_argument("--repo-root", default=None,
4464 help="offline repo root (primary checkout) path")
4465 p_verify_branch.add_argument("--linked-worktree", choices=("true", "false"), default=None,
4466 help="offline: is the head branch in a linked worktree?")
4467 p_verify_branch.add_argument("--json", action="store_true", help="emit structured JSON")
4468 p_verify_branch.set_defaults(func=_cmd_verify_branch)
4470 p_status = sub.add_parser("status", help="show active/recent run progress")
4471 p_status.add_argument("path", help="path to project.yaml")
4472 p_status.add_argument("--root", default=".",
4473 help="repo root for resolving checkpoint and ledger paths")
4474 p_status.add_argument("--live-branch", action="append", default=[],
4475 help="live branch name for orphan detection (repeatable)")
4476 p_status.add_argument("--live-pr", action="append", type=_positive_int, default=[],
4477 help="live pull-request number for orphan detection (repeatable)")
4478 p_status.add_argument("--json", action="store_true", help="emit structured JSON")
4479 p_status.set_defaults(func=_cmd_status)
4481 p_checkpoint = sub.add_parser("checkpoint", help="read or write the resumable checkpoint")
4482 p_checkpoint.add_argument("path", help="path to project.yaml")
4483 p_checkpoint.add_argument("--root", default=".",
4484 help="repo root for resolving the checkpoint path")
4485 p_checkpoint.add_argument("--write", action="store_true",
4486 help="write a checkpoint record instead of reading it")
4487 p_checkpoint.add_argument("--run-id", default="run",
4488 help="run id for --write")
4489 p_checkpoint.add_argument("--checkpoint-command", dest="checkpoint_command_name",
4490 choices=checkpoint.COMMANDS, default="ship",
4491 help="workflow command being checkpointed")
4492 p_checkpoint.add_argument("--step", choices=checkpoint.STEP_IDS,
4493 default="s0", help="current backbone step for --write")
4494 p_checkpoint.add_argument("--target", default=None,
4495 help="target text to store in the checkpoint")
4496 p_checkpoint.add_argument("--issue-queue", type=_positive_int, action="append", default=[],
4497 help="queued issue number; repeatable")
4498 p_checkpoint.add_argument("--active-issue", type=_positive_int, default=None,
4499 help="active issue number")
4500 p_checkpoint.add_argument("--branch", default=None, help="recorded branch")
4501 p_checkpoint.add_argument("--worktree", default=None, help="recorded worktree path")
4502 p_checkpoint.add_argument("--pull-request", type=_positive_int, default=None,
4503 help="recorded pull request number")
4504 p_checkpoint.add_argument("--head-sha", default=None, help="recorded head SHA")
4505 p_checkpoint.add_argument("--completed-step", choices=checkpoint.STEP_IDS,
4506 action="append", default=[],
4507 help="completed backbone step; repeatable")
4508 p_checkpoint.add_argument("--last-gate", default=None, help="last completed gate id")
4509 p_checkpoint.add_argument("--last-review", default=None,
4510 help="last completed review marker")
4511 p_checkpoint.add_argument("--last-check", default=None,
4512 help="last completed CI/check marker")
4513 p_checkpoint.add_argument("--jury-mode", default=None,
4514 help="resolved jury mode at this step (off/advisory/gating); "
4515 "lets a live consumer show jury status before run end")
4516 p_checkpoint.add_argument("--merge-state", choices=checkpoint.MERGE_STATES,
4517 default="not-started")
4518 p_checkpoint.add_argument("--capture-state", choices=checkpoint.CAPTURE_STATES,
4519 default="not-started")
4520 p_checkpoint.add_argument("--close-state", choices=checkpoint.CLOSE_STATES,
4521 default="not-started")
4522 p_checkpoint.add_argument("--stop-reason", default=None,
4523 help="why the run stopped at this checkpoint")
4524 p_checkpoint.add_argument("--json", action="store_true", help="emit structured JSON")
4525 p_checkpoint.set_defaults(func=_cmd_checkpoint)
4527 p_activity = sub.add_parser(
4528 "activity",
4529 help="read/write the additive command-activity records (live board for "
4530 "non-ship commands)")
4531 p_activity.add_argument("path", help="path to project.yaml")
4532 p_activity.add_argument("--root", default=".",
4533 help="repo root for resolving the activity dir")
4534 g_act = p_activity.add_mutually_exclusive_group()
4535 g_act.add_argument("--write", action="store_true",
4536 help="write/update an activity record for --run-id")
4537 g_act.add_argument("--done", action="store_true",
4538 help="mark --run-id's activity record finished")
4539 g_act.add_argument("--clear", action="store_true",
4540 help="remove --run-id's activity record")
4541 p_activity.add_argument("--command", dest="activity_command_name",
4542 choices=flows.command_names(), default="ship",
4543 help="workflow command the activity belongs to")
4544 p_activity.add_argument("--run-id", default="run",
4545 help="run id keying the record (one file each)")
4546 p_activity.add_argument("--phase", default=None,
4547 help="current flow phase id for the command (--write)")
4548 p_activity.add_argument("--status", choices=activity.STATUSES, default="running",
4549 help="activity status for --write")
4550 p_activity.add_argument("--issue", type=_positive_int, default=None,
4551 help="issue number to record")
4552 p_activity.add_argument("--pull-request", type=_positive_int, default=None,
4553 help="pull request number to record")
4554 p_activity.add_argument("--note", default=None, help="optional free-text note")
4555 p_activity.add_argument("--json", action="store_true", help="emit structured JSON")
4556 p_activity.set_defaults(func=_cmd_activity)
4558 p_resume = sub.add_parser("resume", help="render a dry-run resume plan")
4559 p_resume.add_argument("path", help="path to project.yaml")
4560 p_resume.add_argument("--root", default=".",
4561 help="repo root for resolving the checkpoint path")
4562 p_resume.add_argument("--live-pr-state", choices=checkpoint.LIVE_PR_STATES,
4563 default="unknown",
4564 help="adapter-supplied live PR state for dry-run reconcile")
4565 p_resume.add_argument("--live-worktree-state", choices=checkpoint.LIVE_WORKTREE_STATES,
4566 default="unknown",
4567 help="adapter-supplied live worktree state for dry-run reconcile")
4568 p_resume.add_argument("--json", action="store_true", help="emit structured JSON")
4569 p_resume.set_defaults(func=_cmd_resume)
4571 _add_ship_parser(
4572 sub.add_parser("ship", help="dry ship assessment (tier, window, gates, decision)"),
4573 command="ship",
4574 )
4576 p_implement = sub.add_parser(
4577 "implement",
4578 help="standalone implement-step preflight contract",
4579 )
4580 p_implement.add_argument("path", help="path to project.yaml")
4581 p_implement.add_argument("issue", type=_positive_int, help="issue number to implement")
4582 p_implement.add_argument("--root", default=".", help="repo root for git and extensions")
4583 p_implement.add_argument("--delegate", default=None,
4584 help="explicit implementer delegate override")
4585 p_implement.add_argument("--dry-run", action="store_true",
4586 help="explicitly mark the assessment as non-mutating")
4587 p_implement.add_argument("--live", action="store_true",
4588 help="render a live preflight and fail if consent is missing")
4589 p_implement.add_argument("--approve-scope", action="append", default=[],
4590 help="approve a consent scope for this run; repeat or comma-separate")
4591 p_implement.add_argument("--operator", default=None,
4592 help="operator identifier to include in an approved consent record")
4593 p_implement.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4594 help="operator consent mode: explicit, standing, or agent")
4595 p_implement.add_argument("--target", default=None,
4596 help="additional target text for the consent prompt")
4597 p_implement.add_argument("--issue-title", default=None,
4598 help="issue title to include in the intake/readiness contract")
4599 p_implement.add_argument("--issue-body", default=None,
4600 help="issue body markdown to include in the intake/readiness contract")
4601 p_implement.add_argument("--issue-label", action="append", default=[],
4602 help="issue label for intake/readiness; repeat or comma-separate")
4603 p_implement.add_argument("--json", action="store_true", help="emit structured JSON")
4604 p_implement.set_defaults(func=_cmd_standalone, standalone_command="implement")
4606 p_ci = sub.add_parser(
4607 "ci-check",
4608 help="standalone read-only CI diagnostic preflight contract",
4609 )
4610 p_ci.add_argument("path", help="path to project.yaml")
4611 p_ci.add_argument("--root", default=".", help="repo root for capability checks")
4612 p_ci.add_argument("--pr", type=_positive_int, default=None,
4613 help="PR number whose latest checks should be diagnosed")
4614 p_ci.add_argument("--target", default=None,
4615 help="target text to include in the diagnostic contract")
4616 p_ci.add_argument("--json", action="store_true", help="emit structured JSON")
4617 p_ci.set_defaults(func=_cmd_standalone, standalone_command="ci-check")
4619 p_morning = sub.add_parser(
4620 "morning",
4621 help="standalone daily-brief preflight contract",
4622 )
4623 p_morning.add_argument("path", help="path to project.yaml")
4624 p_morning.add_argument("--root", default=".", help="repo root for capability checks")
4625 p_morning.add_argument("--since", default=None,
4626 help="optional brief window start label or timestamp")
4627 p_morning.add_argument("--target", default=None,
4628 help="target text to include in the morning contract")
4629 p_morning.add_argument("--dry-run", action="store_true",
4630 help="explicitly mark the assessment as non-mutating")
4631 p_morning.add_argument("--live", action="store_true",
4632 help="render a live preflight and fail if consent is missing")
4633 p_morning.add_argument("--approve-scope", action="append", default=[],
4634 help="approve a consent scope for this run; repeat or comma-separate")
4635 p_morning.add_argument("--operator", default=None,
4636 help="operator identifier to include in an approved consent record")
4637 p_morning.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4638 help="operator consent mode: explicit, standing, or agent")
4639 p_morning.add_argument("--json", action="store_true", help="emit structured JSON")
4640 p_morning.set_defaults(func=_cmd_standalone, standalone_command="morning")
4642 p_wrap = sub.add_parser(
4643 "wrap",
4644 help="standalone session-wrap preflight contract",
4645 )
4646 p_wrap.add_argument("path", help="path to project.yaml")
4647 p_wrap.add_argument("title", nargs="?", default=None,
4648 help="optional PR title override to include in the contract")
4649 p_wrap.add_argument("--root", default=".", help="repo root for git and capability checks")
4650 p_wrap.add_argument("--since", default=None,
4651 help="optional session start label or timestamp")
4652 p_wrap.add_argument("--target", default=None,
4653 help="target text to include in the wrap contract")
4654 p_wrap.add_argument("--dry-run", action="store_true",
4655 help="explicitly mark the assessment as non-mutating")
4656 p_wrap.add_argument("--live", action="store_true",
4657 help="render a live preflight and fail if consent is missing")
4658 p_wrap.add_argument("--approve-scope", action="append", default=[],
4659 help="approve a consent scope for this run; repeat or comma-separate")
4660 p_wrap.add_argument("--operator", default=None,
4661 help="operator identifier to include in an approved consent record")
4662 p_wrap.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4663 help="operator consent mode: explicit, standing, or agent")
4664 p_wrap.add_argument("--json", action="store_true", help="emit structured JSON")
4665 p_wrap.set_defaults(func=_cmd_standalone, standalone_command="wrap")
4667 p_work_block = sub.add_parser(
4668 "work-block",
4669 help="standalone daytime multi-issue work-block preflight contract",
4670 )
4671 p_work_block.add_argument("path", help="path to project.yaml")
4672 p_work_block.add_argument("issues", nargs="*", type=_positive_int,
4673 help="explicit issue numbers to process in order")
4674 p_work_block.add_argument("--root", default=".",
4675 help="repo root for git and capability checks")
4676 p_work_block.add_argument("--queue", default=None,
4677 help=(
4678 "project queue selector to use when no explicit issues "
4679 "are given"
4680 ))
4681 p_work_block.add_argument("--max", dest="max_items", type=_positive_int, default=None,
4682 help="maximum issues to attempt in this work block")
4683 p_work_block.add_argument("--hours", type=float, default=None,
4684 help="optional time budget in hours")
4685 p_work_block.add_argument("--review-comments", choices=("inline", "summary"),
4686 default="inline",
4687 help="review posting mode to pass through ship handoffs")
4688 p_work_block.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
4689 help="reviewer override for ship handoff contracts")
4690 p_work_block.add_argument("--target", default=None,
4691 help="target text to include in the work-block contract")
4692 p_work_block.add_argument("--dry-run", action="store_true",
4693 help="explicitly mark the assessment as non-mutating")
4694 p_work_block.add_argument("--live", action="store_true",
4695 help="render a live preflight and fail if consent is missing")
4696 p_work_block.add_argument("--approve-scope", action="append", default=[],
4697 help="approve a consent scope for this run; repeat or comma-separate")
4698 p_work_block.add_argument("--operator", default=None,
4699 help="operator identifier to include in an approved consent record")
4700 p_work_block.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4701 help="operator consent mode: explicit, standing, or agent")
4702 p_work_block.add_argument("--json", action="store_true", help="emit structured JSON")
4703 p_work_block.set_defaults(func=_cmd_standalone, standalone_command="work-block")
4705 p_overnight = sub.add_parser(
4706 "overnight",
4707 help="standalone overnight-session preflight contract",
4708 )
4709 p_overnight.add_argument("path", help="path to project.yaml")
4710 p_overnight.add_argument("hours", nargs="?", type=float, default=None,
4711 help="optional time budget in hours")
4712 p_overnight.add_argument("--root", default=".", help="repo root for git and capability checks")
4713 p_overnight.add_argument("--max", dest="max_items", type=_positive_int, default=None,
4714 help="maximum issues to attempt in this session")
4715 p_overnight.add_argument("--review-comments", choices=("inline", "summary"), default="inline",
4716 help="review posting mode to pass through ship handoffs")
4717 p_overnight.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
4718 help="reviewer override for ship handoff contracts")
4719 p_overnight.add_argument("--target", default=None,
4720 help="target text to include in the overnight contract")
4721 p_overnight.add_argument("--dry-run", action="store_true",
4722 help="explicitly mark the assessment as non-mutating")
4723 p_overnight.add_argument("--live", action="store_true",
4724 help="render a live preflight and fail if consent is missing")
4725 p_overnight.add_argument("--approve-scope", action="append", default=[],
4726 help="approve a consent scope for this run; repeat or comma-separate")
4727 p_overnight.add_argument("--operator", default=None,
4728 help="operator identifier to include in an approved consent record")
4729 p_overnight.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4730 help="operator consent mode: explicit, standing, or agent")
4731 p_overnight.add_argument("--json", action="store_true", help="emit structured JSON")
4732 p_overnight.set_defaults(func=_cmd_standalone, standalone_command="overnight")
4734 p_regression = sub.add_parser(
4735 "regression",
4736 help="standalone scan-and-file regression preflight contract",
4737 )
4738 p_regression.add_argument("path", help="path to project.yaml")
4739 p_regression.add_argument("--root", default=".", help="repo root for git/capability checks")
4740 p_regression.add_argument("--scope", choices=("full", "changed", "since"), default="full",
4741 help="scan scope to include in the preflight target")
4742 p_regression.add_argument("--since", default=None,
4743 help="optional ref or timestamp when --scope since is used")
4744 p_regression.add_argument("--target", default=None,
4745 help="target text to include in the regression contract")
4746 p_regression.add_argument("--dry-run", action="store_true",
4747 help="explicitly mark the assessment as non-mutating")
4748 p_regression.add_argument("--live", action="store_true",
4749 help="render a live preflight and fail if consent is missing")
4750 p_regression.add_argument("--approve-scope", action="append", default=[],
4751 help="approve a consent scope for this run; repeat or comma-separate")
4752 p_regression.add_argument("--operator", default=None,
4753 help="operator identifier to include in an approved consent record")
4754 p_regression.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4755 help="operator consent mode: explicit, standing, or agent")
4756 p_regression.add_argument("--json", action="store_true", help="emit structured JSON")
4757 p_regression.set_defaults(func=_cmd_standalone, standalone_command="regression")
4759 p_review_all_day = sub.add_parser(
4760 "review-all-day",
4761 help="standalone time-window scan-and-file preflight contract",
4762 )
4763 p_review_all_day.add_argument("path", help="path to project.yaml")
4764 p_review_all_day.add_argument("days", nargs="?", type=_positive_int, default=1,
4765 help="number of merge-window days to scan")
4766 p_review_all_day.add_argument("--root", default=".", help="repo root for git/capability checks")
4767 p_review_all_day.add_argument("--target", default=None,
4768 help="target text to include in the review-all-day contract")
4769 p_review_all_day.add_argument("--dry-run", action="store_true",
4770 help="explicitly mark the assessment as non-mutating")
4771 p_review_all_day.add_argument("--live", action="store_true",
4772 help="render a live preflight and fail if consent is missing")
4773 p_review_all_day.add_argument("--approve-scope", action="append", default=[],
4774 help=("approve a consent scope for this run; repeat or "
4775 "comma-separate"))
4776 p_review_all_day.add_argument("--operator", default=None,
4777 help=("operator identifier to include in an approved "
4778 "consent record"))
4779 p_review_all_day.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4780 help="operator consent mode: explicit, standing, or agent")
4781 p_review_all_day.add_argument("--json", action="store_true", help="emit structured JSON")
4782 p_review_all_day.set_defaults(func=_cmd_standalone, standalone_command="review-all-day")
4784 p_caps = sub.add_parser("capabilities", help="print runtime capability report")
4785 p_caps.add_argument("--root", default=".", help="repo root for capability checks")
4786 p_caps.add_argument("--project", dest="path", default=None,
4787 help="optional project.yaml to evaluate requirements")
4788 p_caps.add_argument("--for", dest="for_command", default="ship",
4789 help="command requirement to evaluate when --project is set")
4790 p_caps.add_argument("--pr", type=int, default=None,
4791 help="PR number for ship capability requirements")
4792 p_caps.add_argument("--json", action="store_true", help="emit structured JSON")
4793 p_caps.set_defaults(func=_cmd_capabilities)
4795 p_doctor = sub.add_parser(
4796 "doctor",
4797 help="read-only diagnostics: CLI/adapter version drift, orphans, core_version, state",
4798 )
4799 p_doctor.add_argument("path", nargs="?", default=None,
4800 help="optional project.yaml (enables core_version + state checks)")
4801 p_doctor.add_argument("--root", default=".", help="project root to inspect")
4802 p_doctor.add_argument("--offline", action="store_true",
4803 help="skip the PyPI latest-version check (report latest as unknown)")
4804 p_doctor.add_argument("--strict", action="store_true",
4805 help="exit non-zero when any check fails (default: advisory, exit 0)")
4806 p_doctor.add_argument("--json", action="store_true", help="emit structured JSON")
4807 p_doctor.set_defaults(func=_cmd_doctor)
4809 p_proj = sub.add_parser("project-commands",
4810 help="list project-provided commands declared by policy")
4811 p_proj.add_argument("path", help="path to project.yaml")
4812 p_proj.add_argument("--json", action="store_true", help="emit structured JSON")
4813 p_proj.set_defaults(func=_cmd_project_commands)
4815 p_init = sub.add_parser("init", help="scaffold a default .keel/project.yaml for this repo")
4816 p_init.add_argument("--root", default=".", help="repo root to scaffold into")
4817 p_init.add_argument("--force", action="store_true", help="overwrite an existing config")
4818 p_init.add_argument("--wizard", action="store_true", help="prompt for values interactively")
4819 p_init.set_defaults(func=_cmd_init)
4821 p_setup = sub.add_parser(
4822 "setup",
4823 help="scaffold config, install adapters, validate, and render the plan",
4824 )
4825 p_setup.add_argument("--root", default=".", help="project root to set up")
4826 p_setup.add_argument(
4827 "--adapter-target",
4828 choices=("all", *install.TARGETS),
4829 default="all",
4830 help="adapter surface to install (default: all)",
4831 )
4832 p_setup.add_argument(
4833 "--force",
4834 action="store_true",
4835 help="overwrite existing config and generated adapters",
4836 )
4837 p_setup.add_argument("--wizard", action="store_true", help="prompt for config values")
4838 p_setup.set_defaults(func=_cmd_setup)
4840 p_ia = sub.add_parser("install-adapter", help="install the /keel:<command> adapters")
4841 p_ia.add_argument("agent",
4842 help=f"'all', 'plugin', or one of: {', '.join(install.TARGETS)}")
4843 p_ia.add_argument("--root", default=".", help="project root to install into")
4844 p_ia.add_argument("--force", action="store_true", help="overwrite existing adapters")
4845 p_ia.set_defaults(func=_cmd_install_adapter)
4847 p_as = sub.add_parser("adapter-status", help="report generated adapter freshness")
4848 p_as.add_argument("agent", nargs="?", default="all",
4849 help=f"'all' or one of: {', '.join(install.STATUS_TARGETS)}")
4850 p_as.add_argument("--root", default=".", help="project root to inspect")
4851 p_as.add_argument("--include-unmanaged", action="store_true",
4852 help="also report marker-less command-like surfaces (heuristic, opt-in)")
4853 p_as.add_argument("--json", action="store_true",
4854 help="emit adapter freshness + orphan/unmanaged findings as JSON")
4855 p_as.set_defaults(func=_cmd_adapter_status)
4857 p_ua = sub.add_parser("update-adapter", help="safely update generated adapters")
4858 p_ua.add_argument("agent", nargs="?", default="all",
4859 help=f"'all' or one of: {', '.join(install.TARGETS)}")
4860 p_ua.add_argument("--root", default=".", help="project root to update")
4861 p_ua.add_argument("--dry-run", action="store_true", help="show planned updates only")
4862 p_ua.set_defaults(func=_cmd_update_adapter)
4864 p_sync = sub.add_parser("sync", help="sync generated adapters with the installed keel package")
4865 p_sync.add_argument("--root", default=".", help="project root to update")
4866 p_sync.add_argument(
4867 "--target",
4868 choices=("all", *install.TARGETS),
4869 default="all",
4870 help="adapter surface to sync (default: all)",
4871 )
4872 p_sync.add_argument("--dry-run", action="store_true", help="show planned updates only")
4873 p_sync.set_defaults(func=_cmd_sync)
4875 p_lw = sub.add_parser(
4876 "install-legacy-wrappers",
4877 help="install thin legacy command wrappers that delegate to /keel:<command>",
4878 )
4879 p_lw.add_argument("agent", help=f"'all' or one of: {', '.join(install.LEGACY_TARGETS)}")
4880 p_lw.add_argument("--root", default=".", help="project root to install into")
4881 p_lw.add_argument("--force", action="store_true", help="overwrite existing wrappers")
4882 p_lw.add_argument(
4883 "--parity-matrix",
4884 default="docs/keel/parity-matrix.md",
4885 help="markdown parity matrix whose ready rows allow wrapper generation",
4886 )
4887 p_lw.add_argument(
4888 "--command",
4889 action="append",
4890 type=_parse_legacy_mapping,
4891 default=[],
4892 metavar="LEGACY=KEEL",
4893 help="install one wrapper mapping; repeat for multiple commands",
4894 )
4895 p_lw.set_defaults(func=_cmd_install_legacy_wrappers)
4897 return parser
4900def _add_ship_parser(parser: argparse.ArgumentParser, *, command: str) -> None:
4901 parser.add_argument("path", help="path to project.yaml")
4902 parser.add_argument("--root", default=".", help="repo root for git, gates + extensions")
4903 parser.add_argument("--pr", type=int, default=None, help="PR number for CI status (gh)")
4904 parser.add_argument("--hotfix", action="store_true", help="emergency: bypass the merge window")
4905 parser.add_argument("--dry-run", action="store_true",
4906 help="explicitly mark the assessment as non-mutating")
4907 parser.add_argument("--live", action="store_true",
4908 help=("run the live preflight gate and fail before gates "
4909 "if consent is missing"))
4910 parser.add_argument("--approve-scope", action="append", default=[],
4911 help="approve a consent scope for this run; repeat or comma-separate")
4912 parser.add_argument("--operator", default=None,
4913 help="operator identifier to include in an approved consent record")
4914 parser.add_argument("--consent-mode", choices=consent.CONSENT_MODES, default=None,
4915 help="operator consent mode: explicit, standing, or agent")
4916 parser.add_argument("--target", default=None,
4917 help="task target to include in the consent prompt and record")
4918 parser.add_argument("--append-ledger", action="store_true",
4919 help="append the structured ship run record when --live succeeds")
4920 parser.add_argument("--run-id", default=None,
4921 help="operator/session run id to store in the run ledger record")
4922 parser.add_argument("--run-events-file", default=None,
4923 help="JSON run-events file to evaluate and stamp into the ledger record")
4924 parser.add_argument("--max-rounds", type=_positive_int, default=None,
4925 help="explicit run-control work-unit budget override")
4926 parser.add_argument("--issue", type=_positive_int, default=None,
4927 help="issue number to store in the run ledger record")
4928 parser.add_argument("--pull-request", dest="ledger_pr", type=_positive_int, default=None,
4929 help="PR number to store in the run ledger record without CI lookup")
4930 parser.add_argument("--branch", default=None,
4931 help="branch name to store in the run ledger record")
4932 parser.add_argument("--head-sha", default=None,
4933 help="head commit SHA to store in the run ledger record")
4934 parser.add_argument("--declared-file", action="append", default=None,
4935 help="implementer's declared in-scope file path (repeatable); "
4936 "recorded for keel scope-verify branch-contamination checks")
4937 parser.add_argument("--capture-status", type=_capture_status_arg, default=None,
4938 help="capture outcome to store in the run ledger record")
4939 parser.add_argument("--capture-reason", default=None,
4940 help="capture outcome reason to store in the run ledger record")
4941 parser.add_argument("--capture-artifact", default=None,
4942 help="durable capture artifact reference (path or content hash) "
4943 "proving an applied capture; required for clean reconcile")
4944 parser.add_argument("--implementer", default=None,
4945 help="effective implementer codename or vendor/model label")
4946 parser.add_argument("--reviewer-agent", action="append", default=[],
4947 help="effective reviewer codename or vendor/model label; repeatable")
4948 parser.add_argument("--tester", default=None,
4949 help="effective tester codename or vendor/model label")
4950 parser.add_argument("--host-agent", default=None,
4951 help="host agent codename (e.g. claude/codex/agy) for the run context")
4952 parser.add_argument("--transport", choices=("gh", "mcp"), default=None,
4953 help="detected GitHub transport for the run context; "
4954 "defaults to the resolved transport when omitted")
4955 parser.add_argument("--strict-run-context", action="store_true",
4956 help="block live ledger append when required run-context fields "
4957 "would degrade")
4958 parser.add_argument("--issue-title", default=None,
4959 help="issue title to include in the intake/readiness contract")
4960 parser.add_argument("--issue-body", default=None,
4961 help="issue body markdown to include in the intake/readiness contract")
4962 parser.add_argument("--issue-label", action="append", default=[],
4963 help="issue label for intake/readiness; repeat or comma-separate")
4964 parser.add_argument("--review-comments", choices=("inline", "summary"), default="inline",
4965 help="review posting mode for the resolved ship contract")
4966 parser.add_argument("--reviewers", type=int, choices=(1, 2, 3), default=None,
4967 help="override the risk-derived reviewer count")
4968 parser.add_argument("--jury", action="store_true",
4969 help="enable the cross-vendor jury gate")
4970 parser.add_argument("--no-jury", action="store_true",
4971 help="disable the cross-vendor jury gate")
4972 parser.add_argument("--jury-advisory", action="store_true",
4973 help="make an enabled jury advisory instead of merge-gating")
4974 parser.add_argument("--profile", choices=("standard", "compound"), default="standard",
4975 help="workflow profile: standard (default) or compound")
4976 parser.add_argument("--compound", action="store_true",
4977 help="select the compound workflow profile (alias for --profile compound)")
4978 parser.add_argument("--json", action="store_true", help="emit structured JSON")
4979 parser.set_defaults(func=_cmd_ship, ship_command=command)
4982def _positive_int(value: str) -> int:
4983 parsed = int(value)
4984 if parsed <= 0:
4985 raise argparse.ArgumentTypeError("must be a positive integer")
4986 return parsed
4989def _nonnegative_int(value: str) -> int:
4990 parsed = int(value)
4991 if parsed < 0:
4992 raise argparse.ArgumentTypeError("must be a non-negative integer")
4993 return parsed
4996def _parse_pr_issue_mapping(value: str) -> tuple[int, int]:
4997 if "=" not in value:
4998 raise argparse.ArgumentTypeError("linked issue mapping must be PR=ISSUE")
4999 raw_pr, raw_issue = value.split("=", 1)
5000 try:
5001 pr = _positive_int(raw_pr)
5002 issue = _positive_int(raw_issue)
5003 except (argparse.ArgumentTypeError, ValueError) as exc:
5004 raise argparse.ArgumentTypeError("linked issue mapping must be PR=ISSUE") from exc
5005 return pr, issue
5008def _capture_status_arg(value: str) -> str:
5009 if value == "skipped":
5010 return value
5011 try:
5012 capture.normalize_status(value)
5013 except capture.CaptureError as exc:
5014 raise argparse.ArgumentTypeError(str(exc)) from exc
5015 return value
5018def main(argv: list[str] | None = None) -> int:
5019 parser = build_parser()
5020 args = parser.parse_args(argv)
5021 func = getattr(args, "func", None)
5022 if func is None:
5023 parser.print_help()
5024 return 2
5025 try:
5026 return func(args)
5027 except ledger.LedgerError as exc:
5028 print(f"invalid ledger path: {exc}", file=sys.stderr)
5029 return 1
5030 except checkpoint.CheckpointError as exc:
5031 print(f"invalid checkpoint path: {exc}", file=sys.stderr)
5032 return 1