Coverage for src/keel/contracts.py: 100%
307 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
1"""Structured command contracts for adapters and parity tests.
3The contract is intentionally plain JSON-compatible data. Agent adapters can read it before
4mutating work starts, compare required capabilities with the current runtime, and execute the
5same command graph without re-deriving keel behavior from prose.
6"""
8from __future__ import annotations
10import re
11from collections.abc import Mapping
12from dataclasses import asdict
13from pathlib import Path
14from typing import Any
16from . import (
17 agents,
18 artifacts,
19 capture,
20 checkpoint,
21 closure,
22 consent,
23 evidence,
24 gates,
25 github_transport,
26 install,
27 intake,
28 ledger,
29 lock,
30 loop,
31 model,
32 orchestrator,
33 provenance,
34 runcontrols,
35 runtime,
36 stepverifier,
37 tdd,
38 team,
39 workblock,
40 workcreation,
41)
42from . import config as cfg
43from . import ship as ship_decisions
44from .extensions import Extension
45from .project_commands import get_project_command, list_project_commands
47SCHEMA_VERSION = "keel.command-contract.v1"
49_COMPOUND_OVERRIDES: dict[str, str] = {
50 "s4": "compound",
51 "s7": "compound",
52 "s9": "compound",
53 "s11": "compound",
54}
56_STEP_RE = re.compile(
57 r"^#{2,3}\s+(?P<id>(?:Step\s+[0-9A-Za-z.]+|s\d+))\s*(?:[\u2014-]\s*)?"
58 r"(?P<name>.*)$"
59)
61_BASE_SIDE_EFFECTS: dict[str, tuple[str, ...]] = {
62 "ship": (
63 "git_worktree",
64 "git_branch",
65 "file_edit",
66 "git_push",
67 "pull_request",
68 "comments",
69 "reviews",
70 "merge",
71 "issue_close",
72 "capture",
73 ),
74 "pr-loop": ("file_edit", "git_commit", "git_push", "comments", "reviews", "check_runs"),
75 "review-cycle": ("file_edit", "comments", "reviews", "git_commit", "git_push"),
76 "morning": ("issue_read", "pr_read", "report_write"),
77 "wrap": ("git_commit", "git_push", "pull_request", "session_recap"),
78 "work-block": (
79 "git_branch",
80 "git_push",
81 "pull_request",
82 "comments",
83 "reviews",
84 "merge",
85 "deferral_queue",
86 "session_report",
87 ),
88 "overnight": (
89 "git_branch",
90 "git_push",
91 "pull_request",
92 "comments",
93 "reviews",
94 "merge",
95 "deferral_queue",
96 "session_report",
97 ),
98 "implement": (
99 "git_worktree",
100 "git_branch",
101 "file_edit",
102 "git_commit",
103 "git_push",
104 "pull_request",
105 "comments",
106 ),
107 "ci-check": ("check_runs",),
108 "triage": ("labels", "comments"),
109 "stale-prs": ("comments", "git_checkout", "git_push"),
110 "regression": ("git_worktree", "issue_write", "labels"),
111 "review-all-day": ("issue_write", "labels"),
112 "coverage": ("git_worktree", "git_checkout", "comments", "labels", "issue_write"),
113 "deps-audit": ("comments", "issue_write"),
114 "flake-audit": ("issue_write", "comments"),
115}
117_DEFAULT_FEEDBACK_WORKFLOWS: dict[str, dict[str, Any]] = {
118 "pr-loop": {
119 "posting_mode": "summary",
120 "posting_owner": "orchestrator",
121 "reviewer_isolation": {
122 "shared_with_ship": True,
123 "codename_prefix": "PR-LOOP",
124 "no_cross_reading": True,
125 },
126 "inputs": {
127 "auto_detect_current_branch": True,
128 "explicit_pr_targets": True,
129 "reads_review_comments": True,
130 "reads_issue_conversation_comments": True,
131 },
132 "ci": {
133 "recheck_after_push": True,
134 "green_required_to_exit": True,
135 "degrade_when_logs_unavailable": True,
136 },
137 "fix_loop": {
138 "budget": 3,
139 "self_review_before_push": True,
140 "reviewer_fanout_after_each_push": True,
141 },
142 "completion": {
143 "marker": None,
144 "merge": "handoff",
145 "summary_comment": True,
146 },
147 },
148 "review-cycle": {
149 "posting_mode": "inline",
150 "posting_owner": "orchestrator",
151 "reviewer_isolation": {
152 "shared_with_ship": True,
153 "codename_prefix": "REVIEW-CYCLE",
154 "no_cross_reading": True,
155 },
156 "inputs": {
157 "multi_pr": True,
158 "sequential_pr_processing": True,
159 },
160 "review": {
161 "parallel_reviewers_within_pr": True,
162 "partial_reviewer_failures_degrade": True,
163 "severity_histogram_source_of_truth": True,
164 },
165 "fix_loop": {
166 "budget": 3,
167 "enabled": True,
168 },
169 "completion": {
170 "marker": "review-cycle-complete",
171 "marker_after_summary": True,
172 "merge": "never",
173 "formal_approval": "never",
174 },
175 },
176}
178_REPORTING_COMMANDS = {"coverage", "deps-audit", "flake-audit"}
181def available_commands() -> tuple[str, ...]:
182 """Every packaged adapter command that can expose a structured contract."""
183 return tuple(name.removesuffix(".md") for name in install.adapter_names())
186def command_graph(command: str, *, profile: str = "standard") -> list[dict[str, Any]]:
187 """Return the command's step graph as JSON-compatible records.
189 ``ship`` uses the fixed keel backbone as the canonical graph. When the ``compound``
190 profile is selected, the s4/s7/s9/s11 steps are marked as compound overrides. Other
191 commands expose their adapter step headings, so adapters can still reason about their
192 command-local sequence without parsing Markdown themselves.
193 """
194 if command == "ship":
195 compound = profile == "compound"
196 return [
197 {
198 "step_id": step.id,
199 "step_name": step.name,
200 "agentic": step.agentic,
201 "slot": step.slot,
202 "source": "backbone",
203 "profile_step": _COMPOUND_OVERRIDES.get(step.id, "standard")
204 if compound
205 else "standard",
206 }
207 for step in model.BACKBONE
208 ]
209 steps = _adapter_steps(command)
210 return steps if steps else []
213def build_command_contract(
214 *,
215 command: str,
216 profile: str = "standard",
217 config: cfg.ProjectConfig,
218 loaded: dict[str, list[Extension]],
219 plan: tuple[orchestrator.PlanItem, ...],
220 requirement: runtime.CapabilityRequirement,
221 evaluation: runtime.CapabilityEvaluation,
222 transport: github_transport.GitHubTransport,
223 extension_problems: tuple[str, ...] = (),
224 dry_run: bool = True,
225 approved_consent_scopes: tuple[str, ...] = (),
226 consent_approval_source: str = "flag",
227 consent_mode: str = "explicit",
228 operator: str | None = None,
229 target: str | None = None,
230 reviewer_override: int | None = None,
231 review_tier: int | None = None,
232 review_comments: str = "inline",
233 jury: bool = False,
234 no_jury: bool = False,
235 jury_advisory: bool = False,
236 issue_title: str | None = None,
237 issue_body: str | None = None,
238 #: The retrieved past learnings for this task (#1155), measured by the caller
239 #: because reading a directory is I/O. The **plan** contract carries it as well
240 #: as ship's: s4 composes the implement brief from what `keel plan` printed,
241 #: long before s5 runs `keel ship`, so wiring it into ship alone meant the brief
242 #: this feature exists for never saw a lesson.
243 learnings: dict[str, Any] | None = None,
244 issue_labels: tuple[str, ...] = (),
245 role: str | None = None,
246 delegate: str | None = None,
247 review_delegates: tuple[str, ...] = (),
248 host_agent: str = agents.HOST_DEFAULT,
249 tdd_override: bool = False,
250 loop_override: bool = False,
251 #: An explicit `--max-iterations` budget for this run, which outranks both the
252 #: flag and the knob and publishes its own source (#1173).
253 loop_budget: int | None = None,
254 effort: str | None = None,
255 team_profile: str | None = None,
256 jury_availability: Mapping[str, Any] | None = None,
257) -> dict[str, Any]:
258 """Build the stable adapter contract shared by ``plan --json`` and dry-run commands.
260 ``tdd_override`` is the per-run ``--tdd`` flag. The resolved s4 profile is published as
261 ``implement_mode`` — a sibling of ``workflow_profile``, because ``tdd`` is an s4 profile
262 the same way ``compound`` is a workflow one — and is what selects the ``tdd-order`` gate
263 in the contract's ``gates`` list."""
264 implement_mode = tdd.resolve_mode(config.knobs.implement_mode, flag=tdd_override)
265 # The s4 iteration policy rides inside `implement_mode` (#1165): it is not a third
266 # profile but a policy around whichever profile is running, and `wraps` says which.
267 loop_policy = loop.resolve(
268 config.knobs.loop,
269 flag=loop_override,
270 implement_mode=implement_mode.name,
271 max_iterations=loop_budget,
272 )
273 declared_side_effects = command_side_effects(command, config, requirement, loaded)
274 graph = command_graph(command, profile=profile)
275 if not graph and (project_command := get_project_command(config, command)):
276 graph = [
277 {
278 "step_id": f"project-command:{project_command.name}",
279 "step_name": project_command.name,
280 "agentic": bool(project_command.agent_role),
281 "slot": None,
282 "source": "project_command",
283 }
284 ]
285 contract = {
286 "schema_version": SCHEMA_VERSION,
287 "command": command,
288 "mode": "dry-run" if dry_run else "live",
289 "dry_run": dry_run,
290 "no_mutations": dry_run,
291 "project": project_as_dict(config),
292 "workflow_profile": workflow_profile(command, profile=profile),
293 "implement_mode": {**implement_mode.as_dict(), "loop": loop_policy.as_dict()},
294 "graph": graph,
295 "backbone_plan": orchestrator.plan_as_dict(plan),
296 "gates": [
297 gate_as_dict(spec)
298 for spec in gates.plan_gates(config, loaded, implement_mode=implement_mode.name)
299 ],
300 "project_commands": [command.as_dict() for command in list_project_commands(config)],
301 "extension_hooks": extension_hooks_as_dict(config, loaded),
302 "extension_problems": list(extension_problems),
303 "required_capabilities": list(requirement.required),
304 "optional_capabilities": list(requirement.optional),
305 "capabilities": evaluation.as_dict(),
306 "github_transport": transport.as_dict(),
307 "checkpoint": checkpoint.checkpoint_contract_as_dict(config),
308 "capture": capture.contract_as_dict(config),
309 # The same block `keel ship` publishes, so s4 can open the implement brief
310 # from the plan it was told not to re-derive.
311 "learnings": learnings if learnings is not None else capture.learning_retrieval_as_dict(),
312 "run_ledger": ledger.ledger_contract_as_dict(config),
313 "resource_claims": lock.contract_as_dict(),
314 "side_effects": {
315 "declared": list(declared_side_effects),
316 "mutates_in_dry_run": False,
317 },
318 "operator_consent": consent.build_consent_contract(
319 command=command,
320 side_effects=declared_side_effects,
321 dry_run=dry_run,
322 approved_scopes=approved_consent_scopes,
323 approval_source=consent_approval_source,
324 mode=consent_mode,
325 operator=operator,
326 target=target,
327 ),
328 "agent_output_provenance": provenance.contract_as_dict(),
329 }
330 if command == "morning":
331 contract["morning_contract"] = morning_contract_as_dict(
332 config=config,
333 evaluation=evaluation,
334 transport=transport,
335 )
336 if command in _REPORTING_COMMANDS:
337 contract["reporting_contract"] = reporting_contract_as_dict(
338 command=command,
339 config=config,
340 transport=transport,
341 )
342 if command in {"wrap", "work-block", "overnight"}:
343 contract["session_contract"] = session_contract_as_dict(
344 command=command,
345 config=config,
346 transport=transport,
347 delegation=workblock.delegation_as_dict(
348 delegate=delegate,
349 review_delegates=review_delegates,
350 effort=effort,
351 team_profile=team_profile,
352 reviewer_override=reviewer_override,
353 ),
354 )
355 if command in {"regression", "review-all-day"}:
356 contract["scan_contract"] = scan_contract_as_dict(
357 command=command,
358 config=config,
359 transport=transport,
360 )
361 if command == "triage":
362 contract["triage_contract"] = {
363 "scope": "label-only-advisory",
364 "one_comment_per_issue": True,
365 "renderer": "keel.artifacts.render_triage_audit",
366 "marker": artifacts.TRIAGE_AUDIT_MARKER,
367 }
368 if command in {"ship", "pr-loop", "review-cycle", "work-block", "overnight"}:
369 # Resolved once and shared: the reviewer bench the contract publishes and the
370 # assignment a host dispatches have to be the same answer, or two agents reading
371 # the same JSON run different teams (#1014).
372 assignment = team.resolve_assignment(
373 config.knobs.team,
374 tier=review_tier,
375 role=role,
376 default_count=ship_decisions.reviewer_count(review_tier or 2),
377 reviewer_override=reviewer_override,
378 delegate=delegate,
379 review_delegates=review_delegates,
380 host_agent=host_agent,
381 legacy=agents.legacy_team_seats(config),
382 jury_disabled=no_jury,
383 jury_advisory=jury_advisory,
384 team_profile=team_profile,
385 effort=effort,
386 # The panel-availability probe, measured by
387 # `keel.providerprobe.jury_availability` and handed to every resolver
388 # (#1066). This is one more site that resolves a bench, and it has to see
389 # the same measurement as the rest or `keel plan` publishes a panel the run
390 # it plans cannot convene.
391 jury_availability=jury_availability,
392 )
393 contract["assignment"] = assignment
394 contract["review_merge_contract"] = ship_decisions.resolve_review_contract(
395 tier=review_tier,
396 reviewer_override=reviewer_override,
397 review_comments=review_comments,
398 gates=config.gates,
399 policy_pack=config.policy_pack,
400 jury=jury,
401 no_jury=no_jury,
402 jury_advisory=jury_advisory,
403 require_distinct_vendors=config.knobs.evidence_require_distinct_vendors,
404 assignment=assignment,
405 learnings=learnings,
406 )
407 if command == "ship":
408 contract["evidence"] = evidence.contract_as_dict(
409 contract["review_merge_contract"],
410 dry_run=dry_run,
411 )
412 contract["step_verification"] = stepverifier.contract_as_dict(
413 contract["review_merge_contract"],
414 dry_run=dry_run,
415 )
416 contract["run_controls"] = runcontrols.contract_as_dict()
417 if command == "ship":
418 contract["closure_comment"] = closure.contract_as_dict()
419 contract["artifact_renderers"] = artifacts.contract_as_dict()
420 if command in {"ship", "implement", "overnight"}:
421 contract["issue_intake"] = intake.assess_issue(
422 title=issue_title,
423 body=issue_body,
424 labels=issue_labels,
425 )
426 if command in {"pr-loop", "review-cycle"}:
427 contract["feedback_workflow"] = feedback_workflow_as_dict(config, command)
428 return contract
431def workflow_profile(command: str, *, profile: str = "standard") -> dict[str, Any]:
432 """First-class workflow profile metadata for command variants.
434 ``ship`` carries the ``standard`` profile by default; selecting the ``compound``
435 profile swaps the s4/s7/s9/s11 steps to compound step overrides without forking the
436 backbone.
437 """
438 if command == "ship" and profile == "compound":
439 return {
440 "name": "ship",
441 "profile": "compound",
442 "inherits": "ship",
443 "first_class_variant": True,
444 "shared_primitives": [
445 "select",
446 "branch",
447 "worktree",
448 "guard",
449 "classify",
450 "ci",
451 "test",
452 "merge_window",
453 "merge_lock",
454 "merge",
455 "capture_marker",
456 "close",
457 ],
458 "step_overrides": {
459 "s4": {
460 "step": "implement",
461 "mode": "compound",
462 "reason": "compound implement and PR-quality pass",
463 },
464 "s7": {
465 "step": "review",
466 "mode": "compound",
467 "reason": "persona and diff-aware reviewer fan-out",
468 },
469 "s9": {
470 "step": "fixloop",
471 "mode": "compound",
472 "reason": "structured PR-feedback resolution",
473 },
474 "s11": {
475 "step": "capture",
476 "mode": "compound",
477 "reason": "durable-learning capture",
478 },
479 },
480 }
481 if command == "pr-loop":
482 return {
483 "name": "pr-loop",
484 "profile": "feedback-loop",
485 "inherits": "ship.s6-s9",
486 "first_class_variant": True,
487 "shared_primitives": [
488 "linked_worktree_preflight",
489 "github_transport",
490 "reviewer_isolation",
491 "ci_recheck",
492 "fixloop",
493 "summary_comment",
494 "operator_consent",
495 ],
496 "step_overrides": {
497 "merge": {
498 "mode": "handoff",
499 "reason": "pr-loop exits after feedback and CI are satisfied.",
500 },
501 },
502 }
503 if command == "review-cycle":
504 return {
505 "name": "review-cycle",
506 "profile": "review-feedback",
507 "inherits": "ship.s7-s9",
508 "first_class_variant": True,
509 "shared_primitives": [
510 "multi_pr_targets",
511 "reviewer_isolation",
512 "posting_mode",
513 "severity_histogram",
514 "fixloop",
515 "completion_marker",
516 "operator_consent",
517 ],
518 "step_overrides": {
519 "merge": {
520 "mode": "never",
521 "reason": "review-cycle never merges or posts formal approval.",
522 },
523 },
524 }
525 if command == "implement":
526 return {
527 "name": "implement",
528 "profile": "standalone-step",
529 "inherits": "ship.s4",
530 "first_class_variant": True,
531 "shared_primitives": [
532 "issue_target",
533 "branch",
534 "worktree",
535 "implementer_routing",
536 "operator_consent",
537 "handoff",
538 ],
539 "step_overrides": {},
540 }
541 if command == "ci-check":
542 return {
543 "name": "ci-check",
544 "profile": "standalone-diagnostic",
545 "inherits": None,
546 "first_class_variant": True,
547 "shared_primitives": [
548 "github_transport",
549 "check_runs",
550 "latest_run_context",
551 "log_diagnostics",
552 "read_only",
553 "routing_recommendation",
554 ],
555 "step_overrides": {},
556 }
557 if command == "morning":
558 return {
559 "name": "morning",
560 "profile": "daily-brief",
561 "inherits": None,
562 "first_class_variant": True,
563 "shared_primitives": [
564 "date_window",
565 "deferral_queue",
566 "shipped_since",
567 "github_summary",
568 "health_providers",
569 "priority_sources",
570 "ranked_focus",
571 "report_output",
572 ],
573 "step_overrides": {},
574 }
575 if command == "wrap":
576 return {
577 "name": "wrap",
578 "profile": "session-wrap",
579 "inherits": None,
580 "first_class_variant": True,
581 "shared_primitives": [
582 "linked_worktree_preflight",
583 "base_branch_guard",
584 "configured_gates",
585 "conventional_commit",
586 "ready_pr_create",
587 "session_recap",
588 "deferral_queue",
589 "operator_consent",
590 ],
591 "step_overrides": {},
592 }
593 if command == "overnight":
594 return {
595 "name": "overnight",
596 "profile": "session-overnight",
597 "inherits": "ship",
598 "first_class_variant": True,
599 "shared_primitives": [
600 "work_block",
601 "merge_window",
602 "ship_handoff",
603 "priority_queue",
604 "per_issue_worktree",
605 "no_night_merge",
606 "blocker_policy",
607 "session_report",
608 "deferral_queue",
609 "stop_conditions",
610 "operator_consent",
611 ],
612 "step_overrides": {},
613 }
614 if command == "work-block":
615 return {
616 "name": "work-block",
617 "profile": "session-work-block-daytime",
618 "inherits": "ship",
619 "first_class_variant": True,
620 "shared_primitives": [
621 "work_block",
622 "queue_snapshot",
623 "readiness_refresh",
624 "ship_handoff",
625 "per_issue_worktree",
626 "operator_between_item_control",
627 "progress_snapshot",
628 "session_report",
629 "deferral_queue",
630 "stop_conditions",
631 "operator_consent",
632 ],
633 "step_overrides": {},
634 }
635 if command in _REPORTING_COMMANDS:
636 return {
637 "name": command,
638 "profile": "reporting",
639 "inherits": None,
640 "first_class_variant": True,
641 "shared_primitives": [
642 "project_policy",
643 "github_transport",
644 "codename_anchor",
645 "dedupe",
646 "dry_run_no_mutations",
647 "ship_handoff",
648 "operator_consent",
649 ],
650 "step_overrides": {},
651 }
652 if command == "regression":
653 return {
654 "name": "regression",
655 "profile": "scan-and-file",
656 "inherits": None,
657 "first_class_variant": True,
658 "shared_primitives": [
659 "canonical_base_scan",
660 "clean_tree_preflight",
661 "read_only_worktree",
662 "area_fanout",
663 "reviewer_isolation",
664 "confidence_filter",
665 "dedupe",
666 "issue_lock",
667 "issue_create",
668 "ship_handoff",
669 "final_report",
670 "operator_consent",
671 ],
672 "step_overrides": {},
673 }
674 if command == "review-all-day":
675 return {
676 "name": "review-all-day",
677 "profile": "time-window-scan",
678 "inherits": None,
679 "first_class_variant": True,
680 "shared_primitives": [
681 "merge_window_span",
682 "remote_ref_scope",
683 "batch_or_fanout",
684 "reviewer_isolation",
685 "diff_truncation",
686 "finding_filter",
687 "dedupe",
688 "issue_prefix",
689 "issue_create",
690 "final_report",
691 "operator_consent",
692 ],
693 "step_overrides": {},
694 }
695 if command == "ship":
696 return {
697 "name": "ship",
698 "profile": "standard",
699 "inherits": None,
700 "first_class_variant": True,
701 "shared_primitives": [
702 "select",
703 "branch",
704 "guard",
705 "implement",
706 "classify",
707 "ci",
708 "review",
709 "test",
710 "fixloop",
711 "merge",
712 "capture",
713 "close",
714 ],
715 "step_overrides": {},
716 }
717 return {
718 "name": command,
719 "profile": "adapter",
720 "inherits": None,
721 "first_class_variant": False,
722 "shared_primitives": [],
723 "step_overrides": {},
724 }
727def command_side_effects(
728 command: str,
729 config: cfg.ProjectConfig,
730 requirement: runtime.CapabilityRequirement,
731 loaded: dict[str, list[Extension]],
732) -> tuple[str, ...]:
733 """Return command side effects plus project capability-derived consent effects."""
734 effects: list[str] = list(_BASE_SIDE_EFFECTS.get(command, ()))
735 if project_command := get_project_command(config, command):
736 effects.extend(project_command.side_effects)
737 effects.extend(consent.capability_side_effects(requirement.required))
738 effects.extend(consent.capability_side_effects(requirement.optional))
739 for extensions in loaded.values():
740 for ext in extensions:
741 effects.extend(consent.capability_side_effects(ext.required_capabilities))
742 effects.extend(consent.capability_side_effects(ext.optional_capabilities))
743 return tuple(dict.fromkeys(effects))
746def reporting_contract_as_dict(
747 *,
748 command: str,
749 config: cfg.ProjectConfig,
750 transport: github_transport.GitHubTransport | None = None,
751) -> dict[str, Any]:
752 """Project-neutral reporting parity contract for audit/report adapters."""
753 github = transport.as_dict() if transport is not None else {}
754 base = {
755 "command": command,
756 "base_branch": config.base_branch,
757 "timezone": config.timezone,
758 "github_transport": github,
759 "policy_source": "policy_pack + adapter arguments",
760 "dry_run": {
761 "mutates": False,
762 "prints_planned_writes": True,
763 },
764 "handoff": {
765 "fixes_route_to": "ship",
766 "auto_applies_fixes": False,
767 },
768 "work_creation_policy": workcreation.contract_as_dict(
769 near_text_similarity=_near_text_similarity(config),
770 ),
771 }
772 if command == "coverage":
773 return {
774 **base,
775 "target": "pull_request",
776 "codename_prefix": "COVERAGE-<PR>-",
777 "renderer": "keel.artifacts.render_coverage_delta",
778 "marker": artifacts.COVERAGE_DELTA_MARKER,
779 "idempotency": {
780 "scope": "one-comment-per-pr",
781 "find_by_first_line_prefix": "COVERAGE-<PR>-",
782 "existing_comment": "update-in-place",
783 "update_unavailable": "do-not-post-duplicate",
784 },
785 "labels": {
786 "regression": "coverage-regression",
787 "operation": "idempotent-add-or-remove",
788 },
789 "degradation": {
790 "unwired_tool": "skip-area",
791 "coverage_command_failure": "fatal",
792 },
793 "arguments": ["pr", "--base", "--threshold", "--changed", "--open-issues", "--dry-run"],
794 }
795 if command == "deps-audit":
796 return {
797 **base,
798 "target": "daily_tracking_issue",
799 "tracking_issue_title": "deps-audit: <DATE>",
800 "codename_prefix": "DEPS-AUDIT-<DATE>-",
801 "renderer": "keel.artifacts.render_deps_audit",
802 "marker": artifacts.DEPS_AUDIT_MARKER,
803 "idempotency": {
804 "scope": "append-per-run",
805 "find_tracking_issue_by_exact_title": "deps-audit: <DATE>",
806 "find_latest_run_by_first_line_prefix": "DEPS-AUDIT-<DATE>-",
807 "existing_comment": "append-fresh-run-comment",
808 },
809 "degradation": {
810 "per_ecosystem_failure": "skipped-section",
811 "argument_failure": "fatal",
812 },
813 "arguments": [
814 "ecosystem",
815 "--severity",
816 "--security-only",
817 "--open-issues",
818 "--dry-run",
819 ],
820 }
821 if command == "flake-audit":
822 return {
823 **base,
824 "target": "ci_history_or_local_runs",
825 "codename_prefix": "FLAKE-AUDIT-<DATE>-",
826 "renderer": "keel.artifacts.render_flake_audit",
827 "marker": artifacts.FLAKE_AUDIT_MARKER,
828 "idempotency": {
829 "scope": "one-issue-per-flake",
830 "dedupe_issue_title": "flaky test: <fully.qualified.name>",
831 "find_run_by_first_line_prefix": "FLAKE-AUDIT-<DATE>-",
832 },
833 "classification": {
834 "rule": "across-run-disagreement-only",
835 "minimum_failures": 3,
836 "consistent_failures": "real-bug-not-flake",
837 },
838 "degradation": {
839 "artifact_unavailable": "run-level-limitations-section",
840 "no_ci_or_local_gate": "clean-exit",
841 },
842 "arguments": ["--days", "--runs", "--threshold", "--open-issues", "--dry-run"],
843 }
844 return base
847def project_as_dict(config: cfg.ProjectConfig) -> dict[str, Any]:
848 """Resolved project config summary safe for adapter planning."""
849 return {
850 "config_hash": cfg.config_hash(config),
851 "extends": config.extends,
852 "core_version": config.core_version,
853 "base_branch": config.base_branch,
854 "owner": config.owner,
855 "repo": config.repo,
856 "platform": config.platform,
857 "timezone": config.timezone,
858 "merge_window": config.merge_window,
859 "merge_window_mode": config.merge_window_mode,
860 "extensions_dir": config.extensions_dir,
861 "gates": list(config.gates),
862 "extensions": {slot: list(files) for slot, files in sorted(config.extensions.items())},
863 "policy_pack": config.policy_pack,
864 "knobs": {
865 "build_gate_cmd": config.knobs.build_gate_cmd,
866 "lint_cmd": config.knobs.lint_cmd,
867 "implementer_agents": dict(sorted(config.knobs.implementer_agents.items())),
868 **cfg.delegate_profiles_dict(config),
869 **team.canonical(config.knobs.team),
870 "tier3_globs": list(config.knobs.tier3_globs),
871 "ci_workflows": dict(sorted(config.knobs.ci_workflows.items())),
872 "docs_gate_paths": list(config.knobs.docs_gate_paths),
873 "docs_only_allowlist": list(config.knobs.docs_only_allowlist),
874 "sot_doc": config.knobs.sot_doc,
875 "required_capabilities": list(config.knobs.required_capabilities),
876 "optional_capabilities": list(config.knobs.optional_capabilities),
877 },
878 }
881def gate_as_dict(spec: gates.GateSpec) -> dict[str, Any]:
882 """Render a planned gate without losing its capability declarations."""
883 return asdict(spec)
886def gate_outcome_as_dict(outcome: gates.GateOutcome) -> dict[str, Any]:
887 """One gate outcome as ``keel ship --json`` and ``keel run-gates --json`` publish it.
889 The severity and whether the gate ran at all travel with it (#1165): ``keel loop
890 brief`` reads these documents, and without them a failing *soft* gate would hold the
891 loop open and an unrun blocking gate would read as a pass. ``unconfigured`` says the
892 gate cannot judge at all — no command, or no gate planned — which the loop stops on
893 at once rather than iterating against (#1364).
894 """
895 return {
896 "gate": outcome.gate,
897 "ok": outcome.ok,
898 "skipped": outcome.skipped,
899 "timed_out": outcome.timed_out,
900 "on_fail": outcome.on_fail,
901 "not_run": outcome.not_run,
902 "unconfigured": outcome.unconfigured,
903 "error": outcome.error,
904 "findings": [_finding_as_dict(finding) for finding in outcome.findings],
905 **(
906 {"reused_from": outcome.reused_from.as_dict()}
907 if outcome.reused_from is not None
908 else {}
909 ),
910 }
913def extension_hooks_as_dict(
914 config: cfg.ProjectConfig, loaded: dict[str, list[Extension]]
915) -> dict[str, list[dict[str, Any]]]:
916 """Render loaded extension hooks grouped by backbone slot."""
917 return {
918 slot: [
919 {
920 "id": ext.id,
921 "slot": ext.slot,
922 "kind": ext.kind,
923 "mode": ext.mode,
924 "agent": ext.agent,
925 "on_fail": ext.on_fail,
926 "anchorable": ext.anchorable,
927 "source": ext.source,
928 "has_run": ext.run is not None,
929 "has_prompt": ext.prompt is not None or bool(ext.body.strip()),
930 "required_capabilities": list(ext.required_capabilities),
931 "optional_capabilities": list(ext.optional_capabilities),
932 }
933 for ext in loaded.get(slot, [])
934 ]
935 for slot in model.SLOTS
936 }
939def ship_result_as_dict(
940 *,
941 changed_files: list[str] | None,
942 outcomes: list[gates.GateOutcome],
943 verdict,
944 assessment,
945 issue_intake: dict[str, Any] | None = None,
946 run_ledger: dict[str, Any] | None = None,
947 jury_consensus: str | None = None,
948) -> dict[str, Any]:
949 """Normalized deterministic result record for ``keel ship --json``.
951 ``jury_consensus`` is the panel's own verdict (the chair's, as
952 :func:`keel.jury.jury_verdict` reports it) when the caller has one; the
953 ``jury_verdict_template`` renders it, and renders
954 :data:`keel.artifacts.JURY_CONSENSUS_PLACEHOLDER` — which the evidence gate reads as
955 no approval — when it has none (#1429).
956 """
957 closure_comment = None
958 issue_number = None
959 pr_number = None
960 head_sha = None
961 run_id = None
962 implementer_attribution = None
963 if isinstance(run_ledger, dict):
964 record = run_ledger.get("record")
965 if isinstance(record, dict):
966 closure_comment = closure.render_closure_comment(record)
967 issue = record.get("issue")
968 pull_request = record.get("pull_request")
969 issue_number = issue.get("number") if isinstance(issue, dict) else None
970 pr_number = pull_request.get("number") if isinstance(pull_request, dict) else None
971 head_sha = record.get("head_sha")
972 head_sha = head_sha if isinstance(head_sha, str) else None
973 run_id = record.get("run_id")
974 run_id = run_id if isinstance(run_id, str) else None
975 # Derived from the record, never from a caller-supplied string: the
976 # provenance comment and the evidence cross-check must read the same
977 # implementer through the same helper (#1013).
978 implementer_attribution = agents.attribution_from_implementer(
979 evidence.ledger_implementer(record)
980 )
981 finding_dicts = [_finding_as_dict(finding) for finding in verdict.findings]
982 testing = _testing_summary(outcomes)
983 artifact_bodies = {
984 "pr_body": artifacts.render_pr_body(
985 issue_number=issue_number,
986 issue_intake=issue_intake,
987 changed_files=changed_files,
988 testing=testing,
989 docs_impact=_docs_impact(changed_files),
990 ),
991 "issue_update": artifacts.render_issue_update(
992 issue_number=issue_number,
993 pull_request=pr_number,
994 status="ready-for-merge" if not verdict.blocked else "blocked",
995 summary=assessment.merge.reason,
996 next_step="Merge when CI and evidence are green."
997 if not verdict.blocked
998 else "Resolve blocking findings before merge.",
999 ),
1000 "review_verdict_template": artifacts.render_review_verdict(
1001 reviewer="reviewer",
1002 head_sha=head_sha,
1003 verdict="REQUEST_CHANGES" if verdict.blocked else "LGTM",
1004 scope="Full changed-file diff and keel command contract.",
1005 findings=finding_dicts,
1006 testing="; ".join(testing) if testing else "See PR Testing section.",
1007 ),
1008 # The consensus is the panel's, never this run's gate verdict (#1429): an unblocked
1009 # ship says nothing about what the panel concluded, and a template that wrote
1010 # `LGTM` there approved for a panel that may have rejected the change. Without a
1011 # consensus it carries a placeholder the evidence gate reads as no approval.
1012 "jury_verdict_template": artifacts.render_jury_verdict(
1013 head_sha=head_sha,
1014 participants=("reviewer-a", "reviewer-b", "reviewer-c", "orchestrator"),
1015 verdict=jury_consensus or artifacts.JURY_CONSENSUS_PLACEHOLDER,
1016 findings_summary=_finding_summaries(finding_dicts),
1017 remaining_risks=(
1018 ship_decisions.block_reason(verdict) if verdict.blocked else "none identified"
1019 ),
1020 ),
1021 "extension_result_template": artifacts.render_extension_result(
1022 slot="<slot>",
1023 extension_id="<extension-id>",
1024 status="not-run",
1025 mode="advisory",
1026 summary="Extension result summary goes here.",
1027 ),
1028 "ship_provenance": artifacts.render_ship_provenance(
1029 run_id=run_id,
1030 issue=issue_number,
1031 head_sha=head_sha,
1032 implementer_attribution=implementer_attribution,
1033 ),
1034 }
1035 return {
1036 # None stays None: "could not read the diff" must not report as "0 files".
1037 "changed_files": None if changed_files is None else list(changed_files),
1038 "changed_file_count": None if changed_files is None else len(changed_files),
1039 "changed_files_unreadable": changed_files is None,
1040 "issue_intake": issue_intake,
1041 "run_ledger": run_ledger,
1042 "closure_comment": closure_comment,
1043 "artifact_bodies": artifact_bodies,
1044 "gate_outcomes": [gate_outcome_as_dict(outcome) for outcome in outcomes],
1045 "verdict": {
1046 "blocked": verdict.blocked,
1047 "counts": dict(verdict.counts),
1048 "findings": [_finding_as_dict(finding) for finding in verdict.findings],
1049 },
1050 "assessment": {
1051 "tier": assessment.tier,
1052 "reviewers": assessment.reviewers,
1053 "window_open": assessment.window_open,
1054 "ci_ok": assessment.ci_ok,
1055 "merge": {
1056 "action": assessment.merge.action,
1057 "reason": assessment.merge.reason,
1058 },
1059 "halted": assessment.halted,
1060 "bypassed_window": assessment.bypassed_window,
1061 "review_merge_contract": assessment.review_contract,
1062 "assignment": assessment.assignment,
1063 },
1064 }
1067def _testing_summary(outcomes: list[gates.GateOutcome]) -> list[str]:
1068 if not outcomes:
1069 return []
1070 lines: list[str] = []
1071 for outcome in outcomes:
1072 if outcome.skipped:
1073 state = "skipped"
1074 elif outcome.ok:
1075 state = "passed"
1076 elif outcome.timed_out:
1077 # Still a blocking outcome — but "failed" would read as a broken test.
1078 state = "timed out"
1079 else:
1080 state = "failed"
1081 suffix = f" ({outcome.error})" if outcome.error else ""
1082 lines.append(f"{outcome.gate}: {state}{suffix}")
1083 return lines
1086def _docs_impact(changed_files: list[str] | None) -> str:
1087 if changed_files is None:
1088 return "Docs Impact: unknown — the changed-file list could not be read from git."
1089 docs = [file for file in changed_files if _is_doc_path(file)]
1090 if docs:
1091 return "Updated docs: " + ", ".join(f"`{file}`" for file in docs)
1092 return "Docs Impact: none — no documentation files changed."
1095def _is_doc_path(file: str) -> bool:
1096 lowered = file.lower()
1097 return (
1098 "/docs/" in f"/{lowered}"
1099 or lowered.startswith("docs/")
1100 or lowered.endswith((".md", ".mdx", ".rst", ".adoc"))
1101 )
1104def _finding_summaries(findings: list[dict[str, Any]]) -> list[str]:
1105 return [
1106 f"{finding['severity']}: {finding['message']}"
1107 for finding in findings
1108 if isinstance(finding.get("severity"), str) and isinstance(finding.get("message"), str)
1109 ]
1112def standalone_result_as_dict(
1113 *,
1114 command: str,
1115 config: cfg.ProjectConfig,
1116 target: str | None = None,
1117 delegate: str | None = None,
1118 transport: github_transport.GitHubTransport | None = None,
1119 evaluation: runtime.CapabilityEvaluation | None = None,
1120 delegation: dict[str, Any] | None = None,
1121) -> dict[str, Any]:
1122 """Deterministic dry-run result records for standalone non-ship commands."""
1123 if command == "implement":
1124 issue_id = _target_identifier(target)
1125 return {
1126 "command": command,
1127 "target": target,
1128 "base_branch": config.base_branch,
1129 "branch_pattern": f"feature/issue-{issue_id}-<slug>",
1130 "worktree_path_pattern": f"worktrees/issue-{issue_id}",
1131 "implementer": {
1132 "source": "delegate" if delegate else "project-routing-or-host",
1133 "selected": delegate,
1134 # Sorted here, not in the rule: the contract's ordering is the contract's
1135 # business. Which spellings name a role is `agents.known_roles`'.
1136 "routing_keys": sorted(agents.known_roles(config)),
1137 },
1138 "handoff": {
1139 "opens_pr": True,
1140 "merges": False,
1141 "next_commands": ["ship", "pr-loop"],
1142 },
1143 }
1144 if command == "ci-check":
1145 resolved = transport.as_dict() if transport is not None else {}
1146 return {
1147 "command": command,
1148 "target": target,
1149 "base_branch": config.base_branch,
1150 "ci_workflows": dict(sorted(config.knobs.ci_workflows.items())),
1151 "latest_run_context": {
1152 "limit": 3,
1153 "selected": "newest available run",
1154 "history": "previous runs used for flake or infra classification",
1155 },
1156 "diagnostics": {
1157 "read_only": True,
1158 "log_tail": "available when the selected GitHub transport exposes logs",
1159 "classifications": ["real-failure", "flake", "infra-or-quota"],
1160 "proposed_fix_count": 1,
1161 },
1162 "github_transport": resolved,
1163 "routing": {
1164 "never_direct_merge": True,
1165 "recommendations": ["review-cycle", "pr-loop", "ship", "flake-audit"],
1166 },
1167 }
1168 if command == "morning":
1169 return {
1170 "command": command,
1171 "target": target,
1172 "base_branch": config.base_branch,
1173 "brief": morning_contract_as_dict(
1174 config=config,
1175 evaluation=evaluation,
1176 transport=transport,
1177 ),
1178 "execution": {
1179 "runs_project_health_commands": False,
1180 "writes_reports": False,
1181 "live_work_owner": "adapter-or-extension-after-consent",
1182 },
1183 }
1184 if command in {"wrap", "work-block", "overnight"}:
1185 return {
1186 "command": command,
1187 "target": target,
1188 "base_branch": config.base_branch,
1189 "session": session_contract_as_dict(
1190 command=command,
1191 config=config,
1192 transport=transport,
1193 delegation=delegation,
1194 ),
1195 "execution": {
1196 "runs_gates": False,
1197 "creates_prs": False,
1198 "merges": False,
1199 "writes_reports": False,
1200 "live_work_owner": "adapter-after-consent",
1201 },
1202 }
1203 if command in {"pr-loop", "review-cycle"}:
1204 workflow = feedback_workflow_as_dict(config, command)
1205 return {
1206 "command": command,
1207 "target": target,
1208 "base_branch": config.base_branch,
1209 "feedback_workflow": workflow,
1210 "execution": {
1211 "commits": False,
1212 "pushes": False,
1213 "posts_comments": False,
1214 "merges": False,
1215 "live_work_owner": "adapter-after-consent",
1216 },
1217 }
1218 if command in {"regression", "review-all-day"}:
1219 return {
1220 "command": command,
1221 "target": target,
1222 "base_branch": config.base_branch,
1223 "scan": scan_contract_as_dict(
1224 command=command,
1225 config=config,
1226 transport=transport,
1227 ),
1228 "execution": {
1229 "edits_code": False,
1230 "pushes": False,
1231 "merges": False,
1232 "writes_issues": False,
1233 "live_work_owner": "adapter-after-consent",
1234 },
1235 }
1236 return {"command": command, "target": target}
1239def feedback_workflow_as_dict(config: cfg.ProjectConfig, command: str) -> dict[str, Any]:
1240 """Return command-specific feedback-loop policy with project overrides applied."""
1241 defaults = _DEFAULT_FEEDBACK_WORKFLOWS.get(command, {})
1242 policy = _feedback_workflow_policy(config).get(command, {})
1243 return _deep_merge(defaults, policy)
1246def scan_contract_as_dict(
1247 *,
1248 command: str,
1249 config: cfg.ProjectConfig,
1250 transport: github_transport.GitHubTransport | None = None,
1251) -> dict[str, Any]:
1252 """Project-neutral scan-and-file contract for regression and review-all-day."""
1253 pack = config.policy_pack or {}
1254 reports = pack.get("reports") if isinstance(pack.get("reports"), dict) else {}
1255 scan = pack.get("scan") if isinstance(pack.get("scan"), dict) else {}
1256 areas = scan.get("areas") if isinstance(scan.get("areas"), dict) else {}
1257 issue_labels = scan.get("issue_labels") if isinstance(scan.get("issue_labels"), dict) else {}
1258 github = transport.as_dict() if transport is not None else {}
1259 base = {
1260 "timezone": config.timezone,
1261 "merge_window": config.merge_window,
1262 "base_branch": config.base_branch,
1263 "github_transport": github,
1264 "reports": _report_destinations(reports),
1265 "project_policy_sources": {
1266 "scan": "policy_pack.scan",
1267 "areas": "policy_pack.scan.areas",
1268 "active_branch_patterns": "policy_pack.scan.active_branch_patterns",
1269 "risk_globs": "knobs.tier3_globs",
1270 "labels": "policy_pack.labels + policy_pack.scan.issue_labels",
1271 "ci_workflows": "knobs.ci_workflows",
1272 },
1273 "write_safety": {
1274 "dry_run_no_writes": True,
1275 "orchestrator_only_writes": True,
1276 "code_mutation": False,
1277 "pr_mutation": False,
1278 "issue_write_requires_consent": True,
1279 },
1280 "dedupe": {
1281 "source": "policy_pack.scan.dedupe + canonical defaults",
1282 "path_token_boundary": True,
1283 "type_must_match": True,
1284 "near_text_similarity": _near_text_similarity(config),
1285 "open_duplicate": "skip",
1286 "closed_duplicate": "promote-regression-of",
1287 "lock": "mkdir",
1288 },
1289 "reviewer_isolation": {
1290 "parallel": True,
1291 "no_cross_reading": True,
1292 "orchestrator_collects_findings": True,
1293 },
1294 "areas": [
1295 {"name": name, "paths": list(paths)}
1296 for name, paths in sorted(areas.items())
1297 if isinstance(paths, list)
1298 ],
1299 "risk_globs": list(config.knobs.tier3_globs),
1300 "scan_finding": {
1301 "renderer": "keel.artifacts.render_scan_finding_issue",
1302 "marker": artifacts.SCAN_FINDING_MARKER,
1303 },
1304 "issue_labels": {
1305 key: list(value)
1306 for key, value in sorted(issue_labels.items())
1307 if isinstance(value, list)
1308 },
1309 "work_creation_policy": workcreation.contract_as_dict(
1310 near_text_similarity=_near_text_similarity(config),
1311 ),
1312 }
1313 if command == "regression":
1314 base["regression"] = {
1315 "scan_target": {
1316 "source": "canonical base head",
1317 "base_branch": config.base_branch,
1318 "read_only_worktree": True,
1319 "clean_tree_preflight": True,
1320 },
1321 "scope": {
1322 "default": "full",
1323 "supported": ["full", "changed", "since"],
1324 },
1325 "confidence_filter": {
1326 "drop": ["low"],
1327 "downgrade_blocker_when": "medium-confidence",
1328 "file_only_when": ["high-confidence", "medium-security"],
1329 },
1330 "issue_creation": {
1331 "one_issue_per_finding": True,
1332 "route_to": "ship",
1333 "regression_of_line": "regression-of: #N",
1334 "labels": issue_labels.get("regression", []),
1335 },
1336 "final_report": [
1337 "raw_findings",
1338 "after_confidence_filter",
1339 "duplicates_skipped",
1340 "promoted_regressions",
1341 "issues_opened",
1342 "ship_handoffs",
1343 ],
1344 }
1345 elif command == "review-all-day":
1346 base["review_all_day"] = {
1347 "span": {
1348 "timezone": config.timezone,
1349 "merge_window": config.merge_window,
1350 "days_are_inclusive_calendar_days": True,
1351 "n_days_argument_covers_calendar_days": "N+1",
1352 },
1353 "ref_scope": {
1354 "branches": ["trunk", "active-work-branches"],
1355 "active_branch_patterns": list(scan.get("active_branch_patterns") or ()),
1356 "remote_refs_default": True,
1357 "warn_on_stale_fetch": True,
1358 },
1359 "strategy": {
1360 "batch_threshold": _scan_int(scan, "batch_threshold", 5),
1361 "fanout_when_commit_count_gt": _scan_int(scan, "batch_threshold", 5),
1362 },
1363 "diff_truncation": {
1364 "max_bytes": _scan_int(scan, "large_diff_max_bytes", 200000),
1365 "boundary": "file",
1366 },
1367 "finding_filter": {
1368 "skip_minor": True,
1369 "keep_minor_categories": ["security"],
1370 "file_categories": [
1371 "bug-insert",
1372 "regression",
1373 "security",
1374 "config",
1375 "test-coverage",
1376 ],
1377 },
1378 "issue_creation": {
1379 "title_prefix": "[review-all-day] ",
1380 "one_issue_per_serious_finding": True,
1381 "labels": issue_labels.get("review-all-day", []),
1382 },
1383 "final_report": [
1384 "commit_range",
1385 "reviewers",
1386 "findings",
1387 "duplicates_skipped",
1388 "issues_opened",
1389 ],
1390 }
1391 return base
1394def session_contract_as_dict(
1395 *,
1396 command: str,
1397 config: cfg.ProjectConfig,
1398 transport: github_transport.GitHubTransport | None = None,
1399 delegation: dict[str, Any] | None = None,
1400) -> dict[str, Any]:
1401 """Project-neutral session workflow contract for session/work-block commands."""
1402 pack = config.policy_pack or {}
1403 reports = pack.get("reports") if isinstance(pack.get("reports"), dict) else {}
1404 github = transport.as_dict() if transport is not None else {}
1405 base = {
1406 "timezone": config.timezone,
1407 "merge_window": config.merge_window,
1408 "merge_window_mode": config.merge_window_mode,
1409 "base_branch": config.base_branch,
1410 "github_transport": github,
1411 "reports": _report_destinations(reports),
1412 "deferral_queue": _deferral_queue_as_dict(reports),
1413 "run_ledger": ledger.ledger_contract_as_dict(config),
1414 "project_policy_sources": {
1415 "gates": list(config.gates),
1416 "extensions": {slot: list(files) for slot, files in sorted(config.extensions.items())},
1417 "risk_rules": [
1418 rule.get("id") for rule in pack.get("risk_rules", []) if isinstance(rule, dict)
1419 ],
1420 "source_of_truth_doc": config.knobs.sot_doc,
1421 },
1422 }
1423 if command in {"work-block", "overnight"}:
1424 base["work_block"] = workblock.contract_as_dict(
1425 config=config,
1426 mode="overnight" if command == "overnight" else "daytime",
1427 transport=github,
1428 delegation=delegation,
1429 )
1430 if command == "wrap":
1431 base["wrap"] = {
1432 "workspace_preflight": {
1433 "must_run_from_linked_worktree": True,
1434 "abort_on_base_branch": True,
1435 "git_dir_rule": "main worktree returns .git; linked worktree uses .git/worktrees",
1436 },
1437 "quality_gates": {
1438 "runner": "keel run-gates",
1439 "changed_file_policy_source": "policy_pack + extension hooks",
1440 },
1441 "commit": {
1442 "format": "conventional-commits",
1443 "supports_closes_issue": True,
1444 },
1445 "pull_request": {
1446 "ready_not_draft": True,
1447 "base_branch": config.base_branch,
1448 "requires_pr_write": True,
1449 "body_sections": ["Summary", "Docs Impact", "Test Plan"],
1450 },
1451 "recap": {
1452 "report_key": "session",
1453 "path": reports.get("session") or reports.get("wrap"),
1454 "status": "configured"
1455 if reports.get("session") or reports.get("wrap")
1456 else "unconfigured",
1457 },
1458 }
1459 elif command == "work-block":
1460 base["daytime"] = {
1461 "mode_source": {
1462 "command": "keel work-block",
1463 "shared_with_overnight": True,
1464 },
1465 "queue": {
1466 "source": "explicit issue numbers or project queue selector",
1467 "deterministic_order": (
1468 "explicit numbers keep their provided order; selectors sort by policy"
1469 ),
1470 },
1471 "ship_handoff": {
1472 "command": "ship",
1473 "passes_operator_consent_scope": True,
1474 "per_issue_worktree": True,
1475 "refreshes_readiness_between_issues": True,
1476 },
1477 "operator_control": {
1478 "between_items": True,
1479 "stop_on_consent_gap": True,
1480 "stop_on_needs_input": True,
1481 },
1482 "report": {
1483 "day_key": "session",
1484 "day_path": reports.get("session"),
1485 "status": "configured" if reports.get("session") else "unconfigured",
1486 },
1487 }
1488 elif command == "overnight":
1489 base["overnight"] = {
1490 "mode_source": {
1491 "command": "keel window",
1492 "shared_with_ship": True,
1493 "timezone": config.timezone,
1494 "merge_window": config.merge_window,
1495 },
1496 "merge_policy": {
1497 "day": "ship may merge reviewed CI-green PRs inside the configured window",
1498 "night": "no merge outside the configured window except true blockers",
1499 "blocker_source": "ship blocker heuristics + project policy extensions",
1500 "cannot_weaken_core_window": True,
1501 },
1502 "queue": {
1503 "source": "project policy or adapter query",
1504 "tiers": [
1505 "T0-blocker",
1506 "T1-open-prs",
1507 "T2-coverage",
1508 "T3-ci-quality",
1509 "T4-modernization",
1510 "T5-docs-backlog",
1511 ],
1512 },
1513 "ship_handoff": {
1514 "command": "ship",
1515 "passes_operator_consent_scope": True,
1516 "per_issue_worktree": True,
1517 "shared_work_block_contract": True,
1518 },
1519 "report": {
1520 "night_key": "overnight",
1521 "day_key": "session",
1522 "night_path": reports.get("overnight") or reports.get("morning"),
1523 "day_path": reports.get("session"),
1524 "status": (
1525 "configured"
1526 if (
1527 reports.get("overnight") or reports.get("morning") or reports.get("session")
1528 )
1529 else "unconfigured"
1530 ),
1531 },
1532 "stop_conditions": [
1533 "merge-window-close",
1534 "time-budget-exhausted",
1535 "max-items-reached",
1536 "hard-blocker",
1537 "three-consecutive-unresolved-ci-failures",
1538 "user-cancelled",
1539 ],
1540 }
1541 return base
1544def morning_contract_as_dict(
1545 *,
1546 config: cfg.ProjectConfig,
1547 evaluation: runtime.CapabilityEvaluation | None = None,
1548 transport: github_transport.GitHubTransport | None = None,
1549) -> dict[str, Any]:
1550 """Project-neutral morning briefing contract for adapters and dry-run output."""
1551 pack = config.policy_pack or {}
1552 reports = pack.get("reports") if isinstance(pack.get("reports"), dict) else {}
1553 health = pack.get("health_providers") if isinstance(pack.get("health_providers"), dict) else {}
1554 github = transport.as_dict() if transport is not None else {}
1555 github_status = "available" if github.get("transport") not in {None, "none"} else "degraded"
1556 return {
1557 "timezone": config.timezone,
1558 "merge_window": config.merge_window,
1559 "base_branch": config.base_branch,
1560 "sections": [
1561 {
1562 "id": "deferrals",
1563 "source": "shared_queue",
1564 "status": "configured" if reports.get("deferrals") else "unconfigured",
1565 "path": reports.get("deferrals"),
1566 },
1567 {
1568 "id": "shipped_since_last_brief",
1569 "source": "github",
1570 "transport": github.get("transport"),
1571 "status": github_status,
1572 },
1573 {
1574 "id": "github_status",
1575 "source": "github",
1576 "transport": github.get("transport"),
1577 "status": github_status,
1578 },
1579 {
1580 "id": "project_health",
1581 "source": "policy_pack.health_providers",
1582 "status": "configured" if health else "unconfigured",
1583 },
1584 {
1585 "id": "ranked_focus",
1586 "source": "policy_pack + github",
1587 "status": "configured" if _priority_sources(config, reports) else "unconfigured",
1588 },
1589 ],
1590 "health_providers": [
1591 _health_provider_as_dict(name, provider, evaluation)
1592 for name, provider in sorted(health.items())
1593 if isinstance(provider, dict)
1594 ],
1595 "priority_sources": _priority_sources(config, reports),
1596 "reports": _report_destinations(reports),
1597 "deferral_queue": _deferral_queue_as_dict(reports),
1598 "run_ledger": ledger.ledger_contract_as_dict(config),
1599 "missing_optional_policy": "unavailable-not-success",
1600 }
1603def _health_provider_as_dict(
1604 name: str,
1605 provider: dict[str, Any],
1606 evaluation: runtime.CapabilityEvaluation | None,
1607) -> dict[str, Any]:
1608 required = tuple(provider.get("required_capabilities") or ())
1609 optional = tuple(provider.get("optional_capabilities") or ())
1610 missing_required = _missing_capabilities(required, evaluation)
1611 missing_optional = _missing_capabilities(optional, evaluation)
1612 status = "available"
1613 if missing_required:
1614 status = "blocked"
1615 elif missing_optional:
1616 status = "unavailable"
1617 elif provider.get("command") is None and provider.get("kind") == "project-command":
1618 status = "unavailable"
1619 return {
1620 "name": name,
1621 "kind": provider.get("kind"),
1622 "command": provider.get("command"),
1623 "reports": list(provider.get("reports") or ()),
1624 "required_capabilities": list(required),
1625 "optional_capabilities": list(optional),
1626 "missing_required_capabilities": list(missing_required),
1627 "missing_optional_capabilities": list(missing_optional),
1628 "status": status,
1629 }
1632def _missing_capabilities(
1633 names: tuple[str, ...],
1634 evaluation: runtime.CapabilityEvaluation | None,
1635) -> tuple[str, ...]:
1636 if evaluation is None:
1637 return ()
1638 missing = set(evaluation.missing_required) | set(evaluation.missing_optional)
1639 return tuple(name for name in names if name in missing)
1642def _priority_sources(config: cfg.ProjectConfig, reports: dict[str, Any]) -> list[dict[str, Any]]:
1643 sources: list[dict[str, Any]] = []
1644 if config.knobs.sot_doc:
1645 sources.append({"id": "source_of_truth", "path": config.knobs.sot_doc})
1646 if reports.get("priorities"):
1647 sources.append({"id": "priorities_report", "path": reports["priorities"]})
1648 if config.knobs.ci_workflows:
1649 sources.append({"id": "ci_workflows", "names": sorted(config.knobs.ci_workflows)})
1650 return sources
1653def _report_destinations(reports: dict[str, Any]) -> dict[str, dict[str, Any]]:
1654 return {
1655 key: {
1656 "path": value,
1657 "write_status": "skipped-in-dry-run",
1658 }
1659 for key, value in sorted(reports.items())
1660 }
1663def _deferral_queue_as_dict(reports: dict[str, Any]) -> dict[str, Any]:
1664 return {
1665 "source": "policy_pack.reports.deferrals",
1666 "path": reports.get("deferrals"),
1667 "status": "configured" if reports.get("deferrals") else "unconfigured",
1668 "shared_with": ["ship", "overnight", "wrap", "morning"],
1669 }
1672def _feedback_workflow_policy(config: cfg.ProjectConfig) -> dict[str, dict[str, Any]]:
1673 pack = config.policy_pack or {}
1674 policy = pack.get("workflow_policies")
1675 if not isinstance(policy, dict):
1676 return {}
1677 return {
1678 name: value
1679 for name, value in policy.items()
1680 if isinstance(name, str) and isinstance(value, dict)
1681 }
1684def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
1685 merged: dict[str, Any] = {}
1686 for key, value in base.items():
1687 if isinstance(value, dict):
1688 merged[key] = _deep_merge(value, {})
1689 elif isinstance(value, list):
1690 merged[key] = list(value)
1691 else:
1692 merged[key] = value
1693 for key, value in override.items():
1694 if isinstance(value, dict) and isinstance(merged.get(key), dict):
1695 merged[key] = _deep_merge(merged[key], value)
1696 elif isinstance(value, list):
1697 merged[key] = list(value)
1698 else:
1699 merged[key] = value
1700 return merged
1703def _scan_int(scan: dict[str, Any], key: str, default: int) -> int:
1704 value = scan.get(key)
1705 return value if isinstance(value, int) else default
1708def _scan_float(scan: dict[str, Any], key: str, default: float) -> float:
1709 value = scan.get(key)
1710 return value if isinstance(value, int | float) else default
1713def _near_text_similarity(config: cfg.ProjectConfig) -> float:
1714 """The project's resolved ``policy_pack.scan.near_text_similarity``.
1716 Both contract builders embed a ``work_creation_policy`` block carrying this
1717 threshold, and the scan contract carries a second copy under ``dedupe``. One
1718 resolver keeps every copy in agreement — hardcoding the default in
1719 ``workcreation`` shipped two different values under near-identical keys, agreeing
1720 only while the project happened to set the knob to the built-in default (#633).
1721 """
1722 pack = config.policy_pack or {}
1723 scan = pack.get("scan") if isinstance(pack.get("scan"), dict) else {}
1724 return _scan_float(scan, "near_text_similarity", workcreation.DEFAULT_NEAR_TEXT_SIMILARITY)
1727def _target_identifier(target: str | None) -> str:
1728 if not target:
1729 return "selected-issue"
1730 match = re.search(r"#?(\d+)", target)
1731 if match:
1732 return match.group(1)
1733 return re.sub(r"[^a-z0-9]+", "-", target.lower()).strip("-") or "selected-issue"
1736def _adapter_steps(command: str) -> list[dict[str, Any]]:
1737 path = Path(install.ADAPTERS) / f"{command}.md"
1738 if not path.exists():
1739 return []
1740 steps: list[dict[str, Any]] = []
1741 for line in path.read_text(encoding="utf-8").splitlines():
1742 match = _STEP_RE.match(line)
1743 if not match:
1744 continue
1745 raw_id = match.group("id").strip()
1746 step_id = raw_id.lower().replace(" ", "-").replace(".", "-")
1747 steps.append(
1748 {
1749 "step_id": step_id,
1750 "step_name": match.group("name").strip() or raw_id,
1751 "agentic": "agent" in match.group("name").lower(),
1752 "slot": None,
1753 "source": "adapter",
1754 }
1755 )
1756 return steps
1759def _finding_as_dict(finding) -> dict[str, Any]:
1760 tag = provenance.normalize_tag(
1761 getattr(finding, "provenance", None),
1762 fallback_agent=finding.source,
1763 step_id="finding",
1764 )
1765 return {
1766 "severity": finding.severity,
1767 "message": finding.message,
1768 "source": finding.source,
1769 "path": finding.path,
1770 "line": finding.line,
1771 "anchorable": finding.anchorable,
1772 "provenance": tag,
1773 }