Skip to content

Latest commit

 

History

History
453 lines (342 loc) · 20.6 KB

CONTRIBUTING.md

File metadata and controls

453 lines (342 loc) · 20.6 KB

Contributing to Bernstein

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.

Milestone triage and targeting

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.

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 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.

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 in scripts/run_tests.py caps each file at ~200 MB.

Linting & type checking

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."

Development Workflow

  1. Fork the repo and create a branch: git checkout -b feat/my-feature
  2. Make your changes
  3. 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
  4. Commit with a clear message
  5. 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.

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.

Telling us you use it

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.

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.md section.
  • Operator workflows: update docs/operations/<area>.md.
  • Public API surface: regenerate docs/api/ schemas.
  • Architecture or new module: update docs/sdd/ and run uv run bernstein agents-md sync so AGENTS.md, CLAUDE.md, .goosehints, CONVENTIONS.md, and .cursor/rules/*.mdc stay 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.

Release notes

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.

Why a green pull request can be ejected from the merge queue

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 a merge_group ref).
  • A batch merges only if every entry in the combination passes. This ensures main never 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_group Actions run log to see which change caused the combined failure.

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, .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.

Code Style

  • Python 3.12+, type hints on every public function and method
  • from __future__ import annotations at 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 @dataclass or TypedDict, not raw dict[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 through bernstein.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.py

Production reachability (security and identity controls)

src/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 --update

Replace 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.

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/): 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.

Supported CLI Adapters

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.

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,
        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: ...  # optional

Steps:

  1. Create src/bernstein/adapters/mycli.py implementing the two abstract methods (spawn and name). See adapters/claude.py for a complete reference.
  2. Register in adapters/registry.py: _ADAPTERS["mycli"] = MyCLIAdapter
  3. Run checks: uv run ruff check src/ && uv run pyright src/ && uv run python scripts/run_tests.py -x
  4. 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/quality/ci_log_parser.py:

class CILogParser(Protocol):
    name: str

    def parse(self, raw_log: str) -> list[CIFailure]: ...

Steps:

  1. Create src/bernstein/adapters/ci/<name>.py from the template in templates/ci-parsers/TEMPLATE.py. See adapters/ci/github_actions.py for a working example.
  2. Register: from bernstein.core.quality.ci_log_parser import register_parser; register_parser(MyCIParser())
  3. 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 instructions
  • task_prompt.md - per-task instructions template
  • config.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.py ABC
  • Branch is main - never master
  • 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.

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.

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.