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
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
74 changes: 64 additions & 10 deletions tests/e2e/run_e2e_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,9 +612,49 @@ def _ingest_items(call: dict) -> list[dict]:
return p.get("decisions") or p.get("mappings") or []


# Feature-area binding sets for Flow 1. Each seeded decision can legitimately
# anchor to any of several files in the desktop/desktop tree — the asserter
# checks that *some* file in each area is bound, not which specific one.
# Previously the asserter required the exact paths "cherry-pick.ts" and
# "reorder.ts"; LLM nondeterminism on borderline cases (e.g. binding the
# UI-layer commit-list.tsx instead of the git-layer reorder.ts) flaked the
# test even though the functional outcome — drift detection has a code
# anchor for each feature — was satisfied.
#
# The "Improved commit history" decision bundles four ops (drag-to-reorder,
# drag-to-squash, amend, branch-from), so any of the files backing those is
# a legitimate anchor. cherry-pick has both lib and UI surfaces and either
# is acceptable.
_CHERRY_PICK_AREA_PATHS: tuple[str, ...] = (
"cherry-pick.ts",
"cherry-pick.tsx",
)
_COMMIT_HISTORY_AREA_PATHS: tuple[str, ...] = (
# git-layer (canonical anchors for drift on the actual operations)
"/git/reorder.ts",
"/git/squash.ts",
"/git/commit.ts",
# ui-layer (legitimate when the decision is framed as a UX feature)
"/history/commit-list.tsx",
"/history/commit-list-item.tsx",
"/multi-commit-operation/reorder.tsx",
"/multi-commit-operation/squash.tsx",
"/dispatcher/dispatcher.ts",
# models / store layer (when bound as data-shape contracts)
"/models/multi-commit-operation.ts",
"/models/retry-actions.ts",
"/stores/app-store.ts",
)


def _bound_to_area(bind_targets: list[str], area_paths: tuple[str, ...]) -> bool:
"""Return True iff any bound path matches any acceptable substring for the area."""
return any(any(sub in p for sub in area_paths) for p in bind_targets)


def assert_flow_1(calls: list[dict]) -> tuple[bool, str]:
"""Flow 1: PM ingests the seed roadmap decisions, anchors the cherry-pick
decision to cherry-pick.ts and the reorder decision to reorder.ts, and
"""Flow 1: PM ingests the seed roadmap decisions, anchors at least one
file in each of the cherry-pick and commit-history feature areas, and
ratifies. Subsequent flows depend on a CLEAN, RATIFIED, BOUND ledger as
their baseline.

Expand All @@ -623,6 +663,13 @@ def assert_flow_1(calls: list[dict]) -> tuple[bool, str]:
separate ``bicameral.bind`` call for code that already exists. A
follow-up ``bicameral.bind`` is reserved for abstract decisions whose
code doesn't exist yet. This asserter accepts EITHER path.

The check is feature-area-scoped, not file-scoped: any of the files
listed in ``_CHERRY_PICK_AREA_PATHS`` / ``_COMMIT_HISTORY_AREA_PATHS``
counts as a legitimate anchor for the corresponding decision. The
earlier exact-filename check ("cherry-pick.ts" + "reorder.ts" only)
flaked when the LLM picked an equally valid UI-layer file like
``commit-list.tsx`` for the bundled commit-history decision.
"""
bcalls = _bicameral_tool_calls(calls)
names = [c["name"].split("__")[-1] for c in bcalls]
Expand Down Expand Up @@ -662,17 +709,23 @@ def assert_flow_1(calls: list[dict]) -> tuple[bool, str]:
if path:
bind_targets.append(path)

has_cp = any("cherry-pick.ts" in p for p in bind_targets)
has_reorder = any("reorder.ts" in p for p in bind_targets)
if not (has_cp and has_reorder):
has_cp_area = _bound_to_area(bind_targets, _CHERRY_PICK_AREA_PATHS)
has_commit_history_area = _bound_to_area(bind_targets, _COMMIT_HISTORY_AREA_PATHS)
if not (has_cp_area and has_commit_history_area):
missing = [
f
for f, present in (("cherry-pick.ts", has_cp), ("reorder.ts", has_reorder))
label
for label, present in (
("cherry-pick area", has_cp_area),
("commit-history area", has_commit_history_area),
)
if not present
]
return False, (
f"bind missing target(s): {missing}; checked ingest.mappings[].code_regions "
f"and bicameral.bind calls; saw bound paths: {bind_targets}; sequence: {names}"
f"bind missing feature area(s): {missing}; checked "
f"ingest.mappings[].code_regions and bicameral.bind calls; saw bound "
f"paths: {bind_targets}; expected at least one path per missing area "
f"matching cherry-pick: {list(_CHERRY_PICK_AREA_PATHS)} or "
f"commit-history: {list(_COMMIT_HISTORY_AREA_PATHS)}; sequence: {names}"
)

# Ratify: PM blesses the just-ingested decisions. Flow 5 walks the
Expand All @@ -686,7 +739,8 @@ def assert_flow_1(calls: list[dict]) -> tuple[bool, str]:

binding_path = "inline code_regions" if not bind_calls else "inline + follow-up bind"
return True, (
f"ingest({total_items} items, {binding_path}) → cherry-pick.ts + reorder.ts bound; "
f"ingest({total_items} items, {binding_path}) → cherry-pick + commit-history "
f"feature areas bound (paths: {bind_targets}); "
f"ratify({len(ratify_calls)}); sequence: {names}"
)

Expand Down
Loading
Loading