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
2 changes: 1 addition & 1 deletion codex/plugins/ox/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ox",
"version": "0.0.21",
"version": "0.0.22",
"description": "Base plugin — commit command, code quality hooks, auto-format and check hooks for all projects",
"author": {
"name": "Oxidian"
Expand Down
4 changes: 2 additions & 2 deletions codex/plugins/ox/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "sh -c 'root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; bootstrap_dir=\"${CODEX_PLUGINS_BOOTSTRAP_DIR:-.codex/cc-plugins}\"; case \"$bootstrap_dir\" in /*) bootstrap_root=\"$bootstrap_dir\" ;; *) bootstrap_root=\"$root/$bootstrap_dir\" ;; esac; bootstrap_runner=\"$bootstrap_root/codex/plugins/ox/scripts/run_if_changed.py\"; repo_runner=\"$root/codex/plugins/ox/scripts/run_if_changed.py\"; cache_runner=\"$HOME/.codex/plugins/cache/oxidian/ox/0.0.21/scripts/run_if_changed.py\"; if [ -f \"$bootstrap_runner\" ]; then runner=\"$bootstrap_runner\"; elif [ -f \"$repo_runner\" ]; then runner=\"$repo_runner\"; elif [ -f \"$cache_runner\" ]; then runner=\"$cache_runner\"; else echo \"ox hook runner not found; checked $bootstrap_runner, $repo_runner, and $cache_runner\" >&2; exit 2; fi; exec python3 \"$runner\" --runtime codex --action fast'",
"command": "sh -c 'root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; bootstrap_dir=\"${CODEX_PLUGINS_BOOTSTRAP_DIR:-.codex/cc-plugins}\"; case \"$bootstrap_dir\" in /*) bootstrap_root=\"$bootstrap_dir\" ;; *) bootstrap_root=\"$root/$bootstrap_dir\" ;; esac; bootstrap_runner=\"$bootstrap_root/codex/plugins/ox/scripts/run_if_changed.py\"; repo_runner=\"$root/codex/plugins/ox/scripts/run_if_changed.py\"; cache_runner=\"$HOME/.codex/plugins/cache/oxidian/ox/0.0.22/scripts/run_if_changed.py\"; if [ -f \"$bootstrap_runner\" ]; then runner=\"$bootstrap_runner\"; elif [ -f \"$repo_runner\" ]; then runner=\"$repo_runner\"; elif [ -f \"$cache_runner\" ]; then runner=\"$cache_runner\"; else echo \"ox hook runner not found; checked $bootstrap_runner, $repo_runner, and $cache_runner\" >&2; exit 2; fi; exec python3 \"$runner\" --runtime codex --action fast'",
"timeout": 30,
"statusMessage": "Running fast checks"
}
Expand All @@ -18,7 +18,7 @@
"hooks": [
{
"type": "command",
"command": "sh -c 'root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; bootstrap_dir=\"${CODEX_PLUGINS_BOOTSTRAP_DIR:-.codex/cc-plugins}\"; case \"$bootstrap_dir\" in /*) bootstrap_root=\"$bootstrap_dir\" ;; *) bootstrap_root=\"$root/$bootstrap_dir\" ;; esac; bootstrap_runner=\"$bootstrap_root/codex/plugins/ox/scripts/run_if_changed.py\"; repo_runner=\"$root/codex/plugins/ox/scripts/run_if_changed.py\"; cache_runner=\"$HOME/.codex/plugins/cache/oxidian/ox/0.0.21/scripts/run_if_changed.py\"; if [ -f \"$bootstrap_runner\" ]; then runner=\"$bootstrap_runner\"; elif [ -f \"$repo_runner\" ]; then runner=\"$repo_runner\"; elif [ -f \"$cache_runner\" ]; then runner=\"$cache_runner\"; else echo \"ox hook runner not found; checked $bootstrap_runner, $repo_runner, and $cache_runner\" >&2; exit 2; fi; exec python3 \"$runner\" --runtime codex --action slow'",
"command": "sh -c 'root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; bootstrap_dir=\"${CODEX_PLUGINS_BOOTSTRAP_DIR:-.codex/cc-plugins}\"; case \"$bootstrap_dir\" in /*) bootstrap_root=\"$bootstrap_dir\" ;; *) bootstrap_root=\"$root/$bootstrap_dir\" ;; esac; bootstrap_runner=\"$bootstrap_root/codex/plugins/ox/scripts/run_if_changed.py\"; repo_runner=\"$root/codex/plugins/ox/scripts/run_if_changed.py\"; cache_runner=\"$HOME/.codex/plugins/cache/oxidian/ox/0.0.22/scripts/run_if_changed.py\"; if [ -f \"$bootstrap_runner\" ]; then runner=\"$bootstrap_runner\"; elif [ -f \"$repo_runner\" ]; then runner=\"$repo_runner\"; elif [ -f \"$cache_runner\" ]; then runner=\"$cache_runner\"; else echo \"ox hook runner not found; checked $bootstrap_runner, $repo_runner, and $cache_runner\" >&2; exit 2; fi; exec python3 \"$runner\" --runtime codex --action slow'",
"timeout": 120,
"statusMessage": "Running final checks"
}
Expand Down
54 changes: 53 additions & 1 deletion codex/plugins/ox/scripts/run_if_changed.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,58 @@ def _codex_project_dir(hook_input: dict | None) -> str:
return _git_root_or_cwd(os.getcwd())


def _has_plan_mode_marker(data: dict) -> bool:
"""Return True when a hook or transcript record identifies plan mode."""
if data.get("permission_mode") == "plan":
return True
if data.get("collaboration_mode_kind") == "plan":
return True

collaboration_mode = data.get("collaboration_mode")
if collaboration_mode == "plan":
return True
if isinstance(collaboration_mode, dict) and collaboration_mode.get("mode") == "plan":
return True

return False


def _transcript_turn_is_plan_mode(transcript_path: object, turn_id: object) -> bool:
"""Best-effort Codex transcript fallback for plan-mode Stop hooks."""
if not isinstance(transcript_path, str) or not transcript_path:
return False
if not isinstance(turn_id, str) or not turn_id:
return False

try:
with open(transcript_path) as f:
for line in f:
if not line.strip():
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(record, dict):
continue
if record.get("turn_id") == turn_id and _has_plan_mode_marker(record):
return True
except (OSError, UnicodeDecodeError):
return False

return False


def _is_codex_plan_mode(hook_input: dict | None) -> bool:
"""Detect Codex plan-mode turns without depending on project config."""
if not hook_input:
return False
if _has_plan_mode_marker(hook_input):
return True

return _transcript_turn_is_plan_mode(hook_input.get("transcript_path"), hook_input.get("turn_id"))


def _codex_failure_feedback(action: str, failure_outputs: list[str]) -> str:
"""Build the continuation prompt Codex receives when checks fail."""
check_name = "Final checks" if action == "slow" else "Fast checks"
Expand Down Expand Up @@ -376,7 +428,7 @@ def main() -> None:
if not project_dir:
parser.error("--project-dir is required unless --runtime codex can derive cwd")

if args.runtime == RUNTIME_CODEX and hook_input and hook_input.get("permission_mode") == "plan":
if args.runtime == RUNTIME_CODEX and _is_codex_plan_mode(hook_input):
_emit(args.runtime, "Plan mode active, skipping")
sys.exit(SUCCESS_CODE)

Expand Down
2 changes: 1 addition & 1 deletion plugins/ox/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ox",
"description": "Base plugin — commit command, code quality hooks, auto-format and check hooks for all projects",
"version": "0.0.21",
"version": "0.0.22",
"author": {
"name": "Oxidian"
}
Expand Down
54 changes: 53 additions & 1 deletion plugins/ox/scripts/run_if_changed.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,58 @@ def _codex_project_dir(hook_input: dict | None) -> str:
return _git_root_or_cwd(os.getcwd())


def _has_plan_mode_marker(data: dict) -> bool:
"""Return True when a hook or transcript record identifies plan mode."""
if data.get("permission_mode") == "plan":
return True
if data.get("collaboration_mode_kind") == "plan":
return True

collaboration_mode = data.get("collaboration_mode")
if collaboration_mode == "plan":
return True
if isinstance(collaboration_mode, dict) and collaboration_mode.get("mode") == "plan":
return True

return False


def _transcript_turn_is_plan_mode(transcript_path: object, turn_id: object) -> bool:
"""Best-effort Codex transcript fallback for plan-mode Stop hooks."""
if not isinstance(transcript_path, str) or not transcript_path:
return False
if not isinstance(turn_id, str) or not turn_id:
return False

try:
with open(transcript_path) as f:
for line in f:
if not line.strip():
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(record, dict):
continue
if record.get("turn_id") == turn_id and _has_plan_mode_marker(record):
return True
except (OSError, UnicodeDecodeError):
return False

return False


def _is_codex_plan_mode(hook_input: dict | None) -> bool:
"""Detect Codex plan-mode turns without depending on project config."""
if not hook_input:
return False
if _has_plan_mode_marker(hook_input):
return True

return _transcript_turn_is_plan_mode(hook_input.get("transcript_path"), hook_input.get("turn_id"))


def _codex_failure_feedback(action: str, failure_outputs: list[str]) -> str:
"""Build the continuation prompt Codex receives when checks fail."""
check_name = "Final checks" if action == "slow" else "Fast checks"
Expand Down Expand Up @@ -376,7 +428,7 @@ def main() -> None:
if not project_dir:
parser.error("--project-dir is required unless --runtime codex can derive cwd")

if args.runtime == RUNTIME_CODEX and hook_input and hook_input.get("permission_mode") == "plan":
if args.runtime == RUNTIME_CODEX and _is_codex_plan_mode(hook_input):
_emit(args.runtime, "Plan mode active, skipping")
sys.exit(SUCCESS_CODE)

Expand Down
102 changes: 101 additions & 1 deletion tests/ox/test_run_if_changed.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,21 @@ def _init_branch_changed_repo(tmp_path: Path, command: str, *, base_ref: str = "
return subdir


def _run_codex_hook(cwd: Path, action: str, *, permission_mode: str = "default") -> subprocess.CompletedProcess[str]:
def _run_codex_hook(
cwd: Path,
action: str,
*,
permission_mode: str = "default",
extra_payload: dict | None = None,
) -> subprocess.CompletedProcess[str]:
payload = {
"session_id": f"test-{action}",
"cwd": str(cwd),
"hook_event_name": "Stop" if action == "slow" else "PostToolUse",
"permission_mode": permission_mode,
}
if extra_payload:
payload.update(extra_payload)
return subprocess.run(
[sys.executable, str(_script_path), "--runtime", "codex", "--action", action],
input=json.dumps(payload),
Expand Down Expand Up @@ -314,6 +322,98 @@ def test_plan_mode_slow_skips_before_config_parsing(self, tmp_path: Path) -> Non
assert result.stdout == ""
assert result.stderr == ""

def test_plan_mode_slow_skips_direct_collaboration_mode_kind(self, tmp_path: Path) -> None:
subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True, text=True)
(tmp_path / ".claude").mkdir()
(tmp_path / ".claude" / "ox-hooks.json").write_text("{not json\n")
(tmp_path / "changed.txt").write_text("changed\n")
subdir = tmp_path / "subdir"
subdir.mkdir()

result = _run_codex_hook(
subdir,
"slow",
extra_payload={
"permission_mode": "default",
"collaboration_mode_kind": "plan",
},
)

assert result.returncode == 0
assert result.stdout == ""
assert result.stderr == ""

def test_plan_mode_slow_skips_direct_collaboration_mode_object(self, tmp_path: Path) -> None:
subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True, text=True)
(tmp_path / ".claude").mkdir()
(tmp_path / ".claude" / "ox-hooks.json").write_text("{not json\n")
(tmp_path / "changed.txt").write_text("changed\n")
subdir = tmp_path / "subdir"
subdir.mkdir()

result = _run_codex_hook(
subdir,
"slow",
extra_payload={
"permission_mode": "default",
"collaboration_mode": {"mode": "plan"},
},
)

assert result.returncode == 0
assert result.stdout == ""
assert result.stderr == ""

def test_plan_mode_slow_skips_when_matching_transcript_turn_is_plan(self, tmp_path: Path) -> None:
check_script = tmp_path / "check.py"
check_script.write_text("import sys\nprint('bad check output')\nsys.exit(1)\n")
subdir = _init_changed_repo(tmp_path, _command_for_script(check_script))
transcript = tmp_path / "transcript.jsonl"
transcript.write_text(
"\n".join(
[
json.dumps({"turn_id": "other", "collaboration_mode_kind": "default"}),
json.dumps({"turn_id": "turn-1", "collaboration_mode_kind": "plan"}),
]
)
+ "\n"
)

result = _run_codex_hook(
subdir,
"slow",
extra_payload={
"permission_mode": "default",
"transcript_path": str(transcript),
"turn_id": "turn-1",
},
)

assert result.returncode == 0
assert result.stdout == ""
assert result.stderr == ""

def test_non_plan_transcript_turn_runs_slow_checks(self, tmp_path: Path) -> None:
check_script = tmp_path / "check.py"
check_script.write_text("import sys\nprint('bad check output')\nsys.exit(1)\n")
subdir = _init_changed_repo(tmp_path, _command_for_script(check_script))
transcript = tmp_path / "transcript.jsonl"
transcript.write_text(json.dumps({"turn_id": "turn-1", "collaboration_mode": {"mode": "default"}}) + "\n")

result = _run_codex_hook(
subdir,
"slow",
extra_payload={
"permission_mode": "default",
"transcript_path": str(transcript),
"turn_id": "turn-1",
},
)

assert result.returncode == 2
assert "Final checks failed. Fix these issues before finishing." in result.stderr
assert "bad check output" in result.stderr

def test_slow_runs_for_committed_branch_changes(self, tmp_path: Path) -> None:
marker = tmp_path / "marker.txt"
check_script = tmp_path / "check.py"
Expand Down
Loading