cih-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cih/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
cih/agents.py ADDED
@@ -0,0 +1,57 @@
1
+ # cih/agents.py
2
+ import json
3
+ import subprocess
4
+ from typing import Protocol
5
+ from cih.contracts import AgentContract
6
+
7
+ class AgentRunner(Protocol):
8
+ def run(self, contract: AgentContract, input_data: dict) -> dict: ...
9
+
10
+ class StubRunner:
11
+ """Test double: returns canned responses keyed by role."""
12
+ def __init__(self, responses: dict):
13
+ self.responses = responses
14
+ self.calls: list[dict] = []
15
+
16
+ def run(self, contract: AgentContract, input_data: dict) -> dict:
17
+ self.calls.append({"role": contract.role, "input": input_data})
18
+ if contract.role not in self.responses:
19
+ raise KeyError(f"no stub response for role {contract.role}")
20
+ return self.responses[contract.role]
21
+
22
+ class ClaudeCliRunner:
23
+ """Headless adapter: drives `claude -p --append-system-prompt`.
24
+
25
+ Flags precede the prompt; output is expected as JSON on stdout.
26
+ """
27
+ def __init__(self, cwd: str, extra_args: list[str] | None = None):
28
+ self.cwd = cwd
29
+ self.extra_args = extra_args or []
30
+
31
+ def run(self, contract: AgentContract, input_data: dict) -> dict:
32
+ prompt = json.dumps(input_data)
33
+ cmd = ["claude", "-p", "--output-format", "json",
34
+ "--append-system-prompt", contract.role_prompt,
35
+ *self.extra_args, "--", prompt]
36
+ proc = subprocess.run(cmd, cwd=self.cwd, capture_output=True, text=True)
37
+ if proc.returncode != 0:
38
+ raise RuntimeError(f"claude failed for {contract.role}: {proc.stderr}")
39
+ try:
40
+ envelope = json.loads(proc.stdout)
41
+ except json.JSONDecodeError as e:
42
+ raise RuntimeError(f"{contract.role}: non-JSON stdout from claude -p: {proc.stdout[:500]!r}") from e
43
+ if envelope.get("is_error"):
44
+ raise RuntimeError(f"{contract.role}: claude reported error: {envelope.get('result')}")
45
+ result = envelope.get("result")
46
+ if isinstance(result, dict):
47
+ return result
48
+ try:
49
+ return json.loads(result)
50
+ except (TypeError, json.JSONDecodeError) as e:
51
+ from cih.contracts import OutputValidationError
52
+ raise OutputValidationError(f"{contract.role}: result was not JSON: {result!r}") from e
53
+
54
+ def invoke(runner: AgentRunner, contract: AgentContract, input_data: dict) -> dict:
55
+ output = runner.run(contract, input_data)
56
+ contract.validate_output(output)
57
+ return output
cih/attempts.py ADDED
@@ -0,0 +1,61 @@
1
+ from dataclasses import dataclass, asdict
2
+ from enum import Enum
3
+ from typing import Optional
4
+
5
+ class AttemptKind(str, Enum):
6
+ PLAN = "plan_retry"
7
+ EXECUTION = "execution_retry"
8
+ INTEGRATION = "integration_retry"
9
+ FINAL_REJECT = "final_reject"
10
+
11
+ class AttemptCapExceeded(Exception):
12
+ pass
13
+
14
+ @dataclass
15
+ class Attempt:
16
+ attempt_id: str
17
+ kind: str
18
+ base_sha: str
19
+ branch: str
20
+ worktree_path: str
21
+ feedback_input: str
22
+ parent_attempt_id: Optional[str] = None
23
+ is_current: bool = True
24
+
25
+ class AttemptLog:
26
+ def __init__(self, team_id: str, cap: int):
27
+ self.team_id = team_id
28
+ self.cap = cap
29
+ self._attempts: list[Attempt] = []
30
+
31
+ def start(self, kind: AttemptKind, base_sha: str, branch: str,
32
+ worktree_path: str, feedback: str,
33
+ parent: Optional[str] = None) -> Attempt:
34
+ if len(self._attempts) >= self.cap:
35
+ raise AttemptCapExceeded(
36
+ f"{self.team_id}: attempt cap {self.cap} reached")
37
+ for a in self._attempts:
38
+ a.is_current = False
39
+ att = Attempt(
40
+ attempt_id=f"attempt-{len(self._attempts)+1:02d}",
41
+ kind=kind.value if isinstance(kind, AttemptKind) else kind,
42
+ base_sha=base_sha, branch=branch, worktree_path=worktree_path,
43
+ feedback_input=feedback, parent_attempt_id=parent)
44
+ self._attempts.append(att)
45
+ return att
46
+
47
+ def current(self) -> Optional[Attempt]:
48
+ return self._attempts[-1] if self._attempts else None
49
+
50
+ def all(self) -> list[Attempt]:
51
+ return list(self._attempts)
52
+
53
+ def to_dict(self) -> dict:
54
+ return {"team_id": self.team_id, "cap": self.cap,
55
+ "attempts": [asdict(a) for a in self._attempts]}
56
+
57
+ @classmethod
58
+ def from_dict(cls, d: dict) -> "AttemptLog":
59
+ log = cls(team_id=d["team_id"], cap=d["cap"])
60
+ log._attempts = [Attempt(**a) for a in d["attempts"]]
61
+ return log
cih/config.py ADDED
@@ -0,0 +1,81 @@
1
+ import os
2
+ from dataclasses import dataclass, field, asdict
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ class ConfigError(Exception):
7
+ pass
8
+
9
+ _MODES = {"fixed-N", "until-converged"}
10
+
11
+ DEPTH_BUDGET = {"low": 3, "medium": 6, "high": 10}
12
+ DEFAULT_DEPTH = "medium"
13
+
14
+
15
+ def depth_budget(name: Optional[str] = None) -> int:
16
+ """Map a --depth name to its question budget (upper bound). None → default."""
17
+ if name is None:
18
+ name = DEFAULT_DEPTH
19
+ if name not in DEPTH_BUDGET:
20
+ raise ConfigError(
21
+ f"depth must be one of {sorted(DEPTH_BUDGET, key=DEPTH_BUDGET.__getitem__)} (got {name!r})"
22
+ )
23
+ return DEPTH_BUDGET[name]
24
+
25
+
26
+ @dataclass
27
+ class RunConfig:
28
+ mode: str
29
+ target_repo: str
30
+ state_dir: str
31
+ iterations: Optional[int] = None
32
+ max_iterations: int = 25
33
+ budget_cap: Optional[int] = None
34
+ focus_areas: list[str] = field(default_factory=list)
35
+ value_threshold: float = 0.5
36
+ convergence_dry_streak: int = 2
37
+ plan_review_retries: int = 2
38
+ exec_review_retries: int = 2
39
+ max_teams_per_iteration: int = 4
40
+ integration_retries: int = 2
41
+ per_team_attempt_cap: int = 4
42
+ cooldown_iterations: int = 2
43
+ opportunity_max_attempts: int = 3
44
+ tdd_adapter: str = "pytest"
45
+
46
+ @staticmethod
47
+ def _validate_paths(target_repo: str, state_dir: str) -> None:
48
+ for label, p in (("target_repo", target_repo), ("state_dir", state_dir)):
49
+ if not os.path.isabs(p):
50
+ raise ConfigError(f"{label} must be an absolute path: {p}")
51
+ t = Path(target_repo).resolve()
52
+ s = Path(state_dir).resolve()
53
+ if t == s:
54
+ raise ConfigError("target_repo and state_dir must be distinct")
55
+ if t in s.parents or s in t.parents:
56
+ raise ConfigError("state_dir must not be nested inside target_repo (or vice versa)")
57
+ for label, p in (("target_repo", t), ("state_dir", s)):
58
+ if not p.is_dir():
59
+ raise ConfigError(f"{label} must be an existing directory: {p}")
60
+
61
+ @classmethod
62
+ def create(cls, **kwargs) -> "RunConfig":
63
+ mode = kwargs.get("mode")
64
+ if mode not in _MODES:
65
+ raise ConfigError(f"mode must be one of {_MODES}")
66
+ iterations = kwargs.get("iterations")
67
+ if mode == "fixed-N":
68
+ if not isinstance(iterations, int) or iterations <= 0:
69
+ raise ConfigError("fixed-N mode requires iterations to be a positive int")
70
+ elif mode == "until-converged":
71
+ if iterations is not None:
72
+ raise ConfigError("until-converged mode must not set iterations")
73
+ cls._validate_paths(kwargs["target_repo"], kwargs["state_dir"])
74
+ return cls(**kwargs)
75
+
76
+ def to_dict(self) -> dict:
77
+ return asdict(self)
78
+
79
+ @classmethod
80
+ def from_dict(cls, d: dict) -> "RunConfig":
81
+ return cls.create(**d)
cih/contracts.py ADDED
@@ -0,0 +1,32 @@
1
+ # cih/contracts.py
2
+ import hashlib
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from jsonschema import validate, ValidationError
6
+
7
+ class OutputValidationError(Exception):
8
+ pass
9
+
10
+ @dataclass
11
+ class AgentContract:
12
+ role: str
13
+ agent_version: str
14
+ role_prompt: str
15
+ input_schema: dict
16
+ output_schema: dict
17
+ allowed_tools: list = field(default_factory=list)
18
+ runtime_adapter_settings: dict = field(default_factory=dict)
19
+
20
+ def validate_output(self, output: dict) -> None:
21
+ try:
22
+ validate(instance=output, schema=self.output_schema)
23
+ except ValidationError as e:
24
+ raise OutputValidationError(f"{self.role} output invalid: {e.message}") from e
25
+
26
+ def prompt_hash(self) -> str:
27
+ blob = json.dumps({"prompt": self.role_prompt, "in": self.input_schema,
28
+ "out": self.output_schema, "v": self.agent_version,
29
+ "tools": self.allowed_tools,
30
+ "adapter": self.runtime_adapter_settings},
31
+ sort_keys=True)
32
+ return hashlib.sha256(blob.encode()).hexdigest()[:16]
cih/integration.py ADDED
@@ -0,0 +1,192 @@
1
+ # cih/integration.py
2
+ """Real git-backed integration layer for the orchestrator.
3
+
4
+ `build_integration` wires up a `(team_runner, integrate_fn)` pair that share an
5
+ internal `WorktreeManager`, a mutable integration `head`, and a per-iteration
6
+ `pending` registry. team_runner runs each team in its own iteration-scoped
7
+ worktree (branched off the CURRENT integration head), keeping it for passed
8
+ teams and removing it for failed/crashed ones, and persists per-team artifacts.
9
+
10
+ integrate_fn MERGES each passing team's branch into a single, advancing
11
+ integration worktree/branch (`cih/<run_id>/integration`), re-runs the suite +
12
+ execution-reviewer there, and threads the rolling tip via the merge queue.
13
+ Using merge (not rebase) preserves the executor commit SHAs so `reconcile` can
14
+ still resolve them, and lets iteration N+1 build on iteration N's merged result.
15
+ """
16
+ import functools
17
+ import subprocess
18
+ from pathlib import Path
19
+
20
+ from cih import merge_queue
21
+ from cih.agents import invoke
22
+ from cih.safety import GitError, run_git
23
+ from cih.state import StateHeader, write_state
24
+ from cih.tdd_verifier import verify_tdd
25
+ from cih.team import TeamResult, run_team
26
+ from cih.worktree import WorktreeManager
27
+
28
+
29
+ def build_integration(*, contracts, runner, verifier=None, repo, worktrees_root, run_id,
30
+ base_sha, state_dir, plan_review_retries, exec_review_retries,
31
+ attempt_cap, integration_retries, tdd_adapter="pytest", log=None):
32
+ mgr = WorktreeManager(repo, worktrees_root, run_id, log)
33
+ repo = Path(repo)
34
+ worktrees_root = Path(worktrees_root)
35
+ state_dir = Path(state_dir)
36
+ pending: dict[str, dict] = {}
37
+ # Run-scoped (NOT cleared per iteration): every passed-team worktree we keep.
38
+ # teardown() removes these dirs at run end while preserving their branches.
39
+ kept: list = []
40
+
41
+ # Mutable integration state, advances across iterations so improvements compound.
42
+ int_branch = f"cih/{run_id}/integration"
43
+ state = {"head": base_sha, "int_wt": None}
44
+
45
+ def _ensure_int_wt():
46
+ if state["int_wt"] is None:
47
+ int_wt = worktrees_root / run_id / "integration"
48
+ int_wt.parent.mkdir(parents=True, exist_ok=True)
49
+ run_git(["worktree", "add", "-b", int_branch, str(int_wt), state["head"]],
50
+ cwd=repo, log=log)
51
+ state["int_wt"] = int_wt
52
+ return state["int_wt"]
53
+
54
+ def _persist(iteration, team_id, result, wt=None):
55
+ iter_id = f"iter-{iteration:03d}"
56
+ teamdir = state_dir / "iterations" / iter_id / "teams" / team_id
57
+ status = "passed" if result.passed else "failed"
58
+ header = StateHeader(run_id, iter_id, team_id, None, status, "team")
59
+
60
+ body = {"commits": result.commits}
61
+ if wt is not None:
62
+ body["branch"] = wt.branch
63
+ try:
64
+ body["head_sha"] = mgr.head_sha(wt)
65
+ except GitError:
66
+ body["head_sha"] = None
67
+ write_state(teamdir / "plan.json", header, result.plan)
68
+ write_state(teamdir / "execution.json", header, body)
69
+ write_state(teamdir / "exec_review.json", header,
70
+ {"passed": result.passed, "reason": result.reason})
71
+ write_state(teamdir / "attempts.json", header, {"attempts": result.attempts})
72
+
73
+ def team_runner(charters, ctx):
74
+ iteration = ctx["iteration"]
75
+ iter_id = f"iter-{iteration:03d}"
76
+ # Reset pending so integrate_fn only ever processes THIS iteration's teams.
77
+ pending.clear()
78
+ results = []
79
+ for charter in charters:
80
+ team_id = charter["id"]
81
+ # Iteration-scoped worktree/branch: cih/<run_id>/iter-NNN/<team_id>.
82
+ # Branch off the CURRENT integration head so teams build on prior merges.
83
+ wt = mgr.create(f"{iter_id}/{team_id}", state["head"])
84
+ # When no explicit verifier is injected (production), bind a real
85
+ # mechanical TDD verifier to THIS team's worktree path.
86
+ team_verifier = verifier
87
+ if team_verifier is None:
88
+ team_verifier = functools.partial(
89
+ verify_tdd, repo=wt.path, adapter=tdd_adapter)
90
+ try:
91
+ result = run_team(
92
+ charter=charter, contracts=contracts, runner=runner,
93
+ verifier=team_verifier, plan_review_retries=plan_review_retries,
94
+ exec_review_retries=exec_review_retries, attempt_cap=attempt_cap,
95
+ base_sha=state["head"], branch=wt.branch, worktree_path=wt.path)
96
+ except Exception as e: # don't leak the worktree on an unexpected crash
97
+ mgr.remove(wt)
98
+ result = TeamResult(team_id, False, f"team crashed: {e}")
99
+ _persist(iteration, team_id, result)
100
+ results.append(result)
101
+ continue
102
+ _persist(iteration, team_id, result, wt=wt)
103
+ if result.passed:
104
+ pending[team_id] = {"worktree": wt, "charter": charter,
105
+ "result": result}
106
+ kept.append(wt)
107
+ else:
108
+ mgr.remove(wt)
109
+ results.append(result)
110
+ return results
111
+
112
+ def integrate_fn(results, ctx):
113
+ teams = [(tid, pending[tid]["charter"]) for tid in pending
114
+ if pending[tid]["result"].passed]
115
+ if not teams:
116
+ return merge_queue.MergeOutcome(final_base_sha=state["head"])
117
+
118
+ int_wt = _ensure_int_wt()
119
+ base = state["head"]
120
+
121
+ def reverify(team_id, current_base):
122
+ # Operate on the single advancing integration worktree. We merge into
123
+ # the integration branch (which already advanced past prior merges);
124
+ # current_base is bookkeeping only — the actual merge target is the
125
+ # integration HEAD. On any rejection we reset back to `base`.
126
+ team_branch = pending[team_id]["worktree"].branch
127
+ try:
128
+ run_git(["merge", "--no-ff", "--no-edit", team_branch],
129
+ cwd=int_wt, log=log)
130
+ except GitError: # merge conflict
131
+ try:
132
+ run_git(["merge", "--abort"], cwd=int_wt, log=log)
133
+ except GitError:
134
+ pass
135
+ return (False, None)
136
+
137
+ def _reject():
138
+ run_git(["reset", "--hard", base], cwd=int_wt, log=log)
139
+ return (False, None)
140
+
141
+ # Full suite in the integration worktree (exit 5 == no tests, ok).
142
+ proc = subprocess.run(["python", "-m", "pytest", "-q"],
143
+ cwd=int_wt, capture_output=True, text=True)
144
+ if proc.returncode not in (0, 5):
145
+ return _reject()
146
+ review = invoke(runner, contracts["execution-reviewer"],
147
+ {"team_id": team_id, "merged": True})
148
+ if not review["approved"]:
149
+ return _reject()
150
+ return (True, run_git(["rev-parse", "HEAD"], cwd=int_wt, log=log).strip())
151
+
152
+ # integration_retries=0: an in-call retry of a deterministic merge is a
153
+ # no-op. Cross-iteration recovery happens via the orchestrator ledger
154
+ # cooldown -> reopen, which re-runs a FRESH executor against the new base
155
+ # next iteration — that IS "re-execute against a new base" at iteration
156
+ # granularity.
157
+ outcome = merge_queue.integrate(
158
+ teams, base_sha=base, reverify=reverify, integration_retries=0)
159
+
160
+ if outcome.merged:
161
+ state["head"] = outcome.final_base_sha
162
+ # Belt-and-suspenders: the integration worktree branch already points
163
+ # here; keep the stable ref in sync for reconcile/resume.
164
+ run_git(["update-ref", f"refs/heads/{int_branch}", state["head"]],
165
+ cwd=repo, log=log)
166
+ return outcome
167
+
168
+ def teardown():
169
+ # Best-effort: prune the worktree DIRECTORIES (integration + every kept
170
+ # team worktree) at run end while PRESERVING all branch refs, so
171
+ # reconcile/resume still find them. Idempotent — git's `worktree remove`
172
+ # on an already-removed worktree raises GitError, which we swallow.
173
+ int_wt = state.get("int_wt")
174
+ if int_wt is not None:
175
+ try:
176
+ run_git(["worktree", "remove", "--force", str(int_wt)],
177
+ cwd=repo, log=log)
178
+ except GitError:
179
+ pass
180
+ for wt in kept:
181
+ try:
182
+ run_git(["worktree", "remove", "--force", wt.path],
183
+ cwd=repo, log=log)
184
+ except GitError:
185
+ pass
186
+ try:
187
+ run_git(["worktree", "prune"], cwd=repo, log=log)
188
+ except GitError:
189
+ pass
190
+
191
+ integrate_fn.teardown = teardown
192
+ return team_runner, integrate_fn
cih/ledger.py ADDED
@@ -0,0 +1,102 @@
1
+ # cih/ledger.py
2
+ import hashlib
3
+ import re
4
+ from dataclasses import dataclass, field, asdict
5
+ from typing import Optional
6
+
7
+ from cih.transitions import Status, assert_transition
8
+
9
+ def fingerprint(title: str, scope: str) -> str:
10
+ norm = re.sub(r"\s+", " ", title.strip().lower())
11
+ return hashlib.sha256(f"{norm}|{scope}".encode()).hexdigest()[:16]
12
+
13
+ @dataclass
14
+ class Opportunity:
15
+ fp: str
16
+ title: str
17
+ scope: str
18
+ value: float
19
+ confidence: float
20
+ effort: float
21
+ risk: float
22
+ rationale: str
23
+ state: str = "open"
24
+ attempt_count: int = 0
25
+ cooldown_until: Optional[int] = None
26
+
27
+ class Ledger:
28
+ def __init__(self):
29
+ self._items: dict[str, Opportunity] = {}
30
+
31
+ def upsert(self, opp: Opportunity) -> None:
32
+ existing = self._items.get(opp.fp)
33
+ if existing and existing.state in ("merged", "expired"):
34
+ return # terminal; ignore re-discovery
35
+ if existing:
36
+ opp.attempt_count = existing.attempt_count
37
+ opp.state = existing.state
38
+ opp.cooldown_until = existing.cooldown_until
39
+ self._items[opp.fp] = opp
40
+
41
+ def get(self, fp: str) -> Optional[Opportunity]:
42
+ return self._items.get(fp)
43
+
44
+ def _set_state(self, o, dst: str) -> None:
45
+ # A same-state write is an idempotent no-op (e.g. a still-cooling item
46
+ # re-entering cooldown on a subsequent failed retry); it is trivially
47
+ # monotonic, so it does not need a table edge.
48
+ if o.state == dst:
49
+ return
50
+ assert_transition(Status(o.state), Status(dst))
51
+ o.state = dst
52
+
53
+ def _refresh_cooldowns(self, current_iteration: Optional[int]) -> None:
54
+ if current_iteration is None:
55
+ return
56
+ for o in self._items.values():
57
+ if o.state == "cooldown" and o.cooldown_until is not None \
58
+ and current_iteration >= o.cooldown_until:
59
+ self._set_state(o, "open")
60
+ o.cooldown_until = None
61
+
62
+ def select_open(self, value_threshold: float,
63
+ current_iteration: Optional[int] = None) -> list[Opportunity]:
64
+ self._refresh_cooldowns(current_iteration)
65
+ return [o for o in self._items.values()
66
+ if o.state == "open" and o.value >= value_threshold]
67
+
68
+ def is_dry(self, value_threshold: float, current_iteration: int) -> bool:
69
+ # Spec §5: dry = no open opportunity above threshold AND no retryable
70
+ # opportunity OUTSIDE cooldown. select_open() refreshes cooldowns first,
71
+ # so items whose cooldown has elapsed are already reopened and counted;
72
+ # items still cooling are correctly excluded.
73
+ return not self.select_open(value_threshold, current_iteration)
74
+
75
+ def mark_merged(self, fp: str) -> None:
76
+ self._set_state(self._items[fp], "merged")
77
+
78
+ def mark_cooldown(self, fp: str, current_iteration: int,
79
+ cooldown_iterations: int) -> None:
80
+ o = self._items[fp]
81
+ self._set_state(o, "cooldown")
82
+ o.cooldown_until = current_iteration + cooldown_iterations
83
+
84
+ def record_attempt_failure(self, fp: str, current_iteration: int,
85
+ cooldown_iterations: int, max_attempts: int) -> None:
86
+ o = self._items[fp]
87
+ o.attempt_count += 1
88
+ if o.attempt_count >= max_attempts:
89
+ self._set_state(o, "expired")
90
+ o.cooldown_until = None
91
+ else:
92
+ self.mark_cooldown(fp, current_iteration, cooldown_iterations)
93
+
94
+ def to_dict(self) -> dict:
95
+ return {fp: asdict(o) for fp, o in self._items.items()}
96
+
97
+ @classmethod
98
+ def from_dict(cls, d: dict) -> "Ledger":
99
+ led = cls()
100
+ for fp, raw in d.items():
101
+ led._items[fp] = Opportunity(**raw)
102
+ return led
cih/merge_queue.py ADDED
@@ -0,0 +1,37 @@
1
+ # cih/merge_queue.py
2
+ from dataclasses import dataclass, field
3
+ from typing import Callable, Optional
4
+
5
+ @dataclass
6
+ class MergeOutcome:
7
+ merged: list = field(default_factory=list)
8
+ rejected: list = field(default_factory=list)
9
+ final_base_sha: str = ""
10
+
11
+ def order_by_overlap(charters: list[dict]) -> list[dict]:
12
+ # cheap precheck: fewer intended files -> integrate earlier (less collision surface)
13
+ return sorted(charters,
14
+ key=lambda c: len(c.get("impact_manifest", {}).get("intended_files", [])))
15
+
16
+ def integrate(teams: list[tuple], base_sha: str,
17
+ reverify: Callable[[str, str], tuple[bool, Optional[str]]],
18
+ integration_retries: int) -> MergeOutcome:
19
+ """teams: list of (team_id, charter). reverify(team_id, base)->(ok, new_base_sha)
20
+ re-runs the full suite + execution-reviewer on the rebased branch and returns
21
+ the real new base SHA on success."""
22
+ ordered_ids = [c["id"] for c in order_by_overlap([c for _, c in teams])]
23
+ by_id = dict(teams)
24
+ outcome = MergeOutcome(final_base_sha=base_sha)
25
+ for team_id in ordered_ids:
26
+ new_base_sha = None
27
+ for _ in range(integration_retries + 1):
28
+ ok, candidate = reverify(team_id, outcome.final_base_sha)
29
+ if ok:
30
+ new_base_sha = candidate
31
+ break
32
+ if new_base_sha is not None:
33
+ outcome.merged.append(team_id)
34
+ outcome.final_base_sha = new_base_sha
35
+ else:
36
+ outcome.rejected.append(team_id)
37
+ return outcome