Skip to content
Merged
2 changes: 2 additions & 0 deletions .github/workflows/test-mcp-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ jobs:
tests/test_phase1_code_locator.py
tests/test_phase2_ledger.py
tests/test_phase3_integration.py
tests/test_legacy_ledger_fixtures.py
tests/test_schema_recoverable_errors.py
-v --tb=short
--junitxml=test-results/results.xml
--html=test-results/report.html --self-contained-html
Expand Down
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
![Bicameral — without vs with](assets/bicameral-hero.png)

<a href="https://github.com/BicameralAI/bicameral-mcp">
<img src="assets/star-on-github.svg" alt="Star Bicameral MCP on GitHub" />
</a>

<img src="assets/logo.png" width="96" align="right" alt="Bicameral logo">

# Bicameral MCP
Expand All @@ -13,7 +9,9 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![CI](https://img.shields.io/github/actions/workflow/status/BicameralAI/bicameral-mcp/test-mcp-regression.yml?branch=main&label=tests)](https://github.com/BicameralAI/bicameral-mcp/actions)

A local-first [MCP server](https://spec.modelcontextprotocol.io/) that ingests your meeting transcripts, PRDs, and Slack threads, maps every decision to the code that implements it, and surfaces alignment gaps to your AI agent — before they become bugs.
AI agents ship code fast. They forget what your team agreed — and most agreements emerge mid-flight, in corrections and side comments that never reach a doc.

Bicameral MCP is a **spec compliance layer** for AI-assisted engineering. Local-first; runs as an [MCP server](https://spec.modelcontextprotocol.io/). It ingests your meeting transcripts, PRDs, and Slack threads, captures any mid-implementation decision that was not discussed, to be ratified async by your product owner, and pins each one to the code that implements it — so your agent finds out the moment it drifts from either the written spec or the spoken one.

---

Expand Down Expand Up @@ -68,9 +66,25 @@ bicameral-mcp --smoke-test
Source: Slack #payments 2026-03-20
```

**At any time**, the dashboard gives you the full picture:
**See it in motion** — the loop in three beats:

**1. Ingest (PM or dev).** A transcript, PRD, or Slack thread comes in; bicameral extracts decisions and writes them to the ledger.

https://github.com/user-attachments/assets/e74ae39f-dd99-485b-8122-8c5211478eb1

**2. Preflight (auto).** Before the agent edits code, bicameral surfaces prior decisions, drifted regions, and open questions for the file in scope.

https://github.com/user-attachments/assets/8a0fdfb8-fc9a-49fc-9521-e5b5faf8646a

**3. Ratify async (product owner).** The product owner reviews captured proposals and ratifies or rejects them on their own cadence. Drift tracking activates on ratification.

https://github.com/user-attachments/assets/206e269e-49d6-407d-b338-ab3f2a2c70ec

![Bicameral Dashboard](assets/dashboard-preview.png)
<p align="center">
<a href="https://github.com/BicameralAI/bicameral-mcp">
<img src="assets/star-on-github.svg" alt="Star Bicameral MCP on GitHub" />
</a>
</p>

---

Expand Down
3 changes: 3 additions & 0 deletions audit_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ class AuditEventType(enum.StrEnum):
# #252 Layer 2 — wire-format sentinel observability
LEDGER_SCHEMA_VERIFIED = "ledger_schema_verified"
LEDGER_VERSION_DRIFT = "ledger_version_drift"
# #296 — recoverable schema-definition skip (init_schema deferring to migrate)
SCHEMA_DEFINE_SKIPPED = "schema_define_skipped"


_LEVEL_BY_EVENT: dict[AuditEventType, str] = {
Expand All @@ -84,6 +86,7 @@ class AuditEventType(enum.StrEnum):
AuditEventType.ERROR: "error",
AuditEventType.LEDGER_SCHEMA_VERIFIED: "info",
AuditEventType.LEDGER_VERSION_DRIFT: "warn",
AuditEventType.SCHEMA_DEFINE_SKIPPED: "warn",
}

_LEVEL_RANK = {"info": 10, "warn": 20, "error": 30}
Expand Down
78 changes: 58 additions & 20 deletions cli/_diagnose_gather.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@
_RECENT_EVENT_TAIL = 5


def _read_ledger_metadata(adapter) -> tuple[str, int | None, str | None]:
def _read_ledger_metadata_for_url(url: str) -> tuple[str, int | None, str | None]:
"""Return (ledger_url, size_bytes_or_None, mtime_iso_or_None)."""
url = getattr(adapter, "_url", "")
if not url.startswith("surrealkv://"):
return url, None, None
path_str = url.removeprefix("surrealkv://")
Expand All @@ -37,21 +36,23 @@ def _read_ledger_metadata(adapter) -> tuple[str, int | None, str | None]:
return url, stat.st_size, mtime_iso


async def _read_bicameral_meta(
adapter,
) -> tuple[str | None, str | None, str | None, str, str]:
"""Return (first_write, last_write, last_write_at_iso, drift_status, running).
def _read_ledger_metadata(adapter) -> tuple[str, int | None, str | None]:
return _read_ledger_metadata_for_url(getattr(adapter, "_url", ""))

``drift_status`` is one of: ``"first-write"`` / ``"match"`` / ``"drift"`` /
``"unavailable"`` (table missing, e.g., pre-Layer-2 ledger).
"""

async def _read_bicameral_meta_raw(
client,
) -> tuple[str | None, str | None, str | None, str, str]:
"""Same shape as ``_read_bicameral_meta`` but operates on a raw
``LedgerClient``. Used by the MCP ``bicameral_diagnose`` tool, which
must work without ``init_schema``/``migrate`` succeeding."""
try:
running = importlib.metadata.version("surrealdb")
except importlib.metadata.PackageNotFoundError:
running = "unknown"

try:
rows = await adapter._client.query("SELECT * FROM bicameral_meta LIMIT 1")
rows = await client.query("SELECT * FROM bicameral_meta LIMIT 1")
except Exception: # noqa: BLE001 — table missing is the load-bearing case
return None, None, None, "unavailable", running

Expand All @@ -71,9 +72,20 @@ async def _read_bicameral_meta(
return first, last, last_at_iso, "drift", running


async def _read_schema_version(adapter) -> int | None:
async def _read_bicameral_meta(
adapter,
) -> tuple[str | None, str | None, str | None, str, str]:
"""Return (first_write, last_write, last_write_at_iso, drift_status, running).

``drift_status`` is one of: ``"first-write"`` / ``"match"`` / ``"drift"`` /
``"unavailable"`` (table missing, e.g., pre-Layer-2 ledger).
"""
return await _read_bicameral_meta_raw(adapter._client)


async def _read_schema_version_raw(client) -> int | None:
try:
rows = await adapter._client.query("SELECT version FROM schema_meta LIMIT 1")
rows = await client.query("SELECT version FROM schema_meta LIMIT 1")
except Exception: # noqa: BLE001
return None
if not rows:
Expand All @@ -82,11 +94,15 @@ async def _read_schema_version(adapter) -> int | None:
return int(val) if val is not None else None


async def _read_table_counts(adapter) -> dict[str, int]:
async def _read_schema_version(adapter) -> int | None:
return await _read_schema_version_raw(adapter._client)


async def _read_table_counts_raw(client) -> dict[str, int]:
counts: dict[str, int] = {}
for table in _CANONICAL_TABLES:
try:
rows = await adapter._client.query(f"SELECT count() AS n FROM {table} GROUP ALL")
rows = await client.query(f"SELECT count() AS n FROM {table} GROUP ALL")
except Exception: # noqa: BLE001 — missing table is acceptable (pre-v16)
continue
if rows:
Expand All @@ -95,6 +111,10 @@ async def _read_table_counts(adapter) -> dict[str, int]:
return counts


async def _read_table_counts(adapter) -> dict[str, int]:
return await _read_table_counts_raw(adapter._client)


def _resolve_audit_log_channel() -> tuple[str, Path | None]:
"""Return (channel_label, configured_file_path_or_None)."""
raw = os.getenv("BICAMERAL_AUDIT_LOG", "stderr").strip()
Expand Down Expand Up @@ -195,19 +215,28 @@ def _fetch_recommended() -> str | None:
return None


async def gather_diagnosis(adapter) -> Diagnosis:
"""Collect every allowlisted field from the running install + ledger."""
async def gather_diagnosis_raw(client, ledger_url: str) -> Diagnosis:
"""Same allowlisted gather as ``gather_diagnosis`` but takes a raw
``LedgerClient`` and an explicit ``ledger_url``.

Used by the MCP ``bicameral_diagnose`` tool, which opens a raw client
so it can produce a report even when ``adapter.connect()`` (and its
init_schema / migrate calls) would crash on a corrupted ledger. The
CLI ``bicameral-mcp diagnose`` keeps using ``gather_diagnosis``
(adapter-based) because it benefits from the adapter's connection
lifecycle in the happy-path operator-bug-report flow.
"""
try:
bicameral_version = importlib.metadata.version("bicameral-mcp")
except importlib.metadata.PackageNotFoundError:
bicameral_version = "unknown"

from ledger.schema import SCHEMA_VERSION

ledger_url, size_bytes, mtime_iso = _read_ledger_metadata(adapter)
first, last, last_at_iso, drift_status, running = await _read_bicameral_meta(adapter)
schema_recorded = await _read_schema_version(adapter)
table_counts = await _read_table_counts(adapter)
_, size_bytes, mtime_iso = _read_ledger_metadata_for_url(ledger_url)
first, last, last_at_iso, drift_status, running = await _read_bicameral_meta_raw(client)
schema_recorded = await _read_schema_version_raw(client)
table_counts = await _read_table_counts_raw(client)
channel_label, audit_path = _resolve_audit_log_channel()
recent_events = _tail_recent_events(audit_path, _RECENT_EVENT_TAIL)

Expand Down Expand Up @@ -242,3 +271,12 @@ async def gather_diagnosis(adapter) -> Diagnosis:
recent_events=recent_events,
suggestions=suggestions,
)


async def gather_diagnosis(adapter) -> Diagnosis:
"""Adapter-flavoured wrapper over ``gather_diagnosis_raw``.

Reads the ledger URL off the adapter and forwards to the raw helper.
Existing CLI callers (`bicameral-mcp diagnose`) keep this entry point.
"""
return await gather_diagnosis_raw(adapter._client, getattr(adapter, "_url", ""))
37 changes: 37 additions & 0 deletions contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,43 @@ class ResetResponse(BaseModel):
replay_plan: list[ResetReplayEntry] = []
replay_errors: list[str] = []
next_action: str
# #296 Layer E — automated rebuild from .bicameral/events/*.jsonl
# after wipe. Populated only when `replay_from_events=True` and
# `confirm=True`; reports how many events the materializer replayed.
events_replayed: int = 0


# ── Tool 8 (new): /bicameral_diagnose ────────────────────────────────


class DiagnoseResponse(BaseModel):
"""Read-only diagnostic snapshot. Mirrors the CLI ``bicameral-mcp
diagnose`` output but returns structured fields so agents can render
a recovery prompt deterministically.

`recovery_path` classifies the next operator action:
- ``clean`` — ledger looks healthy, no remediation needed
- ``fixable`` — schema is behind binary; next normal call migrates
- ``reset_rebuild`` — ledger broken AND events present → reset
with `replay_from_events=True` recovers without data loss
- ``reset_destructive`` — ledger broken AND no events → reset
loses decision history; user must explicitly accept

`diagnosis` carries the same structural-metadata-only fields the
CLI emits (see ``cli.diagnose.Diagnosis``); empty when the raw
client could not connect.
"""

ledger_url: str
connect_error: str = ""
recovery_path: Literal[
"clean",
"fixable",
"reset_rebuild",
"reset_destructive",
]
diagnosis: dict | None = None
next_action: str


# ── Tool 9: /bicameral_preflight ─────────────────────────────────────
Expand Down
Loading
Loading