Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test-mcp-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
env:
SURREAL_URL: 'memory://'
REPO_PATH: ${{ github.workspace }}
BICAMERAL_SKIP_CONSENT_NOTICE: '1'
steps:
- uses: actions/checkout@v4

Expand Down
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,74 @@
All notable changes to bicameral-mcp are tracked here. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## v0.14.0 — Local-only telemetry counters + usage summary + first-boot consent — built via [QorLogic SDLC](https://github.com/MythologIQ-Labs-LLC/qor-logic)

Privacy-first observability foundation. Adds a local-only counter sink
that runs alongside (not replacing) the existing network relay, a new
`bicameral.usage_summary` MCP tool that aggregates ledger and counter
state into actionable percentages, and a non-blocking first-boot notice
so users upgrading to this binary see the telemetry policy before any
data flows.

### Added

- **`local_counters.py`** (#39) — append-only JSONL sink at
`~/.bicameral/counters.jsonl`. Records only `{tool_name, delta=1, ts}`
per call. Mode `0o600` on POSIX; thread-safe; no network egress.
Always-on regardless of network telemetry consent — counters are
local introspection, distinct from the relay. Kill-switch:
`BICAMERAL_LOCAL_COUNTERS=0`. API: `increment(tool_name)` and
`read_counters() -> dict[str, int]`.
- **`consent.py`** (#39) — owns `~/.bicameral/consent.json`,
`telemetry_allowed()` predicate, and `notify_if_first_run()`. Marker
shape: `{telemetry, policy_version, acknowledged_at, acknowledged_via}`
with `acknowledged_via` distinguishing `"wizard"` (explicit choice)
from `"first_boot_notice"` (passive ack). `POLICY_VERSION` constant
re-fires the notice for everyone once when telemetry policy changes.
- **`bicameral.usage_summary`** MCP tool (#42) — aggregate readout over
the last N days (default 7). Returns ingest/bind call counts (from
the local counters file), decision counts by status (from ledger),
reflected/drift percentages, cosmetic-drift percentage (from
compliance_check verdicts), and error rate. Privacy-preserving:
aggregate counts and floats only.
- **First-boot consent notice** — non-blocking, fires once per
`policy_version` via stderr (always) and MCP `notifications/message`
(when an active session is available). Server keeps running; if
marker write fails, notice is logged at debug and the server
continues. Test escape hatch: `BICAMERAL_SKIP_CONSENT_NOTICE=1`.

### Changed

- **`telemetry.send_event` now uses `consent.telemetry_allowed()`** as
the single gating predicate. Behavior preserved for users without a
marker (default-on); newly opted-out users (marker says `disabled`
via the wizard) suppress the relay even when env var is unset.
- **`telemetry.send_event` always increments the local counter** before
the relay path — never raises, wrapped in try/except. Counter
failure cannot affect the caller; relay path runs independently.
- **`setup_wizard._select_telemetry`** now calls
`consent.write_consent(via="wizard")` after the user's choice. Hard
fails (raises `OSError`) if the marker cannot be written — guarantees
a "no" answer never silently leaves telemetry on.
- **`server.serve_stdio`** calls `consent.notify_if_first_run()` once
during startup. Wrapped in try/except — startup is never blocked by
notice machinery.

### CI

- `BICAMERAL_SKIP_CONSENT_NOTICE: "1"` added to the test job env in
`.github/workflows/test-mcp-regression.yml` so test runs do not emit
notices into job logs.
- `tests/conftest.py` adds a session-scoped autouse fixture that
reroutes `~/.bicameral/` to a per-session tmp dir and sets the skip
env var. Stdlib only — no third-party fixture plugin.

### Closes

#39, #42.
Comment on lines +68 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix issue-reference line that is parsed as a heading.

At Line 70, #39, #42. triggers markdownlint MD018 (missing space after #). Reword to plain text like Closes: #39, #42. (or escape the hashes) so lint/docs checks stay green.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 70-70: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 68 - 70, The "Closes" section in CHANGELOG.md
currently contains a line that begins with "#39, `#42`." which is parsed as a
heading and triggers markdownlint MD018; replace that line with plain text such
as "Closes: `#39`, `#42`." (or escape the hashes) so the entry remains the same
meaning but no longer starts with '#' and the linter/docs checks pass.


---

## v0.11.0 — CodeGenome Phase 1+2 (#59) — adapter boundary + identity records — built via [QorLogic SDLC](https://github.com/MythologIQ-Labs-LLC/qor-logic)

Foundation PR for the three-phase CodeGenome rollout (issues #59 / #60 / #61).
Expand Down
37 changes: 37 additions & 0 deletions cli/_link_commit_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Sync wrapper around handle_link_commit. Shared by branch-scan and
link_commit CLI subcommands. Lazy-imports SurrealDB-touching modules
so callers don't pay the import cost when no ledger is configured.

Promoted from cli/branch_scan.py (#48) to a shared module under #124
when the link_commit CLI subcommand was added.
"""

from __future__ import annotations

import asyncio
from pathlib import Path

from contracts import LinkCommitResponse


def invoke_link_commit(commit_hash: str = "HEAD") -> LinkCommitResponse | None:
"""Drive the async ``handle_link_commit`` from sync context.

Returns ``None`` when:
- ``~/.bicameral/ledger.db`` does not exist (no configured ledger), OR
- the underlying handler raises (graceful skip — caller decides on
loud vs. silent failure semantics).
"""
if not (Path.home() / ".bicameral" / "ledger.db").exists():
return None
from context import BicameralContext
from handlers.link_commit import handle_link_commit

async def _run() -> LinkCommitResponse:
ctx = BicameralContext.from_env()
return await handle_link_commit(ctx, commit_hash=commit_hash)

try:
return asyncio.run(_run())
except Exception: # noqa: BLE001 — caller decides loud vs. silent
return None
31 changes: 31 additions & 0 deletions cli/link_commit_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""link_commit CLI subcommand entry point (#124).

Wraps the shared ``cli._link_commit_runner.invoke_link_commit`` for
human-driven invocation. JSON-to-stdout by default; ``--quiet`` for
hook scripts that pipe to /dev/null.

Always exits 0 — the post-commit hook depends on this so commits are
never blocked. Hook-side loudness (stderr) is handled in the installed
shell script, not here.
"""

from __future__ import annotations

import json

from cli._link_commit_runner import invoke_link_commit


def main(commit_hash: str = "HEAD", *, quiet: bool = False) -> int:
"""Run link_commit against ``commit_hash`` (default HEAD).

Returns 0 on success, on no-ledger graceful skip, and on
handler-exception graceful skip — the runner already collapses
those cases to ``None``. Print JSON to stdout unless ``quiet``.
"""
response = invoke_link_commit(commit_hash)
if response is None:
return 0
if not quiet:
print(json.dumps(response.model_dump(), default=str, indent=2))
return 0
138 changes: 138 additions & 0 deletions consent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""User consent for outbound telemetry (issue #39).

Three responsibilities, kept independent of ``telemetry.py``:

1. **Consent marker** — persisted at ``~/.bicameral/consent.json`` with
``{telemetry: "enabled"|"disabled", policy_version, acknowledged_at,
acknowledged_via}``. File mode 0o600 on POSIX.

2. **First-boot notice** — non-blocking. On the first boot of an
upgraded binary that hasn't acknowledged the current policy version,
emits the notice via MCP ``notifications/message`` (when an active
session is available) and stderr (always). Server keeps running.

3. **``telemetry_allowed()``** — single source of truth for the
network relay. Returns True when env var ``BICAMERAL_TELEMETRY != "0"``
AND (marker missing OR marker.telemetry == "enabled"). Missing
marker preserves current default-on behavior so users don't lose
telemetry between upgrade and first-boot acknowledgment.

Test escape hatch: ``BICAMERAL_SKIP_CONSENT_NOTICE=1`` short-circuits
``notify_if_first_run`` (used by tests/conftest.py and CI).
"""

from __future__ import annotations

import json
import logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable

logger = logging.getLogger(__name__)

POLICY_VERSION = 1
"""Bump when telemetry policy changes (new fields, new endpoints).
Re-fires the first-boot notice once for everyone on the next boot."""

_CONSENT_FILE = Path.home() / ".bicameral" / "consent.json"
_OFF_VALUES = frozenset({"0", "false", "no", "off"})


_NOTICE_TEXT = (
"Bicameral collects anonymous usage statistics (skill name, duration, "
"version, error flag — no code, no decision text, no file paths). "
"To opt out: run `bicameral-mcp setup`, or set BICAMERAL_TELEMETRY=0 "
"in your `.mcp.json` env block. This notice will not appear again "
"unless the telemetry policy changes."
)


def read_consent() -> dict | None:
"""Return the marker contents, or None if missing/malformed."""
if not _CONSENT_FILE.exists():
return None
try:
return json.loads(_CONSENT_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
logger.debug("[consent] read failed: %s", exc)
return None


def write_consent(telemetry: bool, *, via: str) -> None:
"""Atomic write of the consent marker. Mode 0o600 on POSIX.

Raises OSError on disk failure — wizard treats this as fatal;
notify_if_first_run swallows it.
"""
record: dict[str, Any] = {
"telemetry": "enabled" if telemetry else "disabled",
"policy_version": POLICY_VERSION,
"acknowledged_at": datetime.now(timezone.utc).isoformat(),
"acknowledged_via": via,
}
_CONSENT_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = _CONSENT_FILE.with_suffix(".json.tmp")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
fd = os.open(str(tmp), flags, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(record, f, separators=(",", ":"))
os.replace(tmp, _CONSENT_FILE)


def telemetry_allowed() -> bool:
"""Single source of truth for whether the relay path may run.

True when:
- env var BICAMERAL_TELEMETRY != "0" (allows runtime opt-out), AND
- marker is missing (default-on for upgraders) OR
marker.telemetry == "enabled"
"""
env_val = os.getenv("BICAMERAL_TELEMETRY", "1").strip().lower()
if env_val in _OFF_VALUES:
return False
marker = read_consent()
if marker is None:
return True # default-on for users who haven't seen the notice yet
return marker.get("telemetry") == "enabled"


def _should_notify() -> bool:
"""True iff the notice has not been emitted for the current policy version."""
if os.getenv("BICAMERAL_SKIP_CONSENT_NOTICE", "").strip() == "1":
return False
marker = read_consent()
if marker is None:
return True
return int(marker.get("policy_version", 0)) < POLICY_VERSION


def notify_if_first_run(send_mcp_notification: Callable[[str, str], Any] | None = None) -> None:
"""Emit the first-boot notice once and stamp the marker. Never raises.

``send_mcp_notification`` is a callable taking (severity, message).
When provided and a session is active, the notice surfaces in the
user's MCP client (Claude Code, etc.). stderr mirror covers headless
contexts and provides a record either way.
"""
try:
if not _should_notify():
return
# Surface to MCP client if available.
if send_mcp_notification is not None:
try:
send_mcp_notification("info", _NOTICE_TEXT)
except Exception as exc:
logger.debug("[consent] MCP notification failed: %s", exc)
# Stderr mirror — always.
print(_NOTICE_TEXT, file=sys.stderr, flush=True)
# Stamp marker so we don't repeat. Default = enabled (matches
# current opt-out posture); user changes via wizard or env var.
try:
write_consent(telemetry=True, via="first_boot_notice")
except OSError as exc:
logger.debug("[consent] marker write failed: %s", exc)
except Exception as exc:
logger.debug("[consent] notify_if_first_run failed: %s", exc)
12 changes: 12 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@
- [ ] [B3] Issue #61 — CodeGenome Phase 4 semantic drift evaluation in
`resolve_compliance`. Depends on #59; recommended after #60.

- [ ] [B5] Event-sourced ledger RFC — append-only event log with
SurrealDB/SQLite as a rebuildable projection. Tracked as Issue #97.
v1.0.0 candidate; load-bearing iff multi-machine/team sync enters
the roadmap. We already get partial event-sourcing today via the
META_LEDGER chain and the `compliance_check` CHANGEFEED (Phase 4 /
#61); the RFC asks whether to extend that pattern to all
mutation-bearing tables. Cheap v0.14.0 wedge proposed in the issue:
extend `CHANGEFEED 30d INCLUDE ORIGINAL` to `code_subject`,
`subject_identity`, `binds_to`, `code_region` without committing
to the full rewrite. Decision blocked on Jin's call about team
sync as a v1.0.0 goal.

## Wishlist (Nice to Have)
<!-- Format: - [ ] [W#] Description -->
- [ ] [W1] Section-4 razor enforcement on legacy oversized files
Expand Down
50 changes: 50 additions & 0 deletions events/materializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,56 @@ async def replay_new_events(self, inner_adapter) -> int:
payload.get("commit_hash", ""), payload.get("repo_path", ""),
)
replayed += 1
elif etype == "decision_ratified.completed":
# Resolve canonical_id → local decision_id; the
# event was emitted by a peer whose local
# decision_id is meaningless in this DB.
from ledger.queries import find_decision_by_canonical_id

local_id = await find_decision_by_canonical_id(
inner_adapter._client,
payload.get("canonical_id", ""),
)
if local_id is None:
logger.warning(
"[materializer] skipping decision_ratified — "
"canonical_id %r not found locally (ingest event missing or out-of-order)",
payload.get("canonical_id"),
)
continue
await inner_adapter.apply_ratify(
local_id,
payload.get("signoff", {}),
)
replayed += 1
elif etype == "decision_superseded.completed":
from ledger.queries import find_decision_by_canonical_id

local_new = await find_decision_by_canonical_id(
inner_adapter._client,
payload.get("new_canonical_id", ""),
)
local_old = await find_decision_by_canonical_id(
inner_adapter._client,
payload.get("old_canonical_id", ""),
)
if local_new is None or local_old is None:
logger.warning(
"[materializer] skipping decision_superseded — "
"canonical_id resolution failed (new=%r old=%r)",
payload.get("new_canonical_id"),
payload.get("old_canonical_id"),
)
continue
await inner_adapter.apply_supersede(
new_id=local_new,
old_id=local_old,
signer=payload.get("signer", ""),
signoff_note=payload.get("signoff_note", ""),
superseded_at=payload.get("superseded_at", ""),
session_id=payload.get("session_id", ""),
)
replayed += 1
new_offsets[author] = f.tell()
Comment on lines +97 to 147

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don’t advance watermark past unresolved canonical-ID events.

When canonical lookup fails, the event is skipped but the file offset still advances. That permanently drops replay for out-of-order dependencies.

🔧 Suggested approach (stop at first unresolved dependent event)
-            with open(path, "rb") as f:
-                f.seek(start)
-                for raw in f:
+            with open(path, "rb") as f:
+                f.seek(start)
+                while True:
+                    line_pos = f.tell()
+                    raw = f.readline()
+                    if not raw:
+                        break
                     try:
                         event = json.loads(raw)
                     except json.JSONDecodeError:
                         continue
...
-                        if local_id is None:
+                        if local_id is None:
                             logger.warning(...)
-                            continue
+                            f.seek(line_pos)
+                            break
...
-                        if local_new is None or local_old is None:
+                        if local_new is None or local_old is None:
                             logger.warning(...)
-                            continue
+                            f.seek(line_pos)
+                            break
...
                 new_offsets[author] = f.tell()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@events/materializer.py` around lines 97 - 147, The code advances the
per-author watermark (new_offsets[author] = f.tell()) even when canonical-ID
resolution fails for events like "decision_ratified.completed" and
"decision_superseded.completed", which causes permanently dropped replay;
instead, when find_decision_by_canonical_id returns None for either
local_id/local_new/local_old, stop processing further events for that author (do
not "continue" past the unresolved dependent event) and leave
new_offsets[author] pointing to the file position before the unresolved event so
the event can be retried later; modify the handling in the branches that use
find_decision_by_canonical_id (the decision_ratified.completed and
decision_superseded.completed cases), replacing the current continue behavior
with logic that records the current file offset (use f.tell() or the saved
offset before reading the event) and breaks out of the per-author processing
loop so apply_ratify/apply_supersede are only called when canonical resolution
succeeds.


if new_offsets != offsets:
Expand Down
Loading
Loading