fix: document the Codex PreToolUse hook as an intentional no-op (#2165)

`graphify codex install` registers `graphify hook-check` in .codex/hooks.json,
and #2165 reported that as a stale/unrecognized subcommand producing a silent
no-op. `hook-check` is in fact a real, deliberate no-op command: Codex Desktop
rejects hookSpecificOutput.additionalContext on PreToolUse, so the Codex hook
intentionally does nothing and AGENTS.md carries the always-on guidance
(cli.py dispatches `hook-check`; __main__.py lists it in _silent_cmds).
Repointing the installer at `hook-guard` would reintroduce the #522-class
breakage on Codex Desktop, so the behavior is left as is.

What actually misled the report was the documentation and the installer's own
output, which both describe the Codex hook as if it enforced graph usage:

- README: the Codex row claimed a PreToolUse hook that "fires before every Bash
  tool call, same always-on mechanism as Claude Code". It now states that the
  hook is a deliberate no-op, why (Codex Desktop rejects additionalContext), and
  that AGENTS.md is the always-on mechanism on this platform.
- `_install_codex_hook` printed "PreToolUse hook registered (... hook-check)"
  with no hint that the entry is inert. It now says so inline.

Also adds the regression guard the issue implicitly asks for: a test that reads
the command out of the generated .codex/hooks.json and asserts its subcommand is
one the CLI actually dispatches. A genuinely renamed/stale hook command now
fails the suite instead of shipping a permanently dead hook.

Note: contrary to the report, an unrecognized subcommand already exits non-zero
(`graphify totally-bogus-subcommand` -> "error: unknown command", exit 1), so no
change was needed there.
This commit is contained in:
Souptik Chakraborty
2026-07-26 11:10:35 +01:00
committed by safishamsi
parent ffa2a2471a
commit 911d58178f
3 changed files with 64 additions and 2 deletions
+1 -1
View File
@@ -307,7 +307,7 @@ This writes a small config file that tells your assistant to consult the knowled
**CodeBuddy** does the same two things as Claude Code: writes a `CODEBUDDY.md` section telling CodeBuddy to read `graphify-out/GRAPH_REPORT.md` before answering architecture questions, and installs `PreToolUse` hooks (`.codebuddy/settings.json`) that fire before Bash search commands and file reads, nudging toward `graphify query` instead.
**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`, which is what actually carries the always-on graph guidance on this platform. `graphify codex install` also registers a `PreToolUse` hook in `.codex/hooks.json` (`graphify hook-check`), but that entry is deliberately a **no-op**: Codex Desktop rejects `hookSpecificOutput.additionalContext` on `PreToolUse`, so emitting a nudge there would break Bash tool calls. Unlike Claude Code, where the hook (`graphify hook-guard`) does the nudging, on Codex the hook fires and intentionally does nothing, and `AGENTS.md` is the always-on mechanism.
**Kilo Code** installs the Graphify skill to `~/.config/kilo/skills/graphify/SKILL.md` and a native `/graphify` command to `~/.config/kilo/command/graphify.md`. `graphify kilo install` also writes `AGENTS.md` plus a native `tool.execute.before` plugin (`.kilo/plugins/graphify.js` + `.kilo/kilo.json` or `.kilo/kilo.jsonc` registration) so Kilo gets the same always-on graph reminder behavior through native `.kilo` config.
+7 -1
View File
@@ -1428,7 +1428,13 @@ def _install_codex_hook(project_dir: Path) -> None:
hooks["PreToolUse"] = [h for h in pre_tool if "graphify" not in str(h)]
hooks["PreToolUse"].extend(hook_entry["hooks"]["PreToolUse"])
_write_settings_with_backup(hooks_path, existing)
print(f" .codex/hooks.json -> PreToolUse hook registered ({graphify_exe} hook-check)")
print(
f" .codex/hooks.json -> PreToolUse hook registered ({graphify_exe} hook-check"
" - intentional no-op; Codex Desktop rejects additionalContext on PreToolUse,"
" so graph guidance comes from AGENTS.md)"
)
def _uninstall_codex_hook(project_dir: Path) -> None:
"""Remove graphify PreToolUse hook from .codex/hooks.json."""
hooks_path = project_dir / ".codex" / "hooks.json"
+56
View File
@@ -1116,3 +1116,59 @@ def test_hermes_skill_destination_posix_uses_home():
with patch("graphify.__main__.platform.system", return_value="Linux"):
dst = _platform_skill_destination("hermes", project=False)
assert str(dst).endswith(".hermes/skills/graphify/SKILL.md"), dst
def _cli_dispatched_commands() -> set[str]:
"""Subcommand names the CLI actually dispatches.
`graphify`'s dispatcher is an `elif cmd == "..."` chain rather than a declarative
table, so the set is read back out of the source. Used to prove a hook command
written by an installer is not a stale/renamed subcommand (#2165).
"""
import re
from graphify import cli
source = Path(cli.__file__).read_text(encoding="utf-8")
names = set(re.findall(r'cmd\s*==\s*"([a-z0-9][a-z0-9-]*)"', source))
names |= {
m
for group in re.findall(r'cmd\s+in\s+\(([^)]*)\)', source)
for m in re.findall(r'"([a-z0-9][a-z0-9-]*)"', group)
}
return names
def test_codex_hook_command_is_a_real_cli_subcommand(tmp_path):
"""#2165: the PreToolUse command in .codex/hooks.json must be a command the CLI
dispatches, so a renamed subcommand can never leave a permanently dead hook.
`hook-check` is intentionally a no-op on Codex (Codex Desktop rejects
additionalContext on PreToolUse), but it must still be a *recognized* command --
an unrecognized one exits non-zero and would break every Bash tool call.
"""
import json
from graphify.install import _install_codex_hook
_install_codex_hook(tmp_path)
hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8"))
entries = [
h
for group in hooks["hooks"]["PreToolUse"]
for h in group["hooks"]
if "graphify" in h.get("command", "")
]
assert entries, "codex install must register a graphify PreToolUse hook"
dispatched = _cli_dispatched_commands()
assert "hook-check" in dispatched, "sanity: parser must find known commands"
for entry in entries:
# command is "<abs exe path> <subcommand> [args...]"
parts = entry["command"].split()
subcommand = parts[1] if len(parts) > 1 else ""
assert subcommand in dispatched, (
f"codex hook registers {subcommand!r}, which the CLI does not dispatch "
f"(#2165). Known commands: {sorted(dispatched)}"
)