Coverage for src/keel/model.py: 100%
32 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
1"""The keel backbone: the fixed, ordered step machine and its extension slots.
3This module is the single source of truth for the step IDs, the named slots an
4extension may register into, and the invariants the backbone always preserves.
5It is pure data — no I/O, no config — so consumers (config, extensions,
6orchestrator) and tests can all agree on one definition.
7"""
9from __future__ import annotations
11from dataclasses import dataclass
14@dataclass(frozen=True)
15class Step:
16 """One backbone step.
18 ``slot`` is the historical primary extension point, kept for compatibility.
19 New hook-aware code should use :func:`slots_for_step`.
20 """
22 id: str
23 name: str
24 slot: str | None = None
25 agentic: bool = False # True if the step dispatches to an agent (implement/review/classify)
28@dataclass(frozen=True)
29class Slot:
30 """One named extension hook exposed by a backbone step."""
32 name: str
33 step_id: str
34 execution_mode: str = "deterministic"
35 may_block: bool = False
36 adapter_required: bool = False
39#: The fixed backbone, in execution order. Changing this is a keel-core change.
40BACKBONE: tuple[Step, ...] = (
41 Step("s0", "config"),
42 Step("s1", "select"),
43 Step("s2", "branch"),
44 Step("s3", "guard"),
45 Step("s4", "implement", slot="after-implement", agentic=True),
46 Step("s5", "classify", agentic=True),
47 Step("s6", "ci"),
48 Step("s7", "review", slot="reviewers", agentic=True),
49 Step("s8", "test", slot="tester"),
50 Step("s9", "fixloop"),
51 Step("s10", "merge", slot="pre-merge"),
52 Step("s11", "capture", slot="post-merge"),
53 Step("s12", "close"),
54)
56#: The named hooks, in backbone order. Extensions are add-only into these.
57SLOT_DEFINITIONS: tuple[Slot, ...] = (
58 Slot("after:config", "s0"),
59 Slot("before:select", "s1"),
60 Slot("select", "s1", adapter_required=True),
61 Slot("after:select", "s1"),
62 Slot("before:branch", "s2"),
63 Slot("after:branch", "s2"),
64 Slot("guard", "s3", may_block=True),
65 Slot("before:implement", "s4", adapter_required=True),
66 Slot("after-implement", "s4", adapter_required=True),
67 Slot("classify", "s5", adapter_required=True),
68 Slot("after:classify", "s5"),
69 Slot("before:ci", "s6"),
70 Slot("after:ci", "s6"),
71 Slot("reviewers", "s7", execution_mode="agentic", adapter_required=True),
72 Slot("after:review", "s7", adapter_required=True),
73 Slot("tester", "s8", execution_mode="hybrid", may_block=True, adapter_required=True),
74 Slot("test", "s8", execution_mode="hybrid", may_block=True, adapter_required=True),
75 Slot("after:test", "s8"),
76 Slot("before:fixloop", "s9", adapter_required=True),
77 Slot("fixloop", "s9", execution_mode="hybrid", adapter_required=True),
78 Slot("after:fixloop", "s9"),
79 Slot("pre-merge", "s10", may_block=True),
80 Slot("after:merge", "s10"),
81 Slot("capture", "s11", adapter_required=True),
82 Slot("post-merge", "s11", adapter_required=True),
83 Slot("before:close", "s12"),
84 Slot("on-close", "s12", adapter_required=True),
85 Slot("after:close", "s12"),
86)
88#: The named slots, in backbone order. Extensions are add-only into these.
89SLOTS: tuple[str, ...] = tuple(slot.name for slot in SLOT_DEFINITIONS)
91#: Invariants the backbone always preserves — no config or extension can override.
92INVARIANTS: tuple[str, ...] = (
93 "merge_lock", # every merge goes through the mkdir-based lock
94 "window_gate", # the night no-merge window is enforced
95 "fail_soft", # a soft failure degrades to a no-op, never aborts
96 "orchestrator_only_writes", # only the orchestrator writes to the PR
97 "attribution", # implementer/reviewer vendor+model is recorded
98)
100_BY_ID: dict[str, Step] = {s.id: s for s in BACKBONE}
101_SLOT_META: dict[str, Slot] = {slot.name: slot for slot in SLOT_DEFINITIONS}
102_BY_SLOT: dict[str, Step] = {slot: _BY_ID[_SLOT_META[slot].step_id] for slot in SLOTS}
105def step_ids() -> tuple[str, ...]:
106 """All backbone step IDs in order."""
107 return tuple(s.id for s in BACKBONE)
110def get_step(step_id: str) -> Step:
111 """Return the step with ``step_id`` (raises ``KeyError`` if unknown)."""
112 return _BY_ID[step_id]
115def step_for_slot(slot: str) -> Step:
116 """Return the backbone step that exposes ``slot`` (raises ``KeyError``)."""
117 return _BY_SLOT[slot]
120def slot_meta(slot: str) -> Slot:
121 """Return metadata for a named extension hook (raises ``KeyError``)."""
122 return _SLOT_META[slot]
125def slots_for_step(step_id: str) -> tuple[Slot, ...]:
126 """Return extension hooks exposed by ``step_id`` in declared order."""
127 return tuple(slot for slot in SLOT_DEFINITIONS if slot.step_id == step_id)