diff --git a/.claude/settings.json b/.claude/settings.json index 42d7afce140..63465d34f2b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -27,7 +27,7 @@ "hooks": { "PreToolUse": [ { - "matcher": "Task|Agent|Read|Grep|Bash|PowerShell|shell_command|Write|Edit|NotebookEdit|apply_patch|WebSearch|WebFetch|web__run|update_plan|mcp__shaft-memory__(?:remember_memory|save_memory_patch)|mcp__mempalace__.*|mcp__graphify__.*", + "matcher": "Task|Agent|Read|Grep|Bash|PowerShell|shell_command|Write|Edit|NotebookEdit|apply_patch|WebSearch|WebFetch|web__run|update_plan|enter_plan_mode|EnterPlanMode|exit_plan_mode|ExitPlanMode|todo_write|TodoWrite|mcp__shaft-memory__(?:remember_memory|save_memory_patch)|mcp__mempalace__.*|mcp__graphify__.*", "hooks": [ { "type": "command", @@ -40,7 +40,7 @@ ], "PostToolUse": [ { - "matcher": "Read|Grep|Bash|PowerShell|shell_command|WebSearch|WebFetch|web__run|update_plan|mcp__shaft-memory__.*|mcp__mempalace__.*|mcp__graphify__.*", + "matcher": "Read|Grep|Bash|PowerShell|shell_command|WebSearch|WebFetch|web__run|update_plan|enter_plan_mode|EnterPlanMode|exit_plan_mode|ExitPlanMode|todo_write|TodoWrite|mcp__shaft-memory__.*|mcp__mempalace__.*|mcp__graphify__.*", "hooks": [ { "type": "command", @@ -53,7 +53,7 @@ ], "PostToolUseFailure": [ { - "matcher": "Read|Grep|Bash|PowerShell|shell_command|WebSearch|WebFetch|web__run|update_plan|mcp__shaft-memory__.*|mcp__mempalace__.*|mcp__graphify__.*", + "matcher": "Read|Grep|Bash|PowerShell|shell_command|WebSearch|WebFetch|web__run|update_plan|enter_plan_mode|EnterPlanMode|exit_plan_mode|ExitPlanMode|todo_write|TodoWrite|mcp__shaft-memory__.*|mcp__mempalace__.*|mcp__graphify__.*", "hooks": [ { "type": "command", diff --git a/.codex/hooks.json b/.codex/hooks.json index 8fe91820d07..8e4437f4c78 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -3,7 +3,7 @@ "hooks": { "PreToolUse": [ { - "matcher": "Task|Agent|Read|Grep|Bash|PowerShell|shell_command|exec_command|functions[.]exec|Write|Edit|NotebookEdit|apply_patch|WebSearch|WebFetch|web__run|update_plan|mcp__shaft[-_]memory__(?:remember_memory|save_memory_patch)|mcp__mempalace__.*|mcp__graphify__.*", + "matcher": "Task|Agent|Read|Grep|Bash|PowerShell|shell_command|exec_command|functions[.]exec|Write|Edit|NotebookEdit|apply_patch|WebSearch|WebFetch|web__run|update_plan|enter_plan_mode|EnterPlanMode|exit_plan_mode|ExitPlanMode|todo_write|TodoWrite|mcp__shaft[-_]memory__(?:remember_memory|save_memory_patch)|mcp__mempalace__.*|mcp__graphify__.*", "hooks": [ { "type": "command", @@ -16,7 +16,7 @@ ], "PostToolUse": [ { - "matcher": "Read|Grep|Bash|PowerShell|shell_command|exec_command|functions[.]exec|WebSearch|WebFetch|web__run|update_plan|mcp__shaft[-_]memory__.*|mcp__mempalace__.*|mcp__graphify__.*", + "matcher": "Read|Grep|Bash|PowerShell|shell_command|exec_command|functions[.]exec|WebSearch|WebFetch|web__run|update_plan|enter_plan_mode|EnterPlanMode|exit_plan_mode|ExitPlanMode|todo_write|TodoWrite|mcp__shaft[-_]memory__.*|mcp__mempalace__.*|mcp__graphify__.*", "hooks": [ { "type": "command", diff --git a/scripts/agents/guard.py b/scripts/agents/guard.py index 0e94aa8f68f..195086f0035 100644 --- a/scripts/agents/guard.py +++ b/scripts/agents/guard.py @@ -2125,6 +2125,17 @@ def _research_preflight_events( plan = details.get("plan") if isinstance(plan, list) and plan: events.append("record-plan") + plan_surface = lowered_name.replace("_", "").replace("-", "") + if plan_surface == "enterplanmode": + events.append("compare-proven-approaches") + if plan_surface == "exitplanmode": + events.append("record-plan") + if plan_surface == "todowrite": + if "compare proven approaches" in rendered: + events.append("compare-proven-approaches") + todos = details.get("todos") + if isinstance(todos, list) and todos: + events.append("record-plan") if tool_name in _SHELL_TOOLS and _is_plan_receipt_command( str(details.get("command") or details.get("cmd") or "") ): diff --git a/tests/scripts/test_guard_lifecycle.py b/tests/scripts/test_guard_lifecycle.py index 84e121bccf3..a648d7ae54b 100644 --- a/tests/scripts/test_guard_lifecycle.py +++ b/tests/scripts/test_guard_lifecycle.py @@ -1362,6 +1362,34 @@ def test_live_tool_events_map_to_the_receipt_vocabulary(self): ("WebSearch", {"query": "official hook documentation"}, {"url": "https://docs.github.com/en/actions"}, "authoritative-online-research"), ("update_plan", {"explanation": "Compare proven approaches", "plan": []}, None, "compare-proven-approaches"), ("update_plan", {"explanation": "Compare proven approaches", "plan": [{"step": "Implement", "status": "pending"}]}, None, "record-plan"), + ("enter_plan_mode", {}, None, "compare-proven-approaches"), + ("EnterPlanMode", {}, None, "compare-proven-approaches"), + ("exit_plan_mode", {}, None, "record-plan"), + ("ExitPlanMode", {}, None, "record-plan"), + ( + "todo_write", + { + "todos": [ + { + "id": "1", + "content": "Compare proven approaches then implement", + "status": "in_progress", + } + ] + }, + None, + "compare-proven-approaches", + ), + ( + "todo_write", + { + "todos": [ + {"id": "1", "content": "Implement mapping", "status": "pending"} + ] + }, + None, + "record-plan", + ), ) for tool_name, tool_input, tool_result, expected in fixtures: with self.subTest(tool_name=tool_name, expected=expected): @@ -1388,6 +1416,16 @@ def test_live_tool_events_map_to_the_receipt_vocabulary(self): (), ) + def test_todo_write_without_compare_marker_does_not_emit_compare(self): + events = guard._research_preflight_events( + "todo_write", + {"todos": [{"id": "1", "content": "Implement mapping", "status": "pending"}]}, + ) + self.assertIn("record-plan", events) + self.assertNotIn("compare-proven-approaches", events) + self.assertEqual(guard._research_preflight_events("todo_write", {"todos": []}), ()) + self.assertEqual(guard._research_preflight_events("todo_write", {}), ()) + def test_shell_command_maps_research_clis_in_command_order(self): self.assertEqual( guard._research_preflight_events( @@ -1607,10 +1645,21 @@ def test_failed_current_host_research_calls_do_not_certify_success(self): self.assertEqual(observed, []) def test_portable_hook_matchers_observe_receipt_and_mutation_tools(self): + plan_surfaces = ( + "update_plan", + "enter_plan_mode", + "EnterPlanMode", + "exit_plan_mode", + "ExitPlanMode", + "todo_write", + "TodoWrite", + ) for relative in (".claude/settings.json", ".codex/hooks.json"): with self.subTest(relative=relative): - text = (Path(__file__).resolve().parents[2] / relative).read_text(encoding="utf-8") - for tool in ("Read", "WebSearch", "WebFetch", "update_plan", "apply_patch"): + text = (Path(__file__).resolve().parents[2] / relative).read_text( + encoding="utf-8" + ) + for tool in ("Read", "WebSearch", "WebFetch", "apply_patch", *plan_surfaces): self.assertIn(tool, text) self.assertIn("PostToolUse", text) codex = (Path(__file__).resolve().parents[2] / ".codex/hooks.json").read_text( @@ -1622,6 +1671,9 @@ def test_portable_hook_matchers_observe_receipt_and_mutation_tools(self): hooks = json.loads(codex)["hooks"] self.assertIn("functions[.]exec", hooks["PreToolUse"][0]["matcher"]) self.assertIn("functions[.]exec", hooks["PostToolUse"][0]["matcher"]) + for tool in plan_surfaces: + self.assertIn(tool, hooks["PreToolUse"][0]["matcher"]) + self.assertIn(tool, hooks["PostToolUse"][0]["matcher"]) def test_every_live_mutation_lane_requires_the_receipt(self): fixtures = (