Contributing to Bernstein
August 22, 2026 · View on GitHub
Thanks for your interest! Here's how to get started.
Quick Start
git clone https://github.com/sipyourdrink-ltd/bernstein && cd bernstein
uv venv && uv pip install -e ".[dev]"
Picking something to work on
ROADMAP.md says what is being built and when.
Start here: the volunteer workers program (#3863) is the current focus — donated AI compute working through open-source backlogs. It is greenfield, every sub-issue is sliced to be workable on its own with acceptance criteria written out, and design comments on the RFC count as contributions too.
Beyond that, three labelled queues cover the rest:
- good first issue — self-contained, acceptance criteria written out, no prior context needed.
- help wanted — larger, still scoped.
- adapter — support for another coding CLI: one module, one conformance test, one doc entry.
Every issue in those queues states what "done" looks like before you start. If one does not, that is a defect in the issue — say so on it.
Comment to claim an issue. If it is assigned but has been quiet for a couple of weeks, ask anyway; stalled is not the same as taken.
Before proposing something large, read Scope. It lists the boundaries that are already decided and the reason for each, with the decision records behind them. Arguing with a stated reason is a good way to start a discussion. Spending a week working against one is not.
Areas
Land three changes in one area and the area is yours if you want it -
open an issue saying so. As the area's steward you get triage access,
and every pull request touching the area is routed to you for review
automatically (.github/workflows/area-steward-review.yml). Areas are
adapters, the web dashboard, the terminal UI, docs, and packaging.
Stewardship starts at triage rather than write, because write access on this repository reaches the release workflows and their publishing credentials, and that surface is kept least-privilege. After a stretch of established stewardship the write grant follows, and with it your entry in CODEOWNERS - GitHub only honors code owners who hold write.
This is not ceremonial. An area with a name against it gets a second reader who knows it; an area with nobody against it accumulates whatever the last person in a hurry did.
Testing
uv run python scripts/run_tests.py -x # all tests (isolated per-file, stops on first failure)
uv run python scripts/run_tests.py -k router # filter by keyword
uv run python scripts/run_tests.py tests/unit/test_foo.py # single file
uv run python scripts/run_tests.py tests/unit/test_foo.py::test_bar # single test
uv run pytest tests/unit/test_foo.py -x -q # single file, straight pytest
WARNING: Never run
uv run pytest tests/ -x -q- the full suite keeps references across 2000+ tests and can leak 100+ GB RAM. The isolated runner inscripts/run_tests.pycaps each file at ~200 MB.
Linting & type checking
uv run ruff check src/
uv run ruff format src/
uv run pyright src/
All three must pass before committing. No exceptions, no "fix later."
Development Workflow
- Fork the repo and create a branch:
git checkout -b feat/my-feature - Make your changes
- Run checks:
uv run ruff check src/ uv run pyright src/ uv run lint-imports # architecture contracts (adapter/core boundary) uv run python scripts/run_tests.py -x - Commit with a clear message
- Open a PR against
main
All non-trivial changes land via PR with at least one approving review. Security-touching changes need two approvals or operator-only push. Full process and reviewer expectations: docs/CODE_REVIEW.md.
If you cannot open a pull request
Some contributors cannot reach GitHub from where they work. Post the diff as a comment on the issue it addresses -- inline in a fenced block, not as a link to a file elsewhere. Maintainers do not fetch patches from external hosts, so a download link is a dead end; a diff in the thread is reviewable where it lands, and if the issue is already assigned, the assignee can take it or decline it without anyone having to fetch anything.
Include what a PR would carry: what changed, the tests that prove it, and the commit the diff applies to.
Docs alongside code
Every PR that adds or changes a feature MUST update docs in the same PR:
- User-visible behaviour: update the relevant
README.mdsection. - Operator workflows: update
docs/operations/<area>.md. - Public API surface: regenerate
docs/api/schemas. - Architecture or new module: update
docs/sdd/and runuv run bernstein agents-md syncso AGENTS.md, CLAUDE.md,.goosehints,CONVENTIONS.md, and.cursor/rules/*.mdcstay aligned. - New test layer: also update
docs/contributing/testing.md.
PRs without the matching docs change will be sent back. Docs and code ship together.
Pre-push hook
Install the versioned pre-push hook to catch lint and architecture-contract violations before they reach CI:
ln -sf ../../scripts/git-hooks/pre-push .git/hooks/pre-push
chmod +x scripts/git-hooks/pre-push
The hook is blocking on ruff and lint-imports, advisory on pyright.
The most common architecture-contract violation it catches is an adapter
importing a scheduler-internal module, e.g.
from bernstein.core.tasks.models import ModelConfig (wrong) instead of
from bernstein.core.models import ModelConfig (canonical). See
.importlinter for the full contract list.
Auto-heal on CI failure
When CI fails on main, the bernstein-ci-fix workflow
(.github/workflows/bernstein-ci-fix.yml) runs Bernstein in headless mode
against the failing commit, opens an auto-heal/<sha> branch with the
proposed fix, and creates an auto-heal: fix CI on <sha> PR for review.
If Bernstein can't produce a clean diff in 3 iterations within $5, the
workflow falls back to opening a ci-fix issue. Auto-heal is gated by
the BERNSTEIN_CI_FIX_ENABLED repo variable, refuses to recurse on
auto-heal: PRs, and only fires for canonical-repo pushes (never forks).
Code Style
- Python 3.12+, type hints on every public function and method
from __future__ import annotationsat the top of every module- Max line length: 120 (enforced by ruff)
- Ruff rules: E, F, W, I, UP, B, SIM, TCH, RUF
- No dict soup - use
@dataclassorTypedDict, not rawdict[str, Any] - Enums over string literals for any value with a fixed set of options
- Google-style docstrings on all public symbols
- Async only for IO-bound code; sync for CPU-bound/pure logic
- Thin orchestration facade, isolated core logic, separate adapters
Broad-except policy (route handlers)
src/bernstein/core/routes/**.py is policed by a CI lint
(scripts/check_routes_broad_except.py) that fails the build when a
bare except Exception: clause appears without a justification marker on
the line itself or in one of the three preceding comment lines.
Two markers are recognised:
intentional-broad-except: legitimate best-effort path (telemetry, optional analytics, lineage append, etc.). The body should route any sensitive message throughbernstein.core.sanitize.sanitize_log.bot-ack: <short-tag>: previously reviewed broad clause; the tag identifies the rationale (e.g.bot-ack: pre-existing-1723,bot-ack: legacy-shim).
If the clause is not actually best-effort, narrow it to the realistic
exceptions the helper can raise (typically OSError, json.JSONDecodeError,
KeyError, ValueError, or a domain-specific class). Never convert a
broad except Exception: into except BaseException: -- KeyboardInterrupt
and SystemExit must propagate.
Run locally before committing:
uv run python scripts/check_routes_broad_except.py
See AGENTS.md for the full doctrine, including change classification, conflict protocol, and zero-tolerance failures.
CLI Structure
The CLI is split into two layers under src/bernstein/cli/:
Top-level (cli/): main.py (Click group), run.py, run_cmd.py, live.py, dashboard.py, helpers.py, ui.py, status.py
Commands sub-package (cli/commands/): 70+ command modules including:
| Module | Purpose |
|---|---|
run_cmd.py | bernstein run / -g orchestration entry point |
stop_cmd.py | bernstein stop graceful shutdown |
status_cmd.py | bernstein status / bernstein ps |
evolve_cmd.py | bernstein evolve subcommands |
agents_cmd.py | bernstein agents catalog commands |
advanced_cmd.py | Advanced / less common commands |
debug_cmd.py | bernstein debug-bundle diagnostics |
cost.py | Cost tracking display |
doctor_cmd.py | Pre-flight health checks |
checkpoint_cmd.py | Checkpoint save/restore |
task_cmd.py | Direct task manipulation |
workspace_cmd.py | Multi-repo workspace commands |
audit_cmd.py | Audit log inspection |
ci_cmd.py | CI integration commands |
policy_cmd.py | Policy management |
triggers_cmd.py | External trigger management |
When adding a new CLI command, create a new *_cmd.py module in cli/commands/ and register it in main.py.
Supported CLI Adapters
Bernstein ships with 40+ CLI agent adapters, plus a generic catch-all. src/bernstein/adapters/registry.py is the source of truth for the exact set - check it before writing a new adapter. A subset is shown here for orientation:
| Adapter | File | Agent |
|---|---|---|
aider | adapters/aider.py | Aider |
amp | adapters/amp.py | Amp |
claude | adapters/claude.py | Claude Code |
codex | adapters/codex.py | Codex CLI |
cody | adapters/cody.py | Cody |
continue | adapters/continue_dev.py | Continue |
cursor | adapters/cursor.py | Cursor |
devin_terminal | adapters/devin_terminal.py | Devin Terminal (Cognition) |
gemini | adapters/gemini.py | Gemini CLI |
goose | adapters/goose.py | Goose |
iac | adapters/iac.py | Infrastructure-as-Code agent |
junie | adapters/junie.py | JetBrains Junie |
kilo | adapters/kilo.py | Kilo |
kiro | adapters/kiro.py | Kiro |
ollama | adapters/ollama.py | Ollama (local models) |
openai_agents | adapters/openai_agents.py | OpenAI Agents SDK v2 |
opencode | adapters/opencode.py | OpenCode |
q_dev | adapters/q_dev.py | AWS Q Developer CLI |
qwen | adapters/qwen.py | Qwen Code |
generic | adapters/generic.py | Any CLI agent (catch-all) |
Full list: src/bernstein/adapters/registry.py. Adapter guide: docs/adapters/ADAPTER_GUIDE.md.
Writing a Custom Adapter
Adapters implement the CLIAdapter ABC from adapters/base.py:
class CLIAdapter(ABC):
@abstractmethod
def spawn(self, *, prompt, workdir, model_config, session_id, mcp_config=None) -> SpawnResult: ...
@abstractmethod
def is_alive(self, pid: int) -> bool: ...
@abstractmethod
def kill(self, pid: int) -> None: ...
@abstractmethod
def name(self) -> str: ...
def detect_tier(self) -> ApiTierInfo | None: ... # optional
Steps:
- Create
src/bernstein/adapters/mycli.pyimplementing all four abstract methods. Seeadapters/claude.pyfor a complete reference. - Register in
adapters/registry.py:_ADAPTERS["mycli"] = MyCLIAdapter - Run checks:
uv run ruff check src/ && uv run pyright src/ && uv run python scripts/run_tests.py -x - Open a PR - include a short note on how you tested it.
Important: All adapter .spawn() implementations must wrap the CLI command with build_worker_cmd() from adapters/base.py. This sets the process title and writes the PID metadata file that the orchestrator uses for bernstein ps and crash detection.
Writing a Custom CI Parser
CI parsers implement the CILogParser protocol from core/ci_log_parser.py:
class CILogParser(Protocol):
name: str
def parse(self, raw_log: str) -> list[CIFailure]: ...
Steps:
- Create
src/bernstein/adapters/ci/<name>.pyfrom the template intemplates/ci-parsers/TEMPLATE.py. Seeadapters/ci/github_actions.pyfor a working example. - Register:
from bernstein.core.ci_log_parser import register_parser; register_parser(MyCIParser()) - Run checks and open a PR.
Writing a Custom Role
Role templates live in templates/roles/<role-name>/ with three files:
system_prompt.md- agent persona and standing instructionstask_prompt.md- per-task instructions templateconfig.yaml- default model and effort
Built-in roles: adversary, analyst, architect, backend, ci-fixer, devops, docs, frontend, manager, ml-engineer, prompt-engineer, qa, resolver, retrieval, reviewer, security, visionary, vp.
Copy an existing role and customize:
cp -r templates/roles/backend templates/roles/data-engineer
The new role is available immediately - no code changes required.
Architecture Principles
- Deterministic orchestrator - no LLM calls for scheduling/coordination
- Short-lived agents - spawn per task batch, exit when done
- File-based state - everything in
.sdd/, no databases - Pluggable adapters - new CLI agents via
adapters/base.pyABC - Branch is
main- nevermaster - No monoliths - don't create or extend god-files (>400 LOC soft, >600 LOC hard stop)
Process management
Bernstein writes PID metadata to .sdd/runtime/pids/. Use those to find and stop processes. Never pkill -f bernstein or pgrep bernstein - it will kill the orchestrator indiscriminately.
# Correct: signal via file
echo "stop" > .sdd/runtime/signals/<role>-<session>/SHUTDOWN
# Correct: use bernstein CLI
bernstein stop
# WRONG: grep-kill
pkill -f bernstein # kills everything including your own shell session
Recognition
All contributors are listed in CONTRIBUTORS.md. Outstanding contributions are featured in our monthly Community Spotlight blog posts, which are shared on Twitter/X, LinkedIn, and dev.to.
Naming
Forks and derivative builds are welcome and ship under their own name. The Apache-2.0 code grant does not cover the project name itself — the short version of what that means is in TRADEMARKS.md.
License
By contributing, you agree that your contributions will be licensed under the Apache License 2.0.