Repository navigation
Add Codex and Claude Code push and PR hooks #620
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
romainbrenguier
merged 3 commits into
chore/ai-experiment-agent-readiness
from
romain/codex-hooks
Nov 12, 2026
+881
−0
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # Repository Claude Code checks | ||
|
|
||
| `.claude/settings.json` installs a Bash PreToolUse hook and a Stop hook. Both use the same committed-HEAD checks, saved results, and PR status logic as the [Codex hook](../.codex/HOOKS.md). No check runs at commit time. | ||
|
|
||
| Run `git push` as its own Bash call; `git push 2>&1 | tail -30` is supported. Shell wrapped pushes are blocked with instructions to run a standalone push. Before push, the hook checks the committed diff and runs `mvn clean verify -Pcoverage -DskipTests=false -B` in an isolated checkout of `HEAD`. A failure blocks the push. On success, Claude receives each check's command, result, and log path through PreToolUse context. Results live in the checkout-specific state directory described in the [Codex hook](../.codex/HOOKS.md). | ||
|
|
||
| After push, `python3 .codex/checks.py finalize` reports all local checks, required Build PR jobs, and review for exactly `HEAD`. The Stop hook reports their passed, failed, running, and pending states through a visible system message. With an active PR, it asks Claude to fix failures or arrange monitoring for running jobs. Approval alone does not block the stop. If monitoring is arranged, end the reply with `CI-WAITING: <what is monitored>`. A user can explicitly authorize ignoring CI by starting their latest message with `CI-OVERRIDE:`. Then end the reply with `CI-OVERRIDE: "<their entire latest message>"`; the hook accepts only an exact match to that message in the Claude session transcript. After three identical blocks, the loop guard reports instead of blocking. PR fetch errors also only report. | ||
|
|
||
| Run `python3 .codex/checks.py --help` for manual commands. `verify-head` reruns the local checks, and `finalize` lists every required result. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| #!/usr/bin/env python3 | ||
| """Claude Code adapter for the shared committed-HEAD checks in .codex/checks.py.""" | ||
|
|
||
| import importlib.util | ||
| import json | ||
| import os | ||
| from pathlib import Path | ||
| import re | ||
| import sys | ||
|
|
||
|
|
||
| ROOT = Path(__file__).resolve().parents[1] | ||
| SCRIPT = ROOT / ".codex" / "checks.py" | ||
| SPEC = importlib.util.spec_from_file_location("repo_checks", SCRIPT) | ||
| checks = importlib.util.module_from_spec(SPEC) | ||
| SPEC.loader.exec_module(checks) | ||
|
|
||
|
|
||
| def message_texts(entry): | ||
| content = entry.get("message", {}).get("content", "") | ||
| if isinstance(content, str): | ||
| return [content] | ||
| return [part.get("text", "") for part in content | ||
| if isinstance(part, dict) and part.get("type") == "text"] | ||
|
|
||
|
|
||
| def transcript_messages(path_text): | ||
| """Read only Claude project transcripts, never arbitrary hook-supplied paths.""" | ||
| if not path_text: | ||
| return [], "" | ||
| projects = (Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude")) / "projects").resolve() | ||
| try: | ||
| path = Path(path_text).resolve(strict=True) | ||
| if path.suffix != ".jsonl" or not path.is_relative_to(projects): | ||
| return [], "" | ||
| lines = path.read_text().splitlines() | ||
| except OSError: | ||
| return [], "" | ||
| users, last_reply = [], "" | ||
| for line in lines: | ||
| try: | ||
| entry = json.loads(line) | ||
| except ValueError: | ||
| continue | ||
| if entry.get("isMeta") or entry.get("isSidechain"): | ||
| continue | ||
| texts = [part for part in message_texts(entry) if part.strip()] | ||
| if entry.get("type") == "user": | ||
| user = re.sub(r"<system-reminder>.*?</system-reminder>", "", "\n".join(texts), flags=re.DOTALL) | ||
| if user.strip(): | ||
| users.append(user) | ||
| elif entry.get("type") == "assistant" and texts: | ||
| last_reply = "\n".join(texts) | ||
| return users, last_reply | ||
|
|
||
|
|
||
| def authorized_override(reply, users): | ||
| latest = " ".join(users[-1].split()).casefold() if users else "" | ||
| if not latest.startswith("ci-override:"): | ||
| return None | ||
| for match in re.finditer(r'CI-OVERRIDE:\s*["\u201c](.+?)["\u201d]', reply): | ||
| quote = " ".join(match.group(1).split()).casefold() | ||
| if len(quote) >= 8 and quote == latest: | ||
| return match.group(1) | ||
| return None | ||
|
gitar-bot[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
|
|
||
| def pre_tool_use(payload): | ||
| passed, message = checks.pre_tool_use(payload) | ||
| if passed: | ||
| if message: | ||
| print(json.dumps({"hookSpecificOutput": { | ||
| "hookEventName": "PreToolUse", "additionalContext": message}})) | ||
| return 0 | ||
| print(message, file=sys.stderr) | ||
| return 2 | ||
|
|
||
|
|
||
| def stop(payload): | ||
| try: | ||
| state = checks.collect_status() | ||
| except (KeyError, OSError, RuntimeError, ValueError) as error: | ||
| print(json.dumps({"systemMessage": f"Incomplete: Check unavailable: {error}."})) | ||
| return 0 | ||
| users, reply = transcript_messages(payload.get("transcript_path")) | ||
| quote = authorized_override(reply, users) | ||
| if quote and not state["complete"]: | ||
| print(json.dumps({"systemMessage": f'CI override accepted (user said: "{quote}").\nIncomplete: {state["message"]}'})) | ||
| return 0 | ||
| block, message = checks.stop_decision({**payload, "last_assistant_message": reply}, state) | ||
| print(json.dumps({"decision": "block", "reason": message} if block else {"systemMessage": message})) | ||
| return 0 | ||
|
|
||
|
|
||
| def main(): | ||
| if len(sys.argv) != 2 or sys.argv[1] not in ("pre-tool-use", "stop"): | ||
| print("Usage: python3 .claude/hooks.py {pre-tool-use|stop}", file=sys.stderr) | ||
| return 2 | ||
| try: | ||
| payload = json.load(sys.stdin) | ||
| if not isinstance(payload, dict): | ||
| raise ValueError("hook input must be a JSON object") | ||
| return pre_tool_use(payload) if sys.argv[1] == "pre-tool-use" else stop(payload) | ||
| except (KeyError, OSError, RuntimeError, ValueError) as error: | ||
| if sys.argv[1] == "pre-tool-use": | ||
| print(f"Repository check unavailable: {error}", file=sys.stderr) | ||
| return 2 | ||
| print(json.dumps({"systemMessage": f"Incomplete: Check unavailable: {error}."})) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| { | ||
| "hooks": { | ||
| "PreToolUse": [ | ||
| { | ||
| "matcher": "Bash", | ||
| "hooks": [ | ||
| {"type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks.py\" pre-tool-use", "timeout": 3600} | ||
| ] | ||
| } | ||
| ], | ||
| "Stop": [ | ||
| { | ||
| "hooks": [ | ||
| {"type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks.py\" stop"} | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| """Offline tests for the Claude Code adapter and transcript override boundary.""" | ||
|
|
||
| import contextlib | ||
| import importlib.util | ||
| import io | ||
| import json | ||
| import os | ||
| from pathlib import Path | ||
| import sys | ||
| import tempfile | ||
| import unittest | ||
| from unittest import mock | ||
|
|
||
|
|
||
| SCRIPT = Path(__file__).with_name("hooks.py") | ||
| SPEC = importlib.util.spec_from_file_location("claude_hooks", SCRIPT) | ||
| hooks = importlib.util.module_from_spec(SPEC) | ||
| SPEC.loader.exec_module(hooks) | ||
|
|
||
|
|
||
| class HookTests(unittest.TestCase): | ||
| def invoke(self, command, payload): | ||
| with mock.patch.object(sys, "argv", ["hooks.py", command]), \ | ||
| mock.patch.object(sys, "stdin", io.StringIO(json.dumps(payload))), \ | ||
| contextlib.redirect_stdout(io.StringIO()) as output, \ | ||
| contextlib.redirect_stderr(io.StringIO()) as errors: | ||
| code = hooks.main() | ||
| return code, output.getvalue(), errors.getvalue() | ||
|
|
||
| def test_successful_push_reports_to_claude(self): | ||
| with mock.patch.object(hooks.checks, "pre_tool_use", return_value=(True, "maven_verify: passed")): | ||
| code, output, errors = self.invoke("pre-tool-use", {"tool_input": {"command": "git push"}}) | ||
| self.assertEqual(0, code) | ||
| self.assertEqual("", errors) | ||
| self.assertEqual("maven_verify: passed", json.loads(output)["hookSpecificOutput"]["additionalContext"]) | ||
|
|
||
| def test_failed_push_blocks(self): | ||
| with mock.patch.object(hooks.checks, "pre_tool_use", return_value=(False, "maven_verify failed")): | ||
| code, output, errors = self.invoke("pre-tool-use", {"tool_input": {"command": "git push"}}) | ||
| self.assertEqual(2, code) | ||
| self.assertEqual("", output) | ||
| self.assertIn("maven_verify failed", errors) | ||
|
|
||
| def test_stop_blocks_on_failed_ci(self): | ||
| state = {"complete": False, "pr_error": None, "sha": "sha", | ||
| "items": [("job", "Build", "failed (failure)")], "message": "Build: failed"} | ||
| with mock.patch.object(hooks.checks, "collect_status", return_value=state), \ | ||
| mock.patch.object(hooks.checks, "note_block", return_value=False): | ||
| code, output, _ = self.invoke("stop", {"session_id": "session"}) | ||
| self.assertEqual(0, code) | ||
| self.assertEqual("block", json.loads(output)["decision"]) | ||
| self.assertIn("Build: failed", json.loads(output)["reason"]) | ||
|
|
||
| def test_override_requires_user_words_in_claude_transcript(self): | ||
| with tempfile.TemporaryDirectory() as directory: | ||
| projects = Path(directory) / "projects" / "repo" | ||
| projects.mkdir(parents=True) | ||
| transcript = projects / "session.jsonl" | ||
| authorization = "CI-OVERRIDE: Please ignore CI for this PR." | ||
| transcript.write_text("\n".join(json.dumps(item) for item in [ | ||
| {"type": "user", "message": {"content": authorization}}, | ||
| {"type": "assistant", "message": {"content": [{"type": "text", "text": | ||
| f'CI-OVERRIDE: "{authorization}"'}]}}])) | ||
| outside = Path(directory) / "outside.jsonl" | ||
| outside.write_text(transcript.read_text()) | ||
| state = {"complete": False, "pr_error": None, "message": "Build: failed"} | ||
| with mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": directory}), \ | ||
| mock.patch.object(hooks.checks, "collect_status", return_value=state): | ||
| code, output, _ = self.invoke("stop", {"transcript_path": str(transcript)}) | ||
| self.assertEqual(([], ""), hooks.transcript_messages(str(outside))) | ||
| self.assertEqual(0, code) | ||
| self.assertIn("CI override accepted", json.loads(output)["systemMessage"]) | ||
|
|
||
| def test_override_rejects_substrings_and_earlier_messages(self): | ||
| users = ["CI-OVERRIDE: Ignore CI for the old commit.", "Please fix the failing CI build"] | ||
| self.assertIsNone(hooks.authorized_override('CI-OVERRIDE: "fix the failing CI build"', users)) | ||
| self.assertIsNone(hooks.authorized_override('CI-OVERRIDE: "CI-OVERRIDE: Ignore CI for the old commit."', users)) | ||
| self.assertIsNone(hooks.authorized_override('CI-OVERRIDE: "ignore CI failures"', ["Don't ignore CI failures"])) | ||
|
|
||
| def test_nonblocking_stop_reports_system_message(self): | ||
| state = {"complete": True, "pr_error": None, "sha": "sha", "items": [], "message": "Build: passed"} | ||
| with mock.patch.object(hooks.checks, "collect_status", return_value=state): | ||
| code, output, _ = self.invoke("stop", {}) | ||
| self.assertEqual(0, code) | ||
| self.assertIn("Build: passed", json.loads(output)["systemMessage"]) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # Repository Codex checks | ||
|
|
||
| Trust the project and review the hooks with `/hooks` in the Codex CLI. The PreToolUse Bash hook runs only before a standalone `git push` (or `git push 2>&1 | tail -30`). It checks committed `HEAD` in an isolated checkout: `git show --format= --check HEAD` and `mvn clean verify -Pcoverage -DskipTests=false -B`. A failure blocks the push. A success reports each command, result, and log path through Codex hook context. The working tree does not affect the build result. | ||
|
|
||
| After push, `python3 .codex/checks.py finalize` checks the **Build** PR workflow for exactly `HEAD` and requires an approved review. Required jobs are `Build`, `NEXT analysis`, `Promote`, `Promote NuGet`, and `Promote npm`. The Stop hook reports every passed, failed, running, or pending item. When PR CI applies, it asks Codex to fix failures or monitor active jobs; review approval alone does not block stopping. A `CI-WAITING:` line in the last reply allows an incomplete handoff while jobs are being monitored. The loop guard releases repeated identical blocks. Fetch errors only produce a status report. | ||
|
|
||
| Run `python3 .codex/checks.py --help` for commands. `verify-head` runs the local checks manually. Logs and JSON results are in `<state base>/sonar-analyzer-commons/<checkout hash>/`, where the state base is `CODEX_CHECK_STATE` if set, or `codex-repo-checks` in the system temp directory otherwise. The checkout hash isolates worktrees. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "repository": "sonar-analyzer-commons", | ||
| "workflow": "Build", | ||
| "sonar_server": "https://next.sonarqube.com/sonarqube", | ||
| "sonar_project": "org.sonarsource.analyzer-commons:sonar-analyzer-commons-parent", | ||
| "local_checks": { | ||
| "committed_diff": ["git", "show", "--format=", "--check", "HEAD"], | ||
| "maven_verify": ["mvn", "clean", "verify", "-Pcoverage", "-DskipTests=false", "-B"] | ||
| }, | ||
| "required_pr_jobs": ["Build", "NEXT analysis", "Promote", "Promote NuGet", "Promote npm"] | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Edge Case: Override quote regex rejects messages containing quotes or newlines
authorized_overridenow requires the quoted text to equal the user's entire latest message. The extraction regex is["\u201c](.+?)["\u201d], which is lazy and has no DOTALL. If the message contains a", for exampleCI-OVERRIDE: ignore the "NEXT analysis" failure, the capture stops at the first inner quote. If the message spans more than one line,.cannot match the newline. In both cases the capture never equalslatest, so a real user authorization is always rejected, and the documentedCI-OVERRIDE: "<their entire latest message>"procedure cannot work.Fix: capture greedily up to the last closing quote on the reply's final override line, using DOTALL. Alternatively, compare the normalized text after
CI-OVERRIDE:without requiring delimiters.Greedy, DOTALL match anchored at the end of the reply:
Was this helpful? React with 👍 / 👎