diff --git a/.claude/settings.json b/.claude/settings.json
index 36c4619f..2570fabd 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -25,7 +25,7 @@
"hooks": [
{
"type": "command",
- "command": "[ -d .bicameral ] && [ -z \"$BICAMERAL_SESSION_END_RUNNING\" ] && BICAMERAL_SESSION_END_RUNNING=1 claude -p '/bicameral:capture-corrections --auto-ingest' || true"
+ "command": "[ -d .bicameral ] && [ -z \"$BICAMERAL_SESSION_END_RUNNING\" ] && BICAMERAL_SESSION_END_RUNNING=1 claude -p '/bicameral-capture-corrections --auto-ingest' || true"
}
]
}
diff --git a/README.md b/README.md
index 9037ff6d..622b4b91 100644
--- a/README.md
+++ b/README.md
@@ -195,7 +195,7 @@ Running `bicameral-mcp setup` writes these files to your repo:
| `.gitignore` entry | Ignores `.bicameral/` in solo mode | Recommended |
| `.claude/settings.json` | PostToolUse hook: auto-calls `bicameral.link_commit` after git commits | Optional — improves sync |
| `.claude/settings.json` | SessionEnd hook: runs `bicameral-capture-corrections` to catch uningested mid-session decisions | Optional — closes correction capture gap |
-| `.claude/skills/bicameral-*/SKILL.md` | Slash commands (`/bicameral:ingest`, `/bicameral:preflight`, `/bicameral:capture-corrections`, etc.) | Recommended |
+| `.claude/skills/bicameral-*/SKILL.md` | Slash commands (`/bicameral-ingest`, `/bicameral-preflight`, `/bicameral-capture-corrections`, etc.) | Recommended |
### Removing Bicameral
@@ -227,11 +227,11 @@ After setup, Claude Code gets these slash commands:
| Command | When to use |
|---|---|
-| `/bicameral:ingest` | Paste a transcript, PRD, or Slack thread to track its decisions |
-| `/bicameral:preflight` | Surface relevant decisions and drift before implementing |
-| `/bicameral:history` | See all tracked decisions grouped by feature area |
-| `/bicameral:dashboard` | Open the live decision dashboard in your browser |
-| `/bicameral:reset` | Wipe and replay the ledger (emergency use) |
+| `/bicameral-ingest` | Paste a transcript, PRD, or Slack thread to track its decisions |
+| `/bicameral-preflight` | Surface relevant decisions and drift before implementing |
+| `/bicameral-history` | See all tracked decisions grouped by feature area |
+| `/bicameral-dashboard` | Open the live decision dashboard in your browser |
+| `/bicameral-reset` | Wipe and replay the ledger (emergency use) |
The agent also fires these automatically — `preflight` before any code change, `ingest` when you paste a document.
diff --git a/assets/git-for-specs-deck.html b/assets/git-for-specs-deck.html
index bc8fa631..dac7483a 100644
--- a/assets/git-for-specs-deck.html
+++ b/assets/git-for-specs-deck.html
@@ -169,7 +169,7 @@
What This Looks Like in Practice
Symbol: PaymentProcessor.charge
Evidence: PayPal import added
- Commit blocked. Resolve with /bicameral:supersede or --no-verify
+ Commit blocked. Resolve with /bicameral-supersede or --no-verify
Decision context shared via version control, allowing for organic cross-functional context-sharing and alignment
diff --git a/docs/DEV_CYCLE.md b/docs/DEV_CYCLE.md
index 3ece53fe..e1427dad 100644
--- a/docs/DEV_CYCLE.md
+++ b/docs/DEV_CYCLE.md
@@ -64,7 +64,7 @@ Observable evidence that a real user / agent / contributor stubbed their
toe on something that should "just work." Symptoms, not fixes.
Examples:
-- Slack thread from a design partner showing `claude -p '/bicameral:sync'`
+- Slack thread from a design partner showing `claude -p '/bicameral-sync'`
exiting silently (#124).
- Dashboard footage of a mid-session constraint orphaning as a parallel
decision instead of linking to its parent.
diff --git a/pyproject.toml b/pyproject.toml
index ed11c902..29213226 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,6 +50,7 @@ test = [
"tiktoken>=0.7.0,<1.0.0",
"ruff>=0.5.0",
"mypy>=1.10.0",
+ "build>=1.0.0",
]
[project.scripts]
@@ -70,6 +71,13 @@ exclude = [
]
artifacts = ["skills/**/*.md", "skills/**/*.yaml"]
+# `force-include` ships skill source unconditionally, regardless of Hatchling's
+# package-discovery rules. Required because `skills/` has no `__init__.py` and
+# `artifacts` alone does not bundle these files into the wheel (verified empty
+# pre-fix). Locked by tests/test_installer_packaging.py.
+[tool.hatch.build.targets.wheel.force-include]
+"skills" = "skills"
+
[tool.ruff]
line-length = 100
target-version = "py311"
diff --git a/scripts/hooks/post_commit_sync_reminder.py b/scripts/hooks/post_commit_sync_reminder.py
index ed610472..ed136abd 100644
--- a/scripts/hooks/post_commit_sync_reminder.py
+++ b/scripts/hooks/post_commit_sync_reminder.py
@@ -2,7 +2,7 @@
When the agent runs ``git commit`` / ``git merge`` / ``git pull`` /
``git rebase --continue``, inject a system-reminder telling the agent to
-call ``/bicameral:sync`` so the decision ledger picks up the new HEAD,
+call ``/bicameral-sync`` so the decision ledger picks up the new HEAD,
runs compliance checks, and produces authoritative reflected/drifted
verdicts before the next user turn.
@@ -12,7 +12,7 @@
from PostToolUse hooks is silently dropped to the debug log — only
UserPromptSubmit / UserPromptExpansion / SessionStart treat raw stdout
as agent-visible context. Symptom: the agent committed but never
-followed through to call ``link_commit`` / ``/bicameral:sync`` because
+followed through to call ``link_commit`` / ``/bicameral-sync`` because
the reminder never reached the model. Fix: emit the structured
envelope ``{"hookSpecificOutput": {"hookEventName": "PostToolUse",
"additionalContext": "..."}}``.
@@ -33,7 +33,7 @@
BASH_TOOL_NAME = "Bash"
# Substrings that mark a git write-op against HEAD that the agent should
-# follow up with /bicameral:sync. Exact phrasing matches the legacy
+# follow up with /bicameral-sync. Exact phrasing matches the legacy
# inline command's tuple so behavior is byte-identical except for the
# stdout envelope.
WRITE_OP_MARKERS: tuple[str, ...] = (
@@ -44,7 +44,7 @@
)
REMINDER_TEXT = (
- "bicameral: new commit detected — run /bicameral:sync to resolve "
+ "bicameral: new commit detected — run /bicameral-sync to resolve "
"compliance and get authoritative reflected/drifted status"
)
diff --git a/server.py b/server.py
index 9502ebc2..902e1535 100644
--- a/server.py
+++ b/server.py
@@ -133,7 +133,7 @@ async def list_tools() -> list[Tool]:
"Sync a commit into the decision ledger. Updates implemented_by/touches edges, "
"recomputes content hashes, re-evaluates drift for affected decisions. "
"Idempotent — calling twice for the same commit is a no-op. "
- "Slash alias: /bicameral:link-commit"
+ "Slash alias: /bicameral-link-commit"
),
inputSchema={
"type": "object",
@@ -169,7 +169,7 @@ async def list_tools() -> list[Tool]:
"At least one text field per decision must be non-empty or the decision is silently dropped. "
"The `query` field drives the post-ingest auto-brief and gap-judge chain — always pass it. "
"Auto-grounds decisions to code via semantic search over the symbol graph. Ensures the code index is fresh before grounding. "
- "Slash alias: /bicameral:ingest"
+ "Slash alias: /bicameral-ingest"
),
inputSchema={
"type": "object",
@@ -202,7 +202,7 @@ async def list_tools() -> list[Tool]:
"Pass start_line/end_line when you have exact lines (e.g. from a Read call) — "
"omit them to let the server resolve the exact line range automatically. Binding the same "
"(decision, region) pair twice is idempotent. "
- "Slash alias: /bicameral:bind"
+ "Slash alias: /bicameral-bind"
),
inputSchema={
"type": "object",
@@ -288,7 +288,7 @@ async def list_tools() -> list[Tool]:
"before confirming full mode. "
"DRY RUN BY DEFAULT — confirm=false returns the wipe plan without touching anything. "
"Pass confirm=true to actually wipe. "
- "Slash alias: /bicameral:reset"
+ "Slash alias: /bicameral-reset"
),
inputSchema={
"type": "object",
@@ -332,7 +332,7 @@ async def list_tools() -> list[Tool]:
"When fired=false, the agent MUST produce no output and proceed silently — "
"that's the trust contract. When fired=true, render the surfaced context with "
"a '(bicameral surfaced)' attribution before continuing with the implementation. "
- "Slash alias: /bicameral:preflight"
+ "Slash alias: /bicameral-preflight"
),
inputSchema={
"type": "object",
@@ -389,7 +389,7 @@ async def list_tools() -> list[Tool]:
"standalone. Rubric categories: missing_acceptance_criteria, "
"underdefined_edge_cases, infrastructure_gap, underspecified_integration, "
"missing_data_requirements. "
- "Slash alias: /bicameral:judge_gaps"
+ "Slash alias: /bicameral-judge_gaps"
),
inputSchema={
"type": "object",
@@ -427,7 +427,7 @@ async def list_tools() -> list[Tool]:
"decision/region IDs are returned as structured rejections (not "
"exceptions) so the caller can retry the accepted subset. The server "
"never calls an LLM — every semantic judgment lives in the caller's "
- "session. Slash alias: /bicameral:resolve_compliance"
+ "session. Slash alias: /bicameral-resolve_compliance"
),
inputSchema={
"type": "object",
@@ -506,7 +506,7 @@ async def list_tools() -> list[Tool]:
"as a negative signal; agents consult it to avoid implementing what the team rejected. "
"Both actions are idempotent (was_new=false if already in that state). "
"The signer field identifies the human or agent; the optional note captures the rationale. "
- "Slash alias: /bicameral:ratify"
+ "Slash alias: /bicameral-ratify"
),
inputSchema={
"type": "object",
@@ -554,7 +554,7 @@ async def list_tools() -> list[Tool]:
"Writes an input_span→context_for→decision edge (confirmed or rejected). "
"Context-pending decisions with ≥1 confirmed context_for edge become eligible "
"for bicameral.ratify. "
- "Slash alias: /bicameral:resolve-collision"
+ "Slash alias: /bicameral-resolve-collision"
),
inputSchema={
"type": "object",
@@ -597,7 +597,7 @@ async def list_tools() -> list[Tool]:
"Capped at 50 features; use feature_filter to drill in when truncated=True. "
"Does NOT fire on implementation, ingest, or drift-specific queries — use "
"bicameral.preflight or bicameral.ingest for those. "
- "Slash alias: /bicameral:history"
+ "Slash alias: /bicameral-history"
),
inputSchema={
"type": "object",
@@ -629,7 +629,7 @@ async def list_tools() -> list[Tool]:
"Subsequent calls return the existing URL immediately — the server "
"is a singleton and stays running for the session. "
"Fires on: 'open dashboard', 'show live history', 'launch dashboard'. "
- "Slash alias: /bicameral:dashboard"
+ "Slash alias: /bicameral-dashboard"
),
inputSchema={
"type": "object",
diff --git a/setup_wizard.py b/setup_wizard.py
index 246ab69f..d26102b7 100644
--- a/setup_wizard.py
+++ b/setup_wizard.py
@@ -165,22 +165,30 @@ def _select_agents() -> list[str]:
return selected
+class RunnerNotFoundError(RuntimeError):
+ """Raised when no viable runner for bicameral-mcp is on PATH.
+
+ There is no `bicameral_mcp` package — the entry point is `server:cli_main` —
+ so `python -m bicameral_mcp` is not a valid fallback.
+ """
+
+
def _detect_runner() -> tuple[str, list[str]]:
"""Detect the best available runner for bicameral-mcp.
- Preference order:
- 1. bicameral-mcp binary on PATH — uses the actual installed environment,
- so local subpackages (dashboard/, etc.) and editable installs work.
- 2. python3 -m bicameral_mcp — fallback for source checkouts / venvs.
+ Only one runner is supported: the `bicameral-mcp` script installed by
+ `pip install bicameral-mcp` (or an editable install in a venv). pipx run
+ is intentionally NOT used: it downloads a fresh ephemeral copy from PyPI
+ on every server start, missing local-only modules and risking version skew.
- pipx run is intentionally NOT used: it downloads a fresh ephemeral copy
- from PyPI on every server start, which misses local-only modules and can
- run a different version than what the user installed.
+ The previous `python -m bicameral_mcp` fallback was removed because there
+ is no `bicameral_mcp` package; the resulting MCP config was non-functional.
"""
if shutil.which("bicameral-mcp"):
return ("bicameral-mcp", [])
- python = "python3" if shutil.which("python3") else "python"
- return (python, ["-m", "bicameral_mcp"])
+ raise RunnerNotFoundError(
+ "No runner found for bicameral-mcp. Install with: pip install bicameral-mcp"
+ )
def _build_config(
@@ -384,7 +392,7 @@ def _build_session_end_command(mcp_config_path: str | None = None) -> str:
return (
'[ -d .bicameral ] && [ -z "$BICAMERAL_SESSION_END_RUNNING" ] && '
"BICAMERAL_SESSION_END_RUNNING=1 "
- f"claude -p '/bicameral:capture-corrections --auto-ingest'{extra_flags} || true"
+ f"claude -p '/bicameral-capture-corrections --auto-ingest'{extra_flags} || true"
)
@@ -396,7 +404,7 @@ def _build_session_end_command(mcp_config_path: str | None = None) -> str:
# Fires after every Bash tool use. When the command is a git write-op
# (commit / merge / pull / rebase --continue), emits a hookSpecificOutput
# envelope whose additionalContext nudges the agent to invoke
-# /bicameral:sync — running the full link_commit → compliance check
+# /bicameral-sync — running the full link_commit → compliance check
# flow so status is authoritative immediately.
#
# Was a plain-stdout python -c one-liner. Per Claude Code 2.x hook docs
@@ -630,6 +638,9 @@ def _install_skills(repo_path: Path) -> int:
"""Copy skill definitions into .claude/skills/ in the target repo."""
skills_src = Path(__file__).parent / "skills"
if not skills_src.exists():
+ print(f" WARNING: skill source not found at {skills_src}")
+ print(" Skills were not installed. The wheel may have been built without skills/.")
+ print(" Re-install bicameral-mcp from a recent release, or report this bug.")
return 0
skills_dst = repo_path / ".claude" / "skills"
@@ -867,11 +878,11 @@ def run_setup(
agents = _select_agents()
# Step 3: Runner check
- command, _ = _detect_runner()
- if command not in ("bicameral-mcp",):
- print("\n Note: bicameral-mcp binary not found on PATH.")
- print(f" Using '{command} -m bicameral_mcp' as runner.")
- print(" Install for a cleaner setup: pip install bicameral-mcp")
+ try:
+ _detect_runner()
+ except RunnerNotFoundError as e:
+ print(f"\n ERROR: {e}")
+ return 1
# Step 4: Collaboration mode + guided intensity + telemetry + gitignore
collab_mode = _select_collaboration_mode()
@@ -894,8 +905,7 @@ def run_setup(
# Step 6: Install skills + hooks (Claude Code only)
if "claude" in agents:
num_skills = _install_skills(repo_path)
- if num_skills:
- print(f" Claude Code: installed {num_skills} slash commands")
+ print(f" Claude Code: installed {num_skills} skill(s) at {repo_path}/.claude/skills/")
if _install_claude_hooks(repo_path):
print(
" Claude Code: installed hooks → link_commit on commit · capture-corrections on session end"
@@ -927,11 +937,11 @@ def run_setup(
if "claude" in agents:
print(" Claude Code slash commands:")
- print(" /bicameral:ingest — ingest a transcript, Slack thread, or PRD")
- print(" /bicameral:preflight — pre-flight: surface decisions before coding")
- print(" /bicameral:history — list all tracked decisions by feature area")
- print(" /bicameral:dashboard — open live decision dashboard in browser")
- print(" /bicameral:reset — nuke and replay the ledger (emergency)")
+ print(" /bicameral-ingest — ingest a transcript, Slack thread, or PRD")
+ print(" /bicameral-preflight — pre-flight: surface decisions before coding")
+ print(" /bicameral-history — list all tracked decisions by feature area")
+ print(" /bicameral-dashboard — open live decision dashboard in browser")
+ print(" /bicameral-reset — nuke and replay the ledger (emergency)")
print()
print(" Or just ask naturally:")
diff --git a/skills/bicameral-capture-corrections/SKILL.md b/skills/bicameral-capture-corrections/SKILL.md
index b4803a31..dbf2b9c3 100644
--- a/skills/bicameral-capture-corrections/SKILL.md
+++ b/skills/bicameral-capture-corrections/SKILL.md
@@ -146,7 +146,7 @@ capture-corrections output.
## SessionEnd batch mode
Fires via the `SessionEnd` hook in `.claude/settings.json`. Also invocable
-manually as `/bicameral:capture-corrections`.
+manually as `/bicameral-capture-corrections`.
### Steps
@@ -235,7 +235,7 @@ user's project `.claude/settings.json`. No manual configuration needed.
Command written by the setup wizard:
```
-[ -d .bicameral ] && [ -z "$BICAMERAL_SESSION_END_RUNNING" ] && BICAMERAL_SESSION_END_RUNNING=1 claude -p '/bicameral:capture-corrections --auto-ingest' || true
+[ -d .bicameral ] && [ -z "$BICAMERAL_SESSION_END_RUNNING" ] && BICAMERAL_SESSION_END_RUNNING=1 claude -p '/bicameral-capture-corrections --auto-ingest' || true
```
Two guards:
diff --git a/skills/bicameral-config/SKILL.md b/skills/bicameral-config/SKILL.md
index 337c699e..5f0bb561 100644
--- a/skills/bicameral-config/SKILL.md
+++ b/skills/bicameral-config/SKILL.md
@@ -1,6 +1,6 @@
-# /bicameral:config — Interactive Configuration
+# /bicameral-config — Interactive Configuration
-**Trigger**: user types `/bicameral:config`
+**Trigger**: user types `/bicameral-config`
Walk through each bicameral configuration setting interactively, write the
updated `config.yaml`, and reinstall all hooks so changes take effect
diff --git a/skills/bicameral-preflight/SKILL.md b/skills/bicameral-preflight/SKILL.md
index 0b0bc5d6..82a0f4d4 100644
--- a/skills/bicameral-preflight/SKILL.md
+++ b/skills/bicameral-preflight/SKILL.md
@@ -244,7 +244,7 @@ so you can see what your branch changes relative to main.
### 3.5 Scan recent user turns for uningested corrections
Before classifying server-returned findings, invoke
-`/bicameral:capture-corrections` in **in-session mode**:
+`/bicameral-capture-corrections` in **in-session mode**:
```
Skill("bicameral:capture-corrections", args="--mode in-session")
diff --git a/skills/bicameral-reset/SKILL.md b/skills/bicameral-reset/SKILL.md
index f06f9025..0baf130b 100644
--- a/skills/bicameral-reset/SKILL.md
+++ b/skills/bicameral-reset/SKILL.md
@@ -25,7 +25,7 @@ The fail-safe valve. When the ledger gets polluted — bad ingest, stale groundi
## When NOT to fire
- **Never fire automatically.** Reset is always user-initiated.
-- Drift reports that look wrong → run `/bicameral:sync` first, escalate to reset only if that doesn't help.
+- Drift reports that look wrong → run `/bicameral-sync` first, escalate to reset only if that doesn't help.
- If only one ingest looks bad, suggest re-running that ingest rather than wiping everything.
## The two-call pattern (always)
diff --git a/skills/bicameral-sync/SKILL.md b/skills/bicameral-sync/SKILL.md
index 590dab9b..3119ea35 100644
--- a/skills/bicameral-sync/SKILL.md
+++ b/skills/bicameral-sync/SKILL.md
@@ -1,6 +1,6 @@
---
name: bicameral-sync
-description: Full ledger sync after a git COMMIT — runs bicameral.link_commit then evaluates pending compliance checks to write reflected/drifted verdicts. ONLY for post-commit ledger sync. DO NOT trigger for "update", "upgrade", or "new version" requests — those belong to /bicameral:update (binary upgrade). Trigger on: PostToolUse hook "bicameral: new commit detected", _sync_guidance in any tool response, or explicit "sync", "check compliance", "reflect this commit".
+description: Full ledger sync after a git COMMIT — runs bicameral.link_commit then evaluates pending compliance checks to write reflected/drifted verdicts. ONLY for post-commit ledger sync. DO NOT trigger for "update", "upgrade", or "new version" requests — those belong to /bicameral-update (binary upgrade). Trigger on: PostToolUse hook "bicameral: new commit detected", _sync_guidance in any tool response, or explicit "sync", "check compliance", "reflect this commit".
---
# Bicameral Sync
@@ -21,7 +21,7 @@ reads each changed region, evaluates it against the stored decision, and writes
- Explicitly: *"sync the ledger"*, *"check compliance after that commit"*, *"what's the status now?"*
**Never fire for**: "update", "upgrade", "new version", "install update" — those are binary
-upgrade requests; use `/bicameral:update` instead.
+upgrade requests; use `/bicameral-update` instead.
## Telemetry
diff --git a/skills/bicameral-update/SKILL.md b/skills/bicameral-update/SKILL.md
index 13df67fa..1f5fea3a 100644
--- a/skills/bicameral-update/SKILL.md
+++ b/skills/bicameral-update/SKILL.md
@@ -1,6 +1,6 @@
---
name: bicameral-update
-description: Check for and apply a new bicameral-mcp binary release. Upgrades the pip package, reinstalls skills and Claude hooks. NOTHING to do with git commits or ledger sync — those are handled by /bicameral:sync. Trigger on any user request containing "update", "upgrade", "new version", "latest version", or "install update".
+description: Check for and apply a new bicameral-mcp binary release. Upgrades the pip package, reinstalls skills and Claude hooks. NOTHING to do with git commits or ledger sync — those are handled by /bicameral-sync. Trigger on any user request containing "update", "upgrade", "new version", "latest version", or "install update".
---
# Bicameral Update
@@ -8,7 +8,7 @@ description: Check for and apply a new bicameral-mcp binary release. Upgrades th
Check for a new `bicameral-mcp` release and apply it.
**This skill is about upgrading the installed binary.** It has nothing to do
-with git commits, ledger sync, or compliance checks — those are `/bicameral:sync`.
+with git commits, ledger sync, or compliance checks — those are `/bicameral-sync`.
## Telemetry
diff --git a/tests/e2e/_harness_setup.py b/tests/e2e/_harness_setup.py
index 036358f8..d8031b3d 100644
--- a/tests/e2e/_harness_setup.py
+++ b/tests/e2e/_harness_setup.py
@@ -75,7 +75,7 @@ def materialize_settings_with_hooks(
ingest(agent_session) + resolve_collision call so the agent
captures user refinements that contradict surfaced decisions.
- SessionEnd: spawns a subprocess running
- ``/bicameral:capture-corrections --auto-ingest`` (with the test
+ ``/bicameral-capture-corrections --auto-ingest`` (with the test
MCP config) to scan the just-ended session for uningested
mid-session corrections.
- UserPromptSubmit: deterministic verb-list classifier injects a
diff --git a/tests/e2e/run_e2e_flows.py b/tests/e2e/run_e2e_flows.py
index c7858abd..61465be7 100644
--- a/tests/e2e/run_e2e_flows.py
+++ b/tests/e2e/run_e2e_flows.py
@@ -1003,7 +1003,7 @@ def assert_flow_4(calls: list[dict]) -> tuple[bool, str]:
dedup (Step C ran — the rubric processed the markers and just
classified the constraint as ``ask`` instead of mechanical).
- The SessionEnd hook spawns ``/bicameral:capture-corrections`` as a
+ The SessionEnd hook spawns ``/bicameral-capture-corrections`` as a
SEPARATE subprocess; its tool calls are NOT visible in this stream-json.
That out-of-band path is the realistic production behaviour and is
validated by querying the ledger after the harness completes — not
diff --git a/tests/test_installer_packaging.py b/tests/test_installer_packaging.py
new file mode 100644
index 00000000..94e0533f
--- /dev/null
+++ b/tests/test_installer_packaging.py
@@ -0,0 +1,71 @@
+"""Wheel-packaging contract: ship the skill source tree.
+
+Catches regressions where pyproject.toml drops `skills/` from the wheel.
+The bug was silent: pre-fix, the wheel built cleanly with zero skill members
+because `packages = ["."]` does not bundle a directory without `__init__.py`,
+and the `artifacts` directive proved insufficient on its own.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+import zipfile
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+SKILLS_SRC = REPO_ROOT / "skills"
+
+
+def _expected_skill_members() -> list[str]:
+ """Enumerate every skills//SKILL.md present in the source tree.
+
+ Built dynamically so the assertion stays correct as skills are added or
+ removed; pre-fix this would fail because the wheel had zero skill members.
+ """
+ members: list[str] = []
+ for skill_dir in sorted(SKILLS_SRC.iterdir()):
+ if not skill_dir.is_dir():
+ continue
+ skill_md = skill_dir / "SKILL.md"
+ if skill_md.exists():
+ members.append(f"skills/{skill_dir.name}/SKILL.md")
+ return members
+
+
+@pytest.fixture(scope="module")
+def built_wheel(tmp_path_factory) -> Path:
+ out_dir = tmp_path_factory.mktemp("wheel-out")
+ subprocess.run(
+ [sys.executable, "-m", "build", "--wheel", "--outdir", str(out_dir)],
+ cwd=str(REPO_ROOT),
+ check=True,
+ capture_output=True,
+ )
+ wheels = list(out_dir.glob("bicameral_mcp-*.whl"))
+ assert len(wheels) == 1, f"expected 1 wheel, got {wheels}"
+ return wheels[0]
+
+
+def test_wheel_bundles_all_skill_sources(built_wheel: Path):
+ expected = _expected_skill_members()
+ assert expected, "no SKILL.md files found in skills/; nothing to assert"
+ with zipfile.ZipFile(built_wheel) as zf:
+ names = set(zf.namelist())
+ missing = [m for m in expected if m not in names]
+ assert not missing, (
+ f"wheel missing {len(missing)} skill member(s): {missing[:5]}; "
+ "ensure pyproject.toml force-includes skills/"
+ )
+
+
+def test_wheel_skill_md_is_non_empty(built_wheel: Path):
+ expected = _expected_skill_members()
+ sample = expected[0]
+ with zipfile.ZipFile(built_wheel) as zf:
+ with zf.open(sample) as f:
+ content = f.read().decode("utf-8")
+ assert "name:" in content, f"{sample} has no `name:` frontmatter; possible truncation"
+ assert len(content) > 100, f"{sample} suspiciously small ({len(content)} bytes)"
diff --git a/tests/test_post_commit_sync_hook.py b/tests/test_post_commit_sync_hook.py
index bd96d44f..d20215c1 100644
--- a/tests/test_post_commit_sync_hook.py
+++ b/tests/test_post_commit_sync_hook.py
@@ -62,7 +62,7 @@ def test_emits_reminder_on_git_commit():
inner = _hook_output(json.loads(out))
ctx = inner["additionalContext"]
assert "bicameral: new commit detected" in ctx
- assert "/bicameral:sync" in ctx
+ assert "/bicameral-sync" in ctx
def test_emits_reminder_on_git_merge():
diff --git a/tests/test_session_end_hook_drift.py b/tests/test_session_end_hook_drift.py
index a850e1fb..8dd8fe8e 100644
--- a/tests/test_session_end_hook_drift.py
+++ b/tests/test_session_end_hook_drift.py
@@ -10,7 +10,7 @@
[ -d .bicameral ] && [ -z "$BICAMERAL_SESSION_END_RUNNING" ] && \
BICAMERAL_SESSION_END_RUNNING=1 \
- claude -p '/bicameral:capture-corrections --auto-ingest' || true
+ claude -p '/bicameral-capture-corrections --auto-ingest' || true
"""
from __future__ import annotations
@@ -26,7 +26,7 @@
CANONICAL_COMMAND = (
'[ -d .bicameral ] && [ -z "$BICAMERAL_SESSION_END_RUNNING" ] && '
"BICAMERAL_SESSION_END_RUNNING=1 "
- "claude -p '/bicameral:capture-corrections --auto-ingest' || true"
+ "claude -p '/bicameral-capture-corrections --auto-ingest' || true"
)
diff --git a/tests/test_setup_wizard.py b/tests/test_setup_wizard.py
new file mode 100644
index 00000000..99778ffe
--- /dev/null
+++ b/tests/test_setup_wizard.py
@@ -0,0 +1,98 @@
+"""Behavioral tests for setup_wizard helpers.
+
+Covers the installer fixes from issue #177:
+- _install_skills warns loudly when source is missing (no silent return)
+- _install_skills copies all skill folders on the happy path
+- _detect_runner returns the bicameral-mcp script when present;
+ raises RunnerNotFoundError when no runner is on PATH (no broken
+ `python -m bicameral_mcp` fallback)
+- run_setup output does not contain the stale `-m bicameral_mcp` runner-note text
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_ROOT))
+
+import setup_wizard # noqa: E402
+
+
+def test_install_skills_warns_when_source_missing(tmp_path, capsys):
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ fake_wizard_dir = tmp_path / "fake-pkg"
+ fake_wizard_dir.mkdir()
+ fake_module_path = fake_wizard_dir / "setup_wizard.py"
+ fake_module_path.write_text("")
+ with patch.object(setup_wizard, "__file__", str(fake_module_path)):
+ count = setup_wizard._install_skills(repo)
+ assert count == 0
+ captured = capsys.readouterr()
+ assert "WARNING" in captured.out
+ assert "skill source" in captured.out
+ assert not (repo / ".claude" / "skills").exists()
+
+
+def test_install_skills_copies_all_skill_dirs(tmp_path):
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ src = tmp_path / "pkg"
+ (src / "skills" / "alpha").mkdir(parents=True)
+ (src / "skills" / "alpha" / "SKILL.md").write_text("---\nname: alpha\n---\n")
+ (src / "skills" / "beta").mkdir(parents=True)
+ (src / "skills" / "beta" / "SKILL.md").write_text("---\nname: beta\n---\n")
+ fake_module_path = src / "setup_wizard.py"
+ fake_module_path.write_text("")
+ with patch.object(setup_wizard, "__file__", str(fake_module_path)):
+ count = setup_wizard._install_skills(repo)
+ assert count == 2
+ assert (repo / ".claude" / "skills" / "alpha" / "SKILL.md").exists()
+ assert (repo / ".claude" / "skills" / "beta" / "SKILL.md").exists()
+
+
+def test_detect_runner_uses_bicameral_mcp_script_when_present():
+ def which(name):
+ return "/usr/local/bin/bicameral-mcp" if name == "bicameral-mcp" else None
+
+ with patch.object(setup_wizard.shutil, "which", side_effect=which):
+ cmd, args = setup_wizard._detect_runner()
+ assert cmd == "bicameral-mcp"
+ assert args == []
+
+
+def test_detect_runner_raises_when_no_runner_available():
+ with patch.object(setup_wizard.shutil, "which", return_value=None):
+ with pytest.raises(setup_wizard.RunnerNotFoundError):
+ setup_wizard._detect_runner()
+
+
+def test_session_end_command_uses_hyphen_slash_command():
+ """Regression guard: the SessionEnd hook command must invoke
+ /bicameral-capture-corrections (folder-name match), not the broken
+ plugin-namespace form /bicameral:capture-corrections. See issue #177."""
+ cmd = setup_wizard._BICAMERAL_SESSION_END_COMMAND
+ assert "/bicameral-capture-corrections" in cmd
+ assert "/bicameral:capture-corrections" not in cmd
+
+
+def test_detect_runner_does_not_return_broken_module_fallback():
+ """Regression guard for issue #177: the previous `python -m bicameral_mcp`
+ fallback produced a non-functional MCP config because no `bicameral_mcp`
+ package exists. The fix raises instead. This test fails if anyone
+ re-introduces a non-script runner."""
+ with patch.object(setup_wizard.shutil, "which", return_value=None):
+ try:
+ cmd, args = setup_wizard._detect_runner()
+ except setup_wizard.RunnerNotFoundError:
+ return
+ # If we got here, _detect_runner returned without raising — that's the bug.
+ pytest.fail(
+ f"_detect_runner returned ({cmd!r}, {args!r}) instead of raising; "
+ "broken `python -m bicameral_mcp` fallback may have been re-introduced"
+ )