Skip to content

[#279 Phase 1] Pull-based meeting ingestion — sync-and-brief CLI + Granola adapter + SessionStart hook#318

Merged
Knapp-Kevin merged 4 commits into
devfrom
plan/279-phase1-sync-and-brief
May 14, 2026
Merged

[#279 Phase 1] Pull-based meeting ingestion — sync-and-brief CLI + Granola adapter + SessionStart hook#318
Knapp-Kevin merged 4 commits into
devfrom
plan/279-phase1-sync-and-brief

Conversation

@Knapp-Kevin

Copy link
Copy Markdown
Collaborator

Summary

Closes #279 Phase 1 (solo-mode session magic, independent of #277). Delivers the v0 Productization §3 commitment that briefs + drift scans are pre-baked OUTSIDE the agent before Claude sees the first prompt.

Five new surfaces:

  1. `bicameral-mcp sync-and-brief` CLI subcommand — server.py argparse + `cli/sync_and_brief_cli.py`.
  2. SessionStart hook in `setup_wizard.py` (cross-platform POSIX + Windows). Always ends with `exit 0` so it can NEVER block session start. Stderr redirects to `~/.bicameral/hook-errors.log`.
  3. Granola source adapter at `events/sources/granola.py`. Watermark-driven; two-phase commit so a failed ingest doesn't advance the watermark; stdlib `urllib.request` for HTTP (no new dep).
  4. Brief synthesizer (`cli/brief_renderer.py`). Pure function; ≤200 line cap; per-field length caps; respects `signer_email_fallback` from config.
  5. skill + docs — `skills/bicameral-sync-and-brief/SKILL.md` + `docs/policies/sources-config.md` documenting the `sources:` config schema and the `api_key_env` convention.

Prompt-injection isolation

The brief is injected into Claude's pre-session context via the SessionStart hook — that's the explicit goal. So user-sourced content (decision summaries, source refs, drift evidence) becomes a potential prompt-injection vector. Three layers of defense, all pinned by tests:

  • Block-quote data-framing preamble at the top of every brief.
  • Triple-backtick code fences around every user-sourced field. A line containing `IGNORE PRIOR INSTRUCTIONS` is presented as fenced data, not as flowing prose.
  • Fence-break neutralisation: embedded ``` runs in user text are interrupted with a zero-width space so payloads can't break out of their fence.

Audit Pass 1 → Pass 2 deviations (full transparency)

The audit VETOed pass 1 on three findings, all resolved in this PR:

# Finding Resolution
1 Prompt-injection on hook envelope Discipline #6 added; fences + preamble + neutralisation; test pinned
2 Infrastructure: `get_recent_decisions` / `get_unresolved_gaps` do not exist Use `get_all_decisions("all")` + client-side cap; drop gaps section from Phase 1 (future cycle adds it back)
3 `httpx` dependency unjustified Use stdlib `urllib.request`; no new dep
(advisory) `main()` projected >40 LOC Extracted `_run_source` + `_synthesize_brief` helpers

Drift surface uses `bicameral.preflight` (existing) rather than the `bicameral.scan_branch` referenced in the issue text — `handlers/scan_branch.py` does not exist in main; preflight IS the implemented drift surface today. Flagged in plan Open Question 1.

Test plan

  • `python -m pytest tests/test_sources_granola_unit.py -v` — 12 tests
  • `python -m pytest tests/test_brief_renderer.py -v` — 11 tests (incl. prompt-injection canary)
  • `python -m pytest tests/test_sync_and_brief_cli.py -v` — 11 tests (1 skipped on missing mcp[cli] in test env; passes in CI)
  • `python -m pytest tests/test_sessionstart_hook_install.py -v` — 8 tests
  • Manual: `pip install -e .[test]` then `bicameral-mcp sync-and-brief --help`; with a Granola API key + a meeting from today, verify the brief renders correctly and `granola.json` watermark advances only after successful ingest.

Phase 2 (deferred)

Per #279 scope, Phase 2 (team-mode integration via `BackendAdapter.pull_events()` / `push_events()`) was gated on #277. #277 is now CLOSED (merged) so Phase 2 is unblocked — it's a separate follow-up cycle, not part of this PR.

🤖 Generated with Claude Code

…brief)

Closes the v0 Productization §3 solo-mode commitment: a SessionStart hook
runs `bicameral-mcp sync-and-brief` synchronously, the CLI pulls from
configured sources, ingests anything new, runs preflight for drift, and
prints a synthesized brief — all OUTSIDE the agent, before Claude sees
the prompt.

Phase 1 (solo mode, independent of #277):
  - `bicameral-mcp sync-and-brief` CLI subcommand (server.py argparse +
    cli/sync_and_brief_cli.py)
  - SessionStart hook installed by setup_wizard; cross-platform (POSIX +
    Windows shapes), always ends with `exit 0` so it can NEVER block
    session start
  - Granola source adapter (events/sources/granola.py) — first integration
    per the issue. Watermark-driven, two-phase-commit semantics so a
    failed ingest doesn't advance the watermark
  - Brief synthesis (cli/brief_renderer.py): pure function, ≤200 line cap,
    per-field length caps, signer_email_fallback policy respected
  - skills/bicameral-sync-and-brief/SKILL.md + docs/policies/sources-config.md

Prompt-injection isolation (audit Pass 1 finding 1):
  - Brief begins with block-quote data-framing preamble
  - Every user-sourced value (decision summary, source_ref, drift_evidence)
    rendered inside triple-backtick code fences
  - Embedded ``` runs in user text are neutralised so payloads can't break
    out of their fence
  - Pinned by tests/test_brief_renderer.py::
    test_brief_renderer_wraps_user_text_in_code_fences (and 10 others)

Plan deviations from issue text (audit Pass 1 findings 2-3 + Razor advisory):
  - Drift surface uses `bicameral.preflight` (existing) rather than
    `bicameral.scan_branch` (no handler in main codebase)
  - Decisions fetched via `get_all_decisions(filter="all")` + client-side
    cap; `get_recent_decisions(N)` did not exist
  - Gaps section dropped from Phase 1; `get_unresolved_gaps()` did not
    exist. Future cycle adds the query helper + the brief section.
  - Granola HTTP layer uses stdlib `urllib.request` (no new dependency)
    via a `GranolaClient` indirection that lets tests mock transport
  - `cli/sync_and_brief_cli.main()` extracted into `_run_source` +
    `_synthesize_brief` helpers to keep each function ≤ 30 LOC

Security:
  - API key handling: config holds `api_key_env` name, NEVER the key
    itself. Adapter reads from `os.environ` at pull time.
  - Watermarks at `~/.bicameral/source-watermarks/<source>.json` — outside
    repo, outside git. Cannot be checked in.
  - Hook command `exit 0` framing AND stderr-to-log-file redirect.

41 tests pass (12 Granola adapter + 11 brief renderer + 11 CLI + 8 hook
install). The 1 skipped test is the cli_main smoke test which needs
mcp[cli] installed in the test env — passes in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b8864940-4778-47c7-82a9-d83f482986c7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch plan/279-phase1-sync-and-brief

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Knapp-Kevin Knapp-Kevin added flow:feature Standard feature/fix PR targeting BicameralAI/dev (the default flow) P1 High: ship this milestone; user-impacting bug or committed feature feat Feature work or user-visible capability python Pull requests that update python code tool MCP tool or handler surface skill Skill instructions or workflow guidance surface observability Telemetry, diagnostics, and feedback infrastructure labels May 14, 2026
- Remove unused typing.Any from events/sources/granola.py
- Sort imports in tests/test_brief_renderer.py
- Apply ruff format to all 7 new/touched files

41 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+ granola max() type narrowing

- cli/sync_and_brief_cli.py: handle_preflight requires positional 'topic';
  pass 'session-start-brief' as the sync-and-brief sentinel.
- events/sources/granola.py: coerce ended_at_values to list[str] so
  mypy doesn't see Any|None passed to max().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Knapp-Kevin Knapp-Kevin had a problem deploying to recording-approval May 14, 2026 16:48 — with GitHub Actions Failure
@Knapp-Kevin Knapp-Kevin merged commit 0593a30 into dev May 14, 2026
6 of 7 checks passed
Knapp-Kevin added a commit that referenced this pull request May 14, 2026
Wires the existing BackendAdapter interface (shipped in #277, closed) into
the sync-and-brief CLI flow. Phase 2 of #279 — the team-mode complement to
Phase 1's solo-mode session magic (PR #318, merged).

Order invariants:
  1. BEFORE source pull: backend.pull_events() copies every peer's
     <email>.jsonl into the local .bicameral/events/ cache. The
     materializer's existing *.jsonl glob (events/materializer.py:73)
     picks them up alongside the operator's own events. Peer ingest is
     visible in the brief without manual action.

  2. AFTER source ingest succeeds: backend.push_events() uploads each
     local <email>.jsonl to the shared backend. LocalFolderAdapter's
     sha-match skip (events/backends/local_folder.py:44-46) keeps
     re-invocations idempotent.

Failure modes are non-blocking — backend pull/push errors log to stderr
+ ~/.bicameral/cli-errors.log but never block the brief. The hook
wrapper's exit 0 framing makes this invisible to SessionStart users on
a network outage. Solo mode (no team: config) is completely unaffected
— same code path as before.

The brief renderer gains an optional ## Team sync section showing
peer_files_pulled + my_file_pushed. Omitted entirely when team_sync=None
so solo-mode briefs render byte-identically to pre-Phase-2.

Per #205 doctrine — the only operator-facing default in this new path
(no team backend when team: config is absent) is a deterministic gate
at events/backends/__init__.py::get_backend, not a skill-text-only
claim. Backend errors return None safely.

12 tests in tests/test_sync_and_brief_team_mode.py — sociable per
CLAUDE.md (real LocalFolderAdapter, real BicameralContext, real file
system; no backend mocks). The headline two-machine round-trip test
spans Alice's repo → shared remote_root → Bob's repo and verifies
Alice's events land in Bob's cache after his next sync-and-brief.

3 new tests in tests/test_brief_renderer.py covering the Team sync
section (present/absent/zero-counts).

Full Phase 1 + Phase 2 sweep: 56 passed, 1 skipped (mcp[cli] env probe
unchanged).

docs/policies/sources-config.md extended with a "Team backend" section
documenting config schema + failure modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat Feature work or user-visible capability flow:feature Standard feature/fix PR targeting BicameralAI/dev (the default flow) observability Telemetry, diagnostics, and feedback infrastructure P1 High: ship this milestone; user-impacting bug or committed feature python Pull requests that update python code skill Skill instructions or workflow guidance surface tool MCP tool or handler surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant