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