Skip to main content

#!/usr/bin/env python3 """ Framework Sync - Sync Development to Protected Installation

For individual contributors only. Commits and pushes coditect-core changes from the development copy, updates the protected installation, and commits the submodule update in coditect-rollout-master (parent repo).

REQUIRES: Machine must be registered in config/contributors.json

Usage: python3 framework-sync.py # Full sync (requires registered machine) python3 framework-sync.py --register # Register this machine as contributor python3 framework-sync.py --list-machines # List all registered machines python3 framework-sync.py --commit-only # Only commit and push python3 framework-sync.py --update-only # Only update protected from remote python3 framework-sync.py --no-parent # Skip parent repo update python3 framework-sync.py --message "feat:..." # Provide commit message directly python3 framework-sync.py --dry-run # Show what would happen

Part of /framework-sync command implementation.

Created: 2026-01-24 """

import argparse import json import os import shutil import subprocess import sys from datetime import datetime from pathlib import Path from typing import Optional, Tuple, Dict, List

ANSI colors

Shared Colors module (consolidates 36 duplicate definitions)

_script_dir = Path(file).parent sys.path.insert(0, str(_script_dir / "core")) from colors import Colors

def colorize(text: str, color: str) -> str: return f"{color}{text}{Colors.END}"

Paths

HOME = Path.home() PARENT_REPO = HOME / "PROJECTS" / "coditect-rollout-master" DEV_COPY = PARENT_REPO / "submodules" / "core" / "coditect-core"

Platform-specific protected location

if sys.platform == "darwin": PROTECTED_LOC = HOME / "Library" / "Application Support" / "CODITECT" / "core" elif sys.platform == "win32": PROTECTED_LOC = Path(os.environ.get("LOCALAPPDATA", HOME / "AppData" / "Local")) / "CODITECT" / "core" else: PROTECTED_LOC = Path(os.environ.get("XDG_DATA_HOME", HOME / ".local" / "share")) / "coditect" / "core"

REPO_URL = "https://github.com/coditect-ai/coditect-core.git"

Authentication paths

MACHINE_ID_FILE = HOME / ".coditect" / "machine-id.json" CONTRIBUTORS_FILE = DEV_COPY / "config" / "contributors.json"

def get_machine_id() -> Optional[Dict]: """Load the local machine ID.""" if not MACHINE_ID_FILE.exists(): return None try: with open(MACHINE_ID_FILE) as f: return json.load(f) except (json.JSONDecodeError, IOError): return None

def load_contributors() -> Dict: """Load the contributors registry.""" if not CONTRIBUTORS_FILE.exists(): return {"version": "1.0.0", "contributors": []} try: with open(CONTRIBUTORS_FILE) as f: return json.load(f) except (json.JSONDecodeError, IOError): return {"version": "1.0.0", "contributors": []}

def save_contributors(data: Dict) -> bool: """Save the contributors registry.""" try: with open(CONTRIBUTORS_FILE, 'w') as f: json.dump(data, f, indent=2) return True except IOError: return False

def is_machine_registered(machine_id: str) -> Tuple[bool, Optional[str]]: """Check if a machine ID is registered. Returns (is_registered, contributor_name).""" contributors = load_contributors() for contributor in contributors.get("contributors", []): for machine in contributor.get("machines", []): if machine.get("machine_id") == machine_id: return True, contributor.get("name") return False, None

def validate_machine() -> Tuple[bool, Dict, Optional[str]]: """Validate the current machine is authorized. Returns (valid, machine_info, contributor_name).""" machine_info = get_machine_id() if not machine_info: print(colorize("⚠ Machine ID not found. Run CODITECT-CORE-INITIAL-SETUP.py first.", Colors.RED)) return False, {}, None

machine_id = machine_info.get("machine_uuid")
if not machine_id:
print(colorize("⚠ Invalid machine-id.json - missing machine_uuid", Colors.RED))
return False, machine_info, None

is_registered, contributor_name = is_machine_registered(machine_id)
return is_registered, machine_info, contributor_name

def register_machine(machine_info: Dict, contributor_name: Optional[str] = None) -> bool: """Register the current machine as a contributor.""" machine_id = machine_info.get("machine_uuid") hostname = machine_info.get("hostname", "unknown")

# Check if already registered
is_registered, existing_name = is_machine_registered(machine_id)
if is_registered:
print(colorize(f"✓ Machine already registered under: {existing_name}", Colors.GREEN))
return True

contributors = load_contributors()

# Get contributor name
if not contributor_name:
print("\nEnter contributor name (e.g., 'Hal Casteel'):")
contributor_name = input("> ").strip()
if not contributor_name:
print(colorize("⚠ Contributor name required", Colors.RED))
return False

# Check if contributor exists
contributor_found = False
for contributor in contributors.get("contributors", []):
if contributor.get("name") == contributor_name:
contributor["machines"].append({
"machine_id": machine_id,
"hostname": hostname,
"registered": datetime.now().strftime("%Y-%m-%d"),
"last_sync": None,
"notes": "Added via --register"
})
contributor_found = True
break

# Create new contributor if not found
if not contributor_found:
print(f"Enter email for {contributor_name}:")
email = input("> ").strip() or "contributor@example.com"

contributors.setdefault("contributors", []).append({
"name": contributor_name,
"email": email,
"role": "contributor",
"machines": [{
"machine_id": machine_id,
"hostname": hostname,
"registered": datetime.now().strftime("%Y-%m-%d"),
"last_sync": None,
"notes": "Initial registration"
}]
})

if save_contributors(contributors):
print(colorize(f"✓ Machine registered: {hostname}", Colors.GREEN))
print(colorize(f" Contributor: {contributor_name}", Colors.CYAN))
print(colorize(f" Machine ID: {machine_id[:16]}...", Colors.CYAN))
print(colorize("\n Run /framework-sync again to commit and push the registration.", Colors.YELLOW))
return True
else:
print(colorize("⚠ Failed to save contributors.json", Colors.RED))
return False

def list_machines(): """List all registered contributor machines.""" contributors = load_contributors()

print(colorize("\n╔══════════════════════════════════════════════════════════════════╗", Colors.CYAN))
print(colorize("║ CODITECT Registered Contributors ║", Colors.CYAN))
print(colorize("╠══════════════════════════════════════════════════════════════════╣", Colors.CYAN))

for contributor in contributors.get("contributors", []):
name = contributor.get("name", "Unknown")
role = contributor.get("role", "contributor")
print(colorize(f"║ {name} ({role})", Colors.BOLD))

for machine in contributor.get("machines", []):
hostname = machine.get("hostname", "unknown")[:30]
machine_id = machine.get("machine_id", "")[:12]
registered = machine.get("registered", "unknown")
last_sync = machine.get("last_sync", "never")
if last_sync and last_sync != "never":
last_sync = last_sync[:10]

print(f"║ • {hostname:<30} [{machine_id}...] reg:{registered} sync:{last_sync}")

print(colorize("╚══════════════════════════════════════════════════════════════════╝", Colors.CYAN))

# Show current machine status
machine_info = get_machine_id()
if machine_info:
machine_id = machine_info.get("machine_uuid")
hostname = machine_info.get("hostname")
is_registered, contributor_name = is_machine_registered(machine_id)

print(f"\nThis machine: {hostname}")
if is_registered:
print(colorize(f" Status: ✓ Registered ({contributor_name})", Colors.GREEN))
else:
print(colorize(f" Status: ✗ Not registered", Colors.RED))
print(colorize(f" Run: /framework-sync --register", Colors.YELLOW))

def update_last_sync(machine_id: str): """Update the last_sync timestamp for a machine.""" contributors = load_contributors()

for contributor in contributors.get("contributors", []):
for machine in contributor.get("machines", []):
if machine.get("machine_id") == machine_id:
machine["last_sync"] = datetime.now().isoformat()
save_contributors(contributors)
return

def run_cmd(cmd: list, cwd: Optional[Path] = None, capture: bool = True) -> Tuple[int, str, str]: """Run a command and return (returncode, stdout, stderr).""" try: result = subprocess.run( cmd, cwd=cwd, capture_output=capture, text=True ) return result.returncode, result.stdout, result.stderr except Exception as e: return 1, "", str(e)

def verify_contributor_setup() -> bool: """Verify that this is a contributor setup with development copy.""" print(colorize("\n=== Step 1: Verify Contributor Setup ===", Colors.BOLD))

if not DEV_COPY.exists():
print(colorize(f"⚠ Development copy not found: {DEV_COPY}", Colors.RED))
print(" This command is for individual contributors only.")
print(" Expected: coditect-rollout-master/submodules/core/coditect-core")
return False

if not (DEV_COPY / ".git").exists():
print(colorize(f"⚠ Development copy is not a git repository", Colors.RED))
return False

print(colorize(f"✓ Development copy: {DEV_COPY}", Colors.GREEN))

if not PROTECTED_LOC.exists():
print(colorize(f"⚠ Protected installation not found: {PROTECTED_LOC}", Colors.YELLOW))
print(" Run: python3 scripts/CODITECT-CORE-INITIAL-SETUP.py")
print(" Will create protected installation after push.")
else:
print(colorize(f"✓ Protected location: {PROTECTED_LOC}", Colors.GREEN))

return True

def show_pending_changes() -> Tuple[bool, list]: """Show pending changes in development copy.""" print(colorize("\n=== Step 2: Show Pending Changes ===", Colors.BOLD))

os.chdir(DEV_COPY)

# Git status
print("\n--- Git Status ---")
rc, stdout, _ = run_cmd(["git", "status", "--short"], DEV_COPY)
if stdout.strip():
print(stdout)
has_changes = True
else:
print(" (no uncommitted changes)")
has_changes = False

# Unpushed commits
print("\n--- Unpushed Commits ---")
rc, stdout, _ = run_cmd(["git", "log", "origin/main..HEAD", "--oneline"], DEV_COPY)
unpushed = stdout.strip().split('\n') if stdout.strip() else []
if unpushed and unpushed[0]:
for commit in unpushed:
print(f" {commit}")
has_unpushed = True
else:
print(" (no unpushed commits)")
has_unpushed = False

# Modified files diff
print("\n--- Modified Files ---")
rc, stdout, _ = run_cmd(["git", "diff", "--stat"], DEV_COPY)
if stdout.strip():
print(stdout)
else:
print(" (no staged/unstaged changes)")

return has_changes or has_unpushed, unpushed

def commit_changes(message: Optional[str], dry_run: bool = False) -> bool: """Stage and commit changes.""" print(colorize("\n=== Step 3: Commit Changes ===", Colors.BOLD))

os.chdir(DEV_COPY)

# Check for changes to commit
rc, stdout, _ = run_cmd(["git", "status", "--porcelain"], DEV_COPY)
if not stdout.strip():
print(" No changes to commit.")
return True

# Show files to stage
print("\nFiles to stage:")
for line in stdout.strip().split('\n'):
print(f" {line}")

if dry_run:
print(colorize("\n[DRY RUN] Would stage and commit these files", Colors.YELLOW))
return True

# Stage all changes
rc, _, stderr = run_cmd(["git", "add", "-A"], DEV_COPY)
if rc != 0:
print(colorize(f"⚠ Failed to stage changes: {stderr}", Colors.RED))
return False

# Get commit message
if not message:
print("\nEnter commit message (type/scope: description):")
print(" Types: feat, fix, docs, refactor, chore, test")
print(" Example: feat(commands): Add /symlinks command")
message = input("> ").strip()
if not message:
print(colorize("⚠ Commit message required", Colors.RED))
return False

# Commit
full_message = f"{message}\n\nCo-Authored-By: Claude <noreply@anthropic.com>"
rc, stdout, stderr = run_cmd(["git", "commit", "-m", full_message], DEV_COPY)
if rc != 0:
print(colorize(f"⚠ Commit failed: {stderr}", Colors.RED))
return False

print(colorize("✓ Changes committed", Colors.GREEN))
return True

def push_to_remote(dry_run: bool = False) -> bool: """Push changes to remote.""" print(colorize("\n=== Step 4: Push to Remote ===", Colors.BOLD))

os.chdir(DEV_COPY)

# Verify we're on main
rc, stdout, _ = run_cmd(["git", "branch", "--show-current"], DEV_COPY)
branch = stdout.strip()
if branch != "main":
print(colorize(f"⚠ Not on main branch. Current: {branch}", Colors.YELLOW))
print(" Merge to main before syncing.")
return False

# Check for unpushed commits
rc, stdout, _ = run_cmd(["git", "log", "origin/main..HEAD", "--oneline"], DEV_COPY)
if not stdout.strip():
print(" No commits to push.")
return True

if dry_run:
print(colorize("[DRY RUN] Would push these commits:", Colors.YELLOW))
print(stdout)
return True

# Push
print("Pushing to origin/main...")
rc, stdout, stderr = run_cmd(["git", "push", "origin", "main"], DEV_COPY, capture=False)
if rc != 0:
print(colorize(f"⚠ Push failed", Colors.RED))
return False

print(colorize("✓ Pushed to origin/main", Colors.GREEN))
return True

def update_protected_installation(dry_run: bool = False) -> bool: """Update protected installation from remote using atomic swap pattern.

Uses a safe update strategy:
1. Clone to temporary location first
2. Prepare the new installation (remove .git, set permissions)
3. Only then swap old → new atomically

This prevents broken symlinks if clone fails or is interrupted.
"""
print(colorize("\n=== Step 5: Update Protected Installation ===", Colors.BOLD))

if dry_run:
print(colorize(f"[DRY RUN] Would update: {PROTECTED_LOC}", Colors.YELLOW))
return True

print(f"Updating: {PROTECTED_LOC}")

# Use atomic swap pattern to prevent broken symlinks
temp_loc = PROTECTED_LOC.parent / "core_NEW"
old_loc = PROTECTED_LOC.parent / "core_OLD"

# Clean up any leftover temp directories from previous failed attempts
for leftover in [temp_loc, old_loc]:
if leftover.exists():
print(f" Cleaning up leftover: {leftover.name}")
_make_writable_and_remove(leftover)

# Step 1: Clone to temporary location FIRST
print(" Cloning latest from remote to temp location...")
PROTECTED_LOC.parent.mkdir(parents=True, exist_ok=True)
rc, _, stderr = run_cmd(
["git", "clone", "--depth", "1", REPO_URL, str(temp_loc)],
PROTECTED_LOC.parent
)
if rc != 0:
print(colorize(f"⚠ Clone failed: {stderr}", Colors.RED))
# Clean up failed clone attempt
if temp_loc.exists():
shutil.rmtree(temp_loc, ignore_errors=True)
return False

# Step 2: Prepare new installation (while old is still in place)
print(" Preparing new installation...")

# Remove .git to prevent modification
git_dir = temp_loc / ".git"
if git_dir.exists():
shutil.rmtree(git_dir)

# Remove .claude and .coditect symlinks/directories
# These exist in the git repo but should NOT be in the protected installation
# Having them causes Claude Code to look at .claude/.claude/skills/ recursively
# instead of just skills/ (the canonical location)
for symlink_name in [".claude", ".coditect"]:
symlink_path = temp_loc / symlink_name
if symlink_path.is_symlink():
symlink_path.unlink()
print(f" Removed {symlink_name} symlink (prevents recursive path issue)")
elif symlink_path.is_dir():
shutil.rmtree(symlink_path)
print(f" Removed {symlink_name} directory (prevents recursive path issue)")

# Ensure CLAUDE.md -> CODITECT.md symlink (ADR-212)
# CODITECT.md is the controlled source of truth; CLAUDE.md must be a symlink
claude_md = temp_loc / "CLAUDE.md"
coditect_md = temp_loc / "CODITECT.md"
if coditect_md.exists():
if not claude_md.is_symlink() or os.readlink(claude_md) != "CODITECT.md":
if claude_md.exists() or claude_md.is_symlink():
claude_md.unlink()
claude_md.symlink_to("CODITECT.md")
print(" Ensured CLAUDE.md -> CODITECT.md symlink (ADR-212)")

# Set read-only permissions
for root, dirs, files in os.walk(temp_loc):
for f in files:
filepath = Path(root) / f
if f.endswith(('.sh', '.py')):
os.chmod(filepath, 0o555) # rx for scripts
else:
os.chmod(filepath, 0o444) # r for others

# Step 3: Atomic swap - move old out, move new in
print(" Performing atomic swap...")
try:
# Move old installation aside (if exists)
if PROTECTED_LOC.exists():
_make_writable_and_remove(PROTECTED_LOC, rename_to=old_loc)

# Move new installation into place
shutil.move(str(temp_loc), str(PROTECTED_LOC))

# Clean up old installation
if old_loc.exists():
_make_writable_and_remove(old_loc)

except Exception as e:
print(colorize(f"⚠ Swap failed: {e}", Colors.RED))
# Try to restore old installation if swap failed
if old_loc.exists() and not PROTECTED_LOC.exists():
print(" Attempting to restore previous installation...")
try:
shutil.move(str(old_loc), str(PROTECTED_LOC))
print(colorize(" Previous installation restored", Colors.YELLOW))
except Exception:
pass
return False

print(colorize("✓ Protected installation updated", Colors.GREEN))
return True

def _make_writable_and_remove(path: Path, rename_to: Optional[Path] = None) -> None: """Make all files writable and remove or rename directory.

Args:
path: Directory to process
rename_to: If provided, rename instead of remove
"""
# First make all files writable (protected installation is read-only)
for root, dirs, files in os.walk(path):
for d in dirs:
try:
os.chmod(Path(root) / d, 0o755)
except OSError:
pass
for f in files:
try:
os.chmod(Path(root) / f, 0o644)
except OSError:
pass

if rename_to:
shutil.move(str(path), str(rename_to))
else:
shutil.rmtree(path)

def verify_sync() -> bool: """Verify sync between development and protected.""" print(colorize("\n=== Step 6: Verify Sync ===", Colors.BOLD))

if not PROTECTED_LOC.exists():
print(colorize("⚠ Protected installation not found", Colors.YELLOW))
return False

# Get development version
os.chdir(DEV_COPY)
rc, stdout, _ = run_cmd(["git", "rev-parse", "--short", "HEAD"], DEV_COPY)
dev_version = stdout.strip()

print(f" Development: {dev_version}")
print(f" Protected: Latest from remote")

# Compare component counts
def count_md_files(path: Path, subdir: str) -> int:
d = path / subdir
if d.exists():
return len(list(d.glob("*.md")))
return 0

for component_type in ["agents", "commands", "skills"]:
dev_count = count_md_files(DEV_COPY, component_type)
prot_count = count_md_files(PROTECTED_LOC, component_type)
status = "✓" if dev_count == prot_count else "!"
print(f" {status} {component_type}: Dev={dev_count}, Protected={prot_count}")

print(colorize("\n✓ Sync verification complete", Colors.GREEN))
return True

def update_parent_submodule(dry_run: bool = False) -> bool: """Update the submodule reference in coditect-rollout-master.""" print(colorize("\n=== Step 7: Update Parent Repository ===", Colors.BOLD))

if not PARENT_REPO.exists():
print(colorize(f"⚠ Parent repo not found: {PARENT_REPO}", Colors.RED))
return False

os.chdir(PARENT_REPO)

# Get the current coditect-core commit
rc, stdout, _ = run_cmd(["git", "rev-parse", "--short", "HEAD"], DEV_COPY)
core_commit = stdout.strip()

# Check if submodule has changes
rc, stdout, _ = run_cmd(["git", "status", "--porcelain", "submodules/core/coditect-core"], PARENT_REPO)
if not stdout.strip():
print(" Submodule already up to date in parent.")
return True

if dry_run:
print(colorize(f"[DRY RUN] Would commit submodule update to {core_commit}", Colors.YELLOW))
return True

# Remove any stale index.lock
lock_file = PARENT_REPO / ".git" / "index.lock"
if lock_file.exists():
lock_file.unlink()

# Stage submodule
rc, _, stderr = run_cmd(["git", "add", "submodules/core/coditect-core"], PARENT_REPO)
if rc != 0:
print(colorize(f"⚠ Failed to stage submodule: {stderr}", Colors.RED))
return False

# Commit
commit_msg = f"chore(submodules): Update coditect-core to {core_commit}\n\nCo-Authored-By: Claude <noreply@anthropic.com>"
rc, _, stderr = run_cmd(["git", "commit", "-m", commit_msg], PARENT_REPO)
if rc != 0:
print(colorize(f"⚠ Commit failed: {stderr}", Colors.RED))
return False

print(colorize(f"✓ Committed submodule update ({core_commit})", Colors.GREEN))

# Push
print(" Pushing to origin/main...")
rc, _, stderr = run_cmd(["git", "push", "origin", "main"], PARENT_REPO, capture=False)
if rc != 0:
print(colorize(f"⚠ Push failed", Colors.RED))
return False

print(colorize("✓ Parent repo updated and pushed", Colors.GREEN))
return True

def main(): parser = argparse.ArgumentParser(description="Sync coditect-core development to protected installation") parser.add_argument("--register", action="store_true", help="Register this machine as a contributor") parser.add_argument("--list-machines", action="store_true", help="List all registered contributor machines") parser.add_argument("--commit-only", action="store_true", help="Only commit and push, don't update protected") parser.add_argument("--update-only", action="store_true", help="Only update protected from remote") parser.add_argument("--message", "-m", type=str, help="Commit message (skip interactive prompt)") parser.add_argument("--dry-run", action="store_true", help="Show what would happen without making changes") parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompts") parser.add_argument("--no-parent", action="store_true", help="Skip updating coditect-rollout-master (default: updates parent)")

args = parser.parse_args()

print(colorize("\nCODITECT Framework Sync", Colors.BOLD))
print("=" * 40)
print("For Individual Contributors Only")

# Handle --list-machines
if args.list_machines:
list_machines()
sys.exit(0)

# Handle --register
if args.register:
machine_info = get_machine_id()
if not machine_info:
print(colorize("⚠ Machine ID not found. Run CODITECT-CORE-INITIAL-SETUP.py first.", Colors.RED))
sys.exit(1)

print(colorize("\n=== Machine Registration ===", Colors.BOLD))
print(f"Machine: {machine_info.get('hostname')}")
print(f"ID: {machine_info.get('machine_uuid')[:16]}...")

print("\nType REGISTER to confirm:")
confirm = input("> ").strip()
if confirm != "REGISTER":
print("Registration cancelled.")
sys.exit(0)

if register_machine(machine_info):
sys.exit(0)
else:
sys.exit(1)

# Validate machine authorization
print(colorize("\n=== Step 0: Machine Validation ===", Colors.BOLD))
is_valid, machine_info, contributor_name = validate_machine()

if not is_valid:
hostname = machine_info.get("hostname", "unknown") if machine_info else "unknown"
machine_id = machine_info.get("machine_uuid", "")[:16] if machine_info else ""

print(colorize(f"\n⚠ Machine not authorized: {hostname}", Colors.RED))
if machine_id:
print(colorize(f" Machine ID: {machine_id}...", Colors.CYAN))
print(colorize("\nTo register this machine, run:", Colors.YELLOW))
print(colorize(" /framework-sync --register", Colors.YELLOW))
sys.exit(1)

print(colorize(f"✓ Machine authorized: {machine_info.get('hostname')}", Colors.GREEN))
print(colorize(f" Contributor: {contributor_name}", Colors.CYAN))

if args.dry_run:
print(colorize("\n[DRY RUN MODE - No changes will be made]", Colors.YELLOW))

# Step 1: Verify setup
if not verify_contributor_setup():
sys.exit(1)

# Step 2: Show changes
has_changes, unpushed = show_pending_changes()

if args.update_only:
# Skip commit/push, just update protected
if not update_protected_installation(args.dry_run):
sys.exit(1)
verify_sync()
print(colorize("\n✓ Framework sync complete (update-only)", Colors.GREEN))
sys.exit(0)

if not has_changes:
print("\n No changes to sync.")
if not args.yes:
response = input("Update protected installation anyway? [y/N] ").strip().lower()
if response != 'y':
print(" Exiting.")
sys.exit(0)

# Confirm before proceeding
if not args.dry_run and not args.yes:
print(colorize("\n⚠ This will:", Colors.YELLOW))
print(" 1. Stage and commit all changes")
print(" 2. Push to origin/main")
if not args.commit_only:
print(" 3. Re-clone protected installation from remote")
response = input("\nProceed? [y/N] ").strip().lower()
if response != 'y':
print(" Cancelled.")
sys.exit(0)

# Step 3: Commit
if not commit_changes(args.message, args.dry_run):
sys.exit(1)

# Step 4: Push
if not push_to_remote(args.dry_run):
sys.exit(1)

if args.commit_only:
print(colorize("\n✓ Framework sync complete (commit-only)", Colors.GREEN))
sys.exit(0)

# Step 5: Update protected
if not update_protected_installation(args.dry_run):
sys.exit(1)

# Step 6: Verify
verify_sync()

# Step 7: Update parent (default, skip with --no-parent)
if not args.no_parent:
if not update_parent_submodule(args.dry_run):
print(colorize("⚠ Parent update failed, but coditect-core sync succeeded", Colors.YELLOW))

# Update last_sync timestamp
if not args.dry_run:
update_last_sync(machine_info.get("machine_uuid"))

print(colorize("\n✓ Framework sync complete!", Colors.GREEN))
print(f" Development: {DEV_COPY}")
print(f" Protected: {PROTECTED_LOC}")
if not args.no_parent:
print(f" Parent: {PARENT_REPO}")

if name == "main": main()