diff --git a/CHANGELOG.md b/CHANGELOG.md index a920e36..3a58720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Codex `SubagentStop` block surface.** `fscar init --adapter codex` now also registers the `SubagentStop` hook. A scar with `event_type = HookEventType.SUBAGENT_STOP` can keep a subagent from stopping by blocking, via the top-level `decision: "block"` + `reason` shape (e.g. "report batch coverage before you stop"). The output uses only the surface's schema-allowed top-level fields — its schema is `additionalProperties: false` with no `hookSpecificOutput`, so `systemMessage` is the only context channel. Unlike `PermissionRequest`, exit code 2 is a documented block path here, so a block exits 2. Subagent-lifecycle fields (`agent_type`, `last_assistant_message`, …) are preserved on `payload.raw` for the scar's `matches()`. Adds `HookEventType.SUBAGENT_STOP`. + ### Planned -- Codex `SubagentStop` / `SubagentStart` / `Pre`-`PostCompact` hooks (next increment). +- Codex `SubagentStart` / `Pre`-`PostCompact` hooks (next increment — context injection). - Demo GIF rendered with VHS (`assets/demo.tape` storyboard ready in planning doc). - Logo + brand assets. - Homebrew tap. diff --git a/README.md b/README.md index 2986d2d..d894e99 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Once installed, every Claude Code tool call passes through the engine. When a sc | `fscar audit` | Validate + cross-link fires↔opportunities + render dashboard | | `fscar --version` | Print the installed version | -The hook entrypoint is `python -m fscars.run_hook`. Single command across every event type — no per-scar hook scripts. For Codex, `fscar init --adapter codex` registers that entrypoint as a native `command` hook in `.codex/hooks.json` for every parity event and keeps an `AGENTS.md` block as an operational fallback. Run `/hooks` in the Codex CLI once to trust the hooks. Two deny surfaces are wired: `PreToolUse` (deny a `Bash` / `apply_patch` / MCP call before it runs) and `PermissionRequest` (deny the approval of a request — a scar with `event_type = PermissionRequest`). Both remain guardrails, not a complete boundary (WebSearch and other non-shell/non-MCP tools are not intercepted). +The hook entrypoint is `python -m fscars.run_hook`. Single command across every event type — no per-scar hook scripts. For Codex, `fscar init --adapter codex` registers that entrypoint as a native `command` hook in `.codex/hooks.json` for every parity event and keeps an `AGENTS.md` block as an operational fallback. Run `/hooks` in the Codex CLI once to trust the hooks. Three blocking surfaces are wired: `PreToolUse` (deny a `Bash` / `apply_patch` / MCP call before it runs), `PermissionRequest` (deny the approval of a request — a scar with `event_type = PermissionRequest`), and `SubagentStop` (keep a subagent from stopping until a condition is met — `event_type = SubagentStop`). The tool-use surfaces remain guardrails, not a complete boundary (WebSearch and other non-shell/non-MCP tools are not intercepted). --- diff --git a/docs/codex_integration_plan.md b/docs/codex_integration_plan.md index 8061594..aa09e9b 100644 --- a/docs/codex_integration_plan.md +++ b/docs/codex_integration_plan.md @@ -132,6 +132,31 @@ details (confirmed in the PR #12 review against the doc): a Codex-specific surface — the canonical `apply_patch` name is preserved, so a scar uses `tool_matchers = ("apply_patch",)`. +## SubagentStop block surface (added post-v0.7.0) + +`SubagentStop` fires when a Codex subagent is about to stop. A scar with +`event_type = HookEventType.SUBAGENT_STOP` can block it — useful as a gate +("the batch subagent must report coverage before it stops"). The block shape is +the top-level feedback form, same as the other non-`PreToolUse` events: + +```json +{"decision": "block", "reason": "report batch coverage before you stop."} +``` + +Three differences from `PermissionRequest`: + +- **Exit code 2 is a documented block path** for `SubagentStop`, so `run_hook` + returns the normal block exit code here (no special-casing). +- **The output schema is top-level only.** `subagent-stop.command.output.schema.json` + is `additionalProperties: false` and has no `hookSpecificOutput`; the allowed + keys are `continue` / `decision` / `reason` / `stopReason` / `suppressOutput` / + `systemMessage`. So the adapter emits `decision`/`reason` (and `systemMessage` + as the only context channel) — never `hookSpecificOutput` for this surface. +- **Filtering is by `agent_type`, not `tool_name`.** The payload carries no tool, + so a `SubagentStop` scar reads `payload.raw["agent_type"]` / + `payload.raw["last_assistant_message"]` in its `matches()`; `tool_matchers` + does not apply. + ## Resolved verification item — catch-all matcher fscars registers each event hook **without** a `matcher` (catch-all), relying on diff --git a/fscars/adapters/codex/adapter.py b/fscars/adapters/codex/adapter.py index fdfb5c8..d508ae7 100644 --- a/fscars/adapters/codex/adapter.py +++ b/fscars/adapters/codex/adapter.py @@ -39,6 +39,7 @@ "PostToolUse": HookEventType.POST_TOOL_USE, "PermissionRequest": HookEventType.PERMISSION_REQUEST, "Stop": HookEventType.STOP, + "SubagentStop": HookEventType.SUBAGENT_STOP, "Notification": HookEventType.NOTIFICATION, # Lowercase aliases are convenient for wrapper scripts and tests. "session_start": HookEventType.SESSION_START, @@ -48,6 +49,7 @@ "post_tool_use": HookEventType.POST_TOOL_USE, "permission_request": HookEventType.PERMISSION_REQUEST, "stop": HookEventType.STOP, + "subagent_stop": HookEventType.SUBAGENT_STOP, "notification": HookEventType.NOTIFICATION, } @@ -92,6 +94,7 @@ class CodexAdapter(Adapter): "PostToolUse", "PermissionRequest", "Stop", + "SubagentStop", ) # ------------------------------------------------------------------ @@ -161,9 +164,13 @@ def emit_output(self, output: ScarOutput, payload: HookPayload | None = None) -> non-blocking PermissionRequest emits nothing. * ``PreToolUse`` block → ``permissionDecision: "deny"`` (the call is denied before it runs). - * Any other event block → ``decision: "block"`` as feedback only; the - tool already ran (or there is no tool), and ``run_hook`` still exits - with code 2 to signal the block upstream. + * ``SubagentStop`` block → top-level ``decision: "block"`` + ``reason`` + only (its output schema is ``additionalProperties: false`` with no + ``hookSpecificOutput``); ``systemMessage`` is the surface's only context + channel. ``run_hook`` exits 2 (a documented ``SubagentStop`` block path, + unlike ``PermissionRequest``). + * Any other event block → ``decision: "block"`` feedback with the + originating event echoed in ``hookSpecificOutput``; the tool already ran. * Non-blocking context is injected via ``additionalContext``. """ if output.is_empty: @@ -192,6 +199,26 @@ def emit_output(self, output: ScarOutput, payload: HookPayload | None = None) -> decision_result["systemMessage"] = output.system_message return json.dumps(decision_result, ensure_ascii=False) + if event_type == HookEventType.SUBAGENT_STOP: + # Strict output schema (additionalProperties: false, NO + # hookSpecificOutput): only top-level fields are valid. A block uses + # decision/reason; systemMessage is the surface's only context channel. + # See codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json + stop_result: dict[str, Any] = {} + if output.block: + stop_result["decision"] = "block" + stop_result["reason"] = ( + output.additional_context + or output.system_message + or "fscars: blocked by a functional scar." + ) + surface_message = output.system_message or ( + "" if output.block else output.additional_context + ) + if surface_message: + stop_result["systemMessage"] = surface_message + return json.dumps(stop_result, ensure_ascii=False) if stop_result else "{}" + is_pre_tool = event_type == HookEventType.PRE_TOOL_USE hook_specific: dict[str, Any] = {"hookEventName": event_name} diff --git a/fscars/core/payload.py b/fscars/core/payload.py index 76ac46d..c630df4 100644 --- a/fscars/core/payload.py +++ b/fscars/core/payload.py @@ -28,6 +28,7 @@ class HookEventType(str, Enum): POST_TOOL_USE = "PostToolUse" PERMISSION_REQUEST = "PermissionRequest" STOP = "Stop" + SUBAGENT_STOP = "SubagentStop" NOTIFICATION = "Notification" diff --git a/tests/test_adapter_codex.py b/tests/test_adapter_codex.py index affc63d..b7b6ae0 100644 --- a/tests/test_adapter_codex.py +++ b/tests/test_adapter_codex.py @@ -93,6 +93,26 @@ def test_parse_permission_request_apply_patch_keeps_canonical_name(): assert payload.file_path == "src/app.py" +def test_parse_subagent_stop_payload(): + raw = { + "hook_event_name": "SubagentStop", + "session_id": "s", + "cwd": "/repo", + "turn_id": "t-7", + "agent_id": "a-1", + "agent_type": "reviewer", + "last_assistant_message": "done", + "stop_hook_active": False, + } + payload = CodexAdapter().parse_stdin(raw) + assert payload is not None + assert payload.event_type == HookEventType.SUBAGENT_STOP + assert payload.tool_name is None + # Subagent-lifecycle fields are preserved on raw for a scar's matches(). + assert payload.raw["agent_type"] == "reviewer" + assert payload.raw["last_assistant_message"] == "done" + + def test_parse_apply_patch_normalizes_to_edit_and_extracts_path(): patch = ( "*** Begin Patch\n" @@ -174,6 +194,30 @@ def test_emit_permission_request_non_block_is_silent(): assert out == "{}" +def test_emit_subagent_stop_block_uses_top_level_decision(): + out = CodexAdapter().emit_output( + ScarOutput(additional_context="report coverage first", block=True), + _payload(HookEventType.SUBAGENT_STOP), + ) + parsed = json.loads(out) + # SubagentStop blocks with the top-level decision/reason shape only — its + # output schema is additionalProperties:false with NO hookSpecificOutput. + assert parsed["decision"] == "block" + assert parsed["reason"] == "report coverage first" + assert "hookSpecificOutput" not in parsed + + +def test_emit_subagent_stop_non_block_uses_system_message(): + # No additionalContext channel on this surface — context rides systemMessage. + out = CodexAdapter().emit_output( + ScarOutput(additional_context="heads up"), + _payload(HookEventType.SUBAGENT_STOP), + ) + parsed = json.loads(out) + assert parsed == {"systemMessage": "heads up"} + assert "hookSpecificOutput" not in parsed + + def test_emit_pre_tool_use_block_denies(): out = CodexAdapter().emit_output( ScarOutput(additional_context="don't do that", block=True), diff --git a/tests/test_run_hook.py b/tests/test_run_hook.py index e824d60..861fab1 100644 --- a/tests/test_run_hook.py +++ b/tests/test_run_hook.py @@ -135,6 +135,47 @@ def test_run_hook_codex_permission_request_denies(monkeypatch, tmp_path, capsys) assert "rm -rf" in decision["message"] +def test_run_hook_codex_subagent_stop_blocks(monkeypatch, tmp_path, capsys): + """A SubagentStop scar that blocks emits the top-level decision:block shape + and exits 2 (a documented SubagentStop block path).""" + scars_dir = default_store(tmp_path).scars_dir + scars_dir.mkdir(parents=True, exist_ok=True) + (scars_dir / "stop_gate.py").write_text( + "from fscars.core.fire import Severity\n" + "from fscars.core.payload import HookEventType, HookPayload\n" + "from fscars.core.scar import FunctionalScar, ScarOutput\n" + "\n" + "class CoverageGate(FunctionalScar):\n" + " scar_id = 'subagent-coverage-gate'\n" + " name = 'Subagent must report coverage'\n" + " rule = 'a batch subagent must report coverage before stopping'\n" + " severity = Severity.BLOCK\n" + " event_type = HookEventType.SUBAGENT_STOP\n" + " def matches(self, payload: HookPayload) -> bool:\n" + " msg = payload.raw.get('last_assistant_message', '')\n" + " return 'coverage' not in msg.lower()\n" + " def build_output(self, payload: HookPayload) -> ScarOutput:\n" + " return ScarOutput(additional_context='report batch coverage first', block=True)\n" + "\n" + "scar = CoverageGate()\n", + encoding="utf-8", + ) + payload = { + "hook_event_name": "SubagentStop", + "cwd": str(tmp_path), + "session_id": "t", + "agent_type": "reviewer", + "last_assistant_message": "all done", + } + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(payload))) + monkeypatch.chdir(tmp_path) + code = run_hook.main(["--adapter", "codex"]) + parsed = json.loads(capsys.readouterr().out) + assert code == 2 + assert parsed["decision"] == "block" + assert "coverage" in parsed["reason"].lower() + + def test_run_hook_codex_apply_patch_emits_native_shape(monkeypatch, tmp_path, capsys): """A Codex apply_patch over a >200-line .py file routes through the engine (large-write-review fires) and emits the Codex native response shape."""