From 93754a8f25cbe71d643d83956047d39e4c434658 Mon Sep 17 00:00:00 2001 From: cdeust Date: Sat, 26 Sep 2026 22:02:45 +0200 Subject: [PATCH] Emit valid JSON objects for Codex hook reports Codex rejects a bare list on stdout for a PostToolUse hook and reported invalid JSON output. Serialize the cleanup report as a hookSpecificOutput object for PostToolUse and as a systemMessage for the other events, and leave the CLI and Claude output unchanged. The deletion policy is not touched. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Mkvy5MtGG89cNLuQSU8xLe Signed-off-by: cdeust --- plugins/disk-hygiene/hooks/disk_hygiene.py | 3 +- plugins/disk-hygiene/hooks/hook_output.py | 19 +++++++ tests/test_hook_output_protocol.py | 63 ++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 plugins/disk-hygiene/hooks/hook_output.py create mode 100644 tests/test_hook_output_protocol.py diff --git a/plugins/disk-hygiene/hooks/disk_hygiene.py b/plugins/disk-hygiene/hooks/disk_hygiene.py index 865cabb..422b9a8 100755 --- a/plugins/disk-hygiene/hooks/disk_hygiene.py +++ b/plugins/disk-hygiene/hooks/disk_hygiene.py @@ -23,6 +23,7 @@ ) from cleanup_registry import Protected, registry from cleanup_switch import cleanup_enabled +from hook_output import protocol_output def parse_args(): @@ -111,7 +112,7 @@ def main(): "explicit session identity is required; no ownership inferred from cwd" ) result = run_command(args, payload) - print(json.dumps(result)) + print(json.dumps(protocol_output(args, result))) def run_command(args, payload): diff --git a/plugins/disk-hygiene/hooks/hook_output.py b/plugins/disk-hygiene/hooks/hook_output.py new file mode 100644 index 0000000..4c09279 --- /dev/null +++ b/plugins/disk-hygiene/hooks/hook_output.py @@ -0,0 +1,19 @@ +"""Codex hook objects. Source: https://learn.chatgpt.com/docs/hooks""" + +import json + + +def protocol_output(args, result): + if args.host != "codex" or args.command != "hook" or not isinstance(result, list): + return result + if not result: + return {} + report = json.dumps(result) + if args.event == "PostToolUse": + return { + "hookSpecificOutput": { + "hookEventName": args.event, + "additionalContext": report, + } + } + return {"systemMessage": report} diff --git a/tests/test_hook_output_protocol.py b/tests/test_hook_output_protocol.py new file mode 100644 index 0000000..9465f0b --- /dev/null +++ b/tests/test_hook_output_protocol.py @@ -0,0 +1,63 @@ +"""Codex output regression. Source: https://learn.chatgpt.com/docs/hooks""" + +import importlib +import io +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +@pytest.mark.parametrize( + "host,command,event,result,expected", + [ + ("codex", "hook", "PostToolUse", [], {}), + ("codex", "hook", "Stop", [], {}), + ( + "codex", + "hook", + "PostToolUse", + [{"protected": "active process"}], + { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": '[{"protected": "active process"}]', + } + }, + ), + ( + "codex", + "hook", + "Stop", + [{"removed": "owned path"}], + {"systemMessage": '[{"removed": "owned path"}]'}, + ), + ( + "codex", + "hook", + "Stop", + {"decision": "block", "reason": "pending"}, + {"decision": "block", "reason": "pending"}, + ), + ("claude", "hook", "PostToolUse", [], []), + ("codex", "status", None, [], []), + ], +) +def test_main_emits_host_protocol( + monkeypatch, capsys, host, command, event, result, expected +): + monkeypatch.syspath_prepend( + str(Path(__file__).resolve().parents[1] / "plugins/disk-hygiene/hooks") + ) + module = importlib.import_module("disk_hygiene") + args = SimpleNamespace( + host=host, command=command, event=event, session="test-session" + ) + monkeypatch.setattr(module, "parse_args", lambda: args) + monkeypatch.setattr(module, "cleanup_enabled", lambda: True) + monkeypatch.setattr(module.transcript_policy, "delete_enabled", lambda: False) + monkeypatch.setattr(module, "run_command", lambda *args: result) + monkeypatch.setattr(module.sys, "stdin", io.StringIO("{}")) + module.main() + assert json.loads(capsys.readouterr().out) == expected