From 4ef7e3755e3433ae2172b40c3389f7113fb059f6 Mon Sep 17 00:00:00 2001 From: Victor Del Puerto Date: Fri, 5 Jun 2026 11:08:49 -0300 Subject: [PATCH] feat(codex): SubagentStart + Pre/PostCompact context surfaces (parity) Completes native Codex hook parity. fscar init --adapter codex now registers all documented events. These three are context-injection surfaces (no decision in their output schemas, so a scar cannot block them); each emit path matches the surface's generated schema exactly: - SubagentStart: hookSpecificOutput.additionalContext + systemMessage (no decision). - PreCompact/PostCompact: top-level systemMessage only (no hookSpecificOutput, no additionalContext, no decision). run_hook returns exit 0 for all three (no exit-2 decision path), grouped with PermissionRequest in _EXIT_ZERO_EVENTS. Adds HookEventType.SUBAGENT_START / PRE_COMPACT / POST_COMPACT + mappings + WANTED_EVENTS. Output shapes verified against the generated schemas (subagent-start / pre-compact / post-compact .command.output.schema.json). Docs + CHANGELOG. Gate: 193 passed, ruff clean, mypy strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- README.md | 2 +- docs/codex_integration_plan.md | 19 +++++++++++++++ fscars/adapters/codex/adapter.py | 40 ++++++++++++++++++++++++++++++++ fscars/core/payload.py | 3 +++ fscars/run_hook.py | 22 ++++++++++++++---- tests/test_adapter_codex.py | 35 ++++++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a58720..0cc6037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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`. +- **Codex `SubagentStart` / `PreCompact` / `PostCompact` context surfaces — completing native hook parity.** `fscar init --adapter codex` now registers all three. These are context-injection surfaces (no block path in their schemas): a scar with `event_type = HookEventType.SUBAGENT_START` injects `hookSpecificOutput.additionalContext` into a starting subagent; `PRE_COMPACT` / `POST_COMPACT` scars surface context through top-level `systemMessage` (their schema forbids `hookSpecificOutput`, `additionalContext`, and `decision`). Each emit path matches the surface's generated output schema exactly. `run_hook` returns exit 0 for these (no exit-2 decision path). Adds `HookEventType.SUBAGENT_START` / `PRE_COMPACT` / `POST_COMPACT`. ### Planned -- 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 d894e99..77d1b47 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. 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). +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. fscars registers every documented Codex hook. Three are blocking surfaces: `PreToolUse` (deny a `Bash` / `apply_patch` / MCP call before it runs), `PermissionRequest` (deny the approval of a request — `event_type = PermissionRequest`), and `SubagentStop` (keep a subagent from stopping until a condition is met — `event_type = SubagentStop`). The rest inject context: `SubagentStart`, `PreCompact`, and `PostCompact` (each matched to its exact output schema). 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 aa09e9b..268a40d 100644 --- a/docs/codex_integration_plan.md +++ b/docs/codex_integration_plan.md @@ -157,6 +157,25 @@ Three differences from `PermissionRequest`: `payload.raw["last_assistant_message"]` in its `matches()`; `tool_matchers` does not apply. +## Context surfaces — SubagentStart / PreCompact / PostCompact (added post-v0.7.0) + +These complete native hook parity. None has a `decision` in its output schema, so +they are context-injection only (a scar cannot block them), and each emit path is +matched to its exact generated schema: + +- **`SubagentStart`** (`additionalProperties: false`; allows `hookSpecificOutput` + with `additionalContext`, plus `systemMessage`): inject reminders into a + starting subagent via `hookSpecificOutput.additionalContext`. +- **`PreCompact` / `PostCompact`** (allow only `continue` / `stopReason` / + `suppressOutput` / `systemMessage` — no `hookSpecificOutput`, no + `additionalContext`, no `decision`): surface context through top-level + `systemMessage` only. `PostCompact` is a natural place to re-assert scar + context after the transcript is compacted. + +`run_hook` returns exit 0 for all three (no exit-2 decision path), as it does for +`PermissionRequest`. Filtering is by `agent_type` (SubagentStart) or `trigger` +(compaction), handled in the scar's `matches()` via `payload.raw`. + ## 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 d508ae7..941ac4d 100644 --- a/fscars/adapters/codex/adapter.py +++ b/fscars/adapters/codex/adapter.py @@ -39,7 +39,10 @@ "PostToolUse": HookEventType.POST_TOOL_USE, "PermissionRequest": HookEventType.PERMISSION_REQUEST, "Stop": HookEventType.STOP, + "SubagentStart": HookEventType.SUBAGENT_START, "SubagentStop": HookEventType.SUBAGENT_STOP, + "PreCompact": HookEventType.PRE_COMPACT, + "PostCompact": HookEventType.POST_COMPACT, "Notification": HookEventType.NOTIFICATION, # Lowercase aliases are convenient for wrapper scripts and tests. "session_start": HookEventType.SESSION_START, @@ -49,7 +52,10 @@ "post_tool_use": HookEventType.POST_TOOL_USE, "permission_request": HookEventType.PERMISSION_REQUEST, "stop": HookEventType.STOP, + "subagent_start": HookEventType.SUBAGENT_START, "subagent_stop": HookEventType.SUBAGENT_STOP, + "pre_compact": HookEventType.PRE_COMPACT, + "post_compact": HookEventType.POST_COMPACT, "notification": HookEventType.NOTIFICATION, } @@ -94,7 +100,10 @@ class CodexAdapter(Adapter): "PostToolUse", "PermissionRequest", "Stop", + "SubagentStart", "SubagentStop", + "PreCompact", + "PostCompact", ) # ------------------------------------------------------------------ @@ -169,6 +178,11 @@ def emit_output(self, output: ScarOutput, payload: HookPayload | None = None) -> ``hookSpecificOutput``); ``systemMessage`` is the surface's only context channel. ``run_hook`` exits 2 (a documented ``SubagentStop`` block path, unlike ``PermissionRequest``). + * ``SubagentStart`` → context only: ``hookSpecificOutput.additionalContext`` + + ``systemMessage``; its schema has no ``decision``, so it cannot block. + * ``PreCompact`` / ``PostCompact`` → context only via top-level + ``systemMessage`` (their schema forbids ``hookSpecificOutput``, + ``additionalContext`` and ``decision``). * 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``. @@ -219,6 +233,32 @@ def emit_output(self, output: ScarOutput, payload: HookPayload | None = None) -> stop_result["systemMessage"] = surface_message return json.dumps(stop_result, ensure_ascii=False) if stop_result else "{}" + if event_type == HookEventType.SUBAGENT_START: + # Context-injection only. Schema allows hookSpecificOutput + # .additionalContext + systemMessage but NO decision — a scar here + # cannot block. See subagent-start.command.output.schema.json + start_result: dict[str, Any] = {} + if output.additional_context: + start_result["hookSpecificOutput"] = { + "hookEventName": event_name, + "additionalContext": output.additional_context, + } + if output.system_message: + start_result["systemMessage"] = output.system_message + return json.dumps(start_result, ensure_ascii=False) if start_result else "{}" + + if event_type in (HookEventType.PRE_COMPACT, HookEventType.POST_COMPACT): + # Strictest schema: only continue/stopReason/suppressOutput/ + # systemMessage — NO hookSpecificOutput, NO additionalContext, NO + # decision. systemMessage is the only context channel; a scar here + # cannot block. See pre-compact/post-compact output schemas. + message = output.system_message or output.additional_context + return ( + json.dumps({"systemMessage": message}, ensure_ascii=False) + if message + 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 c630df4..e50962a 100644 --- a/fscars/core/payload.py +++ b/fscars/core/payload.py @@ -28,7 +28,10 @@ class HookEventType(str, Enum): POST_TOOL_USE = "PostToolUse" PERMISSION_REQUEST = "PermissionRequest" STOP = "Stop" + SUBAGENT_START = "SubagentStart" SUBAGENT_STOP = "SubagentStop" + PRE_COMPACT = "PreCompact" + POST_COMPACT = "PostCompact" NOTIFICATION = "Notification" diff --git a/fscars/run_hook.py b/fscars/run_hook.py index b008184..9b73b3e 100644 --- a/fscars/run_hook.py +++ b/fscars/run_hook.py @@ -31,6 +31,17 @@ "codex": CodexAdapter, } +# Surfaces where exit code 2 is NOT a documented block path: the block (if any) +# is carried by the JSON response, so run_hook returns 0 for them. +_EXIT_ZERO_EVENTS = frozenset( + { + HookEventType.PERMISSION_REQUEST, + HookEventType.SUBAGENT_START, + HookEventType.PRE_COMPACT, + HookEventType.POST_COMPACT, + } +) + def _force_utf8_io() -> None: """Force stdin/stdout to UTF-8 regardless of platform. @@ -99,11 +110,12 @@ def main(argv: list[str] | None = None) -> int: result = engine.run(payload, registry=registry) sys.stdout.write(adapter.emit_output(result.output, payload)) - # PermissionRequest conveys a denial through the JSON decision object only. - # Unlike PreToolUse / Stop / etc., the Codex docs do not document exit code 2 - # as a decision path for it, so a deny here returns 0 and relies on the - # `decision: {"behavior": "deny"}` contract. See developers.openai.com/codex/hooks. - if payload.event_type == HookEventType.PERMISSION_REQUEST: + # Some Codex surfaces do not use exit code 2 as a decision path, so a block + # there must NOT exit 2: PermissionRequest conveys its denial through the + # nested JSON decision object, and SubagentStart / Pre-PostCompact are + # context-injection surfaces whose schemas have no `decision` at all. Their + # output is the contract; exit 0. See developers.openai.com/codex/hooks. + if payload.event_type in _EXIT_ZERO_EVENTS: return 0 return result.exit_code diff --git a/tests/test_adapter_codex.py b/tests/test_adapter_codex.py index b7b6ae0..cdb5d58 100644 --- a/tests/test_adapter_codex.py +++ b/tests/test_adapter_codex.py @@ -218,6 +218,41 @@ def test_emit_subagent_stop_non_block_uses_system_message(): assert "hookSpecificOutput" not in parsed +def test_emit_subagent_start_injects_context_no_decision(): + out = CodexAdapter().emit_output( + ScarOutput(additional_context="watch for X", block=True), # block ignored here + _payload(HookEventType.SUBAGENT_START), + ) + parsed = json.loads(out) + # SubagentStart schema: hookSpecificOutput.additionalContext, never decision. + assert parsed["hookSpecificOutput"] == { + "hookEventName": "SubagentStart", + "additionalContext": "watch for X", + } + assert "decision" not in parsed + + +def test_emit_precompact_systemmessage_only(): + out = CodexAdapter().emit_output( + ScarOutput(additional_context="about to compact"), + _payload(HookEventType.PRE_COMPACT), + ) + parsed = json.loads(out) + # Strictest schema: only systemMessage — no hookSpecificOutput/additionalContext/decision. + assert parsed == {"systemMessage": "about to compact"} + + +def test_emit_postcompact_block_does_not_emit_decision(): + # Compaction surfaces have no block path; a block must not leak `decision`. + out = CodexAdapter().emit_output( + ScarOutput(additional_context="re-read the scars", block=True), + _payload(HookEventType.POST_COMPACT), + ) + parsed = json.loads(out) + assert parsed == {"systemMessage": "re-read the scars"} + assert "decision" not in parsed and "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),