project-standalo-todo-super/skills/guardrail-orchestrator/scripts/phase_gate.py

716 lines
24 KiB
Python

#!/usr/bin/env python3
"""
Phase Gate Enforcement System
Provides strict enforcement of workflow phases with:
- Blocking checkpoints that MUST pass before proceeding
- Script-based validation gates
- State persistence that survives interruptions
- Fix loops that return to IMPLEMENTING when issues found
Exit codes:
0 = PASS - operation allowed
1 = BLOCKED - conditions not met (with detailed reason)
2 = ERROR - system error
Usage:
phase_gate.py can-enter PHASE # Check if ready to enter phase
phase_gate.py complete PHASE # Mark phase as complete (validates all checkpoints)
phase_gate.py checkpoint NAME # Save a checkpoint
phase_gate.py verify PHASE # Verify all checkpoints for phase
phase_gate.py blockers # Show current blocking conditions
phase_gate.py fix-loop PHASE # Handle fix loop logic
phase_gate.py status # Show full gate state
"""
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
try:
import yaml
HAS_YAML = True
except ImportError:
HAS_YAML = False
# ============================================================================
# Configuration
# ============================================================================
PHASES_ORDER = [
'INITIALIZING',
'DESIGNING',
'AWAITING_DESIGN_APPROVAL',
'IMPLEMENTING',
'REVIEWING',
'SECURITY_REVIEW',
'AWAITING_IMPL_APPROVAL',
'COMPLETING',
'COMPLETED'
]
# Phases that can trigger a fix loop back to IMPLEMENTING
FIX_LOOP_PHASES = ['REVIEWING', 'SECURITY_REVIEW']
# Phase entry requirements - what MUST be true to enter
ENTRY_REQUIREMENTS = {
'INITIALIZING': [],
'DESIGNING': [
{'type': 'phase_completed', 'phase': 'INITIALIZING'},
],
'AWAITING_DESIGN_APPROVAL': [
{'type': 'phase_completed', 'phase': 'DESIGNING'},
{'type': 'file_exists', 'path': '.workflow/versions/{version}/design/design_document.yml'},
{'type': 'min_file_count', 'pattern': '.workflow/versions/{version}/tasks/*.yml', 'minimum': 1},
],
'IMPLEMENTING': [
{'type': 'phase_completed', 'phase': 'AWAITING_DESIGN_APPROVAL'},
{'type': 'approval_granted', 'gate': 'design'},
],
'REVIEWING': [
{'type': 'phase_completed', 'phase': 'IMPLEMENTING'},
{'type': 'script_passes', 'script': 'npm run build', 'name': 'build'},
{'type': 'script_passes', 'script': 'npx tsc --noEmit', 'name': 'type-check'},
{'type': 'script_passes', 'script': 'npm run lint', 'name': 'lint'},
],
'SECURITY_REVIEW': [
{'type': 'phase_completed', 'phase': 'REVIEWING'},
{'type': 'checkpoint_passed', 'checkpoint': 'review_passed'},
],
'AWAITING_IMPL_APPROVAL': [
{'type': 'phase_completed', 'phase': 'SECURITY_REVIEW'},
{'type': 'checkpoint_passed', 'checkpoint': 'security_passed'},
],
'COMPLETING': [
{'type': 'phase_completed', 'phase': 'AWAITING_IMPL_APPROVAL'},
{'type': 'approval_granted', 'gate': 'implementation'},
],
'COMPLETED': [
{'type': 'phase_completed', 'phase': 'COMPLETING'},
],
}
# Phase checkpoints - what MUST be completed within each phase
PHASE_CHECKPOINTS = {
'INITIALIZING': [
'manifest_exists',
'version_created',
],
'DESIGNING': [
'design_document_created',
'design_validated',
'tasks_generated',
],
'AWAITING_DESIGN_APPROVAL': [
'design_approved',
],
'IMPLEMENTING': [
'all_layers_complete',
'build_passes',
'type_check_passes',
'lint_passes',
],
'REVIEWING': [
'review_script_run',
'all_files_verified',
'code_review_passed', # Code review agent must pass
'review_passed', # Critical - umbrella checkpoint, must pass or trigger fix loop
],
'SECURITY_REVIEW': [
'security_scan_run',
'api_contract_validated',
'security_passed', # Critical - must pass or trigger fix loop
],
'AWAITING_IMPL_APPROVAL': [
'implementation_approved',
],
'COMPLETING': [
'tasks_marked_complete',
'version_finalized',
],
}
# ============================================================================
# YAML Helpers
# ============================================================================
def load_yaml(filepath: str) -> dict:
"""Load YAML file (or JSON fallback)."""
if not os.path.exists(filepath):
return {}
with open(filepath, 'r') as f:
content = f.read()
if not content.strip():
return {}
if HAS_YAML:
return yaml.safe_load(content) or {}
# Fallback: try JSON parsing (many .yml files are actually JSON)
try:
return json.loads(content) or {}
except json.JSONDecodeError:
return {}
def save_yaml(filepath: str, data: dict):
"""Save data to YAML file."""
os.makedirs(os.path.dirname(filepath), exist_ok=True)
if HAS_YAML:
with open(filepath, 'w') as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
else:
with open(filepath, 'w') as f:
json.dump(data, f, indent=2)
# ============================================================================
# State Management
# ============================================================================
def get_workflow_dir() -> Path:
return Path('.workflow')
def get_active_version() -> Optional[str]:
"""Get active workflow version."""
current_path = get_workflow_dir() / 'current.yml'
if not current_path.exists():
return None
current = load_yaml(str(current_path))
return current.get('active_version')
def get_gate_state_path(version: str) -> Path:
"""Get path to gate state file."""
return get_workflow_dir() / 'versions' / version / 'gate_state.yml'
def get_session_path(version: str) -> Path:
"""Get path to session file."""
return get_workflow_dir() / 'versions' / version / 'session.yml'
def load_gate_state(version: str) -> dict:
"""Load gate state for version."""
path = get_gate_state_path(version)
if path.exists():
return load_yaml(str(path))
# Initialize new gate state
return {
'version': version,
'current_phase': 'INITIALIZING',
'created_at': datetime.now().isoformat(),
'updated_at': datetime.now().isoformat(),
'phases': {phase: {
'status': 'not_started',
'entered_at': None,
'completed_at': None,
'checkpoints': {cp: {'status': 'pending', 'at': None, 'data': {}}
for cp in PHASE_CHECKPOINTS.get(phase, [])}
} for phase in PHASES_ORDER},
'fix_loops': [], # Track fix loop iterations
}
def save_gate_state(version: str, state: dict):
"""Save gate state."""
state['updated_at'] = datetime.now().isoformat()
save_yaml(str(get_gate_state_path(version)), state)
def get_current_phase(version: str) -> str:
"""Get current phase from session."""
session_path = get_session_path(version)
if not session_path.exists():
return 'INITIALIZING'
session = load_yaml(str(session_path))
return session.get('current_phase', 'INITIALIZING')
# ============================================================================
# Validation Functions
# ============================================================================
def check_file_exists(path: str, version: str) -> tuple[bool, str]:
"""Check if file exists."""
resolved_path = path.format(version=version)
if os.path.exists(resolved_path):
return True, f"File exists: {resolved_path}"
return False, f"File missing: {resolved_path}"
def check_min_file_count(pattern: str, minimum: int, version: str) -> tuple[bool, str]:
"""Check minimum file count matching pattern."""
import glob
resolved_pattern = pattern.format(version=version)
files = glob.glob(resolved_pattern)
count = len(files)
if count >= minimum:
return True, f"Found {count} files (minimum: {minimum})"
return False, f"Found only {count} files, need at least {minimum}"
def check_phase_completed(phase: str, version: str) -> tuple[bool, str]:
"""Check if a phase has been completed."""
state = load_gate_state(version)
phase_state = state['phases'].get(phase, {})
if phase_state.get('status') == 'completed':
return True, f"Phase {phase} is completed"
return False, f"Phase {phase} not completed (status: {phase_state.get('status', 'unknown')})"
def check_approval_granted(gate: str, version: str) -> tuple[bool, str]:
"""Check if approval gate has been granted."""
session_path = get_session_path(version)
if not session_path.exists():
return False, f"No session file found"
session = load_yaml(str(session_path))
approvals = session.get('approvals', {})
gate_approval = approvals.get(gate, {})
if gate_approval.get('status') == 'approved':
return True, f"{gate} approval granted"
return False, f"{gate} approval not granted (status: {gate_approval.get('status', 'pending')})"
def check_checkpoint_passed(checkpoint: str, version: str) -> tuple[bool, str]:
"""Check if a specific checkpoint has passed."""
state = load_gate_state(version)
# Search all phases for this checkpoint
for phase, phase_data in state['phases'].items():
checkpoints = phase_data.get('checkpoints', {})
if checkpoint in checkpoints:
cp_state = checkpoints[checkpoint]
if cp_state.get('status') == 'passed':
return True, f"Checkpoint {checkpoint} passed"
return False, f"Checkpoint {checkpoint} not passed (status: {cp_state.get('status', 'pending')})"
return False, f"Checkpoint {checkpoint} not found"
def check_script_passes(script: str, name: str = None) -> tuple[bool, str]:
"""Run a script and check if it passes (exit code 0)."""
script_name = name or script.split()[0]
try:
result = subprocess.run(
script,
shell=True,
capture_output=True,
text=True,
timeout=300 # 5 minute timeout
)
if result.returncode == 0:
return True, f"Script '{script_name}' passed"
return False, f"Script '{script_name}' failed (exit code: {result.returncode})\n{result.stderr[:500]}"
except subprocess.TimeoutExpired:
return False, f"Script '{script_name}' timed out"
except Exception as e:
return False, f"Script '{script_name}' error: {str(e)}"
def validate_requirement(req: dict, version: str) -> tuple[bool, str]:
"""Validate a single requirement."""
req_type = req.get('type')
if req_type == 'file_exists':
return check_file_exists(req['path'], version)
elif req_type == 'min_file_count':
return check_min_file_count(req['pattern'], req['minimum'], version)
elif req_type == 'phase_completed':
return check_phase_completed(req['phase'], version)
elif req_type == 'approval_granted':
return check_approval_granted(req['gate'], version)
elif req_type == 'checkpoint_passed':
return check_checkpoint_passed(req['checkpoint'], version)
elif req_type == 'script_passes':
return check_script_passes(req['script'], req.get('name'))
else:
return False, f"Unknown requirement type: {req_type}"
# ============================================================================
# Gate Operations
# ============================================================================
def can_enter_phase(phase: str, version: str) -> tuple[bool, list[str]]:
"""Check if all entry requirements are met for a phase."""
requirements = ENTRY_REQUIREMENTS.get(phase, [])
failures = []
for req in requirements:
passed, message = validate_requirement(req, version)
if not passed:
failures.append(message)
return len(failures) == 0, failures
def save_checkpoint(phase: str, checkpoint: str, status: str, version: str, data: dict = None):
"""Save a checkpoint status."""
state = load_gate_state(version)
if phase not in state['phases']:
state['phases'][phase] = {
'status': 'in_progress',
'entered_at': datetime.now().isoformat(),
'completed_at': None,
'checkpoints': {}
}
state['phases'][phase]['checkpoints'][checkpoint] = {
'status': status,
'at': datetime.now().isoformat(),
'data': data or {}
}
save_gate_state(version, state)
return True
def verify_phase_checkpoints(phase: str, version: str) -> tuple[bool, list[str]]:
"""Verify all checkpoints for a phase are passed."""
state = load_gate_state(version)
required_checkpoints = PHASE_CHECKPOINTS.get(phase, [])
phase_state = state['phases'].get(phase, {})
checkpoints = phase_state.get('checkpoints', {})
failures = []
for cp in required_checkpoints:
cp_state = checkpoints.get(cp, {})
if cp_state.get('status') != 'passed':
failures.append(f"Checkpoint '{cp}' not passed (status: {cp_state.get('status', 'missing')})")
return len(failures) == 0, failures
def complete_phase(phase: str, version: str) -> tuple[bool, list[str]]:
"""Mark a phase as complete after verifying all checkpoints."""
# First verify all checkpoints
passed, failures = verify_phase_checkpoints(phase, version)
if not passed:
return False, failures
# Update state
state = load_gate_state(version)
state['phases'][phase]['status'] = 'completed'
state['phases'][phase]['completed_at'] = datetime.now().isoformat()
save_gate_state(version, state)
return True, []
def enter_phase(phase: str, version: str) -> tuple[bool, list[str]]:
"""Enter a new phase after verifying entry requirements."""
# Check entry requirements
can_enter, failures = can_enter_phase(phase, version)
if not can_enter:
return False, failures
# Update state
state = load_gate_state(version)
state['current_phase'] = phase
state['phases'][phase]['status'] = 'in_progress'
state['phases'][phase]['entered_at'] = datetime.now().isoformat()
save_gate_state(version, state)
return True, []
def handle_fix_loop(phase: str, version: str, issues: list[str]) -> dict:
"""Handle fix loop - return to IMPLEMENTING to fix issues."""
state = load_gate_state(version)
# Record fix loop
fix_loop_entry = {
'from_phase': phase,
'issues': issues,
'timestamp': datetime.now().isoformat(),
'iteration': len([fl for fl in state.get('fix_loops', []) if fl['from_phase'] == phase]) + 1
}
if 'fix_loops' not in state:
state['fix_loops'] = []
state['fix_loops'].append(fix_loop_entry)
# Reset phase states for re-run
state['phases'][phase]['status'] = 'needs_fix'
state['phases'][phase]['completed_at'] = None
# Reset checkpoints that need re-verification
if phase == 'REVIEWING':
state['phases'][phase]['checkpoints']['review_passed'] = {'status': 'pending', 'at': None, 'data': {}}
elif phase == 'SECURITY_REVIEW':
state['phases'][phase]['checkpoints']['security_passed'] = {'status': 'pending', 'at': None, 'data': {}}
# Set IMPLEMENTING as needing re-entry
state['phases']['IMPLEMENTING']['status'] = 'needs_reentry'
state['current_phase'] = 'IMPLEMENTING'
save_gate_state(version, state)
return {
'action': 'FIX_REQUIRED',
'return_to': 'IMPLEMENTING',
'issues': issues,
'iteration': fix_loop_entry['iteration']
}
def get_blockers(version: str) -> list[dict]:
"""Get all current blocking conditions."""
state = load_gate_state(version)
current_phase = state.get('current_phase', 'INITIALIZING')
blockers = []
# Check current phase checkpoints
phase_state = state['phases'].get(current_phase, {})
checkpoints = phase_state.get('checkpoints', {})
for cp_name, cp_state in checkpoints.items():
if cp_state.get('status') != 'passed':
blockers.append({
'type': 'checkpoint',
'phase': current_phase,
'name': cp_name,
'status': cp_state.get('status', 'pending')
})
# Check if in fix loop
fix_loops = state.get('fix_loops', [])
if fix_loops:
latest = fix_loops[-1]
if state['phases'].get(latest['from_phase'], {}).get('status') == 'needs_fix':
blockers.append({
'type': 'fix_loop',
'phase': latest['from_phase'],
'issues': latest['issues'],
'iteration': latest['iteration']
})
return blockers
def show_status(version: str):
"""Display full gate state status."""
state = load_gate_state(version)
print()
print("=" * 70)
print(" PHASE GATE STATUS".center(70))
print("=" * 70)
print(f" Version: {version}")
print(f" Current Phase: {state.get('current_phase', 'UNKNOWN')}")
print(f" Updated: {state.get('updated_at', 'N/A')}")
print("=" * 70)
for phase in PHASES_ORDER:
phase_state = state['phases'].get(phase, {})
status = phase_state.get('status', 'not_started')
# Status icon
if status == 'completed':
icon = ""
elif status == 'in_progress':
icon = "🔄"
elif status == 'needs_fix':
icon = "🔧"
elif status == 'needs_reentry':
icon = "↩️ "
else:
icon = ""
print(f"\n {icon} {phase}: {status}")
# Show checkpoints
checkpoints = phase_state.get('checkpoints', {})
if checkpoints:
for cp_name, cp_state in checkpoints.items():
cp_status = cp_state.get('status', 'pending')
cp_icon = "" if cp_status == 'passed' else "" if cp_status == 'pending' else ""
print(f" {cp_icon} {cp_name}: {cp_status}")
# Show fix loops
fix_loops = state.get('fix_loops', [])
if fix_loops:
print("\n" + "-" * 70)
print(" FIX LOOP HISTORY")
print("-" * 70)
for fl in fix_loops[-5:]: # Show last 5
print(f" [{fl['timestamp'][:19]}] {fl['from_phase']} → IMPLEMENTING (iteration {fl['iteration']})")
for issue in fl['issues'][:3]:
print(f" - {issue[:60]}")
print("\n" + "=" * 70)
# ============================================================================
# CLI Interface
# ============================================================================
def main():
parser = argparse.ArgumentParser(description="Phase gate enforcement system")
subparsers = parser.add_subparsers(dest='command', help='Commands')
# can-enter command
enter_parser = subparsers.add_parser('can-enter', help='Check if can enter a phase')
enter_parser.add_argument('phase', choices=PHASES_ORDER, help='Target phase')
# complete command
complete_parser = subparsers.add_parser('complete', help='Complete a phase')
complete_parser.add_argument('phase', choices=PHASES_ORDER, help='Phase to complete')
# checkpoint command
cp_parser = subparsers.add_parser('checkpoint', help='Save a checkpoint')
cp_parser.add_argument('name', help='Checkpoint name')
cp_parser.add_argument('--phase', required=True, help='Phase for checkpoint')
cp_parser.add_argument('--status', default='passed', choices=['passed', 'failed', 'pending'])
cp_parser.add_argument('--data', help='JSON data to store')
# verify command
verify_parser = subparsers.add_parser('verify', help='Verify phase checkpoints')
verify_parser.add_argument('phase', choices=PHASES_ORDER, help='Phase to verify')
# blockers command
subparsers.add_parser('blockers', help='Show current blockers')
# fix-loop command
fix_parser = subparsers.add_parser('fix-loop', help='Trigger fix loop')
fix_parser.add_argument('phase', choices=FIX_LOOP_PHASES, help='Phase triggering fix')
fix_parser.add_argument('--issues', nargs='+', help='Issues that need fixing')
# status command
subparsers.add_parser('status', help='Show full gate state')
# enter command (actually enter a phase)
do_enter_parser = subparsers.add_parser('enter', help='Enter a phase')
do_enter_parser.add_argument('phase', choices=PHASES_ORDER, help='Phase to enter')
args = parser.parse_args()
# Get active version
version = get_active_version()
if not version and args.command not in ['status', 'blockers']:
print("Error: No active workflow version")
print("Run /workflow:spawn to start a new workflow")
sys.exit(2)
if args.command == 'can-enter':
can_enter, failures = can_enter_phase(args.phase, version)
if can_enter:
print(f"✅ CAN ENTER: {args.phase}")
print("All entry requirements met")
sys.exit(0)
else:
print(f"❌ BLOCKED: Cannot enter {args.phase}")
print("\nBlocking conditions:")
for f in failures:
print(f" - {f}")
sys.exit(1)
elif args.command == 'enter':
success, failures = enter_phase(args.phase, version)
if success:
print(f"✅ ENTERED: {args.phase}")
sys.exit(0)
else:
print(f"❌ BLOCKED: Cannot enter {args.phase}")
for f in failures:
print(f" - {f}")
sys.exit(1)
elif args.command == 'complete':
success, failures = complete_phase(args.phase, version)
if success:
print(f"✅ COMPLETED: {args.phase}")
sys.exit(0)
else:
print(f"❌ BLOCKED: Cannot complete {args.phase}")
print("\nMissing checkpoints:")
for f in failures:
print(f" - {f}")
sys.exit(1)
elif args.command == 'checkpoint':
data = None
if args.data:
try:
data = json.loads(args.data)
except json.JSONDecodeError:
data = {'raw': args.data}
save_checkpoint(args.phase, args.name, args.status, version, data)
icon = "" if args.status == 'passed' else "" if args.status == 'failed' else ""
print(f"{icon} Checkpoint saved: {args.name} = {args.status}")
sys.exit(0)
elif args.command == 'verify':
passed, failures = verify_phase_checkpoints(args.phase, version)
if passed:
print(f"✅ VERIFIED: All checkpoints for {args.phase} passed")
sys.exit(0)
else:
print(f"❌ FAILED: {args.phase} has incomplete checkpoints")
for f in failures:
print(f" - {f}")
sys.exit(1)
elif args.command == 'blockers':
if not version:
print("No active workflow")
sys.exit(0)
blockers = get_blockers(version)
if not blockers:
print("✅ No blockers - workflow can proceed")
sys.exit(0)
print("❌ CURRENT BLOCKERS:")
print("-" * 50)
for b in blockers:
if b['type'] == 'checkpoint':
print(f" [{b['phase']}] Checkpoint '{b['name']}': {b['status']}")
elif b['type'] == 'fix_loop':
print(f" [FIX REQUIRED] From {b['phase']} (iteration {b['iteration']})")
for issue in b.get('issues', [])[:3]:
print(f" - {issue[:60]}")
sys.exit(1)
elif args.command == 'fix-loop':
issues = args.issues or ['Unspecified issues found']
result = handle_fix_loop(args.phase, version, issues)
print(f"🔧 FIX LOOP TRIGGERED")
print(f" From: {args.phase}")
print(f" Return to: {result['return_to']}")
print(f" Iteration: {result['iteration']}")
print(f"\n Issues to fix:")
for issue in result['issues']:
print(f" - {issue}")
print(f"\n 👉 Run /workflow:resume to continue fixing")
sys.exit(1) # Exit 1 to indicate fix needed
elif args.command == 'status':
if version:
show_status(version)
else:
print("No active workflow")
sys.exit(0)
else:
parser.print_help()
sys.exit(0)
if __name__ == "__main__":
main()