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: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,6 @@ symbol names back to the server.
|---|---|
| `validate_symbols` | Fuzzy-match symbol name hypotheses against the code index |
| `get_neighbors` | 1-hop structural graph traversal (callers, callees, imports) |
| `extract_symbols` | Tree-sitter symbol extraction from a source file |

</details>

Expand Down
2 changes: 1 addition & 1 deletion docs/v0-architecture-current.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Canonical type: `contracts.py` (see `DecisionStatus = Literal["reflected", "drif
- `bicameral.list_unclassified_decisions` / `bicameral.set_decision_level` / `bicameral.evaluate_governance` — decision-tier classification surface

**Code locator** (deterministic primitives, no LLM in path):
- `validate_symbols`, `search_code`, `get_neighbors`, `extract_symbols`
- `validate_symbols`, `get_neighbors`

There is **no internal/external split** at the MCP boundary — every tool is callable from any client. Discipline lives in the skill layer (`skills/*/SKILL.md`), not in the tool surface.

Expand Down
6 changes: 3 additions & 3 deletions handlers/link_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ def _is_ephemeral_commit(commit_hash: str, repo_path: str, authoritative_ref: st

_GROUNDING_INSTRUCTION_UNGROUNDED = (
" For pending_grounding_checks with reason='ungrounded': use your own "
"code search (Grep/Read), then validate_symbols / extract_symbols to "
"confirm the target, then call bicameral.bind with decision_id, "
"file_path, symbol_name, and optionally start_line/end_line."
"code search (Grep/Read), then validate_symbols to confirm the target, "
"then call bicameral.bind with decision_id, file_path, symbol_name, "
"and optionally start_line/end_line."
)

# V1 D1 / Codex pass-12 finding #2: relocation cases (symbol_disappeared)
Expand Down
33 changes: 2 additions & 31 deletions server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Bicameral MCP Server — Bicameral decision ledger + code locator tools.

15 tools:
14 tools:
bicameral.link_commit — heartbeat: sync a commit into the decision ledger
bicameral.ingest — ingest normalized decision/code evidence and advance source cursors
bicameral.update — check for or apply a recommended bicameral-mcp update
Expand All @@ -15,7 +15,6 @@
bicameral.set_decision_level — set decision_level (L1/L2/L3) on a single decision (#77)
validate_symbols — fuzzy-match candidate symbol names against the code index
get_neighbors — 1-hop structural graph traversal around a symbol
extract_symbols — tree-sitter symbol extraction from a source file

Run with: bicameral-mcp (or python server.py) for stdio transport.

Expand Down Expand Up @@ -59,7 +58,7 @@

SERVER_NAME = "bicameral-mcp"

# In-process map of session_id → {t0, rationale} for skill timing.
# In-process map of session_id → {t0} for skill timing.
# Populated by bicameral.skill_begin, consumed by bicameral.skill_end.
_skill_sessions: dict[str, dict] = {}

Expand Down Expand Up @@ -114,7 +113,6 @@ def _resolve_server_version() -> str:
"bicameral.record_bypass",
"validate_symbols",
"get_neighbors",
"extract_symbols",
]

server = Server(SERVER_NAME)
Expand Down Expand Up @@ -662,10 +660,6 @@ async def list_tools() -> list[Tool]:
"type": "string",
"description": "Caller-generated UUID that correlates this begin with the matching skill_end",
},
"rationale": {
"type": "string",
"description": "One-liner for why this skill was triggered (e.g. 'user pasted transcript and said track this'). Used for quality feedback analysis.",
},
},
"required": ["skill_name", "session_id"],
},
Expand Down Expand Up @@ -931,23 +925,6 @@ async def list_tools() -> list[Tool]:
"required": ["symbol_id"],
},
),
Tool(
name="extract_symbols",
description=(
"Extract all symbols (functions, classes) from a source file via static parsing. "
"Returns symbol names, types, and line ranges. No index required."
),
inputSchema={
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or repo-relative path to the source file",
},
},
"required": ["file_path"],
},
),
]


Expand All @@ -963,7 +940,6 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
session_id = arguments["session_id"]
_skill_sessions[session_id] = {
"t0": time.monotonic(),
"rationale": arguments.get("rationale", ""),
}
return [
TextContent(
Expand Down Expand Up @@ -991,7 +967,6 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
raw_diagnostic = arguments.get("diagnostic") or {}
session_data = _skill_sessions.pop(session_id, None)
t0 = session_data["t0"] if session_data else None
rationale = session_data.get("rationale") if session_data else None
duration_ms = int((time.monotonic() - t0) * 1000) if t0 is not None else 0

# Validate diagnostic against the per-skill Pydantic model.
Expand Down Expand Up @@ -1025,7 +1000,6 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
SERVER_VERSION,
diagnostic=diagnostic,
error_class=error_class,
rationale=rationale,
)
response: dict = {
"session_id": session_id,
Expand Down Expand Up @@ -1229,9 +1203,6 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
elif name == "get_neighbors":
data = await asyncio.to_thread(ctx.code_graph.get_neighbors, arguments["symbol_id"])
return [TextContent(type="text", text=json.dumps(data, indent=2))]
elif name == "extract_symbols":
data = await ctx.code_graph.extract_symbols(arguments["file_path"])
return [TextContent(type="text", text=json.dumps(data, indent=2))]
else:
raise ValueError(f"Unknown tool: {name}")

Expand Down
136 changes: 136 additions & 0 deletions setup_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,138 @@ def _install_git_pre_push_hook(repo_path: Path) -> bool:
return True


# Authoritative list of bicameral MCP tools the wizard offers to
# pre-approve at install time. Excludes bicameral_reset (destructive —
# always prompts) and extract_symbols (no longer exposed as an MCP tool).
_BICAMERAL_ALLOW_TOOLS: list[str] = sorted(
[
"mcp__bicameral__bicameral_bind",
"mcp__bicameral__bicameral_dashboard",
"mcp__bicameral__bicameral_feedback",
"mcp__bicameral__bicameral_history",
"mcp__bicameral__bicameral_ingest",
"mcp__bicameral__bicameral_judge_gaps",
"mcp__bicameral__bicameral_link_commit",
"mcp__bicameral__bicameral_preflight",
"mcp__bicameral__bicameral_ratify",
"mcp__bicameral__bicameral_resolve_collision",
"mcp__bicameral__bicameral_resolve_compliance",
"mcp__bicameral__bicameral_skill_begin",
"mcp__bicameral__bicameral_skill_end",
"mcp__bicameral__bicameral_update",
"mcp__bicameral__bicameral_usage_summary",
"mcp__bicameral__get_neighbors",
"mcp__bicameral__validate_symbols",
]
)
_BICAMERAL_DENY_TOOLS: list[str] = ["mcp__bicameral__bicameral_reset"]


def _confirm_permissions_diff(
settings_path: Path, new_allow: list[str], new_deny: list[str]
) -> bool:
"""Show the diff that would be written and ask y/N.

Non-interactive runs (CI, scripted setup) auto-approve. Interactive
runs default to "no" — explicit consent is required to write the
allowlist, since it persists across every Claude Code session on the
machine.
"""
print()
print(" Pre-approve bicameral MCP tools — about to write:")
print(f" {settings_path}")
print()
print(" permissions.allow += [")
for t in new_allow:
print(f' "{t}",')
print(" ]")
if new_deny:
print(" permissions.deny += [")
for t in new_deny:
print(f' "{t}",')
print(" ]")
print()
print(" Only the bicameral MCP tools above are pre-approved.")
print(" Bash, Edit, Write, and every non-bicameral tool still prompt")
print(" for permission every time — this wizard does not auto-approve")
print(" any shell call or file write.")
print()
print(" This writes to your *user-level* ~/.claude/settings.json —")
print(" the project .claude/settings.json is never touched, and you")
print(" can revoke any line by editing the file above.")
print()

if not _is_interactive():
return True

raw = input(" Pre-approve these bicameral MCP tools? [y/N] ").strip().lower()
return raw in ("y", "yes")


def _install_user_permissions_allowlist() -> bool:
"""Pre-approve bicameral MCP tools in ~/.claude/settings.json.

Implements §1 of the v0 productization plan — moves the permission
friction to install time so catch-up flows (pure-read ledger queries
fired from a SessionStart brief) don't pop a prompt for every tool
call. The user explicitly consents once; after that, only writes
(Bash, Edit, Write) prompt.

Scope discipline:
- User-level only. Never writes to project .claude/settings.json —
that file gets committed and dragging permission state into the
repo pollutes every clone.
- Bicameral MCP tools only. No Bash, no Read/Grep/Glob, no
non-bicameral tools. Shell calls keep their per-invocation
permission prompt.
- bicameral_reset is deny-listed, not allow-listed. It wipes the
ledger and must always prompt.

Idempotent — re-running adds only entries the user is missing.
Returns True if anything was written.
"""
settings_path = Path.home() / ".claude" / "settings.json"
settings_path.parent.mkdir(parents=True, exist_ok=True)

existing: dict = {}
if settings_path.exists():
try:
existing = json.loads(settings_path.read_text())
except (json.JSONDecodeError, OSError):
existing = {}

permissions = existing.setdefault("permissions", {})
allow_list = permissions.get("allow")
if not isinstance(allow_list, list):
allow_list = []
permissions["allow"] = allow_list
deny_list = permissions.get("deny")
if not isinstance(deny_list, list):
deny_list = []
permissions["deny"] = deny_list

new_allow = [t for t in _BICAMERAL_ALLOW_TOOLS if t not in allow_list]
new_deny = [t for t in _BICAMERAL_DENY_TOOLS if t not in deny_list]
if not new_allow and not new_deny:
print(" Permissions: bicameral MCP tools already pre-approved — no change")
return False

if not _confirm_permissions_diff(settings_path, new_allow, new_deny):
print(" Permissions: skipped — settings.json untouched")
return False

allow_list.extend(new_allow)
deny_list.extend(new_deny)
settings_path.write_text(json.dumps(existing, indent=2) + "\n")
print(f" Permissions: pre-approved {len(new_allow)} bicameral MCP tool(s) in {settings_path}")
if new_deny:
print(
f" deny-listed {len(new_deny)} destructive tool(s) "
"(reset will always prompt)"
)
return True


def _install_skills(repo_path: Path) -> int:
"""Copy skill definitions into .claude/skills/ in the target repo."""
skills_src = Path(__file__).parent / "skills"
Expand Down Expand Up @@ -909,6 +1041,10 @@ def run_setup(
print(
" Claude Code: installed hooks → link_commit on commit · capture-corrections on session end"
)
# Step 6b — pre-approve bicameral MCP tools at user-level so
# catch-up flows do not spam the user with permission prompts.
# Only bicameral MCP tools; shell calls remain prompted.
_install_user_permissions_allowlist()

# Step 7: Git post-commit hook (Guided mode only)
if guided:
Expand Down
3 changes: 1 addition & 2 deletions skills/bicameral-capture-corrections/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ Two modes:

**At skill start** (before any tool calls):
```
bicameral.skill_begin(skill_name="bicameral-capture-corrections", session_id=<uuid4>,
rationale="<one-liner: why triggered — e.g. 'SessionEnd hook — scanning full transcript' or 'in-session scan via preflight'>")
bicameral.skill_begin(skill_name="bicameral-capture-corrections", session_id=<uuid4>)
```

**At skill end** (after all work is complete):
Expand Down
3 changes: 1 addition & 2 deletions skills/bicameral-history/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ directly.

**At skill start**:
```
bicameral.skill_begin(skill_name="bicameral-history", session_id=<uuid4>,
rationale="<one-liner: e.g. 'user asked to show full decision ledger'>")
bicameral.skill_begin(skill_name="bicameral-history", session_id=<uuid4>)
```

**At skill end**:
Expand Down
3 changes: 1 addition & 2 deletions skills/bicameral-ingest/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ Ingest **implementation-relevant** decisions from a source document into the dec

**At skill start** (before any other tool calls):
```
bicameral.skill_begin(skill_name="bicameral-ingest", session_id=<uuid4>,
rationale="<one-liner: why this triggered — e.g. 'user pasted sprint notes and said track this'>")
bicameral.skill_begin(skill_name="bicameral-ingest", session_id=<uuid4>)
```

**At skill end** (after all work is complete, including ratification):
Expand Down
3 changes: 1 addition & 2 deletions skills/bicameral-preflight/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ evolve the trigger surface; future configurability will deduplicate.

**At skill start** (before any tool calls):
```
bicameral.skill_begin(skill_name="bicameral-preflight", session_id=<uuid4>,
rationale="<one-liner: why triggered — e.g. 'user said implement Stripe webhook handler'>")
bicameral.skill_begin(skill_name="bicameral-preflight", session_id=<uuid4>)
```

**At skill end**:
Expand Down
Loading
Loading