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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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. 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).

---

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

Expand Down Expand Up @@ -92,6 +94,7 @@ class CodexAdapter(Adapter):
"PostToolUse",
"PermissionRequest",
"Stop",
"SubagentStop",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Return SubagentStop JSON blocks with exit 0

Adding SubagentStop here routes blocking scars through the existing run_hook path, which writes the JSON {"decision":"block","reason":...} to stdout and then returns exit code 2 for every non-PermissionRequest block. I checked the current Codex hooks doc: for SubagentStop, JSON stdout is the contract when the hook exits 0, while the exit-2 alternative requires the continuation reason on stderr. Since this hook emits no stderr, a subagent coverage gate can be ignored or continued without the requested prompt whenever it blocks; this surface needs a Codex-specific exit-0 path (or stderr output) before registration.

Useful? React with 👍 / 👎.

)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions fscars/core/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class HookEventType(str, Enum):
POST_TOOL_USE = "PostToolUse"
PERMISSION_REQUEST = "PermissionRequest"
STOP = "Stop"
SUBAGENT_STOP = "SubagentStop"
NOTIFICATION = "Notification"


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