sembl-stack 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.
Files changed (45) hide show
  1. sembl_stack/__init__.py +3 -0
  2. sembl_stack/adapters/__init__.py +0 -0
  3. sembl_stack/adapters/_redact.py +19 -0
  4. sembl_stack/adapters/base.py +179 -0
  5. sembl_stack/adapters/codegraph_cbm.py +95 -0
  6. sembl_stack/adapters/deploy_vercel.py +215 -0
  7. sembl_stack/adapters/execute_aider.py +115 -0
  8. sembl_stack/adapters/execute_claude.py +114 -0
  9. sembl_stack/adapters/execute_mock.py +53 -0
  10. sembl_stack/adapters/execute_opencode.py +114 -0
  11. sembl_stack/adapters/merge_git.py +107 -0
  12. sembl_stack/adapters/postdeploy_http.py +82 -0
  13. sembl_stack/adapters/review_coderabbit.py +215 -0
  14. sembl_stack/adapters/review_llm.py +142 -0
  15. sembl_stack/adapters/review_mock.py +42 -0
  16. sembl_stack/adapters/sandbox_worktree.py +79 -0
  17. sembl_stack/adapters/spec_sembl.py +91 -0
  18. sembl_stack/adapters/verify_sembl.py +77 -0
  19. sembl_stack/artifacts.py +207 -0
  20. sembl_stack/cli.py +759 -0
  21. sembl_stack/config.py +87 -0
  22. sembl_stack/contextgraph.py +154 -0
  23. sembl_stack/doctor.py +111 -0
  24. sembl_stack/loop.py +380 -0
  25. sembl_stack/onboarding.py +272 -0
  26. sembl_stack/presets.py +114 -0
  27. sembl_stack/profile.py +193 -0
  28. sembl_stack/reconciliation.py +138 -0
  29. sembl_stack/registry.py +91 -0
  30. sembl_stack/rsi.py +188 -0
  31. sembl_stack/runner.py +134 -0
  32. sembl_stack/session.py +86 -0
  33. sembl_stack/specgraph.py +146 -0
  34. sembl_stack/store.py +112 -0
  35. sembl_stack/tracing.py +51 -0
  36. sembl_stack/transport/__init__.py +0 -0
  37. sembl_stack/transport/mcp_client.py +58 -0
  38. sembl_stack/tui.py +86 -0
  39. sembl_stack/views.py +74 -0
  40. sembl_stack/wizard.py +233 -0
  41. sembl_stack-0.1.0.dist-info/METADATA +165 -0
  42. sembl_stack-0.1.0.dist-info/RECORD +45 -0
  43. sembl_stack-0.1.0.dist-info/WHEEL +4 -0
  44. sembl_stack-0.1.0.dist-info/entry_points.txt +2 -0
  45. sembl_stack-0.1.0.dist-info/licenses/LICENSE +201 -0
sembl_stack/presets.py ADDED
@@ -0,0 +1,114 @@
1
+ """Onboarding presets (C4) — a one-command path to a working config.
2
+
3
+ Three presets cover the adoption ramp, lightest first:
4
+
5
+ * just-gate — the wedge: gate a diff/PR with NO model and NO infra (CLI transport,
6
+ so it shells the installed `sembl` — no uvx/MCP required).
7
+ * gate+sandbox — see the whole loop with a deterministic mock executor (no keys): plan
8
+ -> sandbox -> execute -> gate -> retry-on-BLOCK.
9
+ * full-loop — a real agent (Claude Code on the operator's OAuth session) writes, the
10
+ sandbox contains, Sembl gates. Swap `execute` for aider/opencode.
11
+
12
+ Each preset is stored as ANNOTATED YAML (not a dumped dict) so the file a stranger lands on
13
+ explains itself. `render()` returns that text; `config_dict()` parses it back for validation.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import yaml
18
+
19
+ _JUST_GATE = """\
20
+ # sembl-stack config — preset: just-gate
21
+ # The adoption wedge: gate any diff/PR with zero model and zero infra.
22
+ # sembl-stack verify --diff change.patch --bounds bounds.json
23
+ layers:
24
+ spec: sembl # L2 bounds engine (ours)
25
+ execute: mock # not used by `verify`; kept so `loop` still boots if you try it
26
+ sandbox: worktree # L4 disposable sandbox
27
+ verify: sembl # L5 gate (ours)
28
+ transport:
29
+ spec: cli # shell the installed `sembl` — no uvx/MCP needed
30
+ verify: cli
31
+ loop:
32
+ max_attempts: 1
33
+ strict: true # out-of-scope edits BLOCK
34
+ tracing:
35
+ langfuse: false
36
+ """
37
+
38
+ _GATE_SANDBOX = """\
39
+ # sembl-stack config — preset: gate+sandbox
40
+ # See the full loop with a deterministic mock executor (no API keys):
41
+ # plan -> sandbox -> execute -> gate -> retry-on-BLOCK
42
+ # sembl-stack loop task.yaml
43
+ layers:
44
+ spec: sembl
45
+ execute: mock # deterministic: misbehaves once (BLOCK), then complies (PASS)
46
+ sandbox: clone # disposable local git clone — the source repo is never touched
47
+ verify: sembl
48
+ transport:
49
+ spec: cli
50
+ verify: cli
51
+ loop:
52
+ max_attempts: 3
53
+ strict: true
54
+ tracing:
55
+ langfuse: false
56
+ """
57
+
58
+ _FULL_LOOP = """\
59
+ # sembl-stack config — preset: full-loop
60
+ # A real agent writes, the sandbox contains, Sembl gates.
61
+ # requires `claude` on PATH (Claude Code, the operator's own login — no token handled).
62
+ # swap execute: aider | opencode to drive a different agent.
63
+ # sembl-stack loop task.yaml
64
+ layers:
65
+ spec: sembl
66
+ execute: claude
67
+ sandbox: clone
68
+ verify: sembl
69
+ transport:
70
+ spec: cli
71
+ verify: cli
72
+ options:
73
+ execute:
74
+ model: # blank = the operator's default model
75
+ timeout: 900 # seconds before the executor is treated as a failed attempt
76
+ loop:
77
+ max_attempts: 3
78
+ strict: true
79
+ tracing:
80
+ langfuse: false
81
+ """
82
+
83
+ PRESETS: dict[str, str] = {
84
+ "just-gate": _JUST_GATE,
85
+ "gate+sandbox": _GATE_SANDBOX,
86
+ "full-loop": _FULL_LOOP,
87
+ }
88
+
89
+ DEFAULT_PRESET = "gate+sandbox"
90
+
91
+ _STARTER_TASK = """\
92
+ # A task for the short loop. Paths resolve relative to this file.
93
+ text: "Add a VALUE constant to the app module, in scope, without touching infra."
94
+ repo: "."
95
+ # spec_path: "./specs/001-feature" # optional: a Spec Kit feature dir / tasks.md
96
+ """
97
+
98
+
99
+ def names() -> list[str]:
100
+ return list(PRESETS)
101
+
102
+
103
+ def render(preset: str) -> str:
104
+ """The annotated YAML text for a preset (raises KeyError on an unknown name)."""
105
+ return PRESETS[preset]
106
+
107
+
108
+ def config_dict(preset: str) -> dict:
109
+ """The preset parsed to a dict — used to validate it loads and wires."""
110
+ return yaml.safe_load(PRESETS[preset])
111
+
112
+
113
+ def starter_task() -> str:
114
+ return _STARTER_TASK
sembl_stack/profile.py ADDED
@@ -0,0 +1,193 @@
1
+ """Phase-1 onboarding core — the BYO-credentials profile (pure, headless).
2
+
3
+ sembl-stack provides orchestration, not inference: the user brings their own way of paying
4
+ for model calls (their Claude Code login, their API key, a local model — or the no-AI mock
5
+ preview). This module is the deterministic heart of that onboarding: a `Profile` dataclass
6
+ persisted at `~/.sembl/profile.json` (user-level; distinct from the per-repo
7
+ `.sembl/session.json`), auto-detection of what's already on the machine, a doctor-style
8
+ preflight per runner, and the mapping onto the existing `StackConfig` layers.
9
+
10
+ Security invariant (launch-credibility, do not weaken): **no key value is ever stored** —
11
+ `key_source` holds only a pointer like `"env:ANTHROPIC_API_KEY"`, validated on save; the
12
+ actual secret stays in the environment and is read only by the executor at runtime.
13
+ No Textual imports here; fully unit-testable.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import shutil
21
+ from dataclasses import asdict, dataclass
22
+ from pathlib import Path
23
+
24
+ from .doctor import Check
25
+
26
+ # How the user pays for model calls. Order = preference during auto-detection.
27
+ STRATEGIES = ["claude-login", "api-key", "local", "mock"]
28
+
29
+ # runner -> default L3 executor adapter (user may override in Advanced).
30
+ _RUNNER_EXECUTOR = {
31
+ "claude-login": "claude",
32
+ "api-key": "claude",
33
+ "local": "opencode",
34
+ "mock": "mock",
35
+ }
36
+
37
+ # Env vars we auto-detect for the api-key runner, preference order. The var's *presence*
38
+ # is all we ever look at — the value is never read into a Profile or an artifact.
39
+ _KEY_ENV_VARS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY"]
40
+
41
+ # key_source may only ever be a pointer: an env-var name or (post-launch) the keyring.
42
+ _SAFE_KEY_SOURCE = re.compile(r"^(env:[A-Za-z_][A-Za-z0-9_]*|keyring)$")
43
+
44
+ # model must look like a model id ("claude-opus-4-8", "tokenrouter/MiniMax-M3",
45
+ # "ollama/llama3:8b") — short, from a tight charset, and never key-prefixed. This is the
46
+ # second half of the security invariant: the free-form Model input is the one field a
47
+ # user could paste an API key into, and a key there would otherwise reach profile.json,
48
+ # argv (`--model`, visible in the process list), and run reports.
49
+ _SAFE_MODEL = re.compile(r"^(?!sk-)[A-Za-z0-9][A-Za-z0-9._:/\-]{0,63}$")
50
+
51
+
52
+ @dataclass
53
+ class Profile:
54
+ runner: str = "mock" # one of STRATEGIES
55
+ executor: str = "mock" # L3 adapter name (claude|opencode|aider|mock)
56
+ model: str | None = None # e.g. "claude-opus-4-8", "tokenrouter/MiniMax-M3"
57
+ key_source: str | None = None # "env:ANTHROPIC_API_KEY" | "keyring" — NEVER the value
58
+ strict: bool = True
59
+ preset: str | None = None # presets.py name, if the user picked one
60
+
61
+
62
+ def path() -> Path:
63
+ return Path.home() / ".sembl" / "profile.json"
64
+
65
+
66
+ def save(profile: Profile, p: Path | None = None) -> Path:
67
+ """Persist the profile. Refuses anything secret-shaped in `key_source` or `model`."""
68
+ if profile.key_source is not None and not _SAFE_KEY_SOURCE.match(profile.key_source):
69
+ raise ValueError(
70
+ "key_source must be a pointer ('env:VAR_NAME' or 'keyring'), never a key value")
71
+ if profile.model is not None and not _SAFE_MODEL.match(profile.model):
72
+ raise ValueError(
73
+ "model must be a model id (e.g. 'claude-opus-4-8'), never an API key value")
74
+ p = p or path()
75
+ p.parent.mkdir(parents=True, exist_ok=True)
76
+ p.write_text(json.dumps(asdict(profile), indent=2), encoding="utf-8")
77
+ return p
78
+
79
+
80
+ def load(p: Path | None = None) -> Profile | None:
81
+ """Read the saved profile, or None if missing OR unusable.
82
+
83
+ A corrupt/old/hand-edited file must never brick the entrypoint — unusable is treated
84
+ exactly like absent (re-onboard), mirroring `session.load`. A profile whose stored
85
+ key_source fails the pointer rule is also unusable: we never trust a secret-shaped
86
+ value back into memory.
87
+ """
88
+ p = p or path()
89
+ if not p.is_file():
90
+ return None
91
+ try:
92
+ data = json.loads(p.read_text(encoding="utf-8"))
93
+ if not isinstance(data, dict):
94
+ return None
95
+ prof = Profile(**{k: v for k, v in data.items() if k in Profile.__dataclass_fields__})
96
+ except (OSError, ValueError, TypeError):
97
+ return None
98
+ if prof.runner not in STRATEGIES:
99
+ return None
100
+ if prof.key_source is not None and (
101
+ not isinstance(prof.key_source, str) or not _SAFE_KEY_SOURCE.match(prof.key_source)):
102
+ return None
103
+ # The remaining fields flow straight into config/registry/argv — a hand-edited file
104
+ # with the wrong types (or a secret-shaped model) is unusable, same as corrupt.
105
+ if not isinstance(prof.executor, str) or not prof.executor:
106
+ return None
107
+ if prof.model is not None and (
108
+ not isinstance(prof.model, str) or not _SAFE_MODEL.match(prof.model)):
109
+ return None
110
+ if not isinstance(prof.strict, bool):
111
+ return None
112
+ if prof.preset is not None and not isinstance(prof.preset, str):
113
+ return None
114
+ return prof
115
+
116
+
117
+ def to_stack_overrides(profile: Profile) -> dict:
118
+ """The dict the wizard merges into the resolved stack config.
119
+
120
+ Maps the BYO choice onto layers the loop already reads — zero new core wiring:
121
+ `layers.execute` (which adapter), `options.execute.model`, `loop.strict`.
122
+ """
123
+ over: dict = {"layers": {"execute": profile.executor},
124
+ "loop": {"strict": profile.strict}}
125
+ if profile.model:
126
+ over["options"] = {"execute": {"model": profile.model}}
127
+ return over
128
+
129
+
130
+ def detect() -> Profile:
131
+ """Auto-detect the strongest BYO option present; onboarding preselects the result.
132
+
133
+ Preference: an existing `claude` install (their Claude Code login — token-free) >
134
+ a known API key in env > a local-model CLI (`opencode`) > the mock preview.
135
+ Detection only checks binary/env *presence* — never reads a key value.
136
+ """
137
+ if shutil.which("claude"):
138
+ return Profile(runner="claude-login", executor="claude")
139
+ for var in _KEY_ENV_VARS:
140
+ if os.environ.get(var):
141
+ executor = "claude" if var == "ANTHROPIC_API_KEY" else "opencode"
142
+ return Profile(runner="api-key", executor=executor, key_source=f"env:{var}")
143
+ if shutil.which("opencode"):
144
+ return Profile(runner="local", executor="opencode")
145
+ return Profile() # mock — the keyless mechanics preview, never the hero path
146
+
147
+
148
+ def preflight(profile: Profile) -> list[Check]:
149
+ """Doctor-style checks that this runner can actually run — before onboarding proceeds.
150
+
151
+ On failure the wizard shows `hint` (the one concrete fix) and stays on the screen;
152
+ a runner that can't run is never persisted as the profile.
153
+ """
154
+ checks: list[Check] = []
155
+ if profile.executor == "mock":
156
+ checks.append(Check("executor: mock", True, "no binary needed", required=False))
157
+ else:
158
+ # Checked even when runner == "mock": an Advanced executor override means a real
159
+ # binary will run, and a profile that can't run must never be persisted.
160
+ from .doctor import _EXECUTOR_BINARY # single source of binary names + install hints
161
+ binary, hint = _EXECUTOR_BINARY.get(profile.executor, (profile.executor, ""))
162
+ found = shutil.which(binary)
163
+ checks.append(Check(f"executor: {profile.executor}", found is not None,
164
+ found or "not found", hint))
165
+
166
+ if profile.runner == "mock":
167
+ checks.append(Check("runner: mock", True, "no credentials needed", required=False))
168
+ elif profile.runner == "api-key":
169
+ var = (profile.key_source or "")[len("env:"):] if (
170
+ profile.key_source or "").startswith("env:") else ""
171
+ ok = bool(var) and bool(os.environ.get(var))
172
+ checks.append(Check(
173
+ f"api key ({var or 'no env var chosen'})", ok,
174
+ "set in env" if ok else "not set",
175
+ f"set {var or 'your provider API key'} in your environment — sembl only ever "
176
+ "reads it from there at runtime, never stores it"))
177
+ elif profile.runner == "claude-login":
178
+ # Binary presence is the cheap proxy; an un-logged-in `claude` fails loudly on
179
+ # first use with its own login prompt, which is the right UX anyway. Checked
180
+ # against `claude` itself — the executor may be a different binary.
181
+ checks.append(Check(
182
+ "claude login", shutil.which("claude") is not None,
183
+ "uses your existing Claude Code session (token-free)",
184
+ "run `claude` once and log in", required=False))
185
+ return checks
186
+
187
+
188
+ def ready(checks: list[Check]) -> tuple[bool, str]:
189
+ """(ok, first concrete fix) — the wizard's proceed/stay decision."""
190
+ for c in checks:
191
+ if c.required and not c.ok:
192
+ return False, c.hint or f"{c.name}: {c.detail}"
193
+ return True, ""
@@ -0,0 +1,138 @@
1
+ """Advisory SpecGraph-to-code-graph reconciliation.
2
+
3
+ This is not a gate. It gives a human a compact drift report from two already
4
+ materialized graphs. The code graph is supplied as JSON so the current stage can
5
+ consume codebase-memory-mcp output without making that MCP a package dependency.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from pathlib import PurePosixPath
11
+
12
+ from .artifacts import ReconciliationReport, SpecGraph
13
+
14
+
15
+ def reconcile_spec_code(spec_graph: SpecGraph, code_graph: dict) -> ReconciliationReport:
16
+ """Compare declared spec concepts against a supplied code graph JSON payload."""
17
+ spec_concepts = _spec_concepts(spec_graph)
18
+ code_terms, code_files = _code_terms(code_graph)
19
+
20
+ findings: list[dict] = []
21
+ if not code_terms and not code_files:
22
+ return ReconciliationReport(
23
+ status="UNKNOWN",
24
+ summary="code graph had no comparable nodes or files",
25
+ findings=[{
26
+ "severity": "info",
27
+ "kind": "missing_code_graph",
28
+ "message": "No code graph concepts were supplied for reconciliation.",
29
+ }],
30
+ data=_counts(spec_graph, code_graph),
31
+ )
32
+
33
+ for concept in spec_concepts:
34
+ if concept["type"] in ("editable_path", "forbidden_area"):
35
+ if concept["type"] == "editable_path" and not _path_covered(
36
+ concept["name"], code_files):
37
+ findings.append({
38
+ "severity": "info",
39
+ "kind": "scope_without_code_match",
40
+ "spec_node": concept["id"],
41
+ "message": f"Editable path not present in code graph: {concept['name']}",
42
+ })
43
+ continue
44
+
45
+ if not _term_covered(concept["name"], code_terms):
46
+ findings.append({
47
+ "severity": "warn",
48
+ "kind": "spec_concept_without_code_match",
49
+ "spec_node": concept["id"],
50
+ "concept_type": concept["type"],
51
+ "message": f"Spec concept not found in code graph: {concept['name']}",
52
+ })
53
+
54
+ status = "DIVERGENT" if any(f["severity"] == "warn" for f in findings) else "ALIGNED"
55
+ summary = (
56
+ "spec/code divergence found"
57
+ if status == "DIVERGENT"
58
+ else "spec concepts are represented in the supplied code graph"
59
+ )
60
+ return ReconciliationReport(
61
+ status=status,
62
+ summary=summary,
63
+ findings=findings,
64
+ data=_counts(spec_graph, code_graph),
65
+ )
66
+
67
+
68
+ def _spec_concepts(spec_graph: SpecGraph) -> list[dict]:
69
+ keep = {"route", "entity", "data_rule", "editable_path", "forbidden_area"}
70
+ return [
71
+ {"id": n.get("id", ""), "type": n.get("type", ""), "name": n.get("name", "")}
72
+ for n in spec_graph.nodes
73
+ if n.get("type") in keep and n.get("name")
74
+ ]
75
+
76
+
77
+ def _code_terms(code_graph: dict) -> tuple[set[str], set[str]]:
78
+ nodes = _nodes_from_payload(code_graph)
79
+ terms: set[str] = set()
80
+ files: set[str] = set()
81
+ for node in nodes:
82
+ for key in ("name", "qualified_name", "label", "route", "path"):
83
+ value = node.get(key)
84
+ if isinstance(value, str):
85
+ terms.update(_tokens(value))
86
+ for key in ("file", "file_path", "path"):
87
+ value = node.get(key)
88
+ if isinstance(value, str):
89
+ files.add(_norm_path(value))
90
+ terms.update(_tokens(value))
91
+ return terms, files
92
+
93
+
94
+ def _nodes_from_payload(payload) -> list[dict]:
95
+ if isinstance(payload, list):
96
+ return [n for n in payload if isinstance(n, dict)]
97
+ if not isinstance(payload, dict):
98
+ return []
99
+ if isinstance(payload.get("nodes"), list):
100
+ return [n for n in payload["nodes"] if isinstance(n, dict)]
101
+ if isinstance(payload.get("results"), list):
102
+ return [n for n in payload["results"] if isinstance(n, dict)]
103
+ return []
104
+
105
+
106
+ def _term_covered(name: str, code_terms: set[str]) -> bool:
107
+ tokens = _tokens(name)
108
+ return bool(tokens) and all(token in code_terms for token in tokens)
109
+
110
+
111
+ def _path_covered(path: str, code_files: set[str]) -> bool:
112
+ needle = _norm_path(path)
113
+ if not needle:
114
+ return True
115
+ if needle.endswith("/"):
116
+ return any(p.startswith(needle) for p in code_files)
117
+ return needle in code_files or any(PurePosixPath(p).match(needle) for p in code_files)
118
+
119
+
120
+ def _tokens(value: str) -> set[str]:
121
+ value = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", value)
122
+ return {
123
+ token
124
+ for token in re.split(r"[^a-z0-9]+", value.lower())
125
+ if token and token not in {"api", "src", "app", "py", "ts", "tsx", "js", "jsx"}
126
+ }
127
+
128
+
129
+ def _norm_path(path: str) -> str:
130
+ return path.replace("\\", "/").strip().lstrip("./")
131
+
132
+
133
+ def _counts(spec_graph: SpecGraph, code_graph: dict) -> dict:
134
+ return {
135
+ "spec_nodes": len(spec_graph.nodes),
136
+ "spec_edges": len(spec_graph.edges),
137
+ "code_nodes": len(_nodes_from_payload(code_graph)),
138
+ }
@@ -0,0 +1,91 @@
1
+ """Adapter registry — the swap mechanism.
2
+
3
+ `sembl.stack.yaml` names an adapter per layer; the registry resolves the name to a
4
+ class. Register a new implementation here (or via entry points later) and it becomes
5
+ swappable with a one-line config change.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .adapters.execute_aider import AiderExecutor
10
+ from .adapters.execute_claude import ClaudeCodeExecutor
11
+ from .adapters.execute_mock import MockExecutor
12
+ from .adapters.execute_opencode import OpenCodeExecutor
13
+ from .adapters.deploy_vercel import VercelDeployAdapter
14
+ from .adapters.merge_git import GitMergeAdapter
15
+ from .adapters.postdeploy_http import HttpPostDeployGate
16
+ from .adapters.sandbox_worktree import WorktreeSandbox
17
+ from .adapters.spec_sembl import SemblSpecAdapter
18
+ from .adapters.verify_sembl import SemblVerifyAdapter
19
+ from .adapters.codegraph_cbm import CbmCodeGraph
20
+ from .adapters.review_mock import MockReviewAdapter
21
+ from .adapters.review_coderabbit import CodeRabbitReviewAdapter
22
+ from .adapters.review_llm import LLMReviewAdapter
23
+ from .contextgraph import SymgraphGraph
24
+
25
+ # layer -> { adapter name -> factory(transport, mcp_server, opts) }
26
+ # opts is the per-layer `options:` block from sembl.stack.yaml (adapter-specific knobs
27
+ # like which model to drive) — keeps tuning a config change, not a code change.
28
+ _REGISTRY: dict[str, dict[str, object]] = {
29
+ "spec": {
30
+ "sembl": lambda t, s, o: SemblSpecAdapter(transport=t, mcp_server=s),
31
+ },
32
+ "execute": {
33
+ "mock": lambda t, s, o: MockExecutor(),
34
+ "opencode": lambda t, s, o: OpenCodeExecutor(
35
+ model=o.get("model"), timeout=o.get("timeout", 900)),
36
+ "claude": lambda t, s, o: ClaudeCodeExecutor(
37
+ model=o.get("model"), timeout=o.get("timeout", 900)),
38
+ "aider": lambda t, s, o: AiderExecutor(
39
+ model=o.get("model"), timeout=o.get("timeout", 900)),
40
+ },
41
+ "sandbox": {
42
+ "worktree": lambda t, s, o: WorktreeSandbox(), # back-compat name
43
+ "clone": lambda t, s, o: WorktreeSandbox(), # disposable local clone
44
+ },
45
+ "verify": {
46
+ "sembl": lambda t, s, o: SemblVerifyAdapter(transport=t, mcp_server=s),
47
+ },
48
+ "context": { # L1 semantic graph (optional)
49
+ "symgraph": lambda t, s, o: SymgraphGraph(timeout=o.get("timeout", 300)),
50
+ "none": lambda t, s, o: None,
51
+ },
52
+ "codegraph": { # L5.5 code graph for reconcile
53
+ "cbm": lambda t, s, o: CbmCodeGraph(
54
+ binary=o.get("binary", "codebase-memory-mcp"),
55
+ timeout=o.get("timeout", 600), limit=o.get("limit", 5000)),
56
+ "none": lambda t, s, o: None,
57
+ },
58
+ "review": {
59
+ "mock": lambda t, s, o: MockReviewAdapter(),
60
+ "coderabbit": lambda t, s, o: CodeRabbitReviewAdapter(
61
+ binary=o.get("binary", "coderabbit"), timeout=o.get("timeout", 600)),
62
+ "llm": lambda t, s, o: LLMReviewAdapter( # BYO agent CLI (claude/opencode)
63
+ binary=o.get("binary", "claude"), model=o.get("model"),
64
+ timeout=o.get("timeout", 600)),
65
+ },
66
+ "merge": {
67
+ "git": lambda t, s, o: GitMergeAdapter(timeout=o.get("timeout", 300)),
68
+ },
69
+ "deploy": {
70
+ "vercel": lambda t, s, o: VercelDeployAdapter(timeout=o.get("timeout", 1800)),
71
+ },
72
+ "postdeploy": {
73
+ "http": lambda t, s, o: HttpPostDeployGate(
74
+ health_path=o.get("health_path", "/"), expect_json=o.get("expect_json")),
75
+ },
76
+ }
77
+
78
+
79
+ def build(layer: str, name: str, transport: str, mcp_server: list[str],
80
+ opts: dict | None = None):
81
+ try:
82
+ factory = _REGISTRY[layer][name]
83
+ except KeyError:
84
+ avail = ", ".join(_REGISTRY.get(layer, {})) or "(none)"
85
+ raise SystemExit(
86
+ f"Unknown {layer} adapter '{name}'. Available: {avail}")
87
+ return factory(transport, mcp_server, opts or {})
88
+
89
+
90
+ def names(layer: str) -> list[str]:
91
+ return list(_REGISTRY.get(layer, {}))