feat(system): seedgo v2 operational, full system audit, 14/15 branches at 99%

Three days of intensive work bringing seedgo to full operational status
and driving all branches through comprehensive standards compliance.

Seedgo v2.0.0:
- 22 checkers active (up from 20), standards pack fully operational
- New introspection standard researched from Dev-Pass, FPLAN-0017 open
- Bypass system for false positives (.seedgo config)
- Standards query and audit commands fully functional

System-wide audit (FPLAN-0016):
- All 14 auditable branches at 99%+ compliance
- CLI imports standardized across all branches (console from cli.apps.modules)
- handle_command(command, args) → bool contract added to all modules
- print_help() function naming fixed for checker pattern matching
- Handler extraction: large modules split, file I/O moved to handler layer
- New handlers created across ai_mail, backup, daemon, flow, skills, spawn, seedgo

Branch-specific highlights:
- ai_mail: email.py split 840→420 lines, 4 new handlers
- flow: dplan_flow.py 688→591 lines, 4 new handlers
- seedgo: massive restructure — standards moved to handlers/aipass_standards/,
  old standards/ tree removed, bypass system added, diagnostics module
- commons: database module added, CLI imports fixed
- skills: 5 handle_commands added, help function renamed
- trigger: error reporter handler, handle_command routing
- All branches: consistent architecture, clean drone routing

Culture doc (CLAUDE.md) added — documents AIPass philosophy, identity,
memory system, and collaboration principles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-03-10 01:26:42 -07:00
co-authored by Claude Opus 4.6
parent 09e759a8a4
commit babedd9c64
589 changed files with 17140 additions and 24881 deletions
+32 -408
View File
@@ -1,23 +1,16 @@
# ===================AIPASS====================
# META DATA HEADER
# =================== AIPass ====================
# Name: aggregate_central.py
# Date: 2025-11-30
# Version: 1.2.0
# Category: flow/modules
# CODE STANDARDS: Seedgo v3.0
#
# CHANGELOG:
# - v1.2.0 (2025-11-30): Comprehensive sync fix - update branch statistics, recently_closed, and global_statistics
# - v1.1.0 (2025-11-30): Fixed branch-level stale data - update branch_data["active_plans"] after validation
# - v1.0.0 (2025-11-21): Initial creation - self-healing central aggregator
# Description: Aggregate Central Plans Module
# Version: 1.3.0
# Created: 2025-11-30
# Modified: 2025-11-30
# =============================================
"""
Aggregate Central Plans Module
Self-healing central aggregator for PLANS.central.json that validates file
existence and rebuilds the active_plans list.
Thin orchestrator for central plans aggregation.
All business logic delegated to handlers/plan/aggregate_ops.py.
Features:
- Validates all plans in branches.* sections have files on disk
@@ -27,17 +20,6 @@ Features:
- Updates statistics across all branches
- Preserves unknown branch sections
Algorithm:
1. Load PLANS.central.json
2. For each branch section:
- For each plan in active_plans:
- Check if file_path exists on disk
- If missing: find branch registry, mark plan closed, save registry
3. Rebuild top-level active_plans from all valid branch plans
4. Rebuild recently_closed from all branches (last 5)
5. Update statistics
6. Save PLANS.central.json
Usage:
from aipass.flow.apps.modules.aggregate_central import aggregate_central
success = aggregate_central()
@@ -47,19 +29,20 @@ Standalone:
python3 apps/modules/aggregate_central.py aggregate --heal
"""
import json
import sys
from pathlib import Path
from datetime import datetime, timezone
from typing import Dict, Any, List, Tuple, Optional
from typing import Dict, Any, List
# INFRASTRUCTURE IMPORT PATTERN
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
FLOW_ROOT = _PKG_ROOT / "flow"
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import console
# Implementation handler
from aipass.flow.apps.handlers.plan.aggregate_ops import aggregate_central_impl
# =============================================
# CONFIGURATION
# =============================================
@@ -80,279 +63,15 @@ _REPO_ROOT = _find_repo_root()
AI_CENTRAL_DIR = _REPO_ROOT / ".ai_central"
CENTRAL_FILE = AI_CENTRAL_DIR / "PLANS.central.json"
# =============================================
# HELPER FUNCTIONS
# =============================================
def _find_branch_registry(branch_path: Path, branch_name: str) -> Optional[Path]:
"""Find the registry file for a branch
Args:
branch_path: Path to the branch directory
branch_name: Name of the branch
Returns:
Path to registry file if found, None otherwise
Checks common patterns:
- {branch_path}/flow_json/{branch}_registry.json
- {branch_path}/{branch}_json/{branch}_registry.json
- {branch_path}/registry.json
"""
if not branch_path.exists():
return None
# Pattern 1: flow_json/flow_registry.json
candidate = branch_path / "flow_json" / f"{branch_name}_registry.json"
if candidate.exists():
return candidate
# Pattern 2: branch_json/branch_registry.json
candidate = branch_path / f"{branch_name}_json" / f"{branch_name}_registry.json"
if candidate.exists():
return candidate
# Pattern 3: registry.json
candidate = branch_path / "registry.json"
if candidate.exists():
return candidate
return None
def _load_branch_registry(registry_path: Path) -> Dict[str, Any]:
"""Load a branch registry file
Args:
registry_path: Path to the registry file
Returns:
Registry dict or empty structure on error
"""
try:
with open(registry_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"[{MODULE_NAME}] Failed to load registry {registry_path}: {e}")
return {"plans": {}, "next_number": 1}
def _save_branch_registry(registry_path: Path, registry: Dict[str, Any]) -> bool:
"""Save a branch registry file
Args:
registry_path: Path to the registry file
registry: Registry data to save
Returns:
True on success, False on failure
"""
try:
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(registry_path, 'w', encoding='utf-8') as f:
json.dump(registry, f, indent=2, ensure_ascii=False)
return True
except Exception as e:
logger.error(f"[{MODULE_NAME}] Failed to save registry {registry_path}: {e}")
return False
def _extract_plan_number(plan_id: str) -> Optional[str]:
"""Extract plan number from plan_id (e.g., 'FPLAN-0148' -> '0148')
Args:
plan_id: Plan ID string (e.g., 'FPLAN-0148')
Returns:
Plan number string or None if invalid format
"""
if not plan_id or not plan_id.startswith("FPLAN-"):
return None
return plan_id[6:] # Skip 'FPLAN-' prefix
def _auto_close_plan(registry_path: Path, plan_id: str, branch_name: str) -> bool:
"""Auto-close a plan in its branch registry
Args:
registry_path: Path to the branch registry
plan_id: Plan ID (e.g., 'PLAN0148')
branch_name: Name of the branch for logging
Returns:
True if plan was closed, False otherwise
"""
plan_num = _extract_plan_number(plan_id)
if not plan_num:
logger.warning(f"[{MODULE_NAME}] Invalid plan_id format: {plan_id}")
return False
# Load registry
registry = _load_branch_registry(registry_path)
plans = registry.get("plans", {})
# Check if plan exists in registry
if plan_num not in plans:
logger.warning(f"[{MODULE_NAME}] {plan_id} not found in registry {registry_path}")
return False
plan_info = plans[plan_num]
# Skip if already closed
if plan_info.get("status") == "closed":
logger.info(f"[{MODULE_NAME}] {plan_id} already closed in {branch_name}")
return False
# Close the plan
plan_info["status"] = "closed"
plan_info["closed"] = datetime.now(timezone.utc).isoformat()
plan_info["closed_reason"] = "auto_closed_missing_file"
# Save registry
if _save_branch_registry(registry_path, registry):
logger.info(f"[{MODULE_NAME}] SUCCESS: Auto-closed {plan_id} in {branch_name} - file not found")
return True
else:
logger.error(f"[{MODULE_NAME}] Failed to save registry after closing {plan_id}")
return False
def _validate_and_heal_branch(branch_name: str, branch_data: Dict[str, Any],
heal: bool = True) -> Tuple[List[Dict], List[Dict]]:
"""Validate plans in a branch and heal missing files
Args:
branch_name: Name of the branch
branch_data: Branch data from PLANS.central.json
heal: If True, auto-close plans with missing files
Returns:
Tuple of (valid_active_plans, all_closed_plans)
"""
branch_path = Path(branch_data.get("branch_path", ""))
active_plans = branch_data.get("active_plans", [])
recently_closed = branch_data.get("recently_closed", [])
valid_active = []
healed_plans = []
# Find branch registry for healing
registry_path = None
if heal and branch_path.exists():
registry_path = _find_branch_registry(branch_path, branch_name)
# Validate active plans
for plan in active_plans:
file_path = Path(plan.get("file_path", ""))
plan_id = plan.get("plan_id", plan.get("plan", ""))
if file_path.exists():
valid_active.append(plan)
else:
logger.info(f"[{MODULE_NAME}] Missing file for {plan_id} in {branch_name}: {file_path}")
# Attempt to heal
if heal and registry_path:
if _auto_close_plan(registry_path, plan_id, branch_name):
# Add to healed plans for recently_closed
healed_plan = plan.copy()
healed_plan["status"] = "closed"
healed_plan["closed"] = datetime.now(timezone.utc).isoformat()
healed_plan["closed_reason"] = "auto_closed_missing_file"
healed_plans.append(healed_plan)
else:
if heal:
logger.warning(f"[{MODULE_NAME}] Cannot heal {plan_id} - registry not found for {branch_name}")
# Combine recently_closed with healed plans
all_closed = recently_closed + healed_plans
return valid_active, all_closed
def _load_central() -> Dict[str, Any]:
"""Load PLANS.central.json
Returns:
Central data or empty structure if file doesn't exist
"""
if not CENTRAL_FILE.exists():
return {
"generated_at": "",
"active_plans": [],
"recently_closed": [],
"statistics": {
"active_count": 0,
"total_closed": 0,
"recently_closed_included": 0
},
"branches": {},
"global_statistics": {
"total_active": 0,
"total_closed": 0,
"branches_reporting": 0
}
}
try:
with open(CENTRAL_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"[{MODULE_NAME}] Failed to load {CENTRAL_FILE}: {e}")
return {
"generated_at": "",
"active_plans": [],
"recently_closed": [],
"statistics": {
"active_count": 0,
"total_closed": 0,
"recently_closed_included": 0
},
"branches": {},
"global_statistics": {
"total_active": 0,
"total_closed": 0,
"branches_reporting": 0
}
}
def _save_central(central_data: Dict[str, Any]) -> bool:
"""Save PLANS.central.json
Args:
central_data: Central data to save
Returns:
True on success, False on failure
"""
try:
AI_CENTRAL_DIR.mkdir(parents=True, exist_ok=True)
with open(CENTRAL_FILE, 'w', encoding='utf-8') as f:
json.dump(central_data, f, indent=2, ensure_ascii=False)
return True
except Exception as e:
logger.error(f"[{MODULE_NAME}] Failed to save {CENTRAL_FILE}: {e}")
return False
# =============================================
# MAIN AGGREGATION FUNCTION
# MAIN AGGREGATION FUNCTION (thin orchestrator)
# =============================================
def aggregate_central(heal: bool = True) -> bool:
"""Aggregate and validate central plans
"""Aggregate and validate central plans (thin orchestrator)
Algorithm:
1. Load PLANS.central.json
2. For each branch in branches.*:
- Validate all active_plans have files on disk
- If heal=True and file missing: auto-close in branch registry
- Collect valid active plans and all closed plans
3. Rebuild top-level active_plans (sorted by created, newest first)
4. Rebuild top-level recently_closed (last 5, sorted by closed, newest first)
5. Update statistics
6. Save PLANS.central.json
Delegates all business logic to aggregate_ops handler.
Args:
heal: If True, auto-close plans with missing files in their registries
@@ -360,124 +79,29 @@ def aggregate_central(heal: bool = True) -> bool:
Returns:
True on success, False on failure
"""
try:
logger.info(f"[{MODULE_NAME}] Starting central aggregation")
# Load central file
central_data = _load_central()
branches = central_data.get("branches", {})
if not branches:
logger.info(f"[{MODULE_NAME}] No branches found in PLANS.central.json")
return True
# Track all active and closed plans across branches
all_active = []
all_closed = []
# Process each branch
for branch_name, branch_data in branches.items():
logger.info(f"[{MODULE_NAME}] Processing branch: {branch_name}")
# Validate and heal
valid_active, closed_plans = _validate_and_heal_branch(
branch_name, branch_data, heal
)
# Update branch-level active_plans with validated list
branch_data["active_plans"] = valid_active
# Update branch-level recently_closed (sorted, newest first)
branch_recently_closed = sorted(
closed_plans,
key=lambda x: x.get("closed", ""),
reverse=True
)[:5]
branch_data["recently_closed"] = branch_recently_closed
# Update branch-level statistics to match validated arrays
branch_data["statistics"] = {
"active_count": len(valid_active),
"total_closed": len(closed_plans),
"recently_closed_included": len(branch_recently_closed)
}
# Add branch name to each plan for identification
for plan in valid_active:
if "branch" not in plan:
plan["branch"] = branch_name
all_active.append(plan)
for plan in closed_plans:
if "branch" not in plan:
plan["branch"] = branch_name
all_closed.append(plan)
# Sort active by created date (newest first)
all_active.sort(
key=lambda x: x.get("created", ""),
reverse=True
)
# Sort closed by closed date (newest first) and limit to last 5
all_closed.sort(
key=lambda x: x.get("closed", ""),
reverse=True
)
recently_closed = all_closed[:5]
# Update top-level arrays
central_data["active_plans"] = all_active
central_data["recently_closed"] = recently_closed
# Update top-level statistics
central_data["statistics"] = {
"active_count": len(all_active),
"total_closed": len(all_closed),
"recently_closed_included": len(recently_closed)
}
# Update global_statistics (aggregated from all branches)
central_data["global_statistics"] = {
"total_active": len(all_active),
"total_closed": len(all_closed),
"branches_reporting": len([
b for b in branches.values()
if b.get("active_plans") or b.get("recently_closed")
])
}
# Update generated_at timestamp
central_data["generated_at"] = datetime.now(timezone.utc).isoformat()
# Save central file
if _save_central(central_data):
logger.info(f"[{MODULE_NAME}] SUCCESS: Aggregation complete: {len(all_active)} active, {len(recently_closed)} recently closed")
# Fire trigger event
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire('central_aggregated',
active_count=len(all_active),
closed_count=len(recently_closed),
branches_count=len(branches))
except ImportError:
pass
return True
else:
logger.error(f"[{MODULE_NAME}] Failed to save central file")
return False
except Exception as e:
logger.error(f"[{MODULE_NAME}] Aggregation failed: {e}")
return False
return aggregate_central_impl(
heal=heal,
central_file=CENTRAL_FILE,
central_dir=AI_CENTRAL_DIR,
)
# =============================================
# DISPLAY FUNCTIONS
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("aggregate_central Module")
console.print("Thin orchestrator for central plans aggregation and self-healing validation")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/plan/")
console.print(" - aggregate_ops.py (aggregate_central_impl — aggregation and healing logic)")
console.print()
def print_help():
"""Print help information for aggregate_central module"""
console.print()
+119 -293
View File
@@ -1,29 +1,17 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: close_plan.py - PLAN closure module with registry cleanup
# Date: 2025-11-25
# Version: 3.4.0
# Category: flow/modules
#
# CHANGELOG (Max 5 entries):
# - v3.4.0 (2026-02-25): FIX orphaned .md files - idempotency check now cleans up stranded files on re-close
# - v3.3.0 (2026-02-14): FIX race condition - close_all spawns ONE background process instead of N
# - v3.2.0 (2026-02-14): Auto-confirm by default, step-by-step progress, per-step error handling (Seedgo standards)
# - v3.1.0 (2026-02-14): FIX timeout - summary/archive now truly async via subprocess (was synchronous despite comments)
# - v3.0.0 (2026-02-14): DECOUPLE close from archive - close always succeeds, archive is non-blocking
# - v2.4.0 (2026-01-30): FIX close_all EOF error - handle non-interactive stdin in bulk close
# - v2.3.0 (2025-11-25): RE-ADDED template deletion - empty templates now deleted instead of archived
#
# CODE STANDARDS:
# - Seedgo v3.0 compliant (imports, architecture, error handling)
# ==============================================
# =================== AIPass ====================
# Name: close_plan.py
# Description: PLAN closure module with registry cleanup
# Version: 3.6.0
# Created: 2025-11-25
# Modified: 2025-11-25
# =============================================
"""
Close PLAN Module
Thin orchestrator for plan closure workflow.
All business logic delegated to handlers.
Module handles all display output.
Usage:
From flow.py: flow close <number>
@@ -32,17 +20,15 @@ Usage:
"""
import sys
import subprocess
from pathlib import Path
from typing import List
from datetime import datetime, timezone
from typing import List, Dict, Any
# INFRASTRUCTURE IMPORT PATTERN
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
FLOW_ROOT = _PKG_ROOT / "flow"
# External: Prax logger
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.prax import logger
# JSON handler for operation tracking
from aipass.flow.apps.handlers.json import json_handler
@@ -71,10 +57,12 @@ from aipass.flow.apps.handlers.dashboard.update_local import update_dashboard_lo
from aipass.flow.apps.handlers.dashboard.push_central import push_to_plans_central
from aipass.flow.apps.handlers.dashboard.push_branch_dashboard import push_flow_to_branch_dashboard
# Internal: Memory bank template check (lightweight, no API calls)
from aipass.flow.apps.handlers.mbank.process import is_template_content
# Internal: Close operations handler (implementation)
from aipass.flow.apps.handlers.plan.close_ops import close_plan_impl, close_all_plans_impl
# =============================================
# CONFIGURATION
# =============================================
@@ -82,6 +70,73 @@ from aipass.flow.apps.handlers.mbank.process import is_template_content
MODULE_NAME = "close_plan"
# =============================================
# DISPLAY HELPERS
# =============================================
def _display_messages(messages: List[Dict[str, Any]]):
"""Render handler result messages to console
Args:
messages: List of message dicts from handler with type/text keys
"""
for msg in messages:
msg_type = msg.get("type", "")
if msg_type == "error":
error_text = msg.get("text", "general")
plan_num = msg.get("plan_num", "")
details = msg.get("details", None)
console.print(format_plan_error(error_text, plan_num, details=details))
elif msg_type == "warning":
console.print(f"[yellow]{msg['text']}[/yellow]")
elif msg_type == "dim":
console.print(f"[dim]{msg['text']}[/dim]")
elif msg_type == "step":
console.print(f"[dim]{msg['text']}[/dim]")
elif msg_type == "success":
console.print(f"[green]{msg['text']}[/green]")
elif msg_type == "error_text":
console.print(f"[red]{msg['text']}[/red]")
elif msg_type == "header":
console.print(format_plan_deletion_header(msg["plan_key"], msg["plan_info"]))
elif msg_type == "cancelled":
console.print(format_deletion_cancelled())
elif msg_type == "close_success":
console.print(format_plan_deletion_success(msg["plan_key"]))
elif msg_type == "plan_list":
console.print(f"\n[bold yellow]Found {msg['count']} open plan(s) to close:[/bold yellow]")
for plan in msg.get("plans", []):
console.print(f" * FPLAN-{plan['plan_num']}: {plan['subject']}")
elif msg_type == "confirm_warning":
console.print(f"\n[bold red]WARNING: This will close all {msg['count']} plans![/bold red]")
elif msg_type == "closing_all":
console.print(f"\n[bold]Closing all {msg['count']} plan(s)...[/bold]")
console.print("-" * 60)
elif msg_type == "closing_single":
console.print(f"\n[dim]Closing FPLAN-{msg['plan_num']}...[/dim]")
elif msg_type == "close_all_summary":
console.print("\n" + "=" * 60)
console.print("[bold green]CLOSE ALL COMPLETE[/bold green]")
console.print(f" * Successfully closed: {msg['success_count']}")
console.print(f" * Failed to close: {msg['failure_count']}")
console.print(f" * Total processed: {msg['total']}")
console.print("=" * 60 + "\n")
# =============================================
# INTROSPECTION
# =============================================
@@ -96,6 +151,7 @@ def print_introspection():
console.print()
console.print(" [cyan]handlers/plan/[/cyan]")
console.print(" [dim]- close_ops.py (implementation)[/dim]")
console.print(" [dim]- get_open_plans.py[/dim]")
console.print(" [dim]- command_parser.py[/dim]")
console.print(" [dim]- confirmation.py[/dim]")
@@ -119,22 +175,7 @@ def print_introspection():
console.print()
# =============================================
# HELPERS
# =============================================
def _spawn_background_runner():
"""Spawn post_close_runner.py as a fully detached background process"""
bg_runner = FLOW_ROOT / "apps" / "modules" / "post_close_runner.py"
subprocess.Popen(
[sys.executable, str(bg_runner)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
# =============================================
# CLOSE PLAN WORKFLOW
# CLOSE PLAN WORKFLOW (thin orchestrator)
# =============================================
def close_plan(plan_num: str | None = None, confirm: bool = False, all_plans: bool = False, spawn_background: bool = True) -> bool:
@@ -150,6 +191,7 @@ def close_plan(plan_num: str | None = None, confirm: bool = False, all_plans: bo
- File ops: file_ops handler
- Confirmation: confirmation handler
- Display: display handler
- Close implementation: close_ops handler
Args:
plan_num: Plan number (e.g., "0001" or "1" or "42") - required if all_plans=False
@@ -161,185 +203,36 @@ def close_plan(plan_num: str | None = None, confirm: bool = False, all_plans: bo
Returns:
True if successful, False otherwise
"""
# Handle --all flag
if all_plans:
return close_all_plans(confirm)
result = close_plan_impl(
plan_num=plan_num,
confirm=confirm,
all_plans=all_plans,
spawn_background=spawn_background,
# Inject dependencies
normalize_plan_number=normalize_plan_number,
load_registry=load_registry,
save_registry=save_registry,
validate_plan_exists=validate_plan_exists,
confirm_plan_deletion=confirm_plan_deletion,
is_template_content=is_template_content,
update_dashboard_local=update_dashboard_local,
push_to_plans_central=push_to_plans_central,
push_flow_to_branch_dashboard=push_flow_to_branch_dashboard,
close_all_plans_fn=close_all_plans,
)
# Single plan closure
if not plan_num:
logger.warning(f"[{MODULE_NAME}] Plan number required for single plan closure")
console.print(format_plan_error("invalid_number", ""))
return False
# Handle dict result from handler
if isinstance(result, dict):
_display_messages(result.get("messages", []))
return result.get("success", False)
try:
# --- Internal validation (fast, no progress display) ---
# 1. VALIDATE: Normalize plan number (handler)
plan_key = normalize_plan_number(plan_num)
# 2. LOAD DATA: Get registry (service)
registry = load_registry()
# 3. VALIDATE: Check plan exists (handler)
exists, error_msg = validate_plan_exists(plan_key, registry)
if not exists:
logger.warning(f"[{MODULE_NAME}] {error_msg}")
console.print(format_plan_error("not_found", plan_key))
return False
plan_info = registry["plans"][plan_key]
plan_file = Path(plan_info.get("file_path", ""))
# 4. IDEMPOTENCY CHECK: Prevent double-closing (with orphan cleanup)
if plan_info['status'] == 'closed':
closed_date = plan_info.get('closed', 'unknown')
# Check if .md file is orphaned on disk (registry-closed but file never moved)
if plan_file.exists():
console.print(f"[yellow]FPLAN-{plan_key} already closed on {closed_date} — orphaned .md file detected[/yellow]")
console.print(f"[dim] Cleaning up: moving {plan_file.name} to processed_plans/[/dim]")
try:
from aipass.flow.apps.handlers.mbank.process import archive_plan
if archive_plan(plan_file):
logger.info(f"[{MODULE_NAME}] Cleaned up orphaned file for FPLAN-{plan_key}: {plan_file}")
console.print(f"[green] Orphaned file archived successfully[/green]")
else:
logger.warning(f"[{MODULE_NAME}] Failed to archive orphaned file for FPLAN-{plan_key}: {plan_file}")
console.print(f"[red] Failed to move orphaned file — manual cleanup required[/red]")
except Exception as e:
logger.warning(f"[{MODULE_NAME}] Error cleaning orphaned file for FPLAN-{plan_key}: {e}")
console.print(f"[red] Error during cleanup: {e}[/red]")
return True
console.print(f"[yellow]FPLAN-{plan_key} already closed on {closed_date}[/yellow]")
console.print("[dim]Nothing to do - plan is already archived[/dim]")
return False
# --- Step 1/5: Template check (may fast-delete) ---
console.print(f"[dim][1/5][/dim] Checking template status...")
try:
with open(plan_file, 'r', encoding='utf-8') as f:
content = f.read()
if is_template_content(content):
console.print(f"[yellow] FPLAN-{plan_key} is empty template - fast-deleting (not archiving)[/yellow]")
# Delete the file
plan_file.unlink()
logger.info(f"[{MODULE_NAME}] Deleted empty template file: {plan_file}")
# Remove from registry
del registry["plans"][plan_key]
save_registry(registry)
logger.info(f"[{MODULE_NAME}] Removed FPLAN-{plan_key} from registry")
console.print(f"[green] Empty template deleted - FPLAN-{plan_key} removed from system[/green]")
return True
except FileNotFoundError as e:
logger.warning(f"[{MODULE_NAME}] Template check - file not found: {e}")
console.print(f"[yellow] Plan file not found, continuing with registry close[/yellow]")
except Exception as e:
logger.warning(f"[{MODULE_NAME}] Template check failed: {e}")
console.print(f"[yellow] Could not check template status, continuing with normal close[/yellow]")
# DISPLAY: Show plan info header (handler)
console.print(format_plan_deletion_header(plan_key, plan_info))
# CONFIRM: Ask user only if explicitly requested (--confirm/--interactive)
if confirm:
if not confirm_plan_deletion(plan_key):
console.print(format_deletion_cancelled())
logger.info(f"[{MODULE_NAME}] Closure cancelled by user for PLAN{plan_key}")
return False
# --- Step 2/5: Mark as closed ---
console.print(f"[dim][2/5][/dim] Marking plan as closed...")
try:
# CRITICAL: Close ALWAYS succeeds from this point. Archive is non-blocking.
plan_info['status'] = 'closed'
plan_info['closed'] = datetime.now(timezone.utc).isoformat()
save_registry(registry)
logger.info(f"[{MODULE_NAME}] Marked FPLAN-{plan_key} as closed")
except Exception as e:
logger.error(f"[{MODULE_NAME}] Failed to mark plan as closed: {e}")
console.print(f"[red] Failed to update registry: {e}[/red]")
return False
# --- Step 3/5: Background processing ---
if spawn_background:
console.print(f"[dim][3/5][/dim] Starting background processing...")
try:
_spawn_background_runner()
logger.info(f"[{MODULE_NAME}] Spawned background post-processing for FPLAN-{plan_key}")
console.print(f"[dim] Summary generation and archival running in background[/dim]")
except FileNotFoundError as e:
logger.warning(f"[{MODULE_NAME}] Background runner not found: {e}")
console.print(f"[yellow] Background runner not found - will retry on next close[/yellow]")
except Exception as e:
logger.warning(f"[{MODULE_NAME}] Failed to spawn background post-processing: {e}")
console.print(f"[yellow] Background archival failed to start - will retry on next close[/yellow]")
else:
console.print(f"[dim][3/5][/dim] Background processing deferred (batch mode)")
# --- Step 4/5: Update dashboards ---
console.print(f"[dim][4/5][/dim] Updating dashboards...")
try:
dashboard_success = update_dashboard_local()
central_success = push_to_plans_central()
# Log dashboard update results (3-tier: modules log, handlers don't)
if not dashboard_success:
logger.warning(f"[{MODULE_NAME}] Failed to update DASHBOARD.local.json")
if not central_success:
logger.warning(f"[{MODULE_NAME}] Failed to update PLANS.central.json")
# Push flow section to branch's dashboard via write-through
plan_location = plan_info.get("location", "")
if plan_location:
branch_dashboard_success = push_flow_to_branch_dashboard(Path(plan_location))
if not branch_dashboard_success:
logger.warning(f"[{MODULE_NAME}] Failed to push flow section to branch dashboard at {plan_location}")
except Exception as e:
logger.warning(f"[{MODULE_NAME}] Dashboard update error: {e}")
console.print(f"[yellow] Dashboard update failed (non-critical): {e}[/yellow]")
# --- Step 5/5: Done ---
console.print(f"[dim][5/5][/dim] Finalizing...")
console.print(format_plan_deletion_success(plan_key))
# Append to branch's CLOSED_PLANS.local.json
try:
from aipass.flow.apps.handlers.plan.append_closed_plan import append_to_closed_plans
append_to_closed_plans(plan_key, plan_info, plan_file.parent)
except Exception as e:
logger.warning(f"[{MODULE_NAME}] CLOSED_PLANS update failed (non-critical): {e}")
# Fire trigger event for plan closure (optional - trigger module may not be available)
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire('plan_closed', plan_number=plan_key, location=str(plan_file.parent))
except ImportError:
logger.info(f"[{MODULE_NAME}] Trigger module not available, skipping event fire")
except Exception as e:
logger.warning(f"[{MODULE_NAME}] Trigger fire failed (non-critical): {e}")
return True
except ValueError as e:
logger.warning(f"[{MODULE_NAME}] Invalid plan number: {plan_num}: {e}")
console.print(format_plan_error("invalid_number", plan_num))
return False
except Exception as e:
logger.error(f"[{MODULE_NAME}] Unexpected error closing plan: {e}")
console.print(format_plan_error("general", details=str(e)))
return False
# Fallback for bool result
return bool(result)
def close_all_plans(confirm: bool = False) -> bool:
"""
Close all open plans in one operation
Close all open plans in one operation (thin orchestrator)
Args:
confirm: Whether to ask for bulk confirmation (default False, auto-confirms)
@@ -347,86 +240,19 @@ def close_all_plans(confirm: bool = False) -> bool:
Returns:
True if at least one plan closed successfully, False otherwise
"""
try:
# Get all open plans (handler)
open_plans = get_open_plans()
result = close_all_plans_impl(
confirm=confirm,
get_open_plans=get_open_plans,
close_plan_fn=close_plan,
)
if not open_plans:
console.print("\n[yellow]No open plans to close[/yellow]\n")
logger.info(f"[{MODULE_NAME}] close_all: No open plans found")
return False
# Handle dict result from handler
if isinstance(result, dict):
_display_messages(result.get("messages", []))
return result.get("success", False)
# Show what will be closed
console.print(f"\n[bold yellow]Found {len(open_plans)} open plan(s) to close:[/bold yellow]")
for plan_num, plan_info in open_plans:
subject = plan_info.get("subject", "No subject")
console.print(f" • FPLAN-{plan_num}: {subject}")
# Confirm bulk close
if confirm:
console.print(f"\n[bold red]WARNING: This will close all {len(open_plans)} plans![/bold red]")
# Auto-confirm in non-interactive environments (autonomous workflows)
if not sys.stdin.isatty():
console.print("[dim]Non-interactive mode: auto-confirming[/dim]")
response = "yes"
else:
try:
response = input("Type 'yes' to confirm: ").strip().lower()
except EOFError:
# Handle EOF when stdin is not available
console.print("[dim]EOF detected: auto-confirming[/dim]")
response = "yes"
if response != "yes":
console.print("\n[yellow]Close all cancelled[/yellow]\n")
logger.info(f"[{MODULE_NAME}] close_all cancelled by user")
return False
console.print(f"\n[bold]Closing all {len(open_plans)} plan(s)...[/bold]")
console.print("─" * 60)
# Close each plan
success_count = 0
failure_count = 0
for plan_num, plan_info in open_plans:
console.print(f"\n[dim]Closing FPLAN-{plan_num}...[/dim]")
# Call close_plan with spawn_background=False to avoid race condition
success = close_plan(plan_num=plan_num, confirm=False, all_plans=False, spawn_background=False)
if success:
success_count += 1
else:
failure_count += 1
# Spawn ONE background process for all closed plans
if success_count > 0:
try:
_spawn_background_runner()
logger.info(f"[{MODULE_NAME}] Spawned single background process for {success_count} closed plan(s)")
console.print(f"\n[dim]Background processing started for {success_count} plan(s)[/dim]")
except Exception as e:
logger.warning(f"[{MODULE_NAME}] Failed to spawn background post-processing: {e}")
console.print(f"\n[yellow]Background processing failed to start - will retry on next close[/yellow]")
# Summary
console.print("\n" + "═" * 60)
console.print("[bold green]CLOSE ALL COMPLETE[/bold green]")
console.print(f" • Successfully closed: {success_count}")
console.print(f" • Failed to close: {failure_count}")
console.print(f" • Total processed: {len(open_plans)}")
console.print("═" * 60 + "\n")
logger.info(f"[{MODULE_NAME}] close_all completed: {success_count} success, {failure_count} failures")
return success_count > 0
except Exception as e:
error_msg = f"Error in close_all: {e}"
logger.error(f"[{MODULE_NAME}] {error_msg}")
console.print(f"\n[bold red]ERROR:[/bold red] {error_msg}\n")
return False
# Fallback for bool result
return bool(result)
def handle_command(command: str, args: List[str]) -> bool:
+62 -150
View File
@@ -1,17 +1,10 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: create_plan.py - PLAN creation module with location awareness
# Date: 2025-11-16
# Version: 1.0.0
# Category: flow/modules
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2025-11-16): Refactored from archive_temp, handler-based architecture
#
# CODE STANDARDS:
# - Seedgo v3.0 compliant (imports, architecture, error handling)
# ==============================================
# =================== AIPass ====================
# Name: create_plan.py
# Description: PLAN creation module with location awareness
# Version: 1.2.0
# Created: 2025-11-16
# Modified: 2025-11-16
# =============================================
"""
Create PLAN Module - Thin Orchestrator
@@ -20,30 +13,28 @@ Orchestrates plan creation workflow by delegating to handlers.
Module contains NO business logic - only workflow coordination.
Workflow:
1. Parse arguments → command_parser handler
2. Load registry → registry handlers
3. Auto-cleanup → plan handlers
4. Validate location → plan handlers
5. Calculate paths → plan handlers
6. Get template → template handlers
7. Create file → plan handlers
8. Update registry → plan handlers
9. Update dashboards → dashboard handlers
10. Display results → display handlers
1. Parse arguments -> command_parser handler
2. Load registry -> registry handlers
3. Auto-cleanup -> plan handlers
4. Validate location -> plan handlers
5. Calculate paths -> plan handlers
6. Get template -> template handlers
7. Create file -> plan handlers
8. Update registry -> plan handlers
9. Update dashboards -> dashboard handlers
10. Display results -> display handlers
Usage:
From flow.py: flow plan create [location] [subject] [template]
Standalone: python3 create_plan.py [location] [subject] [template]
"""
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Tuple, List
# INFRASTRUCTURE IMPORT PATTERN
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
FLOW_ROOT = _PKG_ROOT / "flow"
# External: Prax logger
@@ -76,6 +67,9 @@ from aipass.flow.apps.handlers.dashboard.update_local import update_dashboard_lo
from aipass.flow.apps.handlers.dashboard.push_central import push_to_plans_central
from aipass.flow.apps.handlers.dashboard.push_branch_dashboard import push_flow_to_branch_dashboard
# Implementation handler
from aipass.flow.apps.handlers.plan.create_ops import create_plan_impl
# =============================================
# CONFIGURATION
@@ -98,6 +92,7 @@ def print_introspection():
console.print()
console.print(" [cyan]handlers/plan/[/cyan]")
console.print(" [dim]- create_ops.py (implementation)[/dim]")
console.print(" [dim]- command_parser.py[/dim]")
console.print(" [dim]- auto_cleanup.py[/dim]")
console.print(" [dim]- resolve_location.py[/dim]")
@@ -147,19 +142,9 @@ def print_help():
console.print(" python3 create_plan.py create @flow \"Research task\" master")
console.print()
# =============================================
# HELPERS
# =============================================
def _slugify_subject(subject: str) -> str:
"""Sanitize subject for filename: lowercase, underscores, max 40 chars."""
slug = re.sub(r'[^\w\s-]', '', subject.lower())
slug = re.sub(r'[\s-]+', '_', slug)
return slug.strip('_')[:40]
# =============================================
# ORCHESTRATION WORKFLOWS (No business logic)
# ORCHESTRATION WORKFLOWS (thin wrappers)
# =============================================
def create_plan(
@@ -168,22 +153,10 @@ def create_plan(
template_type: str = "default"
) -> Tuple[bool, int, str, str, str]:
"""
Orchestrate plan creation workflow
Orchestrate plan creation workflow (thin orchestrator)
THIN ORCHESTRATOR - delegates all business logic to handlers.
This function only coordinates the workflow and passes data between handlers.
Workflow Steps:
1. Load registry → registry handler
2. Auto-cleanup → plan handler
3. Resolve location → plan handler
4. Calculate relative path → plan handler
5. Get template → template handler
6. Create file → plan handler
7. Build registry entry → plan handler
8. Update registry → registry handler
9. Update dashboards → dashboard handlers
10. Log and return results
Delegates all business logic to create_ops handler.
Module handles all display output from handler messages.
Args:
location: Target directory for plan (@folder syntax supported)
@@ -193,103 +166,42 @@ def create_plan(
Returns:
(success, plan_number, location_description, template_type, error_message)
"""
try:
# STEP 1: Load registry
registry = load_registry()
result = create_plan_impl(
location=location,
subject=subject,
template_type=template_type,
# Inject dependencies
ecosystem_root=ECOSYSTEM_ROOT,
load_registry=load_registry,
save_registry=save_registry,
auto_close_orphaned_plans=auto_close_orphaned_plans,
resolve_plan_location=resolve_plan_location,
calculate_relative_location=calculate_relative_location,
get_template=get_template,
create_plan_file=create_plan_file,
build_plan_registry_entry=build_plan_registry_entry,
display_plan_created=display_plan_created,
update_dashboard_local=update_dashboard_local,
push_to_plans_central=push_to_plans_central,
push_flow_to_branch_dashboard=push_flow_to_branch_dashboard,
)
# STEP 2: Auto-cleanup orphaned plans
registry, auto_closed_count = auto_close_orphaned_plans(registry)
if auto_closed_count > 0:
save_registry(registry)
console.print(f"[dim][AUTO-CLEANUP] Closed {auto_closed_count} orphaned plan(s)[/dim]")
# Handler returns 6-tuple: (success, num, loc, tmpl, error, messages)
if len(result) == 6:
ok, num, loc, tmpl, error, messages = result
# Module handles display
for msg in messages:
msg_type = msg.get("type", "")
if msg_type == "dim":
console.print(f"[dim]{msg['text']}[/dim]")
elif msg_type == "warning":
console.print(f"[yellow]{msg['text']}[/yellow]")
elif msg_type == "display":
console.print(msg["text"])
return ok, num, loc, tmpl, error
# STEP 3: Get next plan number
NEXT_NUM = registry["next_number"]
# STEP 4: Resolve location (@folder syntax support)
success, target_dir, error_msg = resolve_plan_location(location, ECOSYSTEM_ROOT)
if not success:
return False, 0, "", "", error_msg
# STEP 5: Calculate relative path for display
RELATIVE_LOCATION = calculate_relative_location(target_dir, ECOSYSTEM_ROOT)
# STEP 6: Build plan file path (FPLAN-XXXX_topic_slug_YYYY-MM-DD.md)
topic_slug = _slugify_subject(subject)
date_str = datetime.now().strftime("%Y-%m-%d")
if topic_slug:
PLAN_FILE = target_dir / f"FPLAN-{NEXT_NUM:04d}_{topic_slug}_{date_str}.md"
else:
PLAN_FILE = target_dir / f"FPLAN-{NEXT_NUM:04d}_{date_str}.md"
# STEP 7: Get template content
try:
CONTENT = get_template(
template_type,
number=NEXT_NUM,
location=RELATIVE_LOCATION,
subject=subject
)
except Exception as e:
error_msg = f"Failed to load template '{template_type}': {e}"
logger.error(f"[{MODULE_NAME}] {error_msg}")
return False, 0, "", "", error_msg
# STEP 8: Create plan file
success, error_msg = create_plan_file(PLAN_FILE, CONTENT)
if not success:
return False, 0, "", "", error_msg
# STEP 9: Build registry entry
if "plans" not in registry:
registry["plans"] = {}
registry["plans"][f"{NEXT_NUM:04d}"] = build_plan_registry_entry(
NEXT_NUM, target_dir, RELATIVE_LOCATION, subject, PLAN_FILE, template_type
)
registry["next_number"] = NEXT_NUM + 1
# STEP 10: Save updated registry
if not save_registry(registry):
error_msg = "Failed to save registry after plan creation"
logger.error(f"[{MODULE_NAME}] {error_msg}")
console.print(f"[yellow][WARNING] {error_msg}[/yellow]")
# STEP 11: Update dashboards (3-tier: modules log, handlers don't)
dashboard_success = update_dashboard_local()
central_success = push_to_plans_central()
# Log dashboard update results
if not dashboard_success:
logger.warning(f"[{MODULE_NAME}] Failed to update DASHBOARD.local.json")
if not central_success:
logger.warning(f"[{MODULE_NAME}] Failed to update PLANS.central.json")
# STEP 11b: Push flow section to branch's dashboard via write-through
branch_dashboard_success = push_flow_to_branch_dashboard(target_dir)
if not branch_dashboard_success:
console.print(f"[dim]⚠ No branch dashboard at {target_dir} — no branch is tracking this plan[/dim]")
# STEP 12: Log success
logger.info(f"[{MODULE_NAME}] Created FPLAN-{NEXT_NUM:04d} in {RELATIVE_LOCATION}")
# Display success messages
display_msg = display_plan_created(NEXT_NUM, RELATIVE_LOCATION, subject, template_type)
console.print(display_msg)
# Fire trigger event for plan creation
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire('plan_created', plan_number=NEXT_NUM, location=RELATIVE_LOCATION, subject=subject)
except ImportError:
pass # Trigger not available, silent fallback
return True, NEXT_NUM, RELATIVE_LOCATION, template_type, ""
except Exception as e:
error_msg = f"Error creating plan: {e}"
logger.error(f"[{MODULE_NAME}] {error_msg}")
return False, 0, "", "", error_msg
# Fallback for 5-tuple (backward compatibility)
return result
def handle_command(command: str, args: List[str]) -> bool:
@@ -299,9 +211,9 @@ def handle_command(command: str, args: List[str]) -> bool:
THIN ORCHESTRATOR - delegates to handlers for all operations.
Workflow:
1. Parse arguments → command_parser handler
1. Parse arguments -> command_parser handler
2. Execute create_plan workflow
3. Display results → display handler
3. Display results -> display handler
Args:
command: Command name
+97 -193
View File
@@ -1,52 +1,26 @@
#!/home/aipass/.venv/bin/python3
# ===================AIPASS====================
# META DATA HEADER
# Name: dev_flow.py - Plan management module (thin orchestrator)
# Date: 2025-12-02
# Version: 4.0.0
# Category: Module
#
# CHANGELOG (Max 5 entries):
# - v4.0.0 (2026-02-19): Multi-type (DPLAN/BPLAN), --type flag, @ branch resolution
# - v3.0.0 (2026-02-18): Tags, filters, registry, dashboard per FPLAN-0355
# - v2.0.0 (2025-12-02): Refactored to thin orchestrator - all logic in handlers
# - v1.0.0 (2025-12-02): Initial version - plan create/list/status commands
#
# CONNECTS:
# - handlers/plan/ (all plan handlers)
# - registry.py (plan tracking)
# - dashboard.py (DASHBOARD.local.json + DEVPULSE.central.json push)
# - BRANCH_REGISTRY.json (@ resolution)
#
# CODE STANDARDS:
# - Modules orchestrate, handlers implement (3-tier architecture)
# - handle_command() interface for drone routing
# - Module does the logging, handlers return errors
# - CLI services for output (no print())
# ==============================================
# =================== AIPass ====================
# Name: dplan_flow.py
# Description: Plan management module (thin orchestrator)
# Version: 5.0.0
# Created: 2025-12-02
# Modified: 2025-12-02
# =============================================
"""
Plan Management Module - Thin Orchestrator
Routes commands to handlers in handlers/plan/.
Routes commands to handlers in handlers/dplan/.
Manages numbered, dated planning documents (DPLAN, BPLAN) in dev_planning/.
Supports @ branch resolution for creating plans in other branches.
"""
# INFRASTRUCTURE IMPORT PATTERN
import sys
import json
from pathlib import Path
from typing import List, Optional
AIPASS_ROOT = Path.home() / "aipass_core"
sys.path.insert(0, str(AIPASS_ROOT))
sys.path.insert(0, str(Path.home()))
from typing import List
# Infrastructure imports (module does the logging)
from prax.apps.modules.logger import system_logger as logger
from cli.apps.modules import console, header, success, error
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import console, header, success, error
# Handler imports
from aipass_os.dev_central.devpulse.apps.handlers.plan.create import create_plan
@@ -64,66 +38,72 @@ from aipass_os.dev_central.devpulse.apps.handlers.plan.registry import (
from aipass_os.dev_central.devpulse.apps.handlers.plan.dashboard import push_all as _push_dashboard_raw
from aipass_os.dev_central.devpulse.apps.handlers.dashboard.operations import write_section
# Local handlers (file I/O extracted from this module)
from aipass.flow.apps.handlers.dplan.branch_resolve import resolve_branch_target as _resolve_branch
from aipass.flow.apps.handlers.dplan.closed_plans_registry import append_closed_dplan
from aipass.flow.apps.handlers.dplan.log_setup import prepare_log_file
from aipass.flow.apps.handlers.dplan.background_spawn import spawn_post_close
def push_dashboard(activity: str | None = None) -> dict:
"""Module-level wrapper: injects write_section into handler."""
return _push_dashboard_raw(activity=activity, write_fn=write_section)
# =============================================================================
# CONFIGURATION
# =============================================================================
BRANCH_REGISTRY_PATH = Path.home() / "BRANCH_REGISTRY.json"
# =============================================================================
# @ BRANCH RESOLUTION
# @ BRANCH RESOLUTION (thin wrapper around handler)
# =============================================================================
def resolve_branch_target(branch_ref: str) -> Optional[Path]:
"""
Resolve @branch reference to a filesystem path via BRANCH_REGISTRY.json.
Args:
branch_ref: Branch reference like "@vera" or "@team_1"
Returns:
Path to the branch directory, or None if not found
"""
name = branch_ref.lstrip("@").upper()
if not BRANCH_REGISTRY_PATH.exists():
logger.warning(f"[dev_flow] BRANCH_REGISTRY.json not found at {BRANCH_REGISTRY_PATH}")
return None
try:
with open(BRANCH_REGISTRY_PATH, "r", encoding="utf-8") as f:
registry = json.load(f)
for branch in registry.get("branches", []):
if branch.get("name", "").upper() == name:
branch_path = Path(branch["path"])
if branch_path.exists():
return branch_path
else:
logger.warning(f"[dev_flow] Branch path does not exist: {branch_path}")
return None
logger.warning(f"[dev_flow] Branch '{name}' not found in registry")
return None
except Exception as e:
logger.warning(f"[dev_flow] Failed to read branch registry: {e}")
def resolve_branch_target(branch_ref: str):
"""Resolve @branch reference — delegates to handler, logs result."""
result = _resolve_branch(branch_ref)
if not result["success"]:
logger.warning(f"[dev_flow] {result['error']}")
return None
return result["path"]
# =============================================================================
# MODULE INTERFACE
# =============================================================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("dplan_flow Module")
console.print("Plan management orchestrator — routes plan commands to handlers")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/plan/ (via aipass_os.dev_central.devpulse)")
console.print(" - create.py (create_plan — create new plans)")
console.print(" - list.py (list_plans — list plans with filters)")
console.print(" - status.py (get_status_summary — plan status aggregation)")
console.print(" - display.py (show_help — help text display)")
console.print(" - close.py (close_plan, get_open_plans — close plans)")
console.print(" - counter.py (VALID_PLAN_TYPES — plan type definitions)")
console.print(" - registry.py (register_plan, update_plan_status — registry ops)")
console.print(" - dashboard.py (push_all — dashboard updates)")
console.print()
console.print(" handlers/dashboard/ (via aipass_os.dev_central.devpulse)")
console.print(" - operations.py (write_section — dashboard section writer)")
console.print()
console.print(" handlers/dplan/")
console.print(" - branch_resolve.py (resolve_branch_target — @ branch resolution)")
console.print(" - closed_plans_registry.py (append_closed_dplan — closed plan tracking)")
console.print(" - log_setup.py (prepare_log_file — log file preparation)")
console.print(" - background_spawn.py (spawn_post_close — background archival)")
console.print()
def print_help():
"""Display D-PLAN help text — thin wrapper around handler's show_help()."""
header("D-PLAN - Development Planning")
console.print(show_help())
def handle_command(command: str, args: List[str]) -> bool:
"""
Handle D-PLAN commands - routes to handlers
Handle D-PLAN commands - routes to handlers.
Args:
command: Command to execute ('plan')
@@ -135,16 +115,9 @@ def handle_command(command: str, args: List[str]) -> bool:
if command != 'plan':
return False
# Handle --help flag
if args and args[0] == '--help':
header("D-PLAN - Development Planning")
console.print(show_help())
return True
# Parse subcommand
if not args:
header("D-PLAN - Development Planning")
console.print(show_help())
# Handle --help / -h flag
if not args or (args[0] in ('--help', '-h')):
print_help()
return True
subcommand = args[0]
@@ -171,8 +144,7 @@ def handle_command(command: str, args: List[str]) -> bool:
def _handle_create(args: List[str]) -> bool:
"""Orchestrate plan creation - delegates to handler"""
# Handle --help flag
if args and args[0] == '--help':
if args and args[0] in ('--help', '-h'):
console.print("\n[bold]USAGE:[/bold]")
console.print(" plan create \"topic name\" [--type type] [--tag tag] [--dir subdir] [@branch]")
console.print("\n[bold]OPTIONS:[/bold]")
@@ -200,7 +172,6 @@ def _handle_create(args: List[str]) -> bool:
target_path = None
target_branch_name = None
# Check for --dir flag
if '--dir' in args:
dir_idx = args.index('--dir')
if dir_idx + 1 < len(args):
@@ -209,7 +180,6 @@ def _handle_create(args: List[str]) -> bool:
error("--dir requires a subdirectory name")
return True
# Check for --tag flag
if '--tag' in args:
tag_idx = args.index('--tag')
if tag_idx + 1 < len(args):
@@ -221,7 +191,6 @@ def _handle_create(args: List[str]) -> bool:
error("--tag requires a tag name")
return True
# Check for --type flag
if '--type' in args:
type_idx = args.index('--type')
if type_idx + 1 < len(args):
@@ -234,7 +203,7 @@ def _handle_create(args: List[str]) -> bool:
error("--type requires a plan type (dplan, bplan)")
return True
# Check for @branch target or pre-resolved path (drone resolves @vera to /path)
# Check for @branch target or pre-resolved path
for arg in args[1:]:
if arg.startswith("@") and not arg.startswith("--"):
target_branch_name = arg
@@ -244,7 +213,6 @@ def _handle_create(args: List[str]) -> bool:
return True
break
elif arg.startswith("/") and Path(arg).exists():
# Drone pre-resolved @branch to absolute path
target_branch_name = f"@{Path(arg).name}"
target_path = Path(arg)
break
@@ -262,10 +230,8 @@ def _handle_create(args: List[str]) -> bool:
error(f"Failed to create plan: {err}")
return True
# Log success (module does logging)
logger.info(f"[dev_flow] Created {prefix}-{result['plan_number']:03d}: {result['filename']}")
# Log cache warning if any
if result.get('cache_warning'):
logger.warning(f"[dev_flow] {result['cache_warning']}")
@@ -284,7 +250,6 @@ def _handle_create(args: List[str]) -> bool:
except Exception as e:
logger.warning(f"[dev_flow] Failed to register plan: {e}")
# Push dashboard update with activity context
try:
activity = f"DPLAN-{result['plan_number']:03d} created ({result['topic'][:30]})"
push_dashboard(activity=activity)
@@ -307,7 +272,6 @@ def _handle_create(args: List[str]) -> bool:
def _handle_list(args: List[str]) -> bool:
"""Orchestrate plan listing with optional filters"""
# Parse filter flags
filter_tag = None
filter_status = None
filter_type = None
@@ -327,7 +291,6 @@ def _handle_list(args: List[str]) -> bool:
if type_idx + 1 < len(args):
filter_type = args[type_idx + 1].lower()
# Delegate to handler (pass type filter for scanning)
plans, err = list_plans(filter_type=filter_type)
if err:
@@ -335,13 +298,11 @@ def _handle_list(args: List[str]) -> bool:
error(f"Failed to list plans: {err}")
return True
# Apply tag/status filters
if filter_tag:
plans = [p for p in plans if p.get("tag") == filter_tag]
if filter_status:
plans = [p for p in plans if p.get("status") == filter_status]
# Display results
console.print()
title = "Plans"
if filter_type:
@@ -366,12 +327,10 @@ def _handle_list(args: List[str]) -> bool:
tag_display = f"({p['tag']})" if p.get("tag") else ""
prefix = p.get("prefix", "DPLAN")
# Get summary from cache or description
summary = get_summary(p["number"])
if not summary:
summary = p.get("description", "")
# Format: icon PREFIX-NNN | Topic | (tag) | summary
line = f" {status_icon} [cyan]{prefix}-{p['number']:03d}[/cyan] | {p['topic'][:30]:<30}"
if tag_display:
line += f" | [dim]{tag_display}[/dim]"
@@ -389,14 +348,12 @@ def _handle_list(args: List[str]) -> bool:
def _handle_status(args: List[str]) -> bool:
"""Orchestrate status display - delegates to handler"""
# Parse --type filter
filter_type = None
if '--type' in args:
type_idx = args.index('--type')
if type_idx + 1 < len(args):
filter_type = args[type_idx + 1].lower()
# Delegate to handler
status_counts, total, err = get_status_summary(filter_type=filter_type)
if err:
@@ -404,7 +361,6 @@ def _handle_status(args: List[str]) -> bool:
error(f"Failed to get status: {err}")
return True
# Display results
console.print()
title = "Plan Status"
if filter_type:
@@ -412,14 +368,14 @@ def _handle_status(args: List[str]) -> bool:
header(title)
console.print()
console.print(f" [yellow]📋 Planning:[/yellow] {status_counts['planning']}")
console.print(f" [blue]🔄 In Progress:[/blue] {status_counts['in_progress']}")
console.print(f" [green]✅ Ready:[/green] {status_counts['ready']}")
console.print(f" [dim]✓ Complete:[/dim] {status_counts['complete']}")
console.print(f" [red]❌ Abandoned:[/red] {status_counts['abandoned']}")
console.print(f" [yellow]Planning:[/yellow] {status_counts['planning']}")
console.print(f" [blue]In Progress:[/blue] {status_counts['in_progress']}")
console.print(f" [green]Ready:[/green] {status_counts['ready']}")
console.print(f" [dim]Complete:[/dim] {status_counts['complete']}")
console.print(f" [red]Abandoned:[/red] {status_counts['abandoned']}")
if status_counts["unknown"] > 0:
console.print(f" [dim]? Unknown:[/dim] {status_counts['unknown']}")
console.print(f" [dim]Unknown:[/dim] {status_counts['unknown']}")
console.print()
console.print(f"[dim]Total: {total} plans[/dim]")
@@ -430,10 +386,7 @@ def _handle_status(args: List[str]) -> bool:
def _handle_close(args: List[str]) -> bool:
"""Orchestrate plan closing - delegates to handler, spawns background archival"""
import subprocess
# Handle --help flag
if args and args[0] == '--help':
if args and args[0] in ('--help', '-h'):
console.print("\n[bold]USAGE:[/bold]")
console.print(" plan close <number>")
console.print(" plan close --all")
@@ -443,7 +396,6 @@ def _handle_close(args: List[str]) -> bool:
console.print(" plan close --all\n")
return True
# Handle --all flag
if args and args[0] == '--all':
return _handle_close_all()
@@ -451,7 +403,6 @@ def _handle_close(args: List[str]) -> bool:
error("Usage: plan close <number>")
return True
# Parse plan number
plan_num, err = normalize_plan_number(args[0])
if err:
logger.warning(f"[dev_flow] {err}")
@@ -474,7 +425,6 @@ def _handle_close(args: List[str]) -> bool:
try:
update_plan_status(plan_num, "complete")
# Generate and cache summary from description
plan_file = Path(result.get('plan_file', ''))
if plan_file.exists():
summary = generate_description_summary(plan_file)
@@ -483,49 +433,29 @@ def _handle_close(args: List[str]) -> bool:
except Exception as e:
logger.warning(f"[dev_flow] Registry update failed: {e}")
# Push dashboard update with activity context
# Push dashboard update
try:
activity = f"DPLAN-{plan_num:03d} closed ({result['topic'][:30]})"
push_dashboard(activity=activity)
except Exception as e:
logger.warning(f"[dev_flow] Dashboard push failed: {e}")
# Append to branch's CLOSED_PLANS.local.json
try:
from datetime import datetime as _dt
_closed_plans_path = Path("/home/aipass/aipass_os/dev_central/CLOSED_PLANS.local.json")
_entry = {
"plan_id": f"DPLAN-{plan_num:03d}",
"type": "DPLAN",
"subject": result.get("topic", ""),
"date_closed": _dt.now().strftime("%Y-%m-%d"),
"location": "dev_central"
}
if _closed_plans_path.exists():
_data = json.loads(_closed_plans_path.read_text())
else:
_data = {"closed_plans": []}
# Skip if already exists
if not any(p["plan_id"] == _entry["plan_id"] for p in _data["closed_plans"]):
_data["closed_plans"].insert(0, _entry)
_closed_plans_path.write_text(json.dumps(_data, indent=2) + "\n")
logger.info(f"[dev_flow] Updated CLOSED_PLANS registry with DPLAN-{plan_num:03d}")
except Exception as _e:
logger.warning(f"[dev_flow] CLOSED_PLANS update failed (non-critical): {_e}")
# Append to CLOSED_PLANS.local.json via handler
reg_result = append_closed_dplan(plan_num, result.get("topic", ""))
if reg_result["success"]:
logger.info(f"[dev_flow] Updated CLOSED_PLANS registry with {reg_result['plan_id']}")
else:
logger.warning(f"[dev_flow] CLOSED_PLANS update failed (non-critical): {reg_result['error']}")
# Step 2/3: Spawn background processing
console.print(f"[dim][2/3][/dim] Starting background archival...")
try:
bg_runner = Path(__file__).parent / "post_close_runner.py"
log_file = Path.home() / "aipass_os" / "logs" / "post_close_runner.log"
log_file.parent.mkdir(parents=True, exist_ok=True)
log_fh = open(log_file, "a")
subprocess.Popen(
[sys.executable, str(bg_runner)],
stdout=log_fh,
stderr=log_fh,
start_new_session=True
)
log_result = prepare_log_file("post_close_runner.log")
if not log_result["success"]:
raise RuntimeError(log_result["error"])
spawn_result = spawn_post_close(log_file_handle=log_result["file_handle"])
if not spawn_result["success"]:
raise RuntimeError(spawn_result["error"])
logger.info(f"[dev_flow] Spawned background post-processing for DPLAN-{plan_num:03d}")
console.print(f"[dim] Memory Bank archival running in background[/dim]")
except Exception as e:
@@ -545,8 +475,6 @@ def _handle_close(args: List[str]) -> bool:
def _handle_close_all() -> bool:
"""Close all open plans"""
import subprocess
open_plans = get_open_plans()
if not open_plans:
@@ -571,39 +499,23 @@ def _handle_close_all() -> bool:
logger.info(f"[dev_flow] Closed DPLAN-{p['number']:03d}")
console.print(f"[green] Marked as complete[/green]")
# Update registry
try:
update_plan_status(p['number'], "complete")
except Exception as reg_err:
logger.warning(f"[dev_flow] Registry update failed for DPLAN-{p['number']:03d}: {reg_err}")
# Append to branch's CLOSED_PLANS.local.json
try:
from datetime import datetime as _dt
_closed_plans_path = Path("/home/aipass/aipass_os/dev_central/CLOSED_PLANS.local.json")
_entry = {
"plan_id": f"DPLAN-{p['number']:03d}",
"type": "DPLAN",
"subject": result.get("topic", ""),
"date_closed": _dt.now().strftime("%Y-%m-%d"),
"location": "dev_central"
}
if _closed_plans_path.exists():
_data = json.loads(_closed_plans_path.read_text())
else:
_data = {"closed_plans": []}
if not any(ep["plan_id"] == _entry["plan_id"] for ep in _data["closed_plans"]):
_data["closed_plans"].insert(0, _entry)
_closed_plans_path.write_text(json.dumps(_data, indent=2) + "\n")
logger.info(f"[dev_flow] Updated CLOSED_PLANS registry with DPLAN-{p['number']:03d}")
except Exception as _e:
logger.warning(f"[dev_flow] CLOSED_PLANS update failed (non-critical): {_e}")
# Append to CLOSED_PLANS.local.json via handler
reg_result = append_closed_dplan(p['number'], result.get("topic", ""))
if reg_result["success"]:
logger.info(f"[dev_flow] Updated CLOSED_PLANS registry with {reg_result['plan_id']}")
else:
logger.warning(f"[dev_flow] CLOSED_PLANS update failed (non-critical): {reg_result['error']}")
else:
failure_count += 1
logger.warning(f"[dev_flow] Failed to close DPLAN-{p['number']:03d}: {err}")
console.print(f"[red] Failed: {err}[/red]")
# Push dashboard update with activity context
# Push dashboard
try:
activity = f"{success_count} plan(s) closed (batch)"
push_dashboard(activity=activity)
@@ -613,24 +525,20 @@ def _handle_close_all() -> bool:
# Spawn ONE background process for all closed plans
if success_count > 0:
try:
bg_runner = Path(__file__).parent / "post_close_runner.py"
subprocess.Popen(
[sys.executable, str(bg_runner)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
spawn_result = spawn_post_close()
if not spawn_result["success"]:
raise RuntimeError(spawn_result["error"])
logger.info(f"[dev_flow] Spawned background processing for {success_count} closed plan(s)")
console.print(f"\n[dim]Background processing started for {success_count} plan(s)[/dim]")
except Exception as e:
logger.warning(f"[dev_flow] Failed to spawn background processing: {e}")
console.print(f"\n[yellow]Background processing failed to start[/yellow]")
console.print("\n" + "═" * 60)
console.print("\n" + "=" * 60)
console.print("[bold green]CLOSE ALL COMPLETE[/bold green]")
console.print(f" - Successfully closed: {success_count}")
console.print(f" - Failed: {failure_count}")
console.print("═" * 60 + "\n")
console.print("=" * 60 + "\n")
return True
@@ -665,18 +573,14 @@ def _handle_sync() -> bool:
# =============================================================================
if __name__ == "__main__":
# Show introspection when run without arguments
if len(sys.argv) == 1:
console.print(print_introspection())
sys.exit(0)
# Handle help flag
if sys.argv[1] in ['--help', '-h', 'help']:
header("D-PLAN - Development Planning")
console.print(show_help())
print_help()
sys.exit(0)
# Route command: plan create "topic" -> handle_command("plan", ["create", "topic"])
subcommand = sys.argv[1]
remaining_args = sys.argv[2:] if len(sys.argv) > 2 else []
@@ -1,18 +1,10 @@
#!/home/aipass/.venv/bin/python3
# ===================AIPASS====================
# META DATA HEADER
# Name: post_close_runner.py - Background post-close processing for DPLANs
# Date: 2026-02-18
# Version: 1.0.0
# Category: devpulse/modules
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-18): Initial version - adapted from Flow's post_close_runner.py
#
# CODE STANDARDS:
# - Seed v3.0 compliant (imports, architecture, error handling)
# ==============================================
# =================== AIPass ====================
# Name: dplan_post_close_runner.py
# Description: Background post-close processing for DPLANs
# Version: 1.1.0
# Created: 2026-02-18
# Modified: 2026-02-18
# =============================================
"""
Post-Close Background Runner for DPLANs
@@ -22,10 +14,6 @@ Called by dev_flow.py via subprocess.Popen so the close command returns fast.
Uses a lock file to prevent concurrent execution - if another instance is
already running, this one exits silently.
Note: This is a background utility script, not a command-routable module.
It has no handle_command() or --help because it is never invoked by users
or drone directly - only by dev_flow.py via subprocess.
"""
import os
@@ -38,8 +26,9 @@ DEVPULSE_ROOT = Path.home() / "aipass_os" / "dev_central" / "devpulse"
sys.path.insert(0, str(AIPASS_ROOT))
sys.path.insert(0, str(Path.home()))
# External: Prax logger
from prax.apps.modules.logger import system_logger as logger
# External: CLI console (Rich display) and Prax logger
from aipass.cli.apps.modules import console
from aipass.prax.apps.modules.logger import system_logger as logger
MODULE_NAME = "dplan_post_close_runner"
@@ -48,6 +37,44 @@ LOCK_FILE = DEVPULSE_ROOT / ".post_close_runner.lock"
from aipass_os.dev_central.devpulse.apps.handlers.mbank.process import process_closed_plans
def handle_command(command: str, args: list) -> bool:
"""Handle commands routed by the entry point.
This module is a background utility runner, not a user-facing command.
It responds to 'dplan_post_close' for drone routing compatibility
and supports --help / -h for introspection.
Args:
command: Command name
args: Command arguments
Returns:
True if command was handled, False otherwise
"""
if command != "dplan_post_close":
return False
if args and args[0] in ("--help", "-h"):
print_help()
return True
# Run the post-close processing directly (foreground)
if not _acquire_lock():
console.print("[yellow]Another instance is already running[/yellow]")
return True
try:
result = process_closed_plans()
console.print(f"[green]Processing complete:[/green] {result.get('processed', 0)} processed, {result.get('errors', 0)} errors")
except Exception as e:
logger.error(f"[{MODULE_NAME}] Background processing failed: {e}")
console.print(f"[red]Processing failed: {e}[/red]")
finally:
_release_lock()
return True
def _acquire_lock() -> bool:
"""Try to acquire lock file. Returns True if acquired, False if another instance is running."""
if LOCK_FILE.exists():
@@ -71,7 +98,35 @@ def _release_lock():
logger.warning(f"[{MODULE_NAME}] Failed to release lock file: {e}")
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("dplan_post_close_runner Module")
console.print("Background post-close processing for DPLANs — runs Memory Bank archival")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/mbank/ (via aipass_os.dev_central.devpulse)")
console.print(" - process.py (process_closed_plans — scan and archive closed plans)")
console.print()
def print_help():
"""Display help for this background runner."""
console.print(f"Usage: python {Path(__file__).name}")
console.print()
console.print("Post-Close Background Runner for DPLANs")
console.print("Runs Memory Bank archival as a background process.")
console.print("Called by dev_flow.py via subprocess — not intended for direct use.")
console.print()
console.print("Options:")
console.print(" -h, --help Show this help message")
if __name__ == "__main__":
if '--help' in sys.argv or '-h' in sys.argv:
print_help()
sys.exit(0)
if not _acquire_lock():
sys.exit(0)
+41 -65
View File
@@ -1,30 +1,21 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: list_plans.py - PLAN listing module with filtering
# Date: 2025-11-21
# Version: 1.0.0
# Category: flow/modules
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2025-11-21): Initial implementation, handler-based architecture
#
# CODE STANDARDS:
# - Seedgo v3.0 compliant (imports, architecture, error handling)
# ==============================================
# =================== AIPass ====================
# Name: list_plans.py
# Description: PLAN listing module with filtering
# Version: 1.1.0
# Created: 2025-11-21
# Modified: 2025-11-21
# =============================================
"""
List PLAN Module - Thin Orchestrator
Orchestrates plan listing workflow by delegating to handlers.
Module contains NO business logic - only workflow coordination.
Module contains NO business logic - only workflow coordination and display.
Workflow:
1. Parse arguments → command_parser handler
2. Load registry → registry handlers
3. Get statistics → registry handlers
4. Filter plans by status
5. Format and display results
1. Parse arguments
2. Call list_ops handler for data
3. Display results via console
Usage:
From flow.py: flow plan list [filter]
@@ -42,7 +33,7 @@ from pathlib import Path
from typing import List, Dict, Any
# INFRASTRUCTURE IMPORT PATTERN
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
FLOW_ROOT = _PKG_ROOT / "flow"
# External: Prax logger
@@ -65,6 +56,9 @@ from aipass.flow.apps.handlers.plan.display import (
format_statistics_summary
)
# Implementation handler
from aipass.flow.apps.handlers.plan.list_ops import list_plans_impl
# =============================================
# CONFIGURATION
# =============================================
@@ -81,15 +75,15 @@ def print_introspection():
console.print("[bold cyan]list_plans Module[/bold cyan]")
console.print()
# List handlers this module actually imports/uses
console.print("[yellow]Connected Handlers:[/yellow]")
console.print()
# List handlers this module actually imports/uses
console.print(" [cyan]handlers/registry/[/cyan]")
console.print(" [dim]- load_registry.py[/dim]")
console.print(" [dim]- statistics.py[/dim]")
console.print()
console.print(" [cyan]handlers/plan/[/cyan]")
console.print(" [dim]- list_ops.py (implementation)[/dim]")
console.print(" [dim]- display.py[/dim]")
console.print()
@@ -136,10 +130,8 @@ def list_plans(filter_type: str = "open") -> bool:
"""
Orchestrate plan listing workflow (thin orchestrator)
Delegates all business logic to handlers:
- Registry loading: load_registry handler
- Statistics: get_registry_statistics handler
- Display: format functions above
Delegates all business logic to list_ops handler.
Module handles all display output.
Args:
filter_type: Filter plans by status ("open", "closed", "all")
@@ -147,53 +139,37 @@ def list_plans(filter_type: str = "open") -> bool:
Returns:
True if successful, False otherwise
"""
try:
# STEP 1: Load registry (handler)
registry = load_registry()
# STEP 2: Get plans
plans = registry.get("plans", {})
if not plans:
console.print("[yellow]No plans found in registry[/yellow]")
logger.info(f"[{MODULE_NAME}] No plans in registry")
return True # Not an error, just empty
# STEP 3: Determine filter
if filter_type == "all":
filter_status = None
else:
filter_status = filter_type # "open" or "closed"
# STEP 4: Format and display plans
formatted_list = format_plans_list(plans, filter_status)
console.print(formatted_list)
# STEP 5: Get and display statistics
stats = get_registry_statistics(registry)
summary = format_statistics_summary(stats)
console.print(summary)
# STEP 6: Log success
logger.info(f"[{MODULE_NAME}] Listed plans (filter: {filter_type})")
result = list_plans_impl(
filter_type=filter_type,
load_registry=load_registry,
get_registry_statistics=get_registry_statistics,
format_plans_list=format_plans_list,
format_statistics_summary=format_statistics_summary,
)
# Module handles display
if result.get("empty") and result.get("success"):
console.print("[yellow]No plans found in registry[/yellow]")
return True
except BrokenPipeError:
# Pipe closed by reader (e.g. automated subprocesses, head)
# Not a real error - command likely completed
logger.info(f"[{MODULE_NAME}] Broken pipe (stdout closed early)")
return True
except Exception as e:
error_msg = f"Error listing plans: {e}"
logger.error(f"[{MODULE_NAME}] {error_msg}")
if not result.get("success"):
error_msg = result.get("error", "Unknown error")
try:
console.print(f"[red]ERROR: {error_msg}[/red]")
except BrokenPipeError:
pass
return False
# Display formatted results
try:
console.print(result["formatted_list"])
console.print(result["formatted_stats"])
except BrokenPipeError:
# Pipe closed by reader - not a real error
logger.info(f"[{MODULE_NAME}] Broken pipe (stdout closed early)")
return True
def handle_command(command: str, args: List[str]) -> bool:
"""
@@ -1,18 +1,10 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: post_close_runner.py - Background post-close processing
# Date: 2026-02-14
# Version: 1.1.0
# Category: flow/modules
#
# CHANGELOG (Max 5 entries):
# - v1.1.0 (2026-02-14): Add lock file to prevent concurrent execution
# - v1.0.0 (2026-02-14): Created - runs summary generation and mbank archival in background
#
# CODE STANDARDS:
# - Seedgo v3.0 compliant (imports, architecture, error handling)
# ==============================================
# =================== AIPass ====================
# Name: post_close_runner.py
# Description: Background post-close processing
# Version: 1.2.0
# Created: 2026-02-14
# Modified: 2026-02-14
# =============================================
"""
Post-Close Background Runner
@@ -25,10 +17,6 @@ already running, this one exits silently (the running instance will pick up
all unprocessed plans since it scans ALL of them).
This script lives inside the flow branch so handler import guards allow it.
Note: This is a background utility script, not a command-routable module.
It has no handle_command() or --help because it is never invoked by users
or drone directly - only by close_plan.py via subprocess.
"""
import os
@@ -39,7 +27,8 @@ from pathlib import Path
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
FLOW_ROOT = _PKG_ROOT / "flow"
# External: Prax logger
# External: CLI console (Rich display) and Prax logger
from aipass.cli.apps.modules import console
from aipass.prax.apps.modules.logger import system_logger as logger
MODULE_NAME = "post_close_runner"
@@ -51,6 +40,44 @@ LOCK_FILE = FLOW_ROOT / ".post_close_runner.lock"
from aipass.flow.apps.handlers.mbank.process import process_closed_plans
def handle_command(command: str, args: list) -> bool:
"""Handle commands routed by the entry point.
This module is a background utility runner, not a user-facing command.
It responds to 'post_close' for drone routing compatibility
and supports --help / -h for introspection.
Args:
command: Command name
args: Command arguments
Returns:
True if command was handled, False otherwise
"""
if command != "post_close":
return False
if args and args[0] in ("--help", "-h"):
print_help()
return True
# Run the post-close processing directly (foreground)
if not _acquire_lock():
console.print("[yellow]Another instance is already running[/yellow]")
return True
try:
process_closed_plans()
console.print("[green]Processing complete[/green]")
except Exception as e:
logger.error(f"[{MODULE_NAME}] Background processing failed: {e}")
console.print(f"[red]Processing failed: {e}[/red]")
finally:
_release_lock()
return True
def _acquire_lock() -> bool:
"""Try to acquire lock file. Returns True if acquired, False if another instance is running."""
if LOCK_FILE.exists():
@@ -74,7 +101,35 @@ def _release_lock():
logger.warning(f"[{MODULE_NAME}] Failed to release lock file: {e}")
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("post_close_runner Module")
console.print("Background post-close processing — runs memory bank archival")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/mbank/")
console.print(" - process.py (process_closed_plans — scan and archive closed plans)")
console.print()
def print_help():
"""Display help for this background runner."""
console.print(f"Usage: python {Path(__file__).name}")
console.print()
console.print("Post-Close Background Runner")
console.print("Runs memory bank archival as a background process.")
console.print("Called by close_plan.py via subprocess — not intended for direct use.")
console.print()
console.print("Options:")
console.print(" -h, --help Show this help message")
if __name__ == "__main__":
if '--help' in sys.argv or '-h' in sys.argv:
print_help()
sys.exit(0)
if not _acquire_lock():
sys.exit(0)
+59 -374
View File
@@ -1,29 +1,20 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: registry_monitor.py - Registry auto-healing and file watching module
# Date: 2025-11-21
# Version: 2.0.0
# Category: flow/modules
#
# CHANGELOG (Max 5 entries):
# - v2.0.0 (2026-01-20): Migrated to Trigger event system - fires events, doesn't handle
# - v1.0.0 (2025-11-21): Initial port from archive_temp with Python watchdog integration
#
# CODE STANDARDS:
# - Seedgo v3.0 compliant (imports, architecture, error handling)
# - Module-level logging (3-tier pattern)
# - Event-driven: fires trigger events, handlers in Trigger branch
# ==============================================
# =================== AIPass ====================
# Name: registry_monitor.py
# Description: Registry auto-healing and file watching module
# Version: 2.1.0
# Created: 2025-11-21
# Modified: 2025-11-21
# =============================================
"""
Registry Monitor Module - Auto-Healing PLAN Registry
Monitors filesystem for PLAN file changes and fires trigger events.
Thin orchestrator for filesystem monitoring and registry healing.
All business logic delegated to handlers/registry/monitor_ops.py.
Architecture (v2.0):
- PlanFileWatcher detects filesystem events via Python watchdog
- Events are fired via trigger.fire() (plan_file_created, plan_file_deleted, plan_file_moved)
- Events are fired via the trigger event bus (plan_file_created, plan_file_deleted, plan_file_moved)
- Trigger handlers in trigger/apps/handlers/events/plan_file.py update the registry
- Decoupled: Flow fires events, Trigger handles reactions
@@ -47,18 +38,12 @@ Commands:
"""
import sys
import re
import os
import time
import threading
from pathlib import Path
from typing import Dict, Any, List, Tuple, Optional
from datetime import datetime, timezone
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from typing import Dict, Any, List
# INFRASTRUCTURE IMPORT PATTERN
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
FLOW_ROOT = _PKG_ROOT / "flow"
# External: Prax logger
@@ -72,7 +57,14 @@ from aipass.cli.apps.modules import console
# Registry handlers
from aipass.flow.apps.handlers.registry.load_registry import load_registry
from aipass.flow.apps.handlers.registry.save_registry import save_registry
# Implementation handler
from aipass.flow.apps.handlers.registry.monitor_ops import (
scan_plan_files_impl,
start_monitoring_impl,
stop_monitoring_impl,
get_status_impl,
)
# =============================================
# CONFIGURATION
@@ -92,375 +84,67 @@ def _find_repo_root() -> Path:
REPO_ROOT = _find_repo_root()
ECOSYSTEM_ROOT = REPO_ROOT # Scan from repo root
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
# PLAN file pattern
PLAN_PATTERN = re.compile(r'^FPLAN-\d{4}\.md$')
# Directories to ignore during monitoring
IGNORE_FOLDERS = {
# Development and version control
".git", ".venv", "venv", "__pycache__", "node_modules",
".pytest_cache", "dist", "build", ".idea", ".vscode",
# Backup and archive
"backup", "backups", ".backup", "archive", ".archive",
"backup_system", "archive_temp", "processed_plans",
# Memory and admin
"MEMORY_BANK", "admin", "aipass-help",
# User directories
".local", "Downloads", "downloads",
# System directories (permission issues)
"proc", "sys", "dev", "run", "boot", "lost+found",
"timeshift", "snapshots", ".snapshots"
}
# Global observer instance
_observer: Any = None
_observer_lock = threading.Lock()
# Event deduplication
_recent_events: List[Tuple[str, str, float]] = [] # [(event_type, plan_num, timestamp)]
DEDUPE_WINDOW = 2.0 # seconds
# =============================================
# FILE WATCHER CLASS
# =============================================
class PlanFileWatcher(FileSystemEventHandler):
"""Monitors PLAN file changes and fires trigger events"""
def on_created(self, event):
"""Handle file creation events - fires trigger event"""
if not event.is_directory and self._is_plan_file(str(event.src_path)):
file_path = Path(str(event.src_path))
plan_num = self._get_plan_number(file_path)
if plan_num and not self._is_duplicate_event("created", plan_num):
logger.info(f"[{MODULE_NAME}] New PLAN file detected: {file_path.name}")
self._schedule_fire_created(file_path)
def on_deleted(self, event):
"""Handle file deletion events - fires trigger event"""
if not event.is_directory and self._is_plan_file(str(event.src_path)):
file_path = Path(str(event.src_path))
plan_num = self._get_plan_number(file_path)
if plan_num and not self._is_duplicate_event("deleted", plan_num):
logger.info(f"[{MODULE_NAME}] PLAN file deleted: {file_path.name}")
self._schedule_fire_deleted(file_path)
def on_moved(self, event):
"""Handle file move/rename events - fires trigger event"""
if not event.is_directory and self._is_plan_file(str(event.dest_path)):
src_path = Path(str(event.src_path))
dest_path = Path(str(event.dest_path))
plan_num = self._get_plan_number(dest_path)
if plan_num and not self._is_duplicate_event("moved", plan_num):
logger.info(f"[{MODULE_NAME}] PLAN file moved: {src_path.name} -> {dest_path}")
self._schedule_fire_moved(src_path, dest_path)
def _is_plan_file(self, file_path: str) -> bool:
"""Check if file is a PLAN file"""
return PLAN_PATTERN.match(Path(file_path).name) is not None
def _get_plan_number(self, file_path: Path) -> Optional[str]:
"""Extract plan number from filename (e.g., FPLAN-0001.md -> 0001)"""
match = re.search(r'FPLAN-(\d{4})\.md$', file_path.name)
return match.group(1) if match else None
def _is_duplicate_event(self, event_type: str, plan_num: str) -> bool:
"""Check if this is a duplicate recent event"""
global _recent_events
now = time.time()
# Clean old events
_recent_events = [(et, pn, ts) for et, pn, ts in _recent_events
if now - ts < DEDUPE_WINDOW]
# Check for duplicates
for et, pn, ts in _recent_events:
if et == event_type and pn == plan_num:
return True
# Add to recent events
_recent_events.append((event_type, plan_num, now))
return False
def _schedule_fire_created(self, file_path: Path):
"""Schedule trigger event with delay to avoid duplicate events"""
timer = threading.Timer(0.5, self._fire_plan_file_created, args=(file_path,))
timer.start()
def _schedule_fire_deleted(self, file_path: Path):
"""Schedule trigger event with delay to avoid duplicate events"""
timer = threading.Timer(0.5, self._fire_plan_file_deleted, args=(file_path,))
timer.start()
def _schedule_fire_moved(self, src_path: Path, dest_path: Path):
"""Schedule trigger event with delay to avoid duplicate events"""
timer = threading.Timer(0.5, self._fire_plan_file_moved, args=(src_path, dest_path))
timer.start()
def _fire_plan_file_created(self, file_path: Path):
"""Fire plan_file_created event - Trigger handles registry update"""
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire('plan_file_created', path=str(file_path))
except ImportError:
logger.warning(f"[{MODULE_NAME}] Trigger not available - plan_file_created event not fired for {file_path.name}")
def _fire_plan_file_deleted(self, file_path: Path):
"""Fire plan_file_deleted event - Trigger handles registry update"""
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire('plan_file_deleted', path=str(file_path))
except ImportError:
logger.warning(f"[{MODULE_NAME}] Trigger not available - plan_file_deleted event not fired for {file_path.name}")
def _fire_plan_file_moved(self, src_path: Path, dest_path: Path):
"""Fire plan_file_moved event - Trigger handles registry update"""
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire('plan_file_moved', src_path=str(src_path), dest_path=str(dest_path))
except ImportError:
logger.warning(f"[{MODULE_NAME}] Trigger not available - plan_file_moved event not fired for {dest_path.name}")
# =============================================
# SCAN AND HEAL FUNCTION
# THIN ORCHESTRATION WRAPPERS
# =============================================
def _fire_event(event_name: str, **kwargs) -> bool:
"""
Fire a trigger event (internal helper)
Args:
event_name: Name of the event to fire
**kwargs: Event data
Returns:
True if event fired successfully, False otherwise
"""
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire(event_name, **kwargs)
return True
except ImportError:
logger.warning(f"[{MODULE_NAME}] Trigger not available - {event_name} event not fired")
return False
def scan_plan_files() -> Dict[str, Any]:
"""
Scan ecosystem for PLAN files and fire events to heal registry
Scan ecosystem for PLAN files and fire events to heal registry (thin orchestrator)
Fires events for:
- Missing registry entries (plan_file_created)
- Orphaned entries (plan_file_deleted)
- Location mismatches (plan_file_moved)
- Duplicate plan numbers are auto-renumbered on filesystem, then fire plan_file_created
Architecture (v2.0):
- This function DETECTS changes and FIRES events
- Trigger handlers in plan_file.py HANDLE the registry updates
- Flow never touches registry directly during scan
Delegates to monitor_ops handler for implementation.
Returns:
Dict with scan results and event stats
"""
logger.info(f"[{MODULE_NAME}] Starting PLAN file scan from: {ECOSYSTEM_ROOT}")
return scan_plan_files_impl(
ecosystem_root=ECOSYSTEM_ROOT,
load_registry=load_registry,
)
# Find all PLAN files (detect duplicates)
plan_files: Dict[str, Path] = {}
duplicates: Dict[str, List[Path]] = {}
def handle_walk_error(error):
"""Handle permission errors during os.walk"""
if not isinstance(error, PermissionError):
logger.warning(f"[{MODULE_NAME}] Error during scan: {error}")
# Use os.walk() with error handling
for root, dirs, files in os.walk(str(ECOSYSTEM_ROOT), topdown=True, onerror=handle_walk_error):
# Skip ignored directories (modify dirs in-place to prevent descent)
dirs[:] = [d for d in dirs if not any(ignored in d for ignored in IGNORE_FOLDERS)]
# Check for PLAN files in this directory
for filename in files:
if PLAN_PATTERN.match(filename):
file_path = Path(root) / filename
match = re.search(r'FPLAN-(\d{4})\.md$', filename)
if match:
plan_number = match.group(1)
# Duplicate detection
if plan_number in plan_files:
if plan_number not in duplicates:
duplicates[plan_number] = [plan_files[plan_number]]
duplicates[plan_number].append(file_path)
logger.warning(f"[{MODULE_NAME}] Duplicate FPLAN-{plan_number} found: {file_path}")
else:
plan_files[plan_number] = file_path
# Auto-renumber duplicates (keep first, renumber rest)
renumbered: List[Dict[str, str]] = []
if duplicates:
logger.warning(f"[{MODULE_NAME}] Found {len(duplicates)} duplicate PLAN files")
# Get next available plan number
current_max = max(int(num) for num in plan_files.keys()) if plan_files else 0
next_available = current_max + 1
for plan_num, paths in duplicates.items():
# Keep first occurrence, renumber the rest
for dup_path in paths[1:]: # Skip first path (already in plan_files)
old_name = dup_path.name
new_num = f"{next_available:04d}"
new_name = f"FPLAN-{new_num}.md"
new_path = dup_path.parent / new_name
try:
# Rename file on filesystem
dup_path.rename(new_path)
logger.info(f"[{MODULE_NAME}] Auto-renumbered: {old_name} -> {new_name} at {dup_path.parent}")
# Add to plan_files with new number
plan_files[new_num] = new_path
renumbered.append({
"old_number": plan_num,
"new_number": new_num,
"path": str(new_path)
})
next_available += 1
except Exception as e:
logger.error(f"[{MODULE_NAME}] Failed to renumber {old_name}: {e}")
# Load current registry to compare (read-only - we don't modify it here)
registry = load_registry()
plans = registry.get("plans", {})
# Track events fired
added: List[str] = []
updated: List[str] = []
removed: List[str] = []
# Fire events for missing files (not in registry)
for plan_number, file_path in plan_files.items():
if plan_number not in plans:
# File exists but not in registry - fire created event
if _fire_event('plan_file_created', path=str(file_path)):
added.append(plan_number)
logger.info(f"[{MODULE_NAME}] Fired plan_file_created for FPLAN-{plan_number}")
else:
# Check if location changed (file moved)
current_path = plans[plan_number].get("file_path", "")
if current_path != str(file_path):
# Fire moved event
if _fire_event('plan_file_moved', src_path=current_path, dest_path=str(file_path)):
updated.append(plan_number)
logger.info(f"[{MODULE_NAME}] Fired plan_file_moved for FPLAN-{plan_number}")
# Fire events for orphaned registry entries (in registry but file doesn't exist)
for plan_number in list(plans.keys()):
if plan_number not in plan_files:
# Registry entry but no file - fire deleted event
file_path = plans[plan_number].get("file_path", f"FPLAN-{plan_number}.md")
if _fire_event('plan_file_deleted', path=file_path):
removed.append(plan_number)
logger.info(f"[{MODULE_NAME}] Fired plan_file_deleted for FPLAN-{plan_number}")
# Log event results
if added or updated or removed or renumbered:
logger.info(f"[{MODULE_NAME}] Events fired - Created: {len(added)}, Moved: {len(updated)}, Deleted: {len(removed)}, Renumbered: {len(renumbered)}")
# Reload registry to get updated count (after handlers processed events)
registry = load_registry()
total_plans = len(registry.get("plans", {}))
logger.info(f"[{MODULE_NAME}] Scan complete - {total_plans} PLAN files in registry")
return {
"total_plans": total_plans,
"added": added,
"updated": updated,
"removed": removed,
"renumbered": renumbered,
"healing_performed": len(added) + len(updated) + len(removed) + len(renumbered) > 0
}
# =============================================
# MONITOR CONTROL
# =============================================
def start_monitoring():
"""Start PLAN file monitoring with watchdog"""
global _observer
"""Start PLAN file monitoring with watchdog (thin orchestrator)
with _observer_lock:
if _observer and _observer.is_alive():
logger.info(f"[{MODULE_NAME}] Monitor already running")
console.print("[yellow]Monitor is already running[/yellow]")
return False
try:
observer = Observer()
observer.schedule(PlanFileWatcher(), str(ECOSYSTEM_ROOT), recursive=True)
observer.start()
_observer = observer
logger.info(f"[{MODULE_NAME}] PLAN file monitor started - watching {ECOSYSTEM_ROOT}")
console.print(f"[green]✓[/green] Monitor started - watching {ECOSYSTEM_ROOT}")
return True
except Exception as e:
logger.error(f"[{MODULE_NAME}] Error starting monitor: {e}")
console.print(f"[red]Error starting monitor: {e}[/red]")
return False
Returns:
True if started successfully, False otherwise
"""
result = start_monitoring_impl(ecosystem_root=ECOSYSTEM_ROOT)
# Module handles display
status = result.get("status", "")
if status == "already_running":
console.print("[yellow]Monitor is already running[/yellow]")
elif status == "started":
console.print(f"[green]OK[/green] {result['message']}")
elif status == "error":
console.print(f"[red]{result['message']}[/red]")
return result.get("success", False)
def stop_monitoring():
"""Stop PLAN file monitoring"""
global _observer
"""Stop PLAN file monitoring (thin orchestrator)
with _observer_lock:
if _observer and _observer.is_alive():
_observer.stop()
_observer.join()
_observer = None
logger.info(f"[{MODULE_NAME}] PLAN file monitor stopped")
console.print("[green]✓[/green] Monitor stopped")
return True
else:
logger.info(f"[{MODULE_NAME}] Monitor is not running")
console.print("[yellow]Monitor is not running[/yellow]")
return False
Returns:
True if stopped successfully, False otherwise
"""
result = stop_monitoring_impl()
# Module handles display
status = result.get("status", "")
if status == "stopped":
console.print("[green]OK[/green] Monitor stopped")
elif status == "not_running":
console.print("[yellow]Monitor is not running[/yellow]")
return result.get("success", False)
def get_status() -> Dict[str, Any]:
"""Get monitoring status"""
global _observer
registry = load_registry()
total_plans = len(registry.get("plans", {}))
open_plans = sum(1 for p in registry.get("plans", {}).values() if p.get("status") == "open")
with _observer_lock:
is_running = _observer and _observer.is_alive()
return {
"module": MODULE_NAME,
"version": "2.0.0",
"monitoring_active": is_running,
"watch_location": str(ECOSYSTEM_ROOT),
"total_plans": total_plans,
"open_plans": open_plans,
"ignore_folders": len(IGNORE_FOLDERS)
}
"""Get monitoring status (thin orchestrator)"""
return get_status_impl(
ecosystem_root=ECOSYSTEM_ROOT,
load_registry=load_registry,
)
# =============================================
@@ -611,6 +295,7 @@ def print_introspection():
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" • [cyan]handlers/registry/monitor_ops.py (implementation)[/cyan]")
console.print(" • [cyan]handlers/registry/load_registry.py[/cyan]")
console.print(" • [cyan]handlers/registry/save_registry.py[/cyan]")
console.print()
+78 -211
View File
@@ -1,27 +1,17 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: restore_plan.py - PLAN restore module (reopen closed plans)
# Date: 2025-11-22
# Version: 1.3.0
# Category: flow/modules
#
# CHANGELOG (Max 5 entries):
# - v1.3.0 (2025-11-22): Use NEWEST backup when multiple exist, show restore location in output
# - v1.2.1 (2025-11-22): Fixed relative path handling (convert "flow" → absolute path)
# - v1.2.0 (2025-11-22): Fixed recovery to restore plans to ORIGINAL location (not hardcoded flow)
# - v1.1.0 (2025-11-22): Added auto-recovery from processed_plans
# - v1.0.0 (2025-11-21): Initial creation - restore closed plans to open status
#
# CODE STANDARDS:
# - Seedgo v3.0 compliant (imports, architecture, error handling)
# ==============================================
# =================== AIPass ====================
# Name: restore_plan.py
# Description: PLAN restore module (reopen closed plans)
# Version: 1.5.0
# Created: 2025-11-22
# Modified: 2025-11-22
# =============================================
"""
Restore PLAN Module
Thin orchestrator for plan restore workflow (reopening closed plans).
All business logic delegated to handlers.
Module handles all display output.
Usage:
From flow.py: flow restore <number>
@@ -30,12 +20,10 @@ Usage:
import sys
from pathlib import Path
from typing import List
from shutil import copy2
from datetime import datetime, timezone
from typing import List, Dict, Any
# INFRASTRUCTURE IMPORT PATTERN
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
FLOW_ROOT = _PKG_ROOT / "flow"
# External: Prax logger
@@ -64,12 +52,52 @@ from aipass.flow.apps.handlers.plan.display import (
from aipass.flow.apps.handlers.dashboard.update_local import update_dashboard_local
from aipass.flow.apps.handlers.dashboard.push_central import push_to_plans_central
# Internal: Restore operations handler (implementation)
from aipass.flow.apps.handlers.plan.restore_ops import (
recover_plan_from_backup as _recover_plan_from_backup_impl,
restore_plan_impl,
)
# =============================================
# CONFIGURATION
# =============================================
MODULE_NAME = "restore_plan"
# =============================================
# DISPLAY HELPERS
# =============================================
def _display_messages(messages: List[Dict[str, Any]]):
"""Render handler result messages to console
Args:
messages: List of message dicts from handler with type/text keys
"""
for msg in messages:
msg_type = msg.get("type", "")
if msg_type == "error":
error_type = msg.get("error_type", "general")
plan_key = msg.get("plan_key", "")
details = msg.get("details", None)
console.print(format_restore_error(error_type, plan_key, details=details))
elif msg_type == "warning":
console.print(f"[yellow]{msg['text']}[/yellow]")
elif msg_type == "dim":
console.print(f"[dim]{msg['text']}[/dim]")
elif msg_type == "success":
console.print(f"[green]{msg['text']}[/green]")
elif msg_type == "restore_header":
console.print(format_restore_header(msg["plan_key"], msg["plan_info"]))
elif msg_type == "restore_success":
console.print(format_restore_success(msg["plan_key"], msg.get("location")))
# =============================================
# INTROSPECTION
# =============================================
@@ -84,6 +112,7 @@ def print_introspection():
console.print()
console.print(" [cyan]handlers/plan/[/cyan]")
console.print(" [dim]- restore_ops.py (implementation)[/dim]")
console.print(" [dim]- command_parser.py[/dim]")
console.print(" [dim]- validator.py[/dim]")
console.print(" [dim]- display.py[/dim]")
@@ -108,7 +137,9 @@ def print_introspection():
def recover_plan_from_backup(plan_key: str) -> tuple[bool, str]:
"""
Attempt to recover a plan from processed_plans backup
Attempt to recover a plan from processed_plans backup (thin orchestrator)
Delegates to restore_ops handler for implementation.
Args:
plan_key: Normalized plan number (e.g., "0165")
@@ -116,88 +147,11 @@ def recover_plan_from_backup(plan_key: str) -> tuple[bool, str]:
Returns:
(success, message)
"""
# Check processed_plans directory
processed_plans = _PKG_ROOT / "backup_system" / "processed_plans"
plan_file = processed_plans / f"FPLAN-{plan_key}.md"
# CRITICAL: If base file doesn't exist, or if timestamp variants exist, use the NEWEST backup
# This handles cases where plan was closed multiple times from different locations
variants = list(processed_plans.glob(f"FPLAN-{plan_key}*.md"))
if variants:
# Sort by modification time, newest first
variants.sort(key=lambda p: p.stat().st_mtime, reverse=True)
plan_file = variants[0] # Use most recent backup
elif not plan_file.exists():
return False, f"FPLAN-{plan_key} not found in backups"
# Read plan file to extract original location from header
try:
with open(plan_file, 'r', encoding='utf-8') as f:
content = f.read()
# Parse location from header (e.g., "**Location**: /path/to/dir")
original_location = None
for line in content.split('\n')[:20]: # Check first 20 lines
if line.startswith("**Location**:"):
original_location = line.split("**Location**:")[1].strip()
break
# If location not found in header, default to FLOW_ROOT
if not original_location:
original_location = str(FLOW_ROOT)
# CRITICAL: Convert relative paths to absolute paths
# If location is relative (like "flow"), resolve it
if not original_location.startswith('/'):
# Relative path - resolve against _PKG_ROOT
if original_location == "flow":
original_location = str(FLOW_ROOT)
else:
# Try resolving relative to _PKG_ROOT
potential_path = _PKG_ROOT / original_location
if potential_path.exists():
original_location = str(potential_path)
else:
# Fallback to FLOW_ROOT
original_location = str(FLOW_ROOT)
# Determine relative path
original_path = Path(original_location)
if original_path == FLOW_ROOT:
relative_path = "flow"
elif original_path == _PKG_ROOT:
relative_path = "root"
else:
try:
relative_path = str(original_path.relative_to(_PKG_ROOT))
except ValueError:
relative_path = str(original_path)
except Exception as e:
# If parsing fails, default to FLOW_ROOT
original_location = str(FLOW_ROOT)
relative_path = "flow"
# Copy file to ORIGINAL location (preserve backup)
target = Path(original_location) / f"FPLAN-{plan_key}.md"
copy2(plan_file, target)
# Create minimal registry entry
registry = load_registry()
registry["plans"][plan_key] = {
"location": original_location,
"relative_path": relative_path,
"file_path": str(target),
"status": "closed",
"created": datetime.now(timezone.utc).isoformat(),
"subject": "Recovered from backup",
"closed": datetime.now(timezone.utc).isoformat(),
"closed_reason": "recovered_from_backup",
"template_type": "default"
}
save_registry(registry)
return True, f"Recovered FPLAN-{plan_key} from {plan_file.name} to {original_location}"
return _recover_plan_from_backup_impl(
plan_key,
load_registry=load_registry,
save_registry=save_registry,
)
# =============================================
# RESTORE PLAN WORKFLOW
@@ -207,13 +161,8 @@ def restore_plan(plan_num: str | None) -> bool:
"""
Orchestrate plan restore workflow (thin orchestrator)
Restores a closed plan back to open status by updating registry metadata.
Does NOT move files - file must already be at registered location.
Delegates all business logic to handlers:
- Validation: validator handler
- Registry ops: registry handlers
- Display: display handler
Delegates all business logic to restore_ops handler.
Module handles all display output.
Args:
plan_num: Plan number (e.g., "0001" or "1" or "42")
@@ -221,107 +170,25 @@ def restore_plan(plan_num: str | None) -> bool:
Returns:
True if successful, False otherwise
"""
if not plan_num:
logger.warning(f"[{MODULE_NAME}] Plan number required for restore")
console.print(format_restore_error("invalid_number", ""))
return False
# Import scan_plan_files here (lazy import to avoid circular dependency)
from aipass.flow.apps.modules.registry_monitor import scan_plan_files
try:
# 0. AUTO-HEAL: Run registry scan to detect moved files (self-healing)
from aipass.flow.apps.modules.registry_monitor import scan_plan_files
scan_plan_files() # Auto-detects files not in registry
logger.info(f"[{MODULE_NAME}] Auto-heal scan completed")
result = restore_plan_impl(
plan_num=plan_num,
# Inject dependencies
normalize_plan_number=normalize_plan_number,
load_registry=load_registry,
save_registry=save_registry,
validate_plan_exists=validate_plan_exists,
recover_plan_from_backup_fn=recover_plan_from_backup,
scan_plan_files=scan_plan_files,
update_dashboard_local=update_dashboard_local,
push_to_plans_central=push_to_plans_central,
)
# 1. VALIDATE: Normalize plan number (handler)
plan_key = normalize_plan_number(plan_num)
# 2. LOAD DATA: Get registry (service)
registry = load_registry()
# 3. VALIDATE: Check plan exists (handler)
exists, error_msg = validate_plan_exists(plan_key, registry)
if not exists:
# AUTO-RECOVERY: Try to recover from processed_plans
console.print(f"[yellow]FPLAN-{plan_key} not in registry - attempting recovery...[/yellow]")
recovered, recovery_msg = recover_plan_from_backup(plan_key)
if recovered:
console.print(f"[green]✓ {recovery_msg}[/green]")
# Reload registry with recovered plan
registry = load_registry()
plan_info = registry["plans"][plan_key]
plan_file = Path(plan_info.get("file_path", ""))
else:
logger.warning(f"[{MODULE_NAME}] {error_msg} - Recovery failed: {recovery_msg}")
console.print(format_restore_error("not_found", plan_key))
console.print(f"[dim]Recovery attempt: {recovery_msg}[/dim]")
return False
else:
plan_info = registry["plans"][plan_key]
plan_file = Path(plan_info.get("file_path", ""))
# 4. VALIDATE: Check plan is closed
if plan_info.get("status") != "closed":
logger.warning(f"[{MODULE_NAME}] FPLAN-{plan_key} is already open")
console.print(format_restore_error("already_open", plan_key))
return False
# 5. VALIDATE: Check file exists at registered location
if not plan_file.exists():
logger.warning(f"[{MODULE_NAME}] File not found at {plan_file}")
console.print(format_restore_error("file_missing", plan_key))
return False
# 6. DISPLAY: Show plan info before restore (handler)
console.print(format_restore_header(plan_key, plan_info))
# 7. UPDATE REGISTRY: Restore to open status
plan_info['status'] = 'open'
# Remove all close-related metadata
plan_info.pop('closed', None)
plan_info.pop('closed_reason', None)
plan_info.pop('memory_created', None)
plan_info.pop('memory_created_date', None)
plan_info.pop('memory_file', None)
save_registry(registry)
logger.info(f"[{MODULE_NAME}] Restored FPLAN-{plan_key} to open status")
# 8. UPDATE DASHBOARDS: Sync dashboard files (handlers)
dashboard_success = update_dashboard_local()
central_success = push_to_plans_central()
# Log dashboard update results (3-tier: modules log, handlers don't)
if not dashboard_success:
logger.warning(f"[{MODULE_NAME}] Failed to update DASHBOARD.local.json")
if not central_success:
logger.warning(f"[{MODULE_NAME}] Failed to update PLANS.central.json")
# 9. DISPLAY: Success message with location (handler)
restored_location = plan_info.get("location", "unknown")
console.print(format_restore_success(plan_key, restored_location))
# Fire trigger event for plan restore (optional - trigger module may not be available)
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire('plan_restored', plan_number=plan_key, location=restored_location)
except ImportError:
logger.info(f"[{MODULE_NAME}] Trigger module not available, skipping event fire")
return True
except ValueError:
error_msg = f"Invalid plan number: {plan_num}"
logger.warning(f"[{MODULE_NAME}] {error_msg}")
console.print(format_restore_error("invalid_number", plan_num))
return False
except Exception as e:
error_msg = f"Error restoring plan: {e}"
logger.error(f"[{MODULE_NAME}] {error_msg}")
console.print(format_restore_error("general", details=str(e)))
return False
# Module handles display
_display_messages(result.get("messages", []))
return result.get("success", False)
def handle_command(command: str, args: List[str]) -> bool: