Coverage for src/keel/contracts.py: 100%
295 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
1"""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 == "triage":
276 contract["triage_contract"] = {
277 "scope": "label-only-advisory",
278 "one_comment_per_issue": True,
279 "renderer": "keel.artifacts.render_triage_audit",
280 "marker": artifacts.TRIAGE_AUDIT_MARKER,
281 }
282 if command in {"ship", "pr-loop", "review-cycle", "work-block", "overnight"}:
283 contract["review_merge_contract"] = ship_decisions.resolve_review_contract(
284 tier=review_tier,
285 reviewer_override=reviewer_override,
286 review_comments=review_comments,
287 gates=config.gates,
288 policy_pack=config.policy_pack,
289 jury=jury,
290 no_jury=no_jury,
291 jury_advisory=jury_advisory,
292 )
293 if command == "ship":
294 contract["evidence"] = evidence.contract_as_dict(
295 contract["review_merge_contract"],
296 dry_run=dry_run,
297 )
298 contract["step_verification"] = stepverifier.contract_as_dict(
299 contract["review_merge_contract"],
300 dry_run=dry_run,
301 )
302 contract["run_controls"] = runcontrols.contract_as_dict()
303 if command == "ship":
304 contract["closure_comment"] = closure.contract_as_dict()
305 contract["artifact_renderers"] = artifacts.contract_as_dict()
306 if command in {"ship", "implement", "overnight"}:
307 contract["issue_intake"] = intake.assess_issue(
308 title=issue_title,
309 body=issue_body,
310 labels=issue_labels,
311 )
312 if command in {"pr-loop", "review-cycle"}:
313 contract["feedback_workflow"] = feedback_workflow_as_dict(config, command)
314 return contract
317def workflow_profile(command: str, *, profile: str = "standard") -> dict[str, Any]:
318 """First-class workflow profile metadata for command variants.
320 ``ship`` carries the ``standard`` profile by default; selecting the ``compound``
321 profile swaps the s4/s7/s9/s11 steps to compound step overrides without forking the
322 backbone.
323 """
324 if command == "ship" and profile == "compound":
325 return {
326 "name": "ship",
327 "profile": "compound",
328 "inherits": "ship",
329 "first_class_variant": True,
330 "shared_primitives": [
331 "select",
332 "branch",
333 "worktree",
334 "guard",
335 "classify",
336 "ci",
337 "test",
338 "merge_window",
339 "merge_lock",
340 "merge",
341 "capture_marker",
342 "close",
343 ],
344 "step_overrides": {
345 "s4": {
346 "step": "implement",
347 "mode": "compound",
348 "reason": "compound implement and PR-quality pass",
349 },
350 "s7": {
351 "step": "review",
352 "mode": "compound",
353 "reason": "persona and diff-aware reviewer fan-out",
354 },
355 "s9": {
356 "step": "fixloop",
357 "mode": "compound",
358 "reason": "structured PR-feedback resolution",
359 },
360 "s11": {
361 "step": "capture",
362 "mode": "compound",
363 "reason": "durable-learning capture",
364 },
365 },
366 }
367 if command == "pr-loop":
368 return {
369 "name": "pr-loop",
370 "profile": "feedback-loop",
371 "inherits": "ship.s6-s9",
372 "first_class_variant": True,
373 "shared_primitives": [
374 "linked_worktree_preflight",
375 "github_transport",
376 "reviewer_isolation",
377 "ci_recheck",
378 "fixloop",
379 "summary_comment",
380 "operator_consent",
381 ],
382 "step_overrides": {
383 "merge": {
384 "mode": "handoff",
385 "reason": "pr-loop exits after feedback and CI are satisfied.",
386 },
387 },
388 }
389 if command == "review-cycle":
390 return {
391 "name": "review-cycle",
392 "profile": "review-feedback",
393 "inherits": "ship.s7-s9",
394 "first_class_variant": True,
395 "shared_primitives": [
396 "multi_pr_targets",
397 "reviewer_isolation",
398 "posting_mode",
399 "severity_histogram",
400 "fixloop",
401 "completion_marker",
402 "operator_consent",
403 ],
404 "step_overrides": {
405 "merge": {
406 "mode": "never",
407 "reason": "review-cycle never merges or posts formal approval.",
408 },
409 },
410 }
411 if command == "implement":
412 return {
413 "name": "implement",
414 "profile": "standalone-step",
415 "inherits": "ship.s4",
416 "first_class_variant": True,
417 "shared_primitives": [
418 "issue_target",
419 "branch",
420 "worktree",
421 "implementer_routing",
422 "operator_consent",
423 "handoff",
424 ],
425 "step_overrides": {},
426 }
427 if command == "ci-check":
428 return {
429 "name": "ci-check",
430 "profile": "standalone-diagnostic",
431 "inherits": None,
432 "first_class_variant": True,
433 "shared_primitives": [
434 "github_transport",
435 "check_runs",
436 "latest_run_context",
437 "log_diagnostics",
438 "read_only",
439 "routing_recommendation",
440 ],
441 "step_overrides": {},
442 }
443 if command == "morning":
444 return {
445 "name": "morning",
446 "profile": "daily-brief",
447 "inherits": None,
448 "first_class_variant": True,
449 "shared_primitives": [
450 "date_window",
451 "deferral_queue",
452 "shipped_since",
453 "github_summary",
454 "health_providers",
455 "priority_sources",
456 "ranked_focus",
457 "report_output",
458 ],
459 "step_overrides": {},
460 }
461 if command == "wrap":
462 return {
463 "name": "wrap",
464 "profile": "session-wrap",
465 "inherits": None,
466 "first_class_variant": True,
467 "shared_primitives": [
468 "linked_worktree_preflight",
469 "base_branch_guard",
470 "configured_gates",
471 "conventional_commit",
472 "ready_pr_create",
473 "session_recap",
474 "deferral_queue",
475 "operator_consent",
476 ],
477 "step_overrides": {},
478 }
479 if command == "overnight":
480 return {
481 "name": "overnight",
482 "profile": "session-overnight",
483 "inherits": "ship",
484 "first_class_variant": True,
485 "shared_primitives": [
486 "work_block",
487 "merge_window",
488 "ship_handoff",
489 "priority_queue",
490 "per_issue_worktree",
491 "no_night_merge",
492 "blocker_policy",
493 "session_report",
494 "deferral_queue",
495 "stop_conditions",
496 "operator_consent",
497 ],
498 "step_overrides": {},
499 }
500 if command == "work-block":
501 return {
502 "name": "work-block",
503 "profile": "session-work-block-daytime",
504 "inherits": "ship",
505 "first_class_variant": True,
506 "shared_primitives": [
507 "work_block",
508 "queue_snapshot",
509 "readiness_refresh",
510 "ship_handoff",
511 "per_issue_worktree",
512 "operator_between_item_control",
513 "progress_snapshot",
514 "session_report",
515 "deferral_queue",
516 "stop_conditions",
517 "operator_consent",
518 ],
519 "step_overrides": {},
520 }
521 if command in _REPORTING_COMMANDS:
522 return {
523 "name": command,
524 "profile": "reporting",
525 "inherits": None,
526 "first_class_variant": True,
527 "shared_primitives": [
528 "project_policy",
529 "github_transport",
530 "codename_anchor",
531 "dedupe",
532 "dry_run_no_mutations",
533 "ship_handoff",
534 "operator_consent",
535 ],
536 "step_overrides": {},
537 }
538 if command == "regression":
539 return {
540 "name": "regression",
541 "profile": "scan-and-file",
542 "inherits": None,
543 "first_class_variant": True,
544 "shared_primitives": [
545 "canonical_base_scan",
546 "clean_tree_preflight",
547 "read_only_worktree",
548 "area_fanout",
549 "reviewer_isolation",
550 "confidence_filter",
551 "dedupe",
552 "issue_lock",
553 "issue_create",
554 "ship_handoff",
555 "final_report",
556 "operator_consent",
557 ],
558 "step_overrides": {},
559 }
560 if command == "review-all-day":
561 return {
562 "name": "review-all-day",
563 "profile": "time-window-scan",
564 "inherits": None,
565 "first_class_variant": True,
566 "shared_primitives": [
567 "merge_window_span",
568 "remote_ref_scope",
569 "batch_or_fanout",
570 "reviewer_isolation",
571 "diff_truncation",
572 "finding_filter",
573 "dedupe",
574 "issue_prefix",
575 "issue_create",
576 "final_report",
577 "operator_consent",
578 ],
579 "step_overrides": {},
580 }
581 if command == "ship":
582 return {
583 "name": "ship",
584 "profile": "standard",
585 "inherits": None,
586 "first_class_variant": True,
587 "shared_primitives": [
588 "select",
589 "branch",
590 "guard",
591 "implement",
592 "classify",
593 "ci",
594 "review",
595 "test",
596 "fixloop",
597 "merge",
598 "capture",
599 "close",
600 ],
601 "step_overrides": {},
602 }
603 return {
604 "name": command,
605 "profile": "adapter",
606 "inherits": None,
607 "first_class_variant": False,
608 "shared_primitives": [],
609 "step_overrides": {},
610 }
613def command_side_effects(
614 command: str,
615 config: cfg.ProjectConfig,
616 requirement: runtime.CapabilityRequirement,
617 loaded: dict[str, list[Extension]],
618) -> tuple[str, ...]:
619 """Return command side effects plus project capability-derived consent effects."""
620 effects: list[str] = list(_BASE_SIDE_EFFECTS.get(command, ()))
621 if project_command := get_project_command(config, command):
622 effects.extend(project_command.side_effects)
623 effects.extend(consent.capability_side_effects(requirement.required))
624 effects.extend(consent.capability_side_effects(requirement.optional))
625 for extensions in loaded.values():
626 for ext in extensions:
627 effects.extend(consent.capability_side_effects(ext.required_capabilities))
628 effects.extend(consent.capability_side_effects(ext.optional_capabilities))
629 return tuple(dict.fromkeys(effects))
632def reporting_contract_as_dict(
633 *,
634 command: str,
635 config: cfg.ProjectConfig,
636 transport: github_transport.GitHubTransport | None = None,
637) -> dict[str, Any]:
638 """Project-neutral reporting parity contract for audit/report adapters."""
639 github = transport.as_dict() if transport is not None else {}
640 base = {
641 "command": command,
642 "base_branch": config.base_branch,
643 "timezone": config.timezone,
644 "github_transport": github,
645 "policy_source": "policy_pack + adapter arguments",
646 "dry_run": {
647 "mutates": False,
648 "prints_planned_writes": True,
649 },
650 "handoff": {
651 "fixes_route_to": "ship",
652 "auto_applies_fixes": False,
653 },
654 "work_creation_policy": workcreation.contract_as_dict(
655 near_text_similarity=_near_text_similarity(config),
656 ),
657 }
658 if command == "coverage":
659 return {
660 **base,
661 "target": "pull_request",
662 "codename_prefix": "COVERAGE-<PR>-",
663 "renderer": "keel.artifacts.render_coverage_delta",
664 "marker": artifacts.COVERAGE_DELTA_MARKER,
665 "idempotency": {
666 "scope": "one-comment-per-pr",
667 "find_by_first_line_prefix": "COVERAGE-<PR>-",
668 "existing_comment": "update-in-place",
669 "update_unavailable": "do-not-post-duplicate",
670 },
671 "labels": {
672 "regression": "coverage-regression",
673 "operation": "idempotent-add-or-remove",
674 },
675 "degradation": {
676 "unwired_tool": "skip-area",
677 "coverage_command_failure": "fatal",
678 },
679 "arguments": ["pr", "--base", "--threshold", "--changed", "--open-issues",
680 "--dry-run"],
681 }
682 if command == "deps-audit":
683 return {
684 **base,
685 "target": "daily_tracking_issue",
686 "tracking_issue_title": "deps-audit: <DATE>",
687 "codename_prefix": "DEPS-AUDIT-<DATE>-",
688 "renderer": "keel.artifacts.render_deps_audit",
689 "marker": artifacts.DEPS_AUDIT_MARKER,
690 "idempotency": {
691 "scope": "append-per-run",
692 "find_tracking_issue_by_exact_title": "deps-audit: <DATE>",
693 "find_latest_run_by_first_line_prefix": "DEPS-AUDIT-<DATE>-",
694 "existing_comment": "append-fresh-run-comment",
695 },
696 "degradation": {
697 "per_ecosystem_failure": "skipped-section",
698 "argument_failure": "fatal",
699 },
700 "arguments": ["ecosystem", "--severity", "--security-only", "--open-issues",
701 "--dry-run"],
702 }
703 if command == "flake-audit":
704 return {
705 **base,
706 "target": "ci_history_or_local_runs",
707 "codename_prefix": "FLAKE-AUDIT-<DATE>-",
708 "renderer": "keel.artifacts.render_flake_audit",
709 "marker": artifacts.FLAKE_AUDIT_MARKER,
710 "idempotency": {
711 "scope": "one-issue-per-flake",
712 "dedupe_issue_title": "flaky test: <fully.qualified.name>",
713 "find_run_by_first_line_prefix": "FLAKE-AUDIT-<DATE>-",
714 },
715 "classification": {
716 "rule": "across-run-disagreement-only",
717 "minimum_failures": 3,
718 "consistent_failures": "real-bug-not-flake",
719 },
720 "degradation": {
721 "artifact_unavailable": "run-level-limitations-section",
722 "no_ci_or_local_gate": "clean-exit",
723 },
724 "arguments": ["--days", "--runs", "--threshold", "--open-issues", "--dry-run"],
725 }
726 return base
729def project_as_dict(config: cfg.ProjectConfig) -> dict[str, Any]:
730 """Resolved project config summary safe for adapter planning."""
731 return {
732 "config_hash": cfg.config_hash(config),
733 "extends": config.extends,
734 "core_version": config.core_version,
735 "base_branch": config.base_branch,
736 "owner": config.owner,
737 "repo": config.repo,
738 "platform": config.platform,
739 "timezone": config.timezone,
740 "merge_window": config.merge_window,
741 "merge_window_mode": config.merge_window_mode,
742 "extensions_dir": config.extensions_dir,
743 "gates": list(config.gates),
744 "extensions": {slot: list(files) for slot, files in sorted(config.extensions.items())},
745 "policy_pack": config.policy_pack,
746 "knobs": {
747 "build_gate_cmd": config.knobs.build_gate_cmd,
748 "lint_cmd": config.knobs.lint_cmd,
749 "implementer_agents": dict(sorted(config.knobs.implementer_agents.items())),
750 **cfg.delegate_profiles_dict(config),
751 "tier3_globs": list(config.knobs.tier3_globs),
752 "ci_workflows": dict(sorted(config.knobs.ci_workflows.items())),
753 "docs_gate_paths": list(config.knobs.docs_gate_paths),
754 "docs_only_allowlist": list(config.knobs.docs_only_allowlist),
755 "sot_doc": config.knobs.sot_doc,
756 "required_capabilities": list(config.knobs.required_capabilities),
757 "optional_capabilities": list(config.knobs.optional_capabilities),
758 },
759 }
762def gate_as_dict(spec: gates.GateSpec) -> dict[str, Any]:
763 """Render a planned gate without losing its capability declarations."""
764 return asdict(spec)
767def extension_hooks_as_dict(
768 config: cfg.ProjectConfig, loaded: dict[str, list[Extension]]
769) -> dict[str, list[dict[str, Any]]]:
770 """Render loaded extension hooks grouped by backbone slot."""
771 return {
772 slot: [
773 {
774 "id": ext.id,
775 "slot": ext.slot,
776 "kind": ext.kind,
777 "mode": ext.mode,
778 "agent": ext.agent,
779 "on_fail": ext.on_fail,
780 "anchorable": ext.anchorable,
781 "source": ext.source,
782 "has_run": ext.run is not None,
783 "has_prompt": ext.prompt is not None or bool(ext.body.strip()),
784 "required_capabilities": list(ext.required_capabilities),
785 "optional_capabilities": list(ext.optional_capabilities),
786 }
787 for ext in loaded.get(slot, [])
788 ]
789 for slot in model.SLOTS
790 }
793def ship_result_as_dict(
794 *,
795 changed_files: list[str] | None,
796 outcomes: list[gates.GateOutcome],
797 verdict,
798 assessment,
799 issue_intake: dict[str, Any] | None = None,
800 run_ledger: dict[str, Any] | None = None,
801) -> dict[str, Any]:
802 """Normalized deterministic result record for ``keel ship --json``."""
803 closure_comment = None
804 issue_number = None
805 pr_number = None
806 head_sha = None
807 if isinstance(run_ledger, dict):
808 record = run_ledger.get("record")
809 if isinstance(record, dict):
810 closure_comment = closure.render_closure_comment(record)
811 issue = record.get("issue")
812 pull_request = record.get("pull_request")
813 issue_number = issue.get("number") if isinstance(issue, dict) else None
814 pr_number = pull_request.get("number") if isinstance(pull_request, dict) else None
815 head_sha = record.get("head_sha")
816 head_sha = head_sha if isinstance(head_sha, str) else None
817 finding_dicts = [_finding_as_dict(finding) for finding in verdict.findings]
818 testing = _testing_summary(outcomes)
819 artifact_bodies = {
820 "pr_body": artifacts.render_pr_body(
821 issue_number=issue_number,
822 issue_intake=issue_intake,
823 changed_files=changed_files,
824 testing=testing,
825 docs_impact=_docs_impact(changed_files),
826 ),
827 "issue_update": artifacts.render_issue_update(
828 issue_number=issue_number,
829 pull_request=pr_number,
830 status="ready-for-merge" if not verdict.blocked else "blocked",
831 summary=assessment.merge.reason,
832 next_step="Merge when CI and evidence are green."
833 if not verdict.blocked else "Resolve blocking findings before merge.",
834 ),
835 "review_verdict_template": artifacts.render_review_verdict(
836 reviewer="reviewer",
837 head_sha=head_sha,
838 verdict="REQUEST_CHANGES" if verdict.blocked else "LGTM",
839 scope="Full changed-file diff and keel command contract.",
840 findings=finding_dicts,
841 testing="; ".join(testing) if testing else "See PR Testing section.",
842 ),
843 "jury_verdict_template": artifacts.render_jury_verdict(
844 head_sha=head_sha,
845 participants=("reviewer-a", "reviewer-b", "reviewer-c", "orchestrator"),
846 verdict="REQUEST_CHANGES" if verdict.blocked else "LGTM",
847 findings_summary=_finding_summaries(finding_dicts),
848 remaining_risks="blocking findings present" if verdict.blocked else "none identified",
849 ),
850 "extension_result_template": artifacts.render_extension_result(
851 slot="<slot>",
852 extension_id="<extension-id>",
853 status="not-run",
854 mode="advisory",
855 summary="Extension result summary goes here.",
856 ),
857 }
858 return {
859 # None stays None: "could not read the diff" must not report as "0 files".
860 "changed_files": None if changed_files is None else list(changed_files),
861 "changed_file_count": None if changed_files is None else len(changed_files),
862 "changed_files_unreadable": changed_files is None,
863 "issue_intake": issue_intake,
864 "run_ledger": run_ledger,
865 "closure_comment": closure_comment,
866 "artifact_bodies": artifact_bodies,
867 "gate_outcomes": [
868 {
869 "gate": outcome.gate,
870 "ok": outcome.ok,
871 "skipped": outcome.skipped,
872 "timed_out": outcome.timed_out,
873 "error": outcome.error,
874 "findings": [_finding_as_dict(finding) for finding in outcome.findings],
875 }
876 for outcome in outcomes
877 ],
878 "verdict": {
879 "blocked": verdict.blocked,
880 "counts": dict(verdict.counts),
881 "findings": [_finding_as_dict(finding) for finding in verdict.findings],
882 },
883 "assessment": {
884 "tier": assessment.tier,
885 "reviewers": assessment.reviewers,
886 "window_open": assessment.window_open,
887 "ci_ok": assessment.ci_ok,
888 "merge": {
889 "action": assessment.merge.action,
890 "reason": assessment.merge.reason,
891 },
892 "halted": assessment.halted,
893 "bypassed_window": assessment.bypassed_window,
894 "review_merge_contract": assessment.review_contract,
895 },
896 }
899def _testing_summary(outcomes: list[gates.GateOutcome]) -> list[str]:
900 if not outcomes:
901 return []
902 lines: list[str] = []
903 for outcome in outcomes:
904 if outcome.skipped:
905 state = "skipped"
906 elif outcome.ok:
907 state = "passed"
908 elif outcome.timed_out:
909 # Still a blocking outcome — but "failed" would read as a broken test.
910 state = "timed out"
911 else:
912 state = "failed"
913 suffix = f" ({outcome.error})" if outcome.error else ""
914 lines.append(f"{outcome.gate}: {state}{suffix}")
915 return lines
918def _docs_impact(changed_files: list[str] | None) -> str:
919 if changed_files is None:
920 return "Docs Impact: unknown — the changed-file list could not be read from git."
921 docs = [file for file in changed_files if _is_doc_path(file)]
922 if docs:
923 return "Updated docs: " + ", ".join(f"`{file}`" for file in docs)
924 return "Docs Impact: none — no documentation files changed."
927def _is_doc_path(file: str) -> bool:
928 lowered = file.lower()
929 return (
930 "/docs/" in f"/{lowered}"
931 or lowered.startswith("docs/")
932 or lowered.endswith((".md", ".mdx", ".rst", ".adoc"))
933 )
936def _finding_summaries(findings: list[dict[str, Any]]) -> list[str]:
937 return [
938 f"{finding['severity']}: {finding['message']}"
939 for finding in findings
940 if isinstance(finding.get("severity"), str) and isinstance(finding.get("message"), str)
941 ]
944def standalone_result_as_dict(
945 *,
946 command: str,
947 config: cfg.ProjectConfig,
948 target: str | None = None,
949 delegate: str | None = None,
950 transport: github_transport.GitHubTransport | None = None,
951 evaluation: runtime.CapabilityEvaluation | None = None,
952) -> dict[str, Any]:
953 """Deterministic dry-run result records for standalone non-ship commands."""
954 if command == "implement":
955 issue_id = _target_identifier(target)
956 return {
957 "command": command,
958 "target": target,
959 "base_branch": config.base_branch,
960 "branch_pattern": f"feature/issue-{issue_id}-<slug>",
961 "worktree_path_pattern": f"worktrees/issue-{issue_id}",
962 "implementer": {
963 "source": "delegate" if delegate else "project-routing-or-host",
964 "selected": delegate,
965 "routing_keys": sorted(config.knobs.implementer_agents),
966 },
967 "handoff": {
968 "opens_pr": True,
969 "merges": False,
970 "next_commands": ["ship", "pr-loop"],
971 },
972 }
973 if command == "ci-check":
974 resolved = transport.as_dict() if transport is not None else {}
975 return {
976 "command": command,
977 "target": target,
978 "base_branch": config.base_branch,
979 "ci_workflows": dict(sorted(config.knobs.ci_workflows.items())),
980 "latest_run_context": {
981 "limit": 3,
982 "selected": "newest available run",
983 "history": "previous runs used for flake or infra classification",
984 },
985 "diagnostics": {
986 "read_only": True,
987 "log_tail": "available when the selected GitHub transport exposes logs",
988 "classifications": ["real-failure", "flake", "infra-or-quota"],
989 "proposed_fix_count": 1,
990 },
991 "github_transport": resolved,
992 "routing": {
993 "never_direct_merge": True,
994 "recommendations": ["review-cycle", "pr-loop", "ship", "flake-audit"],
995 },
996 }
997 if command == "morning":
998 return {
999 "command": command,
1000 "target": target,
1001 "base_branch": config.base_branch,
1002 "brief": morning_contract_as_dict(
1003 config=config,
1004 evaluation=evaluation,
1005 transport=transport,
1006 ),
1007 "execution": {
1008 "runs_project_health_commands": False,
1009 "writes_reports": False,
1010 "live_work_owner": "adapter-or-extension-after-consent",
1011 },
1012 }
1013 if command in {"wrap", "work-block", "overnight"}:
1014 return {
1015 "command": command,
1016 "target": target,
1017 "base_branch": config.base_branch,
1018 "session": session_contract_as_dict(
1019 command=command,
1020 config=config,
1021 transport=transport,
1022 ),
1023 "execution": {
1024 "runs_gates": False,
1025 "creates_prs": False,
1026 "merges": False,
1027 "writes_reports": False,
1028 "live_work_owner": "adapter-after-consent",
1029 },
1030 }
1031 if command in {"pr-loop", "review-cycle"}:
1032 workflow = feedback_workflow_as_dict(config, command)
1033 return {
1034 "command": command,
1035 "target": target,
1036 "base_branch": config.base_branch,
1037 "feedback_workflow": workflow,
1038 "execution": {
1039 "commits": False,
1040 "pushes": False,
1041 "posts_comments": False,
1042 "merges": False,
1043 "live_work_owner": "adapter-after-consent",
1044 },
1045 }
1046 if command in {"regression", "review-all-day"}:
1047 return {
1048 "command": command,
1049 "target": target,
1050 "base_branch": config.base_branch,
1051 "scan": scan_contract_as_dict(
1052 command=command,
1053 config=config,
1054 transport=transport,
1055 ),
1056 "execution": {
1057 "edits_code": False,
1058 "pushes": False,
1059 "merges": False,
1060 "writes_issues": False,
1061 "live_work_owner": "adapter-after-consent",
1062 },
1063 }
1064 return {"command": command, "target": target}
1067def feedback_workflow_as_dict(config: cfg.ProjectConfig, command: str) -> dict[str, Any]:
1068 """Return command-specific feedback-loop policy with project overrides applied."""
1069 defaults = _DEFAULT_FEEDBACK_WORKFLOWS.get(command, {})
1070 policy = _feedback_workflow_policy(config).get(command, {})
1071 return _deep_merge(defaults, policy)
1074def scan_contract_as_dict(
1075 *,
1076 command: str,
1077 config: cfg.ProjectConfig,
1078 transport: github_transport.GitHubTransport | None = None,
1079) -> dict[str, Any]:
1080 """Project-neutral scan-and-file contract for regression and review-all-day."""
1081 pack = config.policy_pack or {}
1082 reports = pack.get("reports") if isinstance(pack.get("reports"), dict) else {}
1083 scan = pack.get("scan") if isinstance(pack.get("scan"), dict) else {}
1084 areas = scan.get("areas") if isinstance(scan.get("areas"), dict) else {}
1085 issue_labels = scan.get("issue_labels") if isinstance(scan.get("issue_labels"), dict) else {}
1086 github = transport.as_dict() if transport is not None else {}
1087 base = {
1088 "timezone": config.timezone,
1089 "merge_window": config.merge_window,
1090 "base_branch": config.base_branch,
1091 "github_transport": github,
1092 "reports": _report_destinations(reports),
1093 "project_policy_sources": {
1094 "scan": "policy_pack.scan",
1095 "areas": "policy_pack.scan.areas",
1096 "active_branch_patterns": "policy_pack.scan.active_branch_patterns",
1097 "risk_globs": "knobs.tier3_globs",
1098 "labels": "policy_pack.labels + policy_pack.scan.issue_labels",
1099 "ci_workflows": "knobs.ci_workflows",
1100 },
1101 "write_safety": {
1102 "dry_run_no_writes": True,
1103 "orchestrator_only_writes": True,
1104 "code_mutation": False,
1105 "pr_mutation": False,
1106 "issue_write_requires_consent": True,
1107 },
1108 "dedupe": {
1109 "source": "policy_pack.scan.dedupe + canonical defaults",
1110 "path_token_boundary": True, # nosec B105
1111 "type_must_match": True,
1112 "near_text_similarity": _near_text_similarity(config),
1113 "open_duplicate": "skip",
1114 "closed_duplicate": "promote-regression-of",
1115 "lock": "mkdir",
1116 },
1117 "reviewer_isolation": {
1118 "parallel": True,
1119 "no_cross_reading": True,
1120 "orchestrator_collects_findings": True,
1121 },
1122 "areas": [
1123 {"name": name, "paths": list(paths)}
1124 for name, paths in sorted(areas.items())
1125 if isinstance(paths, list)
1126 ],
1127 "risk_globs": list(config.knobs.tier3_globs),
1128 "scan_finding": {
1129 "renderer": "keel.artifacts.render_scan_finding_issue",
1130 "marker": artifacts.SCAN_FINDING_MARKER,
1131 },
1132 "issue_labels": {
1133 key: list(value)
1134 for key, value in sorted(issue_labels.items())
1135 if isinstance(value, list)
1136 },
1137 "work_creation_policy": workcreation.contract_as_dict(
1138 near_text_similarity=_near_text_similarity(config),
1139 ),
1140 }
1141 if command == "regression":
1142 base["regression"] = {
1143 "scan_target": {
1144 "source": "canonical base head",
1145 "base_branch": config.base_branch,
1146 "read_only_worktree": True,
1147 "clean_tree_preflight": True,
1148 },
1149 "scope": {
1150 "default": "full",
1151 "supported": ["full", "changed", "since"],
1152 },
1153 "confidence_filter": {
1154 "drop": ["low"],
1155 "downgrade_blocker_when": "medium-confidence",
1156 "file_only_when": ["high-confidence", "medium-security"],
1157 },
1158 "issue_creation": {
1159 "one_issue_per_finding": True,
1160 "route_to": "ship",
1161 "regression_of_line": "regression-of: #N",
1162 "labels": issue_labels.get("regression", []),
1163 },
1164 "final_report": [
1165 "raw_findings",
1166 "after_confidence_filter",
1167 "duplicates_skipped",
1168 "promoted_regressions",
1169 "issues_opened",
1170 "ship_handoffs",
1171 ],
1172 }
1173 elif command == "review-all-day":
1174 base["review_all_day"] = {
1175 "span": {
1176 "timezone": config.timezone,
1177 "merge_window": config.merge_window,
1178 "days_are_inclusive_calendar_days": True,
1179 "n_days_argument_covers_calendar_days": "N+1",
1180 },
1181 "ref_scope": {
1182 "branches": ["trunk", "active-work-branches"],
1183 "active_branch_patterns": list(scan.get("active_branch_patterns") or ()),
1184 "remote_refs_default": True,
1185 "warn_on_stale_fetch": True,
1186 },
1187 "strategy": {
1188 "batch_threshold": _scan_int(scan, "batch_threshold", 5),
1189 "fanout_when_commit_count_gt": _scan_int(scan, "batch_threshold", 5),
1190 },
1191 "diff_truncation": {
1192 "max_bytes": _scan_int(scan, "large_diff_max_bytes", 200000),
1193 "boundary": "file",
1194 },
1195 "finding_filter": {
1196 "skip_minor": True,
1197 "keep_minor_categories": ["security"],
1198 "file_categories": ["bug-insert", "regression", "security", "config",
1199 "test-coverage"],
1200 },
1201 "issue_creation": {
1202 "title_prefix": "[review-all-day] ",
1203 "one_issue_per_serious_finding": True,
1204 "labels": issue_labels.get("review-all-day", []),
1205 },
1206 "final_report": [
1207 "commit_range",
1208 "reviewers",
1209 "findings",
1210 "duplicates_skipped",
1211 "issues_opened",
1212 ],
1213 }
1214 return base
1217def session_contract_as_dict(
1218 *,
1219 command: str,
1220 config: cfg.ProjectConfig,
1221 transport: github_transport.GitHubTransport | None = None,
1222) -> dict[str, Any]:
1223 """Project-neutral session workflow contract for session/work-block commands."""
1224 pack = config.policy_pack or {}
1225 reports = pack.get("reports") if isinstance(pack.get("reports"), dict) else {}
1226 github = transport.as_dict() if transport is not None else {}
1227 base = {
1228 "timezone": config.timezone,
1229 "merge_window": config.merge_window,
1230 "merge_window_mode": config.merge_window_mode,
1231 "base_branch": config.base_branch,
1232 "github_transport": github,
1233 "reports": _report_destinations(reports),
1234 "deferral_queue": _deferral_queue_as_dict(reports),
1235 "run_ledger": ledger.ledger_contract_as_dict(config),
1236 "project_policy_sources": {
1237 "gates": list(config.gates),
1238 "extensions": {slot: list(files) for slot, files in sorted(config.extensions.items())},
1239 "risk_rules": [rule.get("id") for rule in pack.get("risk_rules", [])
1240 if isinstance(rule, dict)],
1241 "source_of_truth_doc": config.knobs.sot_doc,
1242 },
1243 }
1244 if command in {"work-block", "overnight"}:
1245 base["work_block"] = workblock.contract_as_dict(
1246 config=config,
1247 mode="overnight" if command == "overnight" else "daytime",
1248 transport=github,
1249 )
1250 if command == "wrap":
1251 base["wrap"] = {
1252 "workspace_preflight": {
1253 "must_run_from_linked_worktree": True,
1254 "abort_on_base_branch": True,
1255 "git_dir_rule": "main worktree returns .git; linked worktree uses .git/worktrees",
1256 },
1257 "quality_gates": {
1258 "runner": "keel run-gates",
1259 "changed_file_policy_source": "policy_pack + extension hooks",
1260 },
1261 "commit": {
1262 "format": "conventional-commits",
1263 "supports_closes_issue": True,
1264 },
1265 "pull_request": {
1266 "ready_not_draft": True,
1267 "base_branch": config.base_branch,
1268 "requires_pr_write": True,
1269 "body_sections": ["Summary", "Docs Impact", "Test Plan"],
1270 },
1271 "recap": {
1272 "report_key": "session",
1273 "path": reports.get("session") or reports.get("wrap"),
1274 "status": "configured" if reports.get("session") or reports.get("wrap")
1275 else "unconfigured",
1276 },
1277 }
1278 elif command == "work-block":
1279 base["daytime"] = {
1280 "mode_source": {
1281 "command": "keel work-block",
1282 "shared_with_overnight": True,
1283 },
1284 "queue": {
1285 "source": "explicit issue numbers or project queue selector",
1286 "deterministic_order": (
1287 "explicit numbers keep their provided order; selectors sort by policy"
1288 ),
1289 },
1290 "ship_handoff": {
1291 "command": "ship",
1292 "passes_operator_consent_scope": True,
1293 "per_issue_worktree": True,
1294 "refreshes_readiness_between_issues": True,
1295 },
1296 "operator_control": {
1297 "between_items": True,
1298 "stop_on_consent_gap": True,
1299 "stop_on_needs_input": True,
1300 },
1301 "report": {
1302 "day_key": "session",
1303 "day_path": reports.get("session"),
1304 "status": "configured" if reports.get("session") else "unconfigured",
1305 },
1306 }
1307 elif command == "overnight":
1308 base["overnight"] = {
1309 "mode_source": {
1310 "command": "keel window",
1311 "shared_with_ship": True,
1312 "timezone": config.timezone,
1313 "merge_window": config.merge_window,
1314 },
1315 "merge_policy": {
1316 "day": "ship may merge reviewed CI-green PRs inside the configured window",
1317 "night": "no merge outside the configured window except true blockers",
1318 "blocker_source": "ship blocker heuristics + project policy extensions",
1319 "cannot_weaken_core_window": True,
1320 },
1321 "queue": {
1322 "source": "project policy or adapter query",
1323 "tiers": ["T0-blocker", "T1-open-prs", "T2-coverage", "T3-ci-quality",
1324 "T4-modernization", "T5-docs-backlog"],
1325 },
1326 "ship_handoff": {
1327 "command": "ship",
1328 "passes_operator_consent_scope": True,
1329 "per_issue_worktree": True,
1330 "shared_work_block_contract": True,
1331 },
1332 "report": {
1333 "night_key": "overnight",
1334 "day_key": "session",
1335 "night_path": reports.get("overnight") or reports.get("morning"),
1336 "day_path": reports.get("session"),
1337 "status": "configured" if any(
1338 reports.get(key) for key in ("overnight", "morning", "session")
1339 ) else "unconfigured",
1340 },
1341 "stop_conditions": [
1342 "merge-window-close",
1343 "time-budget-exhausted",
1344 "max-items-reached",
1345 "hard-blocker",
1346 "three-consecutive-unresolved-ci-failures",
1347 "user-cancelled",
1348 ],
1349 }
1350 return base
1353def morning_contract_as_dict(
1354 *,
1355 config: cfg.ProjectConfig,
1356 evaluation: runtime.CapabilityEvaluation | None = None,
1357 transport: github_transport.GitHubTransport | None = None,
1358) -> dict[str, Any]:
1359 """Project-neutral morning briefing contract for adapters and dry-run output."""
1360 pack = config.policy_pack or {}
1361 reports = pack.get("reports") if isinstance(pack.get("reports"), dict) else {}
1362 health = pack.get("health_providers") if isinstance(pack.get("health_providers"), dict) else {}
1363 github = transport.as_dict() if transport is not None else {}
1364 github_status = "available" if github.get("transport") not in {None, "none"} else "degraded"
1365 return {
1366 "timezone": config.timezone,
1367 "merge_window": config.merge_window,
1368 "base_branch": config.base_branch,
1369 "sections": [
1370 {
1371 "id": "deferrals",
1372 "source": "shared_queue",
1373 "status": "configured" if reports.get("deferrals") else "unconfigured",
1374 "path": reports.get("deferrals"),
1375 },
1376 {
1377 "id": "shipped_since_last_brief",
1378 "source": "github",
1379 "transport": github.get("transport"),
1380 "status": github_status,
1381 },
1382 {
1383 "id": "github_status",
1384 "source": "github",
1385 "transport": github.get("transport"),
1386 "status": github_status,
1387 },
1388 {
1389 "id": "project_health",
1390 "source": "policy_pack.health_providers",
1391 "status": "configured" if health else "unconfigured",
1392 },
1393 {
1394 "id": "ranked_focus",
1395 "source": "policy_pack + github",
1396 "status": "configured" if _priority_sources(config, reports) else "unconfigured",
1397 },
1398 ],
1399 "health_providers": [
1400 _health_provider_as_dict(name, provider, evaluation)
1401 for name, provider in sorted(health.items())
1402 if isinstance(provider, dict)
1403 ],
1404 "priority_sources": _priority_sources(config, reports),
1405 "reports": _report_destinations(reports),
1406 "deferral_queue": _deferral_queue_as_dict(reports),
1407 "run_ledger": ledger.ledger_contract_as_dict(config),
1408 "missing_optional_policy": "unavailable-not-success",
1409 }
1412def _health_provider_as_dict(
1413 name: str,
1414 provider: dict[str, Any],
1415 evaluation: runtime.CapabilityEvaluation | None,
1416) -> dict[str, Any]:
1417 required = tuple(provider.get("required_capabilities") or ())
1418 optional = tuple(provider.get("optional_capabilities") or ())
1419 missing_required = _missing_capabilities(required, evaluation)
1420 missing_optional = _missing_capabilities(optional, evaluation)
1421 status = "available"
1422 if missing_required:
1423 status = "blocked"
1424 elif missing_optional:
1425 status = "unavailable"
1426 elif provider.get("command") is None and provider.get("kind") == "project-command":
1427 status = "unavailable"
1428 return {
1429 "name": name,
1430 "kind": provider.get("kind"),
1431 "command": provider.get("command"),
1432 "reports": list(provider.get("reports") or ()),
1433 "required_capabilities": list(required),
1434 "optional_capabilities": list(optional),
1435 "missing_required_capabilities": list(missing_required),
1436 "missing_optional_capabilities": list(missing_optional),
1437 "status": status,
1438 }
1441def _missing_capabilities(
1442 names: tuple[str, ...],
1443 evaluation: runtime.CapabilityEvaluation | None,
1444) -> tuple[str, ...]:
1445 if evaluation is None:
1446 return ()
1447 missing = set(evaluation.missing_required) | set(evaluation.missing_optional)
1448 return tuple(name for name in names if name in missing)
1451def _priority_sources(config: cfg.ProjectConfig, reports: dict[str, Any]) -> list[dict[str, Any]]:
1452 sources: list[dict[str, Any]] = []
1453 if config.knobs.sot_doc:
1454 sources.append({"id": "source_of_truth", "path": config.knobs.sot_doc})
1455 if reports.get("priorities"):
1456 sources.append({"id": "priorities_report", "path": reports["priorities"]})
1457 if config.knobs.ci_workflows:
1458 sources.append({"id": "ci_workflows", "names": sorted(config.knobs.ci_workflows)})
1459 return sources
1462def _report_destinations(reports: dict[str, Any]) -> dict[str, dict[str, Any]]:
1463 return {
1464 key: {
1465 "path": value,
1466 "write_status": "skipped-in-dry-run",
1467 }
1468 for key, value in sorted(reports.items())
1469 }
1472def _deferral_queue_as_dict(reports: dict[str, Any]) -> dict[str, Any]:
1473 return {
1474 "source": "policy_pack.reports.deferrals",
1475 "path": reports.get("deferrals"),
1476 "status": "configured" if reports.get("deferrals") else "unconfigured",
1477 "shared_with": ["ship", "overnight", "wrap", "morning"],
1478 }
1481def _feedback_workflow_policy(config: cfg.ProjectConfig) -> dict[str, dict[str, Any]]:
1482 pack = config.policy_pack or {}
1483 policy = pack.get("workflow_policies")
1484 if not isinstance(policy, dict):
1485 return {}
1486 return {
1487 name: value
1488 for name, value in policy.items()
1489 if isinstance(name, str) and isinstance(value, dict)
1490 }
1493def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
1494 merged: dict[str, Any] = {}
1495 for key, value in base.items():
1496 if isinstance(value, dict):
1497 merged[key] = _deep_merge(value, {})
1498 elif isinstance(value, list):
1499 merged[key] = list(value)
1500 else:
1501 merged[key] = value
1502 for key, value in override.items():
1503 if isinstance(value, dict) and isinstance(merged.get(key), dict):
1504 merged[key] = _deep_merge(merged[key], value)
1505 elif isinstance(value, list):
1506 merged[key] = list(value)
1507 else:
1508 merged[key] = value
1509 return merged
1512def _scan_int(scan: dict[str, Any], key: str, default: int) -> int:
1513 value = scan.get(key)
1514 return value if isinstance(value, int) else default
1517def _scan_float(scan: dict[str, Any], key: str, default: float) -> float:
1518 value = scan.get(key)
1519 return value if isinstance(value, int | float) else default
1522def _near_text_similarity(config: cfg.ProjectConfig) -> float:
1523 """The project's resolved ``policy_pack.scan.near_text_similarity``.
1525 Both contract builders embed a ``work_creation_policy`` block carrying this
1526 threshold, and the scan contract carries a second copy under ``dedupe``. One
1527 resolver keeps every copy in agreement — hardcoding the default in
1528 ``workcreation`` shipped two different values under near-identical keys, agreeing
1529 only while the project happened to set the knob to the built-in default (#633).
1530 """
1531 pack = config.policy_pack or {}
1532 scan = pack.get("scan") if isinstance(pack.get("scan"), dict) else {}
1533 return _scan_float(scan, "near_text_similarity",
1534 workcreation.DEFAULT_NEAR_TEXT_SIMILARITY)
1537def _target_identifier(target: str | None) -> str:
1538 if not target:
1539 return "selected-issue"
1540 match = re.search(r"#?(\d+)", target)
1541 if match:
1542 return match.group(1)
1543 return re.sub(r"[^a-z0-9]+", "-", target.lower()).strip("-") or "selected-issue"
1546def _adapter_steps(command: str) -> list[dict[str, Any]]:
1547 path = Path(install.ADAPTERS) / f"{command}.md"
1548 if not path.exists():
1549 return []
1550 steps: list[dict[str, Any]] = []
1551 for line in path.read_text(encoding="utf-8").splitlines():
1552 match = _STEP_RE.match(line)
1553 if not match:
1554 continue
1555 raw_id = match.group("id").strip()
1556 step_id = raw_id.lower().replace(" ", "-").replace(".", "-")
1557 steps.append({
1558 "step_id": step_id,
1559 "step_name": match.group("name").strip() or raw_id,
1560 "agentic": "agent" in match.group("name").lower(),
1561 "slot": None,
1562 "source": "adapter",
1563 })
1564 return steps
1567def _finding_as_dict(finding) -> dict[str, Any]:
1568 tag = provenance.normalize_tag(
1569 getattr(finding, "provenance", None),
1570 fallback_agent=finding.source,
1571 step_id="finding",
1572 )
1573 return {
1574 "severity": finding.severity,
1575 "message": finding.message,
1576 "source": finding.source,
1577 "path": finding.path,
1578 "line": finding.line,
1579 "anchorable": finding.anchorable,
1580 "provenance": tag,
1581 }