Skip to content

Commit 626a3e6

Browse files
authored
fix(codex): add official hooks.json integration
fix(codex): add official hooks.json integration
2 parents 1d1a4e1 + 9d56d37 commit 626a3e6

File tree

13 files changed

+558
-32
lines changed

13 files changed

+558
-32
lines changed

.codex/hooks.json

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"hooks": {
3+
"SessionStart": [
4+
{
5+
"matcher": "startup|resume",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "sh .codex/hooks/session-start.sh 2>/dev/null || sh \"$HOME/.codex/hooks/session-start.sh\" 2>/dev/null || true",
10+
"statusMessage": "Loading planning context"
11+
}
12+
]
13+
}
14+
],
15+
"UserPromptSubmit": [
16+
{
17+
"hooks": [
18+
{
19+
"type": "command",
20+
"command": "sh .codex/hooks/user-prompt-submit.sh 2>/dev/null || sh \"$HOME/.codex/hooks/user-prompt-submit.sh\" 2>/dev/null || true"
21+
}
22+
]
23+
}
24+
],
25+
"PreToolUse": [
26+
{
27+
"matcher": "Bash",
28+
"hooks": [
29+
{
30+
"type": "command",
31+
"command": "python3 .codex/hooks/pre_tool_use.py 2>/dev/null || python3 \"$HOME/.codex/hooks/pre_tool_use.py\" 2>/dev/null || true",
32+
"statusMessage": "Checking plan before Bash"
33+
}
34+
]
35+
}
36+
],
37+
"PostToolUse": [
38+
{
39+
"matcher": "Bash",
40+
"hooks": [
41+
{
42+
"type": "command",
43+
"command": "python3 .codex/hooks/post_tool_use.py 2>/dev/null || python3 \"$HOME/.codex/hooks/post_tool_use.py\" 2>/dev/null || true",
44+
"statusMessage": "Reviewing Bash against plan"
45+
}
46+
]
47+
}
48+
],
49+
"Stop": [
50+
{
51+
"hooks": [
52+
{
53+
"type": "command",
54+
"command": "python3 .codex/hooks/stop.py 2>/dev/null || python3 \"$HOME/.codex/hooks/stop.py\" 2>/dev/null || true",
55+
"timeout": 30
56+
}
57+
]
58+
}
59+
]
60+
}
61+
}

.codex/hooks/codex_hook_adapter.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import json
5+
import subprocess
6+
import sys
7+
from pathlib import Path
8+
from typing import Any
9+
10+
11+
HOOK_DIR = Path(__file__).resolve().parent
12+
13+
14+
def load_payload() -> dict[str, Any]:
15+
raw = sys.stdin.read().strip()
16+
if not raw:
17+
return {}
18+
try:
19+
payload = json.loads(raw)
20+
except json.JSONDecodeError:
21+
return {}
22+
return payload if isinstance(payload, dict) else {}
23+
24+
25+
def cwd_from_payload(payload: dict[str, Any]) -> Path:
26+
cwd = payload.get("cwd")
27+
if isinstance(cwd, str) and cwd:
28+
return Path(cwd)
29+
return Path.cwd()
30+
31+
32+
def emit_json(payload: dict[str, Any]) -> None:
33+
if not payload:
34+
return
35+
json.dump(payload, sys.stdout, ensure_ascii=False)
36+
sys.stdout.write("\n")
37+
38+
39+
def parse_json(text: str) -> dict[str, Any]:
40+
if not text.strip():
41+
return {}
42+
try:
43+
payload = json.loads(text)
44+
except json.JSONDecodeError:
45+
return {}
46+
return payload if isinstance(payload, dict) else {}
47+
48+
49+
def run_shell_script(script_name: str, cwd: Path) -> tuple[str, str]:
50+
result = subprocess.run(
51+
["sh", str(HOOK_DIR / script_name)],
52+
cwd=str(cwd),
53+
text=True,
54+
capture_output=True,
55+
check=False,
56+
)
57+
return result.stdout.strip(), result.stderr.strip()
58+
59+
60+
def main_guard(func) -> int:
61+
try:
62+
func()
63+
except Exception as exc: # pragma: no cover
64+
print(f"[planning-with-files hook] {exc}", file=sys.stderr)
65+
return 0
66+
return 0

.codex/hooks/post-tool-use.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/bin/bash
2+
# planning-with-files: Post-tool-use hook for Codex
3+
# Reused from the Cursor integration.
4+
5+
if [ -f task_plan.md ]; then
6+
echo "[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."
7+
fi
8+
exit 0

.codex/hooks/post_tool_use.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import codex_hook_adapter as adapter
5+
6+
7+
def main() -> None:
8+
payload = adapter.load_payload()
9+
root = adapter.cwd_from_payload(payload)
10+
stdout, _ = adapter.run_shell_script("post-tool-use.sh", root)
11+
if stdout:
12+
adapter.emit_json({"systemMessage": stdout})
13+
14+
15+
if __name__ == "__main__":
16+
raise SystemExit(adapter.main_guard(main))

.codex/hooks/pre-tool-use.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/bin/bash
2+
# planning-with-files: Pre-tool-use hook for Codex
3+
# Reused from the Cursor integration.
4+
5+
PLAN_FILE="task_plan.md"
6+
7+
if [ -f "$PLAN_FILE" ]; then
8+
# Log plan context to stderr so the Codex adapter can surface it as systemMessage.
9+
head -30 "$PLAN_FILE" >&2
10+
fi
11+
12+
echo '{"decision": "allow"}'
13+
exit 0

.codex/hooks/pre_tool_use.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import codex_hook_adapter as adapter
5+
6+
7+
def main() -> None:
8+
payload = adapter.load_payload()
9+
root = adapter.cwd_from_payload(payload)
10+
stdout, stderr = adapter.run_shell_script("pre-tool-use.sh", root)
11+
12+
result = adapter.parse_json(stdout)
13+
decision = result.get("decision")
14+
if decision and decision != "allow":
15+
adapter.emit_json(result)
16+
return
17+
18+
if stderr:
19+
adapter.emit_json({"systemMessage": stderr})
20+
21+
22+
if __name__ == "__main__":
23+
raise SystemExit(adapter.main_guard(main))

.codex/hooks/session-start.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/bin/sh
2+
# planning-with-files: SessionStart hook for Codex
3+
# Runs session catchup, then reuses the same prompt context hook as UserPromptSubmit.
4+
5+
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
6+
CODEX_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
7+
SKILL_DIR="$CODEX_ROOT/skills/planning-with-files"
8+
PYTHON_BIN="${PYTHON_BIN:-$(command -v python3 || command -v python)}"
9+
10+
if [ -n "$PYTHON_BIN" ] && [ -f "$SKILL_DIR/scripts/session-catchup.py" ]; then
11+
"$PYTHON_BIN" "$SKILL_DIR/scripts/session-catchup.py" "$(pwd)"
12+
fi
13+
14+
sh "$SCRIPT_DIR/user-prompt-submit.sh"
15+
exit 0

.codex/hooks/stop.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import codex_hook_adapter as adapter
5+
6+
7+
def main() -> None:
8+
payload = adapter.load_payload()
9+
root = adapter.cwd_from_payload(payload)
10+
stdout, _ = adapter.run_shell_script("stop.sh", root)
11+
result = adapter.parse_json(stdout)
12+
13+
message = result.get("followup_message")
14+
if not isinstance(message, str) or not message:
15+
return
16+
17+
if "ALL PHASES COMPLETE" in message:
18+
adapter.emit_json({"systemMessage": message})
19+
return
20+
21+
if bool(payload.get("stop_hook_active")):
22+
adapter.emit_json({"systemMessage": message})
23+
return
24+
25+
adapter.emit_json({"decision": "block", "reason": message})
26+
27+
28+
if __name__ == "__main__":
29+
raise SystemExit(adapter.main_guard(main))

.codex/hooks/stop.sh

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/bin/bash
2+
# planning-with-files: Stop hook for Codex
3+
# Reused from the Cursor integration; Codex adapts followup_message separately.
4+
5+
PLAN_FILE="task_plan.md"
6+
7+
if [ ! -f "$PLAN_FILE" ]; then
8+
exit 0
9+
fi
10+
11+
TOTAL=$(grep -c "### Phase" "$PLAN_FILE" || true)
12+
COMPLETE=$(grep -cF "**Status:** complete" "$PLAN_FILE" || true)
13+
IN_PROGRESS=$(grep -cF "**Status:** in_progress" "$PLAN_FILE" || true)
14+
PENDING=$(grep -cF "**Status:** pending" "$PLAN_FILE" || true)
15+
16+
if [ "$COMPLETE" -eq 0 ] && [ "$IN_PROGRESS" -eq 0 ] && [ "$PENDING" -eq 0 ]; then
17+
COMPLETE=$(grep -c "\[complete\]" "$PLAN_FILE" || true)
18+
IN_PROGRESS=$(grep -c "\[in_progress\]" "$PLAN_FILE" || true)
19+
PENDING=$(grep -c "\[pending\]" "$PLAN_FILE" || true)
20+
fi
21+
22+
: "${TOTAL:=0}"
23+
: "${COMPLETE:=0}"
24+
: "${IN_PROGRESS:=0}"
25+
: "${PENDING:=0}"
26+
27+
if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
28+
echo "{\"followup_message\": \"[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting.\"}"
29+
exit 0
30+
fi
31+
32+
echo "{\"followup_message\": \"[planning-with-files] Task incomplete ($COMPLETE/$TOTAL phases done). Update progress.md, then read task_plan.md and continue working on the remaining phases.\"}"
33+
exit 0

.codex/hooks/user-prompt-submit.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/bash
2+
# planning-with-files: User prompt submit hook for Codex
3+
# Reused from the Cursor integration.
4+
5+
if [ -f task_plan.md ]; then
6+
echo "[planning-with-files] ACTIVE PLAN — current state:"
7+
head -50 task_plan.md
8+
echo ""
9+
echo "=== recent progress ==="
10+
tail -20 progress.md 2>/dev/null
11+
echo ""
12+
echo "[planning-with-files] Read findings.md for research context. Continue from the current phase."
13+
fi
14+
exit 0

0 commit comments

Comments
 (0)