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
9 changes: 9 additions & 0 deletions .claude/HOOKS.md
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.
113 changes: 113 additions & 0 deletions .claude/hooks.py
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:
Comment on lines +61 to +63

@gitar-bot gitar-bot Bot Nov 12, 2026 •

Copy link
Copy Markdown

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_override now 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 example CI-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 equals latest, so a real user authorization is always rejected, and the documented CI-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:

match = re.search(r'CI-OVERRIDE:\s*["\u201c](.+)["\u201d]\s*$', reply, flags=re.DOTALL)
if match:
    quote = " ".join(match.group(1).split()).casefold()
    if len(quote) >= 8 and quote == latest:
        return match.group(1)
return None

Was this helpful? React with 👍 / 👎

return match.group(1)
return None
Comment thread
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())
19 changes: 19 additions & 0 deletions .claude/settings.json
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"}
]
}
]
}
}
89 changes: 89 additions & 0 deletions .claude/test_hooks.py
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()
7 changes: 7 additions & 0 deletions .codex/HOOKS.md
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.
11 changes: 11 additions & 0 deletions .codex/checks.json
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"]
}
Loading
Loading