Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "python3 -c \"import json,sys,re; d=json.load(sys.stdin); c=d.get('tool_input',{}).get('command',''); ops=('git commit','git merge ','git pull','git rebase --continue'); [print('bicameral: git write-op detected — call bicameral.link_commit(commit_hash=\\'HEAD\\') now to sync the decision ledger') for _ in [1] if any(op in c for op in ops)]\""
"command": "python3 scripts/hooks/post_commit_sync_reminder.py"
}
]
}
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ test = [
bicameral-mcp = "server:cli_main"
bicameral-mcp-classify = "cli.classify:main"
bicameral-mcp-preflight-reminder = "scripts.hooks.preflight_reminder:main"
bicameral-mcp-post-commit-sync-reminder = "scripts.hooks.post_commit_sync_reminder:main"

[tool.hatch.build.targets.wheel]
packages = ["."]
Expand Down
82 changes: 82 additions & 0 deletions scripts/hooks/post_commit_sync_reminder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""PostToolUse hook for the ``Bash`` tool — git write-op detector.

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,
runs compliance checks, and produces authoritative reflected/drifted
verdicts before the next user turn.

Replaces the plain-stdout one-liner ``_BICAMERAL_POST_COMMIT_COMMAND``
that previously lived inline in ``setup_wizard.py``. Per Claude Code
2.x hook docs (https://code.claude.com/docs/en/hooks), plain stdout
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
the reminder never reached the model. Fix: emit the structured
envelope ``{"hookSpecificOutput": {"hookEventName": "PostToolUse",
"additionalContext": "..."}}``.

The reminder text preserves the canonical ``"bicameral: new commit
detected"`` phrase — the ``bicameral-sync`` skill watches for that
exact prefix as one of its trigger signals.

Errors are swallowed silently (exit 0, empty response) so a broken
hook never blocks a user.
"""

from __future__ import annotations

import json
import sys

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
# inline command's tuple so behavior is byte-identical except for the
# stdout envelope.
WRITE_OP_MARKERS: tuple[str, ...] = (
"git commit",
"git merge ",
"git pull",
"git rebase --continue",
)

REMINDER_TEXT = (
"bicameral: new commit detected — run /bicameral:sync to resolve "
"compliance and get authoritative reflected/drifted status"
)


def _is_git_write_op(command: str) -> bool:
return any(marker in command for marker in WRITE_OP_MARKERS)


def main() -> int:
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
return 0
if not isinstance(payload, dict):
return 0
if payload.get("tool_name") != BASH_TOOL_NAME:
return 0
tool_input = payload.get("tool_input") or {}
command = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
if not isinstance(command, str) or not _is_git_write_op(command):
return 0
json.dump(
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": REMINDER_TEXT,
}
},
sys.stdout,
)
return 0


if __name__ == "__main__":
sys.exit(main())
25 changes: 13 additions & 12 deletions setup_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,18 +394,19 @@ def _build_session_end_command(mcp_config_path: str | None = None) -> str:
_BICAMERAL_SESSION_END_COMMAND = _build_session_end_command()

# Fires after every Bash tool use. When the command is a git write-op
# (commit / merge / pull / rebase --continue), prints a trigger line that
# causes the agent to invoke /bicameral:sync — running the full
# link_commit → compliance check flow so status is authoritative immediately.
_BICAMERAL_POST_COMMIT_COMMAND = (
'python3 -c "'
"import json,sys; "
"d=json.load(sys.stdin); "
"c=d.get('tool_input',{}).get('command',''); "
"ops=('git commit','git merge ','git pull','git rebase --continue'); "
"[print('bicameral: new commit detected — run /bicameral:sync to resolve compliance and get authoritative reflected/drifted status') "
'for _ in [1] if any(op in c for op in ops)]"'
)
# (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
# flow so status is authoritative immediately.
#
# Was a plain-stdout python -c one-liner. Per Claude Code 2.x hook docs
# (https://code.claude.com/docs/en/hooks), plain stdout from PostToolUse
# is dropped to the debug log — only UserPromptSubmit / UserPromptExpansion
# / SessionStart treat raw stdout as agent-visible context. Symptom: the
# agent committed but never followed through with link_commit because
# the reminder never reached the model. Console script writes the proper
# envelope; source: scripts/hooks/post_commit_sync_reminder.py.
_BICAMERAL_POST_COMMIT_COMMAND = "bicameral-mcp-post-commit-sync-reminder"

# UserPromptSubmit hook: deterministic regex over a verb list elevates
# bicameral.preflight above the agent's default tool-selection priority
Expand Down
135 changes: 135 additions & 0 deletions tests/test_post_commit_sync_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Functionality tests for scripts/hooks/post_commit_sync_reminder.py.

The hook is invoked as a subprocess by Claude Code on every PostToolUse
matching ``Bash``. Tests run it the same way to exercise stdin/stdout
exactly as production does.

Claude Code 2.x requires PostToolUse hook output shaped as
``{"hookSpecificOutput": {"hookEventName": "PostToolUse",
"additionalContext": "..."}}``. Plain stdout from PostToolUse hooks is
silently dropped to the debug log (per
https://code.claude.com/docs/en/hooks). These tests assert against the
envelope shape — anything else is a broken contract.
"""

from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
HOOK_SCRIPT = REPO_ROOT / "scripts" / "hooks" / "post_commit_sync_reminder.py"


def _run_hook(stdin_text: str) -> tuple[int, str, str]:
proc = subprocess.run(
[sys.executable, str(HOOK_SCRIPT)],
input=stdin_text,
capture_output=True,
text=True,
timeout=10,
)
return proc.returncode, proc.stdout, proc.stderr


def _make_stdin(*, tool_name: str = "Bash", command: str = "") -> str:
return json.dumps({"tool_name": tool_name, "tool_input": {"command": command}})


def _hook_output(parsed: dict) -> dict:
"""Extract hookSpecificOutput.additionalContext, asserting envelope shape."""
assert "hookSpecificOutput" in parsed, (
f"hook must emit hookSpecificOutput envelope (Claude Code 2.x contract); got {parsed!r}"
)
inner = parsed["hookSpecificOutput"]
assert inner.get("hookEventName") == "PostToolUse"
return inner


def _assert_silent(out: str) -> None:
"""No envelope written. Tolerate fully-empty stdout or `{}`."""
if not out.strip():
return
parsed = json.loads(out)
assert "hookSpecificOutput" not in parsed


def test_emits_reminder_on_git_commit():
rc, out, _ = _run_hook(_make_stdin(command="git commit -m 'feat: add foo'"))
assert rc == 0
inner = _hook_output(json.loads(out))
ctx = inner["additionalContext"]
assert "bicameral: new commit detected" in ctx
assert "/bicameral:sync" in ctx


def test_emits_reminder_on_git_merge():
rc, out, _ = _run_hook(_make_stdin(command="git merge feature/foo --no-ff"))
assert rc == 0
inner = _hook_output(json.loads(out))
assert "bicameral: new commit detected" in inner["additionalContext"]


def test_emits_reminder_on_git_pull():
rc, out, _ = _run_hook(_make_stdin(command="git pull origin main"))
assert rc == 0
inner = _hook_output(json.loads(out))
assert "bicameral: new commit detected" in inner["additionalContext"]


def test_emits_reminder_on_git_rebase_continue():
rc, out, _ = _run_hook(_make_stdin(command="git rebase --continue"))
assert rc == 0
inner = _hook_output(json.loads(out))
assert "bicameral: new commit detected" in inner["additionalContext"]


def test_silent_on_read_only_git_command():
"""git status, git log, git diff, etc. → silent."""
for cmd in ["git status", "git log -10", "git diff HEAD", "git branch -a"]:
rc, out, _ = _run_hook(_make_stdin(command=cmd))
assert rc == 0
_assert_silent(out)


def test_silent_on_non_bash_tool():
"""Hook only fires for Bash; other tools → silent."""
rc, out, _ = _run_hook(_make_stdin(tool_name="Edit", command="git commit"))
assert rc == 0
_assert_silent(out)


def test_silent_on_non_git_bash_command():
rc, out, _ = _run_hook(_make_stdin(command="ls -la"))
assert rc == 0
_assert_silent(out)


def test_handles_malformed_stdin():
rc, out, _ = _run_hook("this is not JSON at all {[}")
assert rc == 0
_assert_silent(out)


def test_handles_missing_tool_input():
payload = json.dumps({"tool_name": "Bash"})
rc, out, _ = _run_hook(payload)
assert rc == 0
_assert_silent(out)


def test_handles_non_dict_tool_input():
payload = json.dumps({"tool_name": "Bash", "tool_input": "git commit"})
rc, out, _ = _run_hook(payload)
assert rc == 0
_assert_silent(out)


def test_idempotent_on_double_fire():
stdin = _make_stdin(command="git commit -m 'whatever'")
rc1, out1, _ = _run_hook(stdin)
rc2, out2, _ = _run_hook(stdin)
assert rc1 == rc2 == 0
assert out1 == out2
Loading