diff --git a/codex/plugins/ox/.codex-plugin/plugin.json b/codex/plugins/ox/.codex-plugin/plugin.json index ce2b5aa..a98126a 100644 --- a/codex/plugins/ox/.codex-plugin/plugin.json +++ b/codex/plugins/ox/.codex-plugin/plugin.json @@ -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" diff --git a/codex/plugins/ox/hooks.json b/codex/plugins/ox/hooks.json index 9ea323d..b62db17 100644 --- a/codex/plugins/ox/hooks.json +++ b/codex/plugins/ox/hooks.json @@ -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" } @@ -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" } diff --git a/codex/plugins/ox/scripts/run_if_changed.py b/codex/plugins/ox/scripts/run_if_changed.py index 93920a1..0203962 100644 --- a/codex/plugins/ox/scripts/run_if_changed.py +++ b/codex/plugins/ox/scripts/run_if_changed.py @@ -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" @@ -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) diff --git a/plugins/ox/.claude-plugin/plugin.json b/plugins/ox/.claude-plugin/plugin.json index eaccc2e..f7b6ccb 100644 --- a/plugins/ox/.claude-plugin/plugin.json +++ b/plugins/ox/.claude-plugin/plugin.json @@ -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" } diff --git a/plugins/ox/scripts/run_if_changed.py b/plugins/ox/scripts/run_if_changed.py index 93920a1..0203962 100644 --- a/plugins/ox/scripts/run_if_changed.py +++ b/plugins/ox/scripts/run_if_changed.py @@ -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" @@ -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) diff --git a/tests/ox/test_run_if_changed.py b/tests/ox/test_run_if_changed.py index 91f16a3..95c0b1b 100644 --- a/tests/ox/test_run_if_changed.py +++ b/tests/ox/test_run_if_changed.py @@ -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), @@ -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"