Thank you for your interest in contributing to roam-code! This document covers everything you need to get started.
- Fork the repository
- Clone your fork:
git clone https://github.com/<you>/roam-code.git - Install in development mode:
pip install -e ".[mcp,dev]" - Run tests:
pytest tests/ - Create a branch, make changes, submit a PR
- Python 3.10+
- Git
git clone https://github.com/Cranot/roam-code.git
cd roam-code
pip install -e ".[dev]" # core + pytest, pytest-xdist, ruff
pip install -e ".[mcp,dev]" # also includes fastmcp for MCP server work
# Enable the commit-msg hook (rejects Co-Authored-By trailers + AI attribution)
git config core.hooksPath .githooks
# (Optional) Install the local pre-commit hooks for fast-fail count-drift +
# Co-Authored-By rejection. CI runs the same checks independently.
pre-commit install # pre-commit stage: count-drift
pre-commit install --hook-type commit-msg # commit-msg stage: no-coauthorThe local hooks live in .pre-commit-config.yaml and mirror two CI gates:
count-driftrunsscripts/sync_surface_counts.pyand blocks the commit when README / pyproject / landing-page counts diverge from the live CLI surface insrc/roam/cli.py+src/roam/mcp_server.py.no-coauthorparses the commit message and rejects anyCo-Authored-By:trailer — project policy is single-author on this repo.
roam-code keeps its git hooks under version control in .githooks/ so every
clone runs the same gates. Enable them once per clone:
git config core.hooksPath .githooksThat single command activates all three tracked hooks:
| Hook | When | What it does |
|---|---|---|
pre-commit |
every git commit |
Anti-leak scan of the staged changes (scripts/scan_internal_language.py --staged) + count-drift checks. Blocks the commit on any internal-language leak (day-job customer name, session markers, sales-positioning shorthand, personal paths, etc). |
pre-push |
every git push |
Anti-leak scan of every tracked file (--all) as a --no-verify backstop + the structural-gate bundle (scripts/prepush_check.py). |
commit-msg |
every git commit |
Rejects Co-Authored-By: trailers and AI-attribution lines (single-author project policy). |
Why required: the anti-leak gate used to run only in CI. With no installed hook, a leak could reach the public repo before CI caught it. The commit-time and push-time hooks now block leaks locally, on the staged content and the whole tree respectively.
The forbidden-pattern catalogue is a single source of truth at
scripts/internal_language_patterns.py (stdlib-only), shared by the hooks
and the CI gate tests/test_no_internal_language.py. If a hit is
intentional, add the file to WHITELIST_FILES there (with a comment) or
tighten the offending regex — do not bypass with --no-verify.
# Full test suite
pytest tests/
# Parallel execution (faster, requires pytest-xdist)
pytest tests/ -n auto
# Skip timing-sensitive performance tests
pytest tests/ -m "not slow"
# Single test file
pytest tests/test_comprehensive.py -x -v
# Single test class or method
pytest tests/test_comprehensive.py::TestHealth -x -v -n 0
# Sequential execution (useful for debugging)
pytest tests/ -n 0The dev pytest isn't always on PATH outside the venv. Use the venv
binary directly:
.venv\Scripts\pytest.exe tests\test_yourfile.py -x -v -n 0On macOS / Linux the equivalent is .venv/bin/pytest tests/....
All tests must pass before submitting a PR.
After a meaningful change, run roam on its own source tree to confirm the index still builds and the preflight gate behaves on a known symbol:
roam init # build the SQLite index
roam health # composite health score
roam preflight ensure_index # blast radius + tests + fitness on a real symbolIf roam health drops sharply or roam preflight fails the fitness
gate on a stable symbol, treat that as a regression and investigate
before opening the PR. The same loop is the canonical "earn the right
to change code" rehearsal documented in AGENTS.md.
ruff check src/ tests/The project uses ruff with target-version = "py310" and line-length = 120.
Selected rule sets: E, F, W, I, T20 (pyflakes, pycodestyle, isort, print statements).
- Functions and methods:
snake_case - Classes:
PascalCase - Imports: Absolute imports for cross-directory references
- Future annotations: Every source file must start with
from __future__ import annotationsso type hints stay strings at runtime (cheaper import, safer forward references, avoids PEP 604 union evaluation costs). The project requires Python 3.10+ (pyproject.toml); this is a code-quality convention, not a back-compat shim. - Output format: Plain ASCII only -- no emojis, no colors, no box-drawing characters. This keeps output token-efficient for LLM consumption.
- Output abbreviations:
fn(function),cls(class),meth(method) -- viaabbrev_kind()
See the Architecture Guide for the complete conventions reference.
roam-code ships a .pre-commit-hooks.yaml so you can run roam checks as
pre-commit hooks in any project that has roam-code
installed.
Add the following to your project's .pre-commit-config.yaml:
repos:
- repo: https://github.com/Cranot/roam-code
rev: v13.4 # pin to a release tag
hooks:
- id: roam-secrets # secret scanning -- no index required
- id: roam-syntax-check # tree-sitter syntax validation -- no index required
- id: roam-verify # convention consistency check
- id: roam-health # composite health score (informational)Available hook IDs and what they do:
| Hook ID | Command | Fails on | Index required? |
|---|---|---|---|
roam-secrets |
roam secrets --fail-on-found |
Any secret found | No |
roam-syntax-check |
roam syntax-check --changed |
Syntax errors | No |
roam-verify |
roam verify --changed |
Score < 70 | Yes (auto-init) |
roam-health |
roam health |
Never (informational) | Yes (auto-init) |
roam-vibe-check |
roam vibe-check |
Never by default | Yes (auto-init) |
Notes:
roam-secretsandroam-syntax-checkoperate directly on files and work without a pre-existing roam index.roam-verify,roam-health, androam-vibe-checkcallensure_index()internally and will auto-index the project on first run (equivalent toroam init).- All hooks use
pass_filenames: falseandalways_run: truebecause roam operates on the whole repository rather than individual files. - To enforce a health threshold in CI, use the
gateinput of the GitHub Action rather thanroam-healthalone. - To enable the
--thresholdgate onroam-vibe-check, override the hook args in your config:- id: roam-vibe-check args: ['--threshold', '50']
Before picking up work, skim:
AGENTS.md— the agent-OS substrate, the 12 substrate packages, and the canonical 4-mode loop (read_only/safe_edit/migration/autonomous_pr).CLAUDE.md— Claude-specific operator guide; mirrors AGENTS.md plus quality discipline and the LAW-4 concrete-noun anchor rules enforced bytests/test_law4_lint.py.- GitHub Issues — the public queue for community contributions. Comment on an issue before starting non-trivial work so we can coordinate scope.
Use the Bug Report issue template. Please include:
- roam-code version (
roam --version) - Python version (
python --version) - Operating system
- Steps to reproduce
- Actual vs expected output
Use the Feature Request issue template. Explain the use case and why it matters.
-
Create
src/roam/commands/cmd_yourcommand.pyfollowing the command template:from __future__ import annotations import click from roam.db.connection import open_db from roam.output.formatter import to_json, json_envelope from roam.commands.resolve import ensure_index @click.command() @click.pass_context def your_command(ctx): json_mode = ctx.obj.get('json') if ctx.obj else False ensure_index() with open_db(readonly=True) as conn: # ... query the DB ... if json_mode: click.echo(to_json(json_envelope("your-command", summary={"verdict": "...", ...}, ... ))) return # Text output click.echo("VERDICT: ...")
-
Register in
cli.py:- Add to
_COMMANDSdict:"your-command": ("roam.commands.cmd_yourcommand", "your_command") - Add to the appropriate category in
_CATEGORIESdict
- Add to
-
Add an MCP tool wrapper in
mcp_server.pyif the command would be useful for AI agents. Four skip categories: setup/bootstrap (init,surface,version), local-state-only (mode,memory,runs,lease,annotate), daemon (watch), and REPL/interactive helpers. Otherwise add the wrapper, declare the read/write side-effect flag, and mark non-idempotent tools so the mode gate can enforce policy on them. The advisorytests/test_mcp_wrapper_coverage.pysurfaces commands lacking a wrapper that aren't in the skip-taxonomy allowlist. -
Add
@roam_capability(name="...", category="...", ...)to the click command — the auto-derivedtests/test_capability_decoration.pywill fail without it. Aliases sharing the same(module, function)tuple in_COMMANDSgo into_DEPRECATED_COMMANDSinstead. -
Anchor any
agent_contract.factsstrings on concrete-noun terminals (LAW 4); thetests/test_law4_lint.pylint blocks merges on un-anchored facts. See CLAUDE.md "Concrete-noun anchor vocabulary" for the accepted terminal tokens. -
Add tests in
tests/ -
Refresh the command/MCP-tool counts that appear in
README.md,CLAUDE.md,llms-install.md, and the MCP server cards:python dev/build_readme_counts.py --apply
CI runs
python dev/build_readme_counts.py --checkin thedoc-hygienejob and will fail if any count drifts from the source of truth insrc/roam/cli.py+src/roam/mcp_server.py.
roam's MCP boundary is where agent-emitted tool calls meet the assurance substrate. Three guarantees ship today: (a) egress redaction prevents secret leak on output; (b) 4-mode policy enforcement gates state-mutating calls; (c) every receipt is HMAC-linked to a signed run-ledger event for tamper-evident audit. These map to compliance evidence; they do not by themselves make any project compliant.
When you'd touch this:
- Adding a new
@_toolwrapper that mutates state - Modifying
_wrap_with_receiptredaction call sites - Extending the
policy_decisionclosed enum - Adding a new mode classification
- Touching
src/roam/runs/signing.py
Closed-enum vocabulary (extend the source-of-truth file; never hardcode a new string at the call site):
policy_decision(6 at the MCP boundary —allow,deny,escalate,redact,not_evaluated,would_deny_dry_run):src/roam/evidence/mcp_receipt.py(strict subset of the 9-memberPOLICY_DECISIONSin_vocabulary.py).redactions[]reasons (9):src/roam/evidence/_vocabulary.py:REDACTION_REASONS.receipt_integrity(4 —ok,missing,tampered,not_linked):src/roam/runs/signing.py:RECEIPT_INTEGRITY_STATES.
Schema export. The receipt JSON Schema (Draft 2020-12, $id =
.../mcp-receipt/v1.json) lives at src/roam/evidence/mcp_receipt_schema.py;
export via scripts/export_mcp_receipt_schema.py. Enums and receipt fields are
append-only; gateways pin the $id to observe breaking bumps.
Shadow mode. ROAM_MODE_DRY_RUN evaluates policy and emits
would_deny_dry_run instead of blocking, so gateways can observe enforcement
without disabling it. Findings still persist to the registry.
Drift-guard tests every contributor should know:
tests/test_w_mcp_redact_egress.py— P0.1 redaction wiringtests/test_w_mcp_mode_enforcement.py— P0.2 4-mode enforcementtests/test_w_mcp_receipt_hmac_link.py— P0.3 HMAC-link integritytests/test_mcp_receipt_json_schema.py— P2.2 schema paritytests/test_evidence_v0.py/tests/test_evidence_schema_migration.py— vocabulary + golden-hash drift
Hash-stability discipline. The ChangeEvidence content hash and the HMAC
ledger chain MUST stay byte-identical when default-valued fields are absent;
pre-P0.3 chains and pre-P2.2 receipts continue to verify cleanly with no
migration. See the _W210_OMIT_WHEN_DEFAULT_FIELDS discipline in
src/roam/evidence/change_evidence.py.
Where to read more: dev/MCP-SECURITY-POSTURE.md (gateway-integrator
audience), the "MCP runtime security" section of CLAUDE.md, the 12 substrate
packages and 8 evidence questions in AGENTS.md, and Discussion
#37 reply.
Canonical mandate: new detectors are only strategically useful when they emit into the shared findings / evidence layer, and new exporters consume from that layer rather than querying the graph independently.
- Call
emit_finding(conn, FindingRecord(...))fromsrc/roam/db/findings.py. Carry a<DETECTOR>_DETECTOR_VERSIONconstant at the call site for drift tracking (not insrc/roam/catalog/versions.py). roam findings countreturns last-run state per detector, not a cumulative tally — totals reflect the most recent invocation of each detector.- Mirror the closest sibling detector in
tests/test_findings_*.pywhen adding a new one.
Envelope-only is a narrow exception for invocation-scoped findings (diff-region
transients) or commands that gate the registry write behind a --persist flag.
Reference patterns: cmd_clones, cmd_flag_dead, cmd_path_coverage.
- Create
src/roam/languages/yourlang_lang.pyinheriting fromLanguageExtractor - Use
go_lang.pyorphp_lang.pyas clean templates - Register in
src/roam/languages/registry.py - Add tests in
tests/
- Add column in
src/roam/db/schema.py(CREATE TABLE statements) - Add migration in
src/roam/db/connection.pyviaensure_schema()using_safe_alter() - Populate the new column in
src/roam/index/indexer.py
- Verdict-first output: Key commands should emit a one-line
VERDICT:as the first text output and includeverdictin the JSON summary. - JSON envelope: All JSON output uses
json_envelope(command_name, summary={...}, **data). - Batched IN-clauses: Never write raw
WHERE id IN (...)with a list > 400 items. Usebatched_in()fromconnection.py. - Lazy-loading: Commands are lazy-loaded via
LazyGroupincli.pyto avoid importing networkx on every CLI call.
See the Architecture Guide for the full list of patterns and conventions.
Format: <type>: <imperative summary> — keep the summary under 72 chars.
Types: feat, fix, docs, refactor, test, ci, chore, perf,
build, style.
Body lines are optional. When you add one, explain why, not what — the diff already shows the what. Lead with the user-visible problem or the design constraint.
Good:
feat: add roam stale-refs --attest for in-toto v1 attestations
fix: skip changelog.html in linkcheck (auto-rendered, contains examples)
docs: consolidate to roam-code.com, disable github pages
refactor: collapse two adjacent provider classes in stale-refs hints
ci: render changelog.html on every push
Don't:
- Phase numbering:
phase 1-5 build: …,phase 4C polish - Round numbering:
round 4 #15: …,pass 79 polish - Polish-speak:
5 micro passes,5 conversion-leverage polish - Session-named bundles:
25-phase polish round (5 leaks + 5 polish + …)
This is professional, not personal-journal. The CHANGELOG entry is where the colour goes; the commit subject is just the index.
Single-author project policy: every commit is authored by Cranot. Do NOT append
Co-Authored-By: trailers, AI-attribution lines, or "Generated with" footers.
The local no-coauthor pre-commit hook and .githooks/commit-msg both reject
these trailers; CI re-runs the same check. If a hook rejects your message,
strip the trailer and re-stage — do not bypass via --no-verify.
CI lints generated reports AND commit messages / docs for over-claim wording:
- Compliance / governance: use "maps to" or "supports evidence for" only — the codebase intelligence layer emits portable evidence; external GRC tools consume it. Never "certifies" or "makes compliant".
- MCP runtime security: describe behaviour with the shipped capability names — "egress redaction", "mode-gated policy enforcement", "tamper-evident receipt chain". Avoid absolute claims ("prevents all secret leaks", "fully sandboxed"); the substrate is a defence layer, not a guarantee.
Single source of truth: pyproject.toml → version. Everything else
(server.json, mcp-server-card.json x2, README badge, llms-install
counts) syncs from it via scripts/sync_surface_counts.py.
Workflow:
- Every PR / direct push lands under
[Unreleased]inCHANGELOG.md. - A release is a deliberate event — bump
pyproject.toml, rename[Unreleased]→[X.Y] - YYYY-MM-DD, add a fresh empty[Unreleased]block, publish to PyPI. - Aim for weekly to bi-weekly releases. Patches (
X.Y.Z) for hotfixes only. Don't bump version per commit.
SemVer interpretation here:
| Bump | Meaning |
|---|---|
Major (X) |
Breaking change to CLI / MCP API surface |
Minor (Y) |
New commands, new MCP tools, new languages, schema |
Patch (Z) |
Bug fixes, doc updates, internal cleanup, CI tweaks |
CI runs four scripts on every push. If your change fails a gate, fix the underlying drift; don't bypass it.
tests/test_no_internal_language.py— fails on internal-session shorthand (phase numbering, sales-positioning words, day-job customer names, etc.).scripts/sync_surface_counts.py— fails if README / llms-install / landing pages quote stale command / MCP-tool / language counts.scripts/build_changelog_html.py— fails if the renderedchangelog.htmldrifts fromCHANGELOG.md.scripts/linkcheck.py— fails if any internal landing-page link or anchor 404s.
Four rules that consistently surface as fix-forward causes — fold them into the same commit as the original change, not a follow-up PR:
- When a count changes, ship a structural drift-guard the same session. Run
python dev/build_readme_counts.py --applyAND add a test asserting the new count in the same commit. The test stops the next agent from silently reverting your bump. - Render
changelog.htmlimmediately after editingCHANGELOG.mdviapython scripts/build_changelog_html.py. The drift gate hard-fails on stale renders, so a "doc-only" CHANGELOG edit will block the PR otherwise. - Phantom-annotate when a test pins a missing doc. Use
skip/xfailwith a reason and a TODO pointing at the producing task. Silent-pass viaif path.exists()trains future readers to ignore the gate. - Add a rationale comment when a canonical list outlives a refactor. Lists
like
_COMPOUND_INVOKERSthat reference modules no longer present need a short comment (# kept for historical alias resolutionor# moved to <path>) in the same commit as the refactor.
Cloudflare Pages goes out by hand:
wrangler pages deploy templates/distribution/landing-page \
--project-name roam-code --branch main --commit-dirty=truePyPI publishes from a tag (.github/workflows/publish.yml). After a
version-bump commit lands on main, tag it with the new pyproject.toml
version and push the tag: git tag vX.Y && git push origin vX.Y.
- One feature or fix per PR
- Include tests for new functionality
- All tests must pass (
pytest tests/) - Follow existing code conventions
- Please open an issue first to discuss larger changes
- Tests create temporary project directories with fixture files
- Use
CliRunnerfrom Click for command tests - Mark tests that need sequential execution with
@pytest.mark.xdist_group("groupname") - Use
-m "not slow"to skip timing-sensitive performance tests during development
roam-code is organized into these key areas:
| Directory | Purpose |
|---|---|
src/roam/cli.py |
Click CLI entry point with lazy-loaded commands |
src/roam/commands/ |
One cmd_*.py module per CLI command |
src/roam/db/ |
SQLite schema, connection management, queries |
src/roam/index/ |
Indexing pipeline: discovery, parsing, extraction, resolution |
src/roam/languages/ |
One *_lang.py per language, inheriting LanguageExtractor |
src/roam/graph/ |
NetworkX graph algorithms (PageRank, SCC, clustering, layers) |
src/roam/bridges/ |
Cross-language symbol resolution |
src/roam/output/ |
Formatting, JSON envelopes, SARIF output |
src/roam/mcp_server.py |
MCP server with 243 tools (16 in the default core preset) |
tests/ |
Test suite |
For full architectural details, see the Architecture Guide.
- Add a Tier 1 language extractor (see
go_lang.pyorphp_lang.pyas templates) - Improve reference resolution for an existing language
- Add benchmark repos to the test suite
- Extend SARIF converters
- Add MCP tool wrappers for existing commands
- Improve documentation
- Open an issue for questions
- Check existing issues before creating new ones
- See the Architecture Guide for detailed technical conventions