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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
19 changes: 19 additions & 0 deletions docs/codex_integration_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions fscars/adapters/codex/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}

Expand Down Expand Up @@ -94,7 +100,10 @@ class CodexAdapter(Adapter):
"PostToolUse",
"PermissionRequest",
"Stop",
"SubagentStart",
"SubagentStop",
"PreCompact",
"PostCompact",
)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -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``.
Expand Down Expand Up @@ -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)
Comment on lines +250 to +257

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve compaction blocks with continue:false

For PreCompact/PostCompact scars that set block=True (for example, a project scar that wants to stop an auto-compaction until state is saved), this branch drops the block entirely and only emits systemMessage, so Codex proceeds. The Codex hook docs' common output fields include continue, and the PreCompact/PostCompact sections state that returning continue: false stops before/after compaction, so blocking compaction scars need to emit that schema-supported field (with stopReason) rather than being treated as context-only.

Useful? React with 👍 / 👎.

if message
else "{}"
)

is_pre_tool = event_type == HookEventType.PRE_TOOL_USE

hook_specific: dict[str, Any] = {"hookEventName": event_name}
Expand Down
3 changes: 3 additions & 0 deletions fscars/core/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
22 changes: 17 additions & 5 deletions fscars/run_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
35 changes: 35 additions & 0 deletions tests/test_adapter_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading