730 lines
22 KiB
Python
730 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Alignment fixer library for PROJECT.md bidirectional sync.
|
|
|
|
Implements bidirectional alignment sync between PROJECT.md (strategic intent),
|
|
documentation (README.md, CLAUDE.md), and code (implementation).
|
|
|
|
Features:
|
|
- Proposes PROJECT.md updates with approval workflow
|
|
- Only allows SCOPE (In Scope) and ARCHITECTURE updates (never GOALS, CONSTRAINTS, Out of Scope)
|
|
- Backup before modification
|
|
- Atomic updates
|
|
- Security validation (CWE-22 path traversal prevention)
|
|
- Audit logging for all operations
|
|
|
|
Date: 2025-12-13
|
|
Issue: #129 (Bidirectional alignment sync)
|
|
Agent: implementer
|
|
|
|
See error-handling-patterns skill for exception hierarchy and error handling best practices.
|
|
|
|
Design Patterns:
|
|
See library-design-patterns skill for standardized design patterns.
|
|
See state-management-patterns skill for standardized design patterns.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
# Import security utilities (standard pattern from project libraries)
|
|
try:
|
|
from .security_utils import audit_log, validate_path
|
|
except ImportError:
|
|
# Direct script execution - add lib dir to path
|
|
lib_dir = Path(__file__).parent.resolve()
|
|
sys.path.insert(0, str(lib_dir))
|
|
from security_utils import audit_log
|
|
|
|
# Import user state manager for consent workflow
|
|
try:
|
|
from .user_state_manager import (
|
|
UserStateManager,
|
|
DEFAULT_STATE_FILE,
|
|
get_user_preference,
|
|
set_user_preference,
|
|
)
|
|
except ImportError:
|
|
from user_state_manager import (
|
|
DEFAULT_STATE_FILE,
|
|
get_user_preference,
|
|
set_user_preference,
|
|
)
|
|
|
|
|
|
# Consent preference key
|
|
BIDIRECTIONAL_SYNC_CONSENT_KEY = "bidirectional_sync_enabled"
|
|
|
|
# Protected sections that should NEVER be auto-updated
|
|
PROTECTED_SECTIONS = ["GOALS", "CONSTRAINTS"]
|
|
|
|
# Sections that can be proposed for update (with approval)
|
|
PROPOSABLE_SECTIONS = ["SCOPE", "ARCHITECTURE"]
|
|
|
|
# Sub-sections within SCOPE that are protected
|
|
PROTECTED_SCOPE_SUBSECTIONS = ["Out of Scope"]
|
|
|
|
|
|
class AlignmentFixerError(Exception):
|
|
"""Exception raised for alignment fixer errors."""
|
|
pass
|
|
|
|
|
|
class ProposedUpdate:
|
|
"""Represents a proposed update to PROJECT.md."""
|
|
|
|
def __init__(
|
|
self,
|
|
section: str,
|
|
subsection: Optional[str],
|
|
action: str, # "add", "update", "remove"
|
|
current_value: Optional[str],
|
|
proposed_value: str,
|
|
reason: str,
|
|
):
|
|
"""
|
|
Initialize a proposed update.
|
|
|
|
Args:
|
|
section: Section name (e.g., "SCOPE", "ARCHITECTURE")
|
|
subsection: Optional subsection (e.g., "In Scope", "Commands")
|
|
action: Type of change ("add", "update", "remove")
|
|
current_value: Current value if updating/removing
|
|
proposed_value: Proposed new value
|
|
reason: Reason for the change
|
|
"""
|
|
self.section = section
|
|
self.subsection = subsection
|
|
self.action = action
|
|
self.current_value = current_value
|
|
self.proposed_value = proposed_value
|
|
self.reason = reason
|
|
self.approved = False
|
|
self.declined = False
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
f"ProposedUpdate(section={self.section!r}, "
|
|
f"subsection={self.subsection!r}, "
|
|
f"action={self.action!r}, "
|
|
f"proposed={self.proposed_value!r})"
|
|
)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert to dictionary for serialization."""
|
|
return {
|
|
"section": self.section,
|
|
"subsection": self.subsection,
|
|
"action": self.action,
|
|
"current_value": self.current_value,
|
|
"proposed_value": self.proposed_value,
|
|
"reason": self.reason,
|
|
"approved": self.approved,
|
|
"declined": self.declined,
|
|
}
|
|
|
|
|
|
class AlignmentFixer:
|
|
"""
|
|
Manages bidirectional alignment sync for PROJECT.md.
|
|
|
|
Handles proposing, reviewing, and applying updates to PROJECT.md
|
|
with approval workflow and security validation.
|
|
"""
|
|
|
|
def __init__(self, project_root: Path, state_file: Path = DEFAULT_STATE_FILE):
|
|
"""
|
|
Initialize AlignmentFixer.
|
|
|
|
Args:
|
|
project_root: Root directory of the project
|
|
state_file: Path to user state file for consent
|
|
|
|
Raises:
|
|
AlignmentFixerError: If path validation fails
|
|
"""
|
|
self.project_root = self._validate_project_root(project_root)
|
|
self.project_md_path = self._find_project_md()
|
|
self.state_file = state_file
|
|
self.pending_updates: List[ProposedUpdate] = []
|
|
self.backup_path: Optional[Path] = None
|
|
|
|
def _validate_project_root(self, path: Path) -> Path:
|
|
"""
|
|
Validate project root path for security (CWE-22).
|
|
|
|
Args:
|
|
path: Path to validate
|
|
|
|
Returns:
|
|
Validated Path object
|
|
|
|
Raises:
|
|
AlignmentFixerError: If path is unsafe
|
|
"""
|
|
if isinstance(path, str):
|
|
path = Path(path)
|
|
|
|
# Check for path traversal
|
|
path_str = str(path)
|
|
if ".." in path_str:
|
|
audit_log(
|
|
"security_violation",
|
|
"failure",
|
|
{
|
|
"type": "path_traversal",
|
|
"path": path_str,
|
|
"component": "alignment_fixer"
|
|
}
|
|
)
|
|
raise AlignmentFixerError(f"Path traversal detected: {path_str}")
|
|
|
|
# Resolve to absolute path
|
|
try:
|
|
resolved_path = path.resolve()
|
|
except (OSError, RuntimeError) as e:
|
|
raise AlignmentFixerError(f"Failed to resolve path: {e}")
|
|
|
|
# Check if directory exists
|
|
if not resolved_path.is_dir():
|
|
raise AlignmentFixerError(f"Project root is not a directory: {resolved_path}")
|
|
|
|
return resolved_path
|
|
|
|
def _find_project_md(self) -> Path:
|
|
"""
|
|
Find PROJECT.md in project root or .claude directory.
|
|
|
|
Returns:
|
|
Path to PROJECT.md
|
|
|
|
Raises:
|
|
AlignmentFixerError: If PROJECT.md not found
|
|
"""
|
|
# Check root level first
|
|
root_path = self.project_root / "PROJECT.md"
|
|
if root_path.exists():
|
|
return root_path
|
|
|
|
# Check .claude directory (follow symlink if needed)
|
|
claude_path = self.project_root / ".claude" / "PROJECT.md"
|
|
if claude_path.exists():
|
|
# If it's a symlink, resolve to actual file
|
|
if claude_path.is_symlink():
|
|
resolved = claude_path.resolve()
|
|
if resolved.exists():
|
|
return resolved
|
|
return claude_path
|
|
|
|
raise AlignmentFixerError(
|
|
f"PROJECT.md not found in {self.project_root} or {self.project_root / '.claude'}"
|
|
)
|
|
|
|
def is_consent_enabled(self) -> bool:
|
|
"""
|
|
Check if bidirectional sync consent is enabled.
|
|
|
|
Returns:
|
|
True if consent given, False otherwise
|
|
"""
|
|
# Check environment variable override first
|
|
env_value = os.environ.get("BIDIRECTIONAL_SYNC_ENABLED", "").lower()
|
|
if env_value in ("true", "1", "yes"):
|
|
return True
|
|
if env_value in ("false", "0", "no"):
|
|
return False
|
|
|
|
# Fall back to user state
|
|
return get_user_preference(
|
|
BIDIRECTIONAL_SYNC_CONSENT_KEY,
|
|
self.state_file,
|
|
default=None, # None means not yet asked
|
|
)
|
|
|
|
def record_consent(self, enabled: bool) -> None:
|
|
"""
|
|
Record user consent for bidirectional sync.
|
|
|
|
Args:
|
|
enabled: Whether sync is enabled
|
|
"""
|
|
set_user_preference(
|
|
BIDIRECTIONAL_SYNC_CONSENT_KEY,
|
|
enabled,
|
|
self.state_file,
|
|
)
|
|
audit_log(
|
|
"bidirectional_sync_consent",
|
|
"success",
|
|
{
|
|
"enabled": enabled,
|
|
"state_file": str(self.state_file),
|
|
}
|
|
)
|
|
|
|
def is_section_protected(self, section: str, subsection: Optional[str] = None) -> bool:
|
|
"""
|
|
Check if a section is protected from auto-updates.
|
|
|
|
Args:
|
|
section: Section name
|
|
subsection: Optional subsection name
|
|
|
|
Returns:
|
|
True if protected, False if can be proposed
|
|
"""
|
|
# Top-level protected sections
|
|
if section in PROTECTED_SECTIONS:
|
|
return True
|
|
|
|
# Protected subsections within SCOPE
|
|
if section == "SCOPE" and subsection in PROTECTED_SCOPE_SUBSECTIONS:
|
|
return True
|
|
|
|
return False
|
|
|
|
def propose_update(
|
|
self,
|
|
section: str,
|
|
proposed_value: str,
|
|
reason: str,
|
|
subsection: Optional[str] = None,
|
|
action: str = "add",
|
|
current_value: Optional[str] = None,
|
|
) -> ProposedUpdate:
|
|
"""
|
|
Propose an update to PROJECT.md.
|
|
|
|
Args:
|
|
section: Section to update (must be in PROPOSABLE_SECTIONS)
|
|
proposed_value: Value to add/update
|
|
reason: Reason for the change
|
|
subsection: Optional subsection
|
|
action: "add", "update", or "remove"
|
|
current_value: Current value if updating/removing
|
|
|
|
Returns:
|
|
ProposedUpdate object
|
|
|
|
Raises:
|
|
AlignmentFixerError: If section is protected
|
|
"""
|
|
# Validate section
|
|
if section not in PROPOSABLE_SECTIONS:
|
|
raise AlignmentFixerError(
|
|
f"Section '{section}' cannot be proposed for update. "
|
|
f"Only {PROPOSABLE_SECTIONS} can be updated. "
|
|
f"Protected sections: {PROTECTED_SECTIONS}"
|
|
)
|
|
|
|
# Validate subsection
|
|
if self.is_section_protected(section, subsection):
|
|
raise AlignmentFixerError(
|
|
f"Subsection '{subsection}' within '{section}' is protected and cannot be updated."
|
|
)
|
|
|
|
# Create proposal
|
|
update = ProposedUpdate(
|
|
section=section,
|
|
subsection=subsection,
|
|
action=action,
|
|
current_value=current_value,
|
|
proposed_value=proposed_value,
|
|
reason=reason,
|
|
)
|
|
|
|
self.pending_updates.append(update)
|
|
|
|
audit_log(
|
|
"project_md_update_proposed",
|
|
"success",
|
|
{
|
|
"section": section,
|
|
"subsection": subsection,
|
|
"action": action,
|
|
"proposed_value": proposed_value[:100], # Truncate for log
|
|
"reason": reason,
|
|
}
|
|
)
|
|
|
|
return update
|
|
|
|
def format_proposals_for_display(self) -> str:
|
|
"""
|
|
Format pending proposals for user display.
|
|
|
|
Returns:
|
|
Formatted string showing all proposals
|
|
"""
|
|
if not self.pending_updates:
|
|
return "No pending PROJECT.md updates."
|
|
|
|
lines = [
|
|
"Proposed PROJECT.md updates:",
|
|
"━" * 40,
|
|
]
|
|
|
|
for i, update in enumerate(self.pending_updates, 1):
|
|
section_display = update.section
|
|
if update.subsection:
|
|
section_display = f"{update.section} ({update.subsection})"
|
|
|
|
lines.append(f"\n{i}. {section_display}:")
|
|
lines.append(f" Action: {update.action}")
|
|
|
|
if update.current_value:
|
|
lines.append(f" Current: {update.current_value}")
|
|
|
|
lines.append(f" Proposed: {update.proposed_value}")
|
|
lines.append(f" Reason: {update.reason}")
|
|
|
|
lines.append("\n" + "━" * 40)
|
|
|
|
return "\n".join(lines)
|
|
|
|
def create_backup(self) -> Path:
|
|
"""
|
|
Create a backup of PROJECT.md before modification.
|
|
|
|
Returns:
|
|
Path to backup file
|
|
|
|
Raises:
|
|
AlignmentFixerError: If backup fails
|
|
"""
|
|
if not self.project_md_path.exists():
|
|
raise AlignmentFixerError("PROJECT.md does not exist")
|
|
|
|
# Create backup directory
|
|
backup_dir = Path.home() / ".autonomous-dev" / "backups" / "project_md"
|
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Ensure secure permissions
|
|
backup_dir.chmod(0o700)
|
|
|
|
# Create timestamped backup
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
backup_name = f"PROJECT.md.{timestamp}.backup"
|
|
backup_path = backup_dir / backup_name
|
|
|
|
try:
|
|
shutil.copy2(self.project_md_path, backup_path)
|
|
backup_path.chmod(0o600)
|
|
self.backup_path = backup_path
|
|
|
|
audit_log(
|
|
"project_md_backup_created",
|
|
"success",
|
|
{
|
|
"source": str(self.project_md_path),
|
|
"backup": str(backup_path),
|
|
}
|
|
)
|
|
|
|
return backup_path
|
|
|
|
except (OSError, shutil.Error) as e:
|
|
audit_log(
|
|
"project_md_backup_failed",
|
|
"failure",
|
|
{
|
|
"source": str(self.project_md_path),
|
|
"error": str(e),
|
|
}
|
|
)
|
|
raise AlignmentFixerError(f"Failed to create backup: {e}")
|
|
|
|
def apply_approved_updates(self) -> Tuple[int, List[str]]:
|
|
"""
|
|
Apply all approved updates to PROJECT.md.
|
|
|
|
Returns:
|
|
Tuple of (applied_count, list of applied descriptions)
|
|
|
|
Raises:
|
|
AlignmentFixerError: If update fails
|
|
"""
|
|
approved = [u for u in self.pending_updates if u.approved]
|
|
|
|
if not approved:
|
|
return 0, []
|
|
|
|
# Create backup first
|
|
self.create_backup()
|
|
|
|
# Read current content
|
|
content = self.project_md_path.read_text()
|
|
applied_descriptions = []
|
|
|
|
try:
|
|
for update in approved:
|
|
content = self._apply_single_update(content, update)
|
|
applied_descriptions.append(
|
|
f"{update.action} in {update.section}: {update.proposed_value[:50]}"
|
|
)
|
|
|
|
# Atomic write: write to temp file, then rename
|
|
temp_path = self.project_md_path.with_suffix(".tmp")
|
|
temp_path.write_text(content)
|
|
temp_path.replace(self.project_md_path)
|
|
|
|
audit_log(
|
|
"project_md_updates_applied",
|
|
"success",
|
|
{
|
|
"applied_count": len(approved),
|
|
"backup": str(self.backup_path),
|
|
}
|
|
)
|
|
|
|
# Clear applied updates from pending
|
|
self.pending_updates = [u for u in self.pending_updates if not u.approved]
|
|
|
|
return len(approved), applied_descriptions
|
|
|
|
except Exception as e:
|
|
# Attempt rollback
|
|
if self.backup_path and self.backup_path.exists():
|
|
self._rollback()
|
|
|
|
audit_log(
|
|
"project_md_update_failed",
|
|
"failure",
|
|
{
|
|
"error": str(e),
|
|
"rollback_attempted": self.backup_path is not None,
|
|
}
|
|
)
|
|
raise AlignmentFixerError(f"Failed to apply updates: {e}")
|
|
|
|
def _apply_single_update(self, content: str, update: ProposedUpdate) -> str:
|
|
"""
|
|
Apply a single update to PROJECT.md content.
|
|
|
|
Args:
|
|
content: Current file content
|
|
update: Update to apply
|
|
|
|
Returns:
|
|
Modified content
|
|
"""
|
|
if update.section == "SCOPE":
|
|
return self._apply_scope_update(content, update)
|
|
elif update.section == "ARCHITECTURE":
|
|
return self._apply_architecture_update(content, update)
|
|
else:
|
|
raise AlignmentFixerError(f"Unknown section: {update.section}")
|
|
|
|
def _apply_scope_update(self, content: str, update: ProposedUpdate) -> str:
|
|
"""Apply update to SCOPE section."""
|
|
# Find the "In Scope" section
|
|
in_scope_pattern = r"(\*\*What's IN Scope\*\*.+?)(\n\n\*\*What's OUT)"
|
|
match = re.search(in_scope_pattern, content, re.DOTALL)
|
|
|
|
if not match:
|
|
# Try alternate pattern
|
|
in_scope_pattern = r"(## SCOPE.+?IN Scope.+?)(\n\n.*?OUT)"
|
|
match = re.search(in_scope_pattern, content, re.DOTALL)
|
|
|
|
if not match:
|
|
raise AlignmentFixerError("Could not find 'In Scope' section in PROJECT.md")
|
|
|
|
in_scope_section = match.group(1)
|
|
|
|
if update.action == "add":
|
|
# Add new item to end of In Scope section
|
|
new_line = f"- ✅ **{update.proposed_value}**"
|
|
if update.reason:
|
|
new_line += f" - {update.reason}"
|
|
|
|
# Find the last bullet point in the section
|
|
bullets = list(re.finditer(r"- ✅ .+", in_scope_section))
|
|
if bullets:
|
|
last_bullet = bullets[-1]
|
|
insert_pos = match.start(1) + last_bullet.end()
|
|
content = content[:insert_pos] + "\n" + new_line + content[insert_pos:]
|
|
else:
|
|
# Just append to section
|
|
content = content[:match.end(1)] + "\n" + new_line + content[match.end(1):]
|
|
|
|
return content
|
|
|
|
def _apply_architecture_update(self, content: str, update: ProposedUpdate) -> str:
|
|
"""Apply update to ARCHITECTURE section."""
|
|
# Handle count updates (e.g., "Commands: 7 → 8")
|
|
if update.subsection and "count" in update.subsection.lower():
|
|
# Pattern like "**Commands**: 7 active"
|
|
pattern = rf"(\*\*{update.subsection}\*\*[:\s]+)(\d+)"
|
|
|
|
def replace_count(m):
|
|
return m.group(1) + update.proposed_value
|
|
|
|
content = re.sub(pattern, replace_count, content)
|
|
|
|
return content
|
|
|
|
def _rollback(self) -> None:
|
|
"""Rollback to backup if available."""
|
|
if not self.backup_path or not self.backup_path.exists():
|
|
return
|
|
|
|
try:
|
|
shutil.copy2(self.backup_path, self.project_md_path)
|
|
audit_log(
|
|
"project_md_rollback",
|
|
"success",
|
|
{
|
|
"backup": str(self.backup_path),
|
|
"target": str(self.project_md_path),
|
|
}
|
|
)
|
|
except Exception as e:
|
|
audit_log(
|
|
"project_md_rollback_failed",
|
|
"failure",
|
|
{
|
|
"backup": str(self.backup_path),
|
|
"error": str(e),
|
|
}
|
|
)
|
|
|
|
def mark_approved(self, indices: List[int]) -> int:
|
|
"""
|
|
Mark specific proposals as approved.
|
|
|
|
Args:
|
|
indices: 1-based indices of proposals to approve
|
|
|
|
Returns:
|
|
Number of proposals marked approved
|
|
"""
|
|
count = 0
|
|
for idx in indices:
|
|
if 1 <= idx <= len(self.pending_updates):
|
|
self.pending_updates[idx - 1].approved = True
|
|
count += 1
|
|
return count
|
|
|
|
def mark_declined(self, indices: List[int]) -> int:
|
|
"""
|
|
Mark specific proposals as declined.
|
|
|
|
Args:
|
|
indices: 1-based indices of proposals to decline
|
|
|
|
Returns:
|
|
Number of proposals marked declined
|
|
"""
|
|
count = 0
|
|
for idx in indices:
|
|
if 1 <= idx <= len(self.pending_updates):
|
|
self.pending_updates[idx - 1].declined = True
|
|
count += 1
|
|
|
|
# Log declined proposals
|
|
for idx in indices:
|
|
if 1 <= idx <= len(self.pending_updates):
|
|
update = self.pending_updates[idx - 1]
|
|
audit_log(
|
|
"project_md_update_declined",
|
|
"success",
|
|
{
|
|
"section": update.section,
|
|
"proposed_value": update.proposed_value[:100],
|
|
"reason": update.reason,
|
|
}
|
|
)
|
|
|
|
return count
|
|
|
|
|
|
# Module-level convenience functions
|
|
|
|
def check_bidirectional_sync_consent(state_file: Path = DEFAULT_STATE_FILE) -> Optional[bool]:
|
|
"""
|
|
Check if bidirectional sync consent has been given.
|
|
|
|
Returns:
|
|
True if enabled, False if disabled, None if not yet asked
|
|
"""
|
|
return get_user_preference(
|
|
BIDIRECTIONAL_SYNC_CONSENT_KEY,
|
|
state_file,
|
|
default=None,
|
|
)
|
|
|
|
|
|
def record_bidirectional_sync_consent(
|
|
enabled: bool,
|
|
state_file: Path = DEFAULT_STATE_FILE
|
|
) -> None:
|
|
"""
|
|
Record bidirectional sync consent.
|
|
|
|
Args:
|
|
enabled: Whether sync is enabled
|
|
state_file: Path to state file
|
|
"""
|
|
set_user_preference(
|
|
BIDIRECTIONAL_SYNC_CONSENT_KEY,
|
|
enabled,
|
|
state_file,
|
|
)
|
|
audit_log(
|
|
"bidirectional_sync_consent",
|
|
"success",
|
|
{
|
|
"enabled": enabled,
|
|
"state_file": str(state_file),
|
|
}
|
|
)
|
|
|
|
|
|
def propose_scope_addition(
|
|
project_root: Path,
|
|
feature_name: str,
|
|
reason: str,
|
|
) -> ProposedUpdate:
|
|
"""
|
|
Convenience function to propose adding a feature to SCOPE.
|
|
|
|
Args:
|
|
project_root: Project root directory
|
|
feature_name: Name of feature to add
|
|
reason: Reason for addition
|
|
|
|
Returns:
|
|
ProposedUpdate object
|
|
"""
|
|
fixer = AlignmentFixer(project_root)
|
|
return fixer.propose_update(
|
|
section="SCOPE",
|
|
subsection="In Scope",
|
|
action="add",
|
|
proposed_value=feature_name,
|
|
reason=reason,
|
|
)
|
|
|
|
|
|
def is_section_protected(section: str, subsection: Optional[str] = None) -> bool:
|
|
"""
|
|
Check if a section is protected from auto-updates.
|
|
|
|
Args:
|
|
section: Section name
|
|
subsection: Optional subsection name
|
|
|
|
Returns:
|
|
True if protected, False if can be proposed
|
|
"""
|
|
if section in PROTECTED_SECTIONS:
|
|
return True
|
|
if section == "SCOPE" and subsection in PROTECTED_SCOPE_SUBSECTIONS:
|
|
return True
|
|
return False
|