-
Notifications
You must be signed in to change notification settings - Fork 1
release: v0.13.5 (triage) — bug fixes + event vocabulary backport (#74, #95, #97, #98, #124) #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
85a5dc7
docs(backlog): B5 — event-sourced ledger RFC (tracks #97) (#98)
Knapp-Kevin b420abc
feat: local telemetry counters + usage_summary + first-boot consent (…
Knapp-Kevin cb682c4
fix(#74): make events.writer cross-platform (POSIX fcntl + Windows ms…
Knapp-Kevin 5f60eed
feat(#97): extend event vocabulary with ratify + supersede emit/replay
jinhongkuan bb76ad5
feat(#124): register link_commit CLI subcommand + harden post-commit …
Knapp-Kevin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
|
|
||
| if new_offsets != offsets: | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 likeCloses:#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