252 lines
8.1 KiB
Python
Executable File
252 lines
8.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Enforce Orchestrator Validation - PROJECT.md Gatekeeper (Phase 1)
|
||
|
||
Ensures orchestrator validated PROJECT.md alignment before implementation.
|
||
|
||
This prevents:
|
||
- Users bypassing /auto-implement
|
||
- Features implemented without PROJECT.md alignment check
|
||
- Work proceeding without strategic direction validation
|
||
|
||
Source of truth: PROJECT.md ARCHITECTURE (orchestrator PRIMARY MISSION)
|
||
|
||
Exit codes:
|
||
0: Orchestrator validation found (or strict mode disabled)
|
||
2: No orchestrator validation - BLOCKS commit
|
||
|
||
Usage:
|
||
# As PreCommit hook (automatic in strict mode)
|
||
python enforce_orchestrator.py
|
||
"""
|
||
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
from datetime import datetime, timedelta
|
||
import subprocess
|
||
|
||
|
||
def is_strict_mode_enabled() -> bool:
|
||
"""Check if strict mode is enabled."""
|
||
settings_file = Path(".claude/settings.local.json")
|
||
if not settings_file.exists():
|
||
return False
|
||
|
||
try:
|
||
with open(settings_file) as f:
|
||
settings = json.load(f)
|
||
return settings.get("strict_mode", False)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def has_project_md() -> bool:
|
||
"""Check if PROJECT.md exists."""
|
||
return Path(".claude/PROJECT.md").exists()
|
||
|
||
|
||
def check_orchestrator_in_sessions() -> bool:
|
||
"""
|
||
Check for orchestrator activity in recent session files.
|
||
|
||
Looks for evidence in last 3 session files or files from last hour.
|
||
"""
|
||
sessions_dir = Path("docs/sessions")
|
||
if not sessions_dir.exists():
|
||
return False
|
||
|
||
# Get recent session files (last 3 or last hour)
|
||
cutoff_time = datetime.now() - timedelta(hours=1)
|
||
recent_sessions = []
|
||
|
||
for session_file in sessions_dir.glob("*.md"):
|
||
# Check modification time
|
||
mtime = datetime.fromtimestamp(session_file.stat().st_mtime)
|
||
if mtime > cutoff_time:
|
||
recent_sessions.append(session_file)
|
||
|
||
# If no sessions in last hour, check last 3 files
|
||
if not recent_sessions:
|
||
all_sessions = sorted(sessions_dir.glob("*.md"),
|
||
key=lambda f: f.stat().st_mtime,
|
||
reverse=True)
|
||
recent_sessions = all_sessions[:3]
|
||
|
||
# Search for orchestrator evidence
|
||
for session in recent_sessions:
|
||
try:
|
||
content = session.read_text().lower()
|
||
|
||
# Look for orchestrator markers
|
||
markers = [
|
||
"orchestrator",
|
||
"project.md alignment",
|
||
"validates alignment",
|
||
"alignment check",
|
||
]
|
||
|
||
if any(marker in content for marker in markers):
|
||
return True
|
||
except Exception:
|
||
continue
|
||
|
||
return False
|
||
|
||
|
||
def check_commit_message() -> bool:
|
||
"""Check if commit message indicates orchestrator validation."""
|
||
try:
|
||
# Get the staged commit message if it exists
|
||
result = subprocess.run(
|
||
["git", "log", "-1", "--pretty=%B"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False
|
||
)
|
||
|
||
if result.returncode == 0:
|
||
commit_msg = result.stdout.lower()
|
||
|
||
# Look for orchestrator markers in commit message
|
||
if "orchestrator" in commit_msg or "project.md" in commit_msg:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
return False
|
||
|
||
|
||
def get_staged_files() -> list:
|
||
"""Get list of staged files."""
|
||
try:
|
||
result = subprocess.run(
|
||
["git", "diff", "--cached", "--name-only"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=True
|
||
)
|
||
return [f for f in result.stdout.strip().split('\n') if f]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def is_docs_only_commit() -> bool:
|
||
"""Check if this is a documentation-only commit (allow without orchestrator)."""
|
||
staged = get_staged_files()
|
||
if not staged:
|
||
return True
|
||
|
||
# If all files are docs, markdown, or configs, allow
|
||
doc_extensions = {'.md', '.txt', '.json', '.yml', '.yaml', '.toml'}
|
||
doc_paths = {'docs/', 'README', 'CHANGELOG', 'LICENSE', '.claude/'}
|
||
|
||
for file in staged:
|
||
# Skip if it's a source file
|
||
if file.startswith('src/') or file.startswith('lib/'):
|
||
return False
|
||
|
||
# Check extension
|
||
ext = Path(file).suffix.lower()
|
||
if ext and ext not in doc_extensions:
|
||
return False
|
||
|
||
# Check if in doc path
|
||
if not any(file.startswith(path) for path in doc_paths):
|
||
# Check if it's a hook or test file (allow)
|
||
if not (file.startswith('hooks/') or file.startswith('tests/')):
|
||
return False
|
||
|
||
return True
|
||
|
||
|
||
def main():
|
||
"""Enforce orchestrator validation in strict mode."""
|
||
|
||
# Only run on PreCommit
|
||
try:
|
||
data = json.loads(sys.stdin.read())
|
||
if data.get("hook") != "PreCommit":
|
||
sys.exit(0)
|
||
except Exception:
|
||
# If not running as hook, exit
|
||
sys.exit(0)
|
||
|
||
# Check if strict mode is enabled
|
||
if not is_strict_mode_enabled():
|
||
# Not in strict mode - no enforcement
|
||
sys.exit(0)
|
||
|
||
# Check if PROJECT.md exists
|
||
if not has_project_md():
|
||
# No PROJECT.md - can't enforce alignment
|
||
print("ℹ️ No PROJECT.md found - orchestrator enforcement skipped",
|
||
file=sys.stderr)
|
||
sys.exit(0)
|
||
|
||
# Check if this is a docs-only commit (allow without orchestrator)
|
||
if is_docs_only_commit():
|
||
print("ℹ️ Documentation-only commit - orchestrator not required",
|
||
file=sys.stderr)
|
||
sys.exit(0)
|
||
|
||
# Check for orchestrator evidence
|
||
has_orchestrator = (
|
||
check_orchestrator_in_sessions() or
|
||
check_commit_message()
|
||
)
|
||
|
||
if has_orchestrator:
|
||
print("✅ Orchestrator validation detected", file=sys.stderr)
|
||
sys.exit(0)
|
||
|
||
# No orchestrator evidence - BLOCK
|
||
print("\n" + "=" * 80, file=sys.stderr)
|
||
print("❌ ORCHESTRATOR VALIDATION REQUIRED", file=sys.stderr)
|
||
print("=" * 80, file=sys.stderr)
|
||
print(file=sys.stderr)
|
||
print("Strict mode requires orchestrator to validate PROJECT.md alignment",
|
||
file=sys.stderr)
|
||
print("before implementation work begins.", file=sys.stderr)
|
||
print(file=sys.stderr)
|
||
print("PROJECT.md ARCHITECTURE (orchestrator PRIMARY MISSION):", file=sys.stderr)
|
||
print(" 1. Read PROJECT.md (GOALS, SCOPE, CONSTRAINTS)", file=sys.stderr)
|
||
print(" 2. Validate: Does feature serve GOALS?", file=sys.stderr)
|
||
print(" 3. Validate: Is feature IN SCOPE?", file=sys.stderr)
|
||
print(" 4. Validate: Respects CONSTRAINTS?", file=sys.stderr)
|
||
print(" 5. BLOCK if not aligned OR proceed with agent pipeline",
|
||
file=sys.stderr)
|
||
print(file=sys.stderr)
|
||
print("No orchestrator activity found in:", file=sys.stderr)
|
||
print(" - Recent session files (docs/sessions/)", file=sys.stderr)
|
||
print(" - Commit message", file=sys.stderr)
|
||
print(file=sys.stderr)
|
||
print("=" * 80, file=sys.stderr)
|
||
print("HOW TO FIX", file=sys.stderr)
|
||
print("=" * 80, file=sys.stderr)
|
||
print(file=sys.stderr)
|
||
print("Option 1: Use /auto-implement (recommended):", file=sys.stderr)
|
||
print(" /auto-implement \"your feature description\"", file=sys.stderr)
|
||
print(" → orchestrator validates alignment automatically", file=sys.stderr)
|
||
print(" → Full 7-agent pipeline executes", file=sys.stderr)
|
||
print(file=sys.stderr)
|
||
print("Option 2: Manual orchestrator invocation:", file=sys.stderr)
|
||
print(" \"orchestrator: validate this feature against PROJECT.md\"", file=sys.stderr)
|
||
print(" → Creates session file with validation evidence", file=sys.stderr)
|
||
print(file=sys.stderr)
|
||
print("Option 3: Disable strict mode (not recommended):", file=sys.stderr)
|
||
print(" Edit .claude/settings.local.json:", file=sys.stderr)
|
||
print(' {"strict_mode": false}', file=sys.stderr)
|
||
print(file=sys.stderr)
|
||
print("=" * 80, file=sys.stderr)
|
||
print("Strict mode enforces PROJECT.md as gatekeeper.", file=sys.stderr)
|
||
print("This prevents scope drift and misaligned features.", file=sys.stderr)
|
||
print("=" * 80, file=sys.stderr)
|
||
print(file=sys.stderr)
|
||
|
||
sys.exit(2) # Block commit
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|