Thanks for your interest! Here's how to get started.
git clone https://github.com/sipyourdrink-ltd/bernstein && cd bernstein
uv venv && uv pip install -e ".[dev]"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.
Bernstein follows a two-track release model: patch releases (every 3 days) and minor releases (every 2 weeks). Details in docs/operations/release.md.
Issues are assigned to milestones to plan what ships in each release. A milestone is not a parking lot — every issue in a milestone should be on track to ship in that release.
Triage rules:
- Small issues (size/xs, size/s) and high priority (P0, P1) land in the nearest milestone
- Larger (size/m, size/l) or lower priority (P2, P3) issues move to the next milestone out
- An open issue with no milestone is untriaged — say so on the issue so it gets attention
- Advanced-tier issues (lineage/audit-chain/verifiability/replay) can ship in an earlier minor release once the underlying dependencies are mature and the feature is ready for early adopters
The fastest path in is still size/s + up-for-grabs — those issues are sliced for quick completion and explicitly marked as beginner-friendly.
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, and wait for the assignment: a maintainer assigns it, and the assignee field is the record. A comment alone does not reserve an issue, and one person holds at most three open assignments at a time -- finish or release one before asking for the next. If an issue 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.
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 maintainer can confirm you as a committer, which is the write grant, and on a record of reviews that found real problems as a core reviewer for the area, which is what an entry in CODEOWNERS means - GitHub only honors code owners who hold write. Both roles, and the floor for each, are defined in the review charter.
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.
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 pytestWARNING: 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.
uv run ruff check src/
uv run ruff format src/
uv run pyright src/ruff check and ruff format must pass before committing (CI runs ruff format --check). pyright src/ is advisory repo-wide in CI, but the curated strict zone blocks: uv run pyright --project pyrightconfig.strict.json. No exceptions, no "fix later."
- 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
Every change lands through the merge queue with the approvals the
review charter requires: two, from
committers who are not the author, at least one of them a core reviewer
(three, two of them core, for a change over 400 lines or one touching a
sandbox, security or audit path outside tests/ and docs/; the
roster is .github/quorum-roster.toml). Ownership is separate: any
path with a named owner in CODEOWNERS needs that
owner's approval as well, rather than the core approval standing in for
it. Protected paths also need the maintainer. Reviewer checklist:
docs/CODE_REVIEW.md.
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.
If you depend on Bernstein, add yourself to ADOPTERS.md — one row, in your own pull request. Nobody is listed by a maintainer on their behalf, and usage that could be inferred from public activity is not listed either: inference is not consent.
It is worth doing for a reason that is not promotion. It is how a contributor finds out that the thing they fixed mattered to an air-gapped install, and how we learn which surfaces are load-bearing rather than guessing.
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.
Add docs/release-notes/fragments/<issue-or-slug>.md describing a
user-visible change: a ## <title> heading and a short paragraph, same as
any existing entry. One file per PR means two PRs never conflict on the
same line the way appending to docs/release-notes/unreleased.md did.
Editing unreleased.md directly still works during the transition; see
docs/release-notes/README.md.
Bernstein uses a GitHub merge queue configured with ALLGREEN grouping (see merge-queue.md). Under this setting:
- Pull requests are tested together in speculative batches on top of
main(on amerge_groupref). - A batch merges only if every entry in the combination passes. This ensures
mainnever breaks from unexpected interactions between individually green changes. - If a pull request in the batch fails, the entire batch fails. If your PR was ejected despite having green checks on its own branch, this is not necessarily a flake: it usually means an earlier queued PR failed in combination. The queue automatically bisects and ejects the failing PR, re-queueing the remaining entries. Check the
merge_groupActions run log to see which change caused the combined failure.
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-pushThe 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.
When CI fails on main, .github/workflows/auto-heal.yml - routed by
the post-CI dispatcher - attempts a fix and opens an auto-heal/<sha>
pull request for review; see docs/operations/post-ci-dispatcher.md.
- 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
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.security.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.pysrc/bernstein/core/security/**.py and src/bernstein/core/identity/**.py
are policed by a CI lint (scripts/check_unreachable_controls.py) that fails
the build when a public symbol has no caller inside the shipped package. A
control whose only importers are its own tests, an __init__ re-export, or
the lazy module map in src/bernstein/core/__init__.py passes every other
check while never running.
Only Name loads and attribute accesses count as callers. Imports and string
literals do not, which is what makes a re-export and the lazy module map
invisible to the check. Reachability is a fixpoint, so a symbol called only by
another unreached symbol is still unreached.
Known cases live in unreachable_controls_allowlist.txt, one line per symbol
with a mandatory written reason. Removing an entry is the goal: an entry whose
symbol becomes reachable fails the gate, so the list cannot rot.
Run locally before committing:
uv run python scripts/check_unreachable_controls.py
# after adding or deleting a symbol, refresh the entry list (reasons survive):
uv run python scripts/check_unreachable_controls.py --updateReplace every REASON REQUIRED marker --update writes before committing --
the gate rejects it.
See AGENTS.md for the full doctrine, including change classification, conflict protocol, and zero-tolerance failures.
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/): 200+ command modules including:
| Module | Purpose |
|---|---|
run_cmd.py (in cli/) |
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.
Bernstein ships with 50+ 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.
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,
timeout_seconds=...,
task_scope="medium",
budget_multiplier=1.0,
system_addendum="",
multimodal_context=None,
) -> SpawnResult: ...
@abstractmethod
def name(self) -> str: ...
def is_alive(self, pid: int) -> bool: ... # default provided
def kill(self, pid: int) -> ProcessReapReceipt: ... # default provided
def detect_tier(self) -> ApiTierInfo | None: ... # optionalSteps:
- Create
src/bernstein/adapters/mycli.pyimplementing the two abstract methods (spawnandname). 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.
CI parsers implement the CILogParser protocol from core/quality/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.quality.ci_log_parser import register_parser; register_parser(MyCIParser()) - Run checks and open a PR.
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-engineerThe new role is available immediately - no code changes required.
- 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)
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 sessionAll contributors are listed in CONTRIBUTORS.md.
Roughly once every couple of weeks the project's LinkedIn page names, in one consolidated post, the contributors who asked to be named and says what they built. Opting in is one email; the how, the soft gate and the exception path are in the pinned recognition issue, and the operator side is docs/community/recognition.md. If you did the work, put it on your CV; the post exists so that there is a public record you did not write about yourself.
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.
By contributing, you agree that your contributions will be licensed under the Apache License 2.0.