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" + )