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 plugins/onex/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,15 @@
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre_tool_use_task_boundary_tests.sh"
}
]
},
{
"matcher": "^SendMessage$",
"hooks": [
Expand Down
3 changes: 2 additions & 1 deletion plugins/onex/hooks/lib/hook_bits.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# GENERATED BY omnibase_core/scripts/gen_hook_bits.py — DO NOT EDIT
# Regenerate via: uv run python scripts/gen_hook_bits.py --write <this-file>

HOOK_BITS_DEFAULT_MASK=0x3ffffffffffffff
HOOK_BITS_DEFAULT_MASK=0x7ffffffffffffff
Comment thread
jonahgabriel marked this conversation as resolved.

# hook_bits_bit_for_name NAME
# Prints the bit assigned to NAME. Bash 3.2 has no associative arrays, and
Expand Down Expand Up @@ -70,6 +70,7 @@ hook_bits_bit_for_name() {
SESSION_START) echo 0x80000000000000 ;;
SESSION_START_ONEX_CLI_PIN_CHECK) echo 0x100000000000000 ;;
HANDOFF_NUDGE) echo 0x200000000000000 ;;
TASK_BOUNDARY_TESTS) echo 0x400000000000000 ;;
*) return 1 ;;
esac
}
Expand Down
190 changes: 190 additions & 0 deletions plugins/onex/hooks/lib/task_boundary_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT
"""Task-boundary test discovery and execution hook.

Fired on PreToolUse(Bash) when the command contains ``git commit`` or
``gh pr create``. Discovers relevant tests for staged Python files and
runs them with a 120 s budget. Blocks the tool call on test failure;
fails open on timeout / infrastructure errors.

Exit codes:
0 — all checks pass (or no tests applicable / infra failure)
2 — test failures found (blocks the commit/PR)
"""

from __future__ import annotations

import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
import time
from pathlib import Path

DEBOUNCE_SECONDS = 60
TEST_TIMEOUT_SECONDS = 120

_RE_GIT_COMMIT = re.compile(r"(^|\s|&&|\|\||;)\s*git\s+commit\b")
_RE_GH_PR_CREATE = re.compile(r"(^|\s|&&|\|\||\||;)\s*gh\s+pr\s+create\b")


def _is_trigger_command(command: str) -> bool:
return bool(_RE_GIT_COMMIT.search(command) or _RE_GH_PR_CREATE.search(command))
Comment thread
jonahgabriel marked this conversation as resolved.


def _repo_root_for(cwd: str | None) -> Path | None:
start = Path(cwd) if cwd else Path.cwd()
for p in [start, *start.parents]:
if (p / ".git").exists() or (p / ".git").is_file():
return p
return None


def _staged_python_files(repo_root: Path) -> list[str]:
try:
r = subprocess.run(
["git", "diff", "--cached", "--name-only", "--", "*.py"],
capture_output=True,
text=True,
cwd=repo_root,
timeout=5,
check=False,
)
if r.returncode == 0:
return [f for f in r.stdout.strip().split("\n") if f]
except (subprocess.TimeoutExpired, Exception):
pass
return []


def _discover_tests(repo_root: Path, changed_files: list[str]) -> list[str]:
tests_dir = repo_root / "tests"
if not tests_dir.is_dir():
return []

collected: list[str] = []
for changed in changed_files:
stem = Path(changed).stem
candidates = [
tests_dir / f"test_{stem}.py",
]
parent = Path(changed).parent
if str(parent) != ".":
candidates.append(tests_dir / parent / f"test_{stem}.py")
candidates.append(tests_dir / "hooks" / f"test_{stem}.py")

for cand in candidates:
if cand.is_file() and str(cand) not in collected:
collected.append(str(cand))

return collected


def _debounce_key(repo_root: Path) -> str:
h = hashlib.sha256(str(repo_root).encode()).hexdigest()[:12]
return os.path.join(tempfile.gettempdir(), f"onex_task_boundary_last_run_{h}")


def _should_skip_debounce(key: str) -> bool:
try:
p = Path(key)
if not p.exists():
return False
mtime = p.stat().st_mtime
return (time.time() - mtime) < DEBOUNCE_SECONDS
except OSError:
return False


def _touch_debounce(key: str) -> None:
try:
Path(key).touch()
except OSError:
pass


def _run_tests(repo_root: Path, test_files: list[str]) -> tuple[bool, str]:
try:
r = subprocess.run(
[
sys.executable,
"-m",
"pytest",
*test_files,
"-q",
"--tb=short",
],
capture_output=True,
text=True,
cwd=repo_root,
timeout=TEST_TIMEOUT_SECONDS,
check=False,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
except subprocess.TimeoutExpired:
return True, "task-boundary-tests: timed out after 120s (failing open)"
except FileNotFoundError:
return True, "task-boundary-tests: pytest not found (failing open)"

if r.returncode == 0:
return True, ""

output = (r.stdout + "\n" + r.stderr).strip()
if len(output) > 2000:
output = output[:2000] + "\n... (truncated)"
return False, output


def main() -> None:
tool_info = json.loads(sys.stdin.read())

tool_name = tool_info.get("tool_name", "")
if tool_name != "Bash":
json.dump(tool_info, sys.stdout)
sys.exit(0)

command = tool_info.get("tool_input", {}).get("command", "")
if not _is_trigger_command(command):
json.dump(tool_info, sys.stdout)
sys.exit(0)

cwd = tool_info.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR")
repo_root = _repo_root_for(cwd)
if repo_root is None:
json.dump(tool_info, sys.stdout)
sys.exit(0)

debounce_key = _debounce_key(repo_root)
if _should_skip_debounce(debounce_key):
json.dump(tool_info, sys.stdout)
sys.exit(0)

changed = _staged_python_files(repo_root)
if not changed:
json.dump(tool_info, sys.stdout)
sys.exit(0)

tests = _discover_tests(repo_root, changed)
if not tests:
json.dump(tool_info, sys.stdout)
sys.exit(0)

passed, message = _run_tests(repo_root, tests)
Comment thread
jonahgabriel marked this conversation as resolved.
if passed:
_touch_debounce(debounce_key)
json.dump(tool_info, sys.stdout)
sys.exit(0)

result = {
"decision": "block",
"reason": "Task-boundary tests failed:\n" + message,
}
json.dump(result, sys.stdout)
sys.exit(2)


if __name__ == "__main__":
main()
168 changes: 168 additions & 0 deletions plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/bin/bash
# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT
# PreToolUse Task-Boundary Tests Hook [OMN-7261]
# Intercepts git commit / gh pr create and runs targeted tests first.

set -euo pipefail
_OMNICLAUDE_HOOK_NAME="$(basename "${BASH_SOURCE[0]}")"
source "$(dirname "${BASH_SOURCE[0]}")/error-guard.sh" 2>/dev/null || true

_OMNICLAUDE_CALLER_CWD="${CLAUDE_PROJECT_DIR:-$PWD}"

# shellcheck source=../lib/repo_guard.sh
. "$(dirname "${BASH_SOURCE[0]}")/../lib/repo_guard.sh" 2>/dev/null || true
if declare -F is_omninode_repo >/dev/null 2>&1; then
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$_OMNICLAUDE_CALLER_CWD}" \
is_omninode_repo || {
_OMNICLAUDE_PASSTHROUGH=$(cat)
echo "$_OMNICLAUDE_PASSTHROUGH"
trap - EXIT 2>/dev/null || true
exit 0
}
fi

cd "$HOME" 2>/dev/null || cd /tmp || true

if ! _SELF="$(realpath "${BASH_SOURCE[0]}" 2>/dev/null \
|| python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "${BASH_SOURCE[0]}")"; then
_OMNICLAUDE_PASSTHROUGH=$(cat)
echo "$_OMNICLAUDE_PASSTHROUGH"
exit 0
fi
if ! SCRIPT_DIR="$(cd "$(dirname "${_SELF}")" && pwd)"; then
_OMNICLAUDE_PASSTHROUGH=$(cat)
echo "$_OMNICLAUDE_PASSTHROUGH"
exit 0
fi
if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
elif ! PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"; then
_OMNICLAUDE_PASSTHROUGH=$(cat)
echo "$_OMNICLAUDE_PASSTHROUGH"
exit 0
fi
unset _SELF
HOOKS_DIR="${PLUGIN_ROOT}/hooks"
HOOKS_LIB="${HOOKS_DIR}/lib"
TOOL_INFO=$(cat)
source "${SCRIPT_DIR}/onex-paths.sh" || {
echo "ONEX_STATE_DIR not set" >&2
echo "$TOOL_INFO"
exit 0
}
unset SCRIPT_DIR
LOG_FILE="${ONEX_HOOK_LOG}"

HOOK_BITS_PATH="${HOOKS_LIB}/hook_bits.sh"
if [[ -f "$HOOK_BITS_PATH" ]]; then
# shellcheck source=../lib/hook_bits.sh
if ! source "$HOOK_BITS_PATH"; then
echo "$TOOL_INFO"
exit 0
fi
TASK_BOUNDARY_BIT="$(hook_bits_bit_for_name TASK_BOUNDARY_TESTS 2>/dev/null || true)"
if [[ -n "$TASK_BOUNDARY_BIT" ]]; then
if [[ -z "${ONEX_HOOKS_MASK:-}" ]]; then
echo "$TOOL_INFO"
exit 0
fi
if ! TASK_BOUNDARY_MASK="$(hook_bits_parse_mask "$ONEX_HOOKS_MASK" 2>/dev/null)"; then
echo "$TOOL_INFO"
exit 0
fi
if [[ -z "$TASK_BOUNDARY_MASK" ]]; then
echo "$TOOL_INFO"
exit 0
fi
if ! hook_bits_is_enabled "$TASK_BOUNDARY_MASK" "$TASK_BOUNDARY_BIT"; then
echo "$TOOL_INFO"
exit 0
fi
fi
fi
unset HOOK_BITS_PATH TASK_BOUNDARY_BIT TASK_BOUNDARY_MASK

PROJECT_ROOT="${PLUGIN_ROOT}/../.."
if [[ -f "${PROJECT_ROOT}/.env" ]]; then
PROJECT_ROOT="$(cd "${PROJECT_ROOT}" && pwd)"
elif [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
PROJECT_ROOT="${CLAUDE_PROJECT_DIR}"
else
PROJECT_ROOT="$(pwd)"
fi

mkdir -p "$(dirname "$LOG_FILE")"

export PYTHONPATH="${PROJECT_ROOT}:${PLUGIN_ROOT}/lib:${HOOKS_LIB}:${PYTHONPATH:-}"

if [[ -f "$PROJECT_ROOT/.env" ]]; then
set -a
source "$PROJECT_ROOT/.env" 2>/dev/null || true
set +a
fi

COMMON_SH="${HOOKS_DIR}/scripts/common.sh"
if [[ ! -f "$COMMON_SH" ]]; then
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: common.sh missing; failing open" >> "$LOG_FILE"
echo "$TOOL_INFO"
exit 0
fi
if ! source "$COMMON_SH"; then
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: failed to source common.sh; failing open" >> "$LOG_FILE"
echo "$TOOL_INFO"
exit 0
fi
unset COMMON_SH
if ! TOOL_NAME=$(echo "$TOOL_INFO" | jq -er '.tool_name // empty' 2>>"$LOG_FILE"); then
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: invalid hook JSON; failing open" >> "$LOG_FILE"
echo "$TOOL_INFO"
exit 0
fi

if [[ "$TOOL_NAME" != "Bash" ]]; then
echo "$TOOL_INFO"
exit 0
fi

COMMAND=$(echo "$TOOL_INFO" | jq -r '.tool_input.command // empty' 2>/dev/null)
if [[ -z "$COMMAND" ]]; then
echo "$TOOL_INFO"
exit 0
fi

if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\|\||\||;)git\s+commit(\s|$)'; then
if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\|\||\||;)gh\s+pr\s+create(\s|$)'; then
echo "$TOOL_INFO"
exit 0
fi
fi

echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Task-boundary tests triggered" >> "$LOG_FILE"

RUNNER="${HOOKS_LIB}/task_boundary_tests.py"
if [[ ! -r "$RUNNER" ]]; then
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: runner missing/unreadable, failing open" >> "$LOG_FILE"
echo "$TOOL_INFO"
exit 0
fi

set +e
RESULT=$(echo "$TOOL_INFO" | \
env -u PYTHONPATH "$PYTHON_CMD" "$RUNNER" 2>>"$LOG_FILE")
EXIT_CODE=$?
set -e
unset RUNNER

if [ $EXIT_CODE -eq 0 ]; then
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Task-boundary tests PASSED" >> "$LOG_FILE"
echo "$RESULT"
elif [ $EXIT_CODE -eq 2 ]; then
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Task-boundary tests BLOCKED" >> "$LOG_FILE"
echo "$RESULT"
exit 2
else
Comment thread
coderabbitai[bot] marked this conversation as resolved.
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: Task-boundary tests failed with code $EXIT_CODE, failing open" >> "$LOG_FILE"
echo "$TOOL_INFO"
exit 0
fi
Loading
Loading