Merge pull request #614 from robertmonka/codex/use-user-prompt-submit-hook

Use UserPromptSubmit for Codex graph reminders
This commit is contained in:
Safi
2026-05-02 16:37:47 +01:00
committed by GitHub
3 changed files with 148 additions and 22 deletions
+3 -3
View File
@@ -97,7 +97,7 @@ pip install graphifyy && graphify install
| Cursor | `graphify cursor install` |
| Google Antigravity | `graphify antigravity install` |
Codex users also need `multi_agent = true` under `[features]` in `~/.codex/config.toml` for parallel extraction. Factory Droid uses the `Task` tool for parallel subagent dispatch. OpenClaw and Aider use sequential extraction (parallel agent support is still early on those platforms). Trae uses the Agent tool for parallel subagent dispatch and does **not** support PreToolUse hooks — AGENTS.md is the always-on mechanism. Codex supports PreToolUse hooks — `graphify codex install` installs one in `.codex/hooks.json` in addition to writing AGENTS.md.
Codex users also need `multi_agent = true` under `[features]` in `~/.codex/config.toml` for parallel extraction. Factory Droid uses the `Task` tool for parallel subagent dispatch. OpenClaw and Aider use sequential extraction (parallel agent support is still early on those platforms). Trae uses the Agent tool for parallel subagent dispatch and does **not** support PreToolUse hooks — AGENTS.md is the always-on mechanism. Codex supports UserPromptSubmit hooks — `graphify codex install` installs one in `.codex/hooks.json` in addition to writing AGENTS.md.
Then open your AI coding assistant and type:
@@ -131,7 +131,7 @@ After building a graph, run this once in your project:
**Claude Code** does two things: writes a `CLAUDE.md` section telling Claude to read `graphify-out/GRAPH_REPORT.md` before answering architecture questions, and installs a **PreToolUse hook** (`settings.json`) that fires before every Glob and Grep call. If a knowledge graph exists, Claude sees: _"graphify: Knowledge graph exists. Read GRAPH_REPORT.md for god nodes and community structure before searching raw files."_ — so Claude navigates via the graph instead of grepping through every file.
**Codex** writes to `AGENTS.md` and also installs a **PreToolUse hook** in `.codex/hooks.json` that fires before every Bash tool call — same always-on mechanism as Claude Code.
**Codex** writes to `AGENTS.md` and also installs a **UserPromptSubmit hook** in `.codex/hooks.json` that fires when the user submits a prompt. This reminds Codex about `GRAPH_REPORT.md` before it decides whether to search files.
**OpenCode** writes to `AGENTS.md` and also installs a **`tool.execute.before` plugin** (`.opencode/plugins/graphify.js` + `opencode.json` registration) that fires before bash tool calls and injects the graph reminder into tool output when the graph exists.
@@ -299,7 +299,7 @@ graphify hook status
# always-on assistant instructions - platform-specific
graphify claude install # CLAUDE.md + PreToolUse hook (Claude Code)
graphify claude uninstall
graphify codex install # AGENTS.md + PreToolUse hook in .codex/hooks.json (Codex)
graphify codex install # AGENTS.md + UserPromptSubmit hook in .codex/hooks.json (Codex)
graphify opencode install # AGENTS.md + tool.execute.before plugin (OpenCode)
graphify cursor install # .cursor/rules/graphify.mdc (Cursor)
graphify cursor uninstall
+81 -19
View File
@@ -712,15 +712,14 @@ def _uninstall_opencode_plugin(project_dir: Path) -> None:
_CODEX_HOOK = {
"hooks": {
"PreToolUse": [
"UserPromptSubmit": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": (
"[ -f graphify-out/graph.json ] && "
r"""echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."}}' """
r"""echo '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."}}' """
"|| true"
),
}
@@ -731,40 +730,103 @@ _CODEX_HOOK = {
}
_LEGACY_CODEX_PRE_TOOL_HOOK = {
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": (
"[ -f graphify-out/graph.json ] && "
r"""echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."}}' """
"|| true"
),
}
],
}
def _is_graphify_codex_hook(hook: object) -> bool:
"""Return True for Codex hooks generated by graphify."""
return hook in (
_CODEX_HOOK["hooks"]["UserPromptSubmit"][0],
_LEGACY_CODEX_PRE_TOOL_HOOK,
)
def _remove_graphify_codex_hooks(existing: dict) -> None:
"""Remove graphify Codex hooks while preserving unrelated hooks."""
hooks = existing.get("hooks")
if not isinstance(hooks, dict):
return
for event in ("UserPromptSubmit", "PreToolUse"):
event_hooks = hooks.get(event)
if not isinstance(event_hooks, list):
continue
filtered = [h for h in event_hooks if not _is_graphify_codex_hook(h)]
if filtered:
hooks[event] = filtered
else:
hooks.pop(event, None)
if not hooks:
existing.pop("hooks", None)
def _install_codex_hook(project_dir: Path) -> None:
"""Add graphify PreToolUse hook to .codex/hooks.json."""
"""Add graphify UserPromptSubmit hook to .codex/hooks.json."""
hooks_path = project_dir / ".codex" / "hooks.json"
hooks_path.parent.mkdir(parents=True, exist_ok=True)
if hooks_path.exists():
try:
existing = json.loads(hooks_path.read_text(encoding="utf-8"))
loaded = json.loads(hooks_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
existing = {}
print(" .codex/hooks.json -> invalid JSON; left unchanged")
return
if not isinstance(loaded, dict):
print(" .codex/hooks.json -> top-level value is not an object; left unchanged")
return
existing = loaded
else:
existing = {}
pre_tool = existing.setdefault("hooks", {}).setdefault("PreToolUse", [])
existing["hooks"]["PreToolUse"] = [h for h in pre_tool if "graphify" not in str(h)]
existing["hooks"]["PreToolUse"].extend(_CODEX_HOOK["hooks"]["PreToolUse"])
_remove_graphify_codex_hooks(existing)
hooks = existing.get("hooks")
if hooks is None:
hooks = existing["hooks"] = {}
elif not isinstance(hooks, dict):
print(" .codex/hooks.json -> existing hooks value is not an object; left unchanged")
return
user_prompt = hooks.get("UserPromptSubmit")
if user_prompt is None:
user_prompt = hooks["UserPromptSubmit"] = []
elif not isinstance(user_prompt, list):
print(" .codex/hooks.json -> UserPromptSubmit hooks are not a list; left unchanged")
return
user_prompt.extend(_CODEX_HOOK["hooks"]["UserPromptSubmit"])
hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
print(f" .codex/hooks.json -> PreToolUse hook registered")
print(f" .codex/hooks.json -> UserPromptSubmit hook registered")
def _uninstall_codex_hook(project_dir: Path) -> None:
"""Remove graphify PreToolUse hook from .codex/hooks.json."""
"""Remove graphify Codex hooks from .codex/hooks.json."""
hooks_path = project_dir / ".codex" / "hooks.json"
if not hooks_path.exists():
return
try:
existing = json.loads(hooks_path.read_text(encoding="utf-8"))
loaded = json.loads(hooks_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return
pre_tool = existing.get("hooks", {}).get("PreToolUse", [])
filtered = [h for h in pre_tool if "graphify" not in str(h)]
existing["hooks"]["PreToolUse"] = filtered
hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
print(f" .codex/hooks.json -> PreToolUse hook removed")
if not isinstance(loaded, dict):
return
before = json.dumps(loaded, sort_keys=True)
_remove_graphify_codex_hooks(loaded)
if json.dumps(loaded, sort_keys=True) != before:
hooks_path.write_text(json.dumps(loaded, indent=2), encoding="utf-8")
print(f" .codex/hooks.json -> graphify hooks removed")
def _agents_install(project_dir: Path, platform: str) -> None:
@@ -1024,8 +1086,8 @@ def main() -> None:
print(" cursor uninstall remove .cursor/rules/graphify.mdc")
print(" claude install write graphify section to CLAUDE.md + PreToolUse hook (Claude Code)")
print(" claude uninstall remove graphify section from CLAUDE.md + PreToolUse hook")
print(" codex install write graphify section to AGENTS.md (Codex)")
print(" codex uninstall remove graphify section from AGENTS.md")
print(" codex install write AGENTS.md section + UserPromptSubmit hook (Codex)")
print(" codex uninstall remove AGENTS.md section + graphify Codex hook")
print(" opencode install write graphify section to AGENTS.md + tool.execute.before plugin (OpenCode)")
print(" opencode uninstall remove graphify section from AGENTS.md + plugin")
print(" aider install write graphify section to AGENTS.md (Aider)")
+64
View File
@@ -1,4 +1,5 @@
"""Tests for graphify install --platform routing."""
import json
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -129,6 +130,69 @@ def test_codex_agents_install_writes_agents_md(tmp_path):
assert "GRAPH_REPORT.md" in agents_md.read_text()
def test_codex_agents_install_writes_user_prompt_submit_hook(tmp_path):
_agents_install(tmp_path, "codex")
hooks_path = tmp_path / ".codex" / "hooks.json"
hooks = json.loads(hooks_path.read_text())
command = hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
assert "UserPromptSubmit" in hooks["hooks"]
assert "PreToolUse" not in hooks["hooks"]
assert "UserPromptSubmit" in command
assert "additionalContext" in command
assert "graphify-out/GRAPH_REPORT.md" in command
def test_codex_agents_install_removes_only_graphify_generated_hooks(tmp_path):
from graphify.__main__ import _LEGACY_CODEX_PRE_TOOL_HOOK
unrelated_hook = {
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo graphify for my own plugin",
}
],
}
hooks_path = tmp_path / ".codex" / "hooks.json"
hooks_path.parent.mkdir(parents=True)
hooks_path.write_text(json.dumps({
"hooks": {
"PreToolUse": [
_LEGACY_CODEX_PRE_TOOL_HOOK,
unrelated_hook,
]
}
}))
_agents_install(tmp_path, "codex")
hooks = json.loads(hooks_path.read_text())
pre_tool = hooks["hooks"].get("PreToolUse", [])
user_prompt = hooks["hooks"].get("UserPromptSubmit", [])
assert pre_tool == [unrelated_hook]
assert len(user_prompt) == 1
assert "graphify-out/GRAPH_REPORT.md" in user_prompt[0]["hooks"][0]["command"]
def test_codex_agents_install_preserves_non_object_hooks_value(tmp_path):
hooks_path = tmp_path / ".codex" / "hooks.json"
hooks_path.parent.mkdir(parents=True)
hooks_path.write_text(json.dumps({
"hooks": ["recoverable user config"],
"other": {"keep": True},
}))
_agents_install(tmp_path, "codex")
hooks = json.loads(hooks_path.read_text())
assert hooks["hooks"] == ["recoverable user config"]
assert hooks["other"] == {"keep": True}
def test_opencode_agents_install_writes_agents_md(tmp_path):
_agents_install(tmp_path, "opencode")
assert (tmp_path / "AGENTS.md").exists()