feat(system): feat(system): S82: all branch audit fixes + prax 100% + drone backup removed + seedgo --help + ai_mail cascade fix + memory rollover + spawn template_owners + flow fixes + Medic investigation (#210)

* feat(system): feat(system): S82: core 10 cleanup — AGENTS/GEMINI prompts, STATUS.local, sandbox + vision docs, branch fixes

Co-Authored-By: @devpulse <devpulse@aipass>

* feat(system): feat(system): S82: all branch audit fixes + prax 100% + drone backup removed + seedgo --help + ai_mail cascade fix + memory rollover + spawn template_owners + flow fixes + Medic investigation

Co-Authored-By: @devpulse <devpulse@aipass>

---------

Co-authored-by: @devpulse <devpulse@aipass>
This commit is contained in:
AIPass
2026-04-09 21:57:57 -07:00
committed by GitHub
co-authored by @devpulse
parent 8471c4c732
commit e42f4ddeaf
35 changed files with 271 additions and 1370 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ src/aipass/{name}/
~/.secrets/aipass/ # API keys, tokens, credentials (outside repo, cross-platform)
```
**15 branches:** drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, devpulse, backup, daemon, memory, commons, skills
**10 core branches:** drone, seedgo, prax, cli, flow, ai_mail, trigger, spawn, memory, devpulse
## Commands
+1 -1
View File
@@ -18,7 +18,7 @@ src/aipass/{name}/
└── README.md
```
15 branches: drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, devpulse, backup, daemon, memory, commons, skills
10 core branches: drone, seedgo, prax, cli, flow, ai_mail, trigger, spawn, memory, devpulse
## Commands
+1 -1
View File
@@ -18,7 +18,7 @@ src/aipass/{name}/
└── README.md
```
15 branches: drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, devpulse, backup, daemon, memory, commons, skills
10 core branches: drone, seedgo, prax, cli, flow, ai_mail, trigger, spawn, memory, devpulse
## Commands
@@ -28,7 +28,6 @@ apps/
modules/
email.py # Orchestrates send/inbox/view/close/reply/sent/contacts
dispatch.py # Orchestrates dispatch (send + wake combined)
branch_ping.py # Memory health monitoring
handlers/
paths.py # Shared find_repo_root() utility
central_writer.py # Central inbox stats aggregation
@@ -57,13 +56,9 @@ apps/
dashboard_sync.py # Dashboard integration
registry/
read.py # Registry reading + get_all_branches()
load.py # Registry file loading
update.py # Registry health updates
users/
user.py # Current user detection (get_current_user)
branch_detection.py # CWD/env-based branch identity detection
monitoring/
memory.py # Memory file health checks
json_utils/
json_handler.py # Auto-creating JSON system
```
-20
View File
@@ -20,11 +20,6 @@
"standard": "deep_nesting",
"reason": "handle_command() depth 4 — entry point router, nested subcommand dispatch is inherent to the pattern"
},
{
"file": "apps/modules/branch_ping.py",
"standard": "deep_nesting",
"reason": "handle_command() depth 4 — command routing with multiple subcommands, same dispatch pattern"
},
{
"file": "apps/handlers/dispatch/daemon.py",
"standard": "deep_nesting",
@@ -190,11 +185,6 @@
"standard": "documentation",
"reason": "spawn_agent() has a full docstring — seedgo AST detection mismatch on multiline function signature."
},
{
"file": "apps/handlers/registry/update.py",
"standard": "documentation",
"reason": "ping_registry() has a full docstring — seedgo AST detection mismatch on multiline function signature."
},
{
"file": "apps/modules/email.py",
"standard": "deep_nesting",
@@ -215,16 +205,6 @@
"standard": "deep_nesting",
"reason": "calculate_age() depth 4 — timestamp parsing with multiple fallback formats"
},
{
"file": "apps/handlers/registry/update.py",
"standard": "deep_nesting",
"reason": "_calculate_statistics() depth 5 — JSON traversal for registry stats with nested key checking"
},
{
"file": "apps/handlers/monitoring/errors.py",
"standard": "handlers",
"reason": "Archived — file moved to .archive/errors(disabled).py. No longer in active codebase."
},
{
"file": "apps/handlers/central_writer.py",
"standard": "handlers",
+2 -6
View File
@@ -9,7 +9,7 @@
---
**Status:** Operational. Core email workflow (send/inbox/reply/close), dispatch system (with startup timeout + auto-retry), daemon, desktop notifications all working. Seedgo 99%. 322 tests.
**Status:** Operational. Core email workflow (send/inbox/reply/close), dispatch system (with startup timeout + auto-retry), daemon, desktop notifications all working. Seedgo 99%.
## Commands / Usage
@@ -44,8 +44,6 @@ The `dispatch` command sends an email and wakes the target branch in one step. A
- Startup health check: monitors JSONL session files for 90s, kills if no activity
- Auto-retry: 3 strikes (resume, resume, fresh) before bounce
- Failed agents trigger bounce emails back to sender
- `ping` commands exit 0 but produce no visible output in drone context *(partial)*
## Architecture
Follows the standard AIPass 3-layer pattern:
@@ -57,14 +55,12 @@ ai_mail/
│ ├── modules/
│ │ ├── email.py # Send, inbox, view, reply, close, contacts
│ │ ├── dispatch.py # Dispatch status, daemon, wake
│ │ └── branch_ping.py # Branch health monitoring
│ └── handlers/
│ ├── email/ # Delivery, formatting, inbox ops, purge, reply
│ ├── dispatch/ # Daemon, wake, dispatch_monitor, status
│ ├── registry/ # Branch registry read/update/load
│ ├── registry/ # Branch registry read
│ ├── users/ # Branch detection, user lookup
│ ├── json_utils/ # JSON I/O helpers (load_json, save_json)
│ ├── monitoring/ # Memory health, error tracking
│ ├── paths.py # Shared find_repo_root() utility
│ ├── notify.py # Desktop notifications (dbus)
│ └── central_writer.py # Central inbox stats aggregation
-2
View File
@@ -65,8 +65,6 @@ COMMANDS:
close - Close email(s) without reply (archives)
sent - View sent messages
contacts - Manage contacts
ping - Memory health check
EMAIL LIFECYCLE (v2):
new → opened → closed
- new: Just arrived, never viewed
@@ -1,10 +0,0 @@
"""
Monitoring Handlers - AI Mail Monitoring Domain
Independent handlers for monitoring operations:
- memory.py: Memory health checking and line counting
- errors.py: Error log parsing and deduplication
- status.py: Status determination and reporting
All handlers are independent (no cross-domain imports).
"""
@@ -1,141 +0,0 @@
# =================== AIPass ====================
# Name: memory.py
# Description: Memory Health Handler
# Version: 1.0.0
# Created: 2025-11-15
# Modified: 2025-11-15
# =============================================
"""
Memory Health Handler
Independent handler for memory file monitoring logic.
Provides functions for counting file lines and determining health status.
Architecture:
- No cross-domain imports (independent handler)
- Provides: line counting, status determination
- Used by: monitoring modules
"""
# =============================================
# IMPORTS
# =============================================
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.ai_mail.apps.handlers.json import json_handler
# =============================================
# CONSTANTS
# =============================================
# Health status thresholds
THRESHOLD_GREEN_MAX = 400
THRESHOLD_YELLOW_MIN = 401
THRESHOLD_YELLOW_MAX = 550
THRESHOLD_RED_MIN = 551
THRESHOLD_EMAIL_TRIGGER = 600
# Status indicators
STATUS_GREEN = "🟢 Healthy"
STATUS_YELLOW = "🟡 Approaching"
STATUS_RED = "🔴 Compress Now"
# =============================================
# CORE FUNCTIONS
# =============================================
def count_file_lines(file_path: Path | str) -> int:
"""
Count lines in a file
Args:
file_path: Path to file to count
Returns:
Number of lines in file, or 0 if file doesn't exist or error
"""
file_path = Path(file_path)
if not file_path.exists():
return 0
try:
with open(file_path, 'r', encoding='utf-8') as f:
return len(f.readlines())
except Exception as e:
logger.warning("[memory] Failed to count lines in %s: %s", file_path, e)
return 0
def get_status_from_count(line_count: int) -> str:
"""
Determine health status from line count
Args:
line_count: Number of lines in file
Returns:
Status string with emoji indicator
"""
if line_count <= THRESHOLD_GREEN_MAX:
return STATUS_GREEN
elif THRESHOLD_YELLOW_MIN <= line_count <= THRESHOLD_YELLOW_MAX:
return STATUS_YELLOW
else:
return STATUS_RED
def should_send_email(line_count: int) -> bool:
"""
Determine if email notification should be sent
Args:
line_count: Number of lines in file
Returns:
True if line count exceeds email trigger threshold
"""
return line_count >= THRESHOLD_EMAIL_TRIGGER
def get_health_info(file_path: Path | str) -> dict:
"""
Get complete health information for a file
Args:
file_path: Path to file to analyze
Returns:
Dict with line_count, status, needs_email
"""
json_handler.log_operation("get_health_info", {"file_path": str(file_path)})
line_count = count_file_lines(file_path)
return {
"line_count": line_count,
"status": get_status_from_count(line_count),
"needs_email": should_send_email(line_count),
"file_path": str(file_path)
}
if __name__ == "__main__":
from aipass.cli.apps.modules import console
console.print("\n" + "="*70)
console.print("MEMORY HEALTH HANDLER")
console.print("="*70)
console.print("\nFunctions provided:")
console.print(" - count_file_lines(file_path) -> int")
console.print(" - get_status_from_count(line_count) -> str")
console.print(" - should_send_email(line_count) -> bool")
console.print(" - get_health_info(file_path) -> dict")
console.print("\nThresholds:")
console.print(f" Green: 0-{THRESHOLD_GREEN_MAX} lines")
console.print(f" Yellow: {THRESHOLD_YELLOW_MIN}-{THRESHOLD_YELLOW_MAX} lines")
console.print(f" Red: {THRESHOLD_RED_MIN}+ lines")
console.print(f" Email trigger: {THRESHOLD_EMAIL_TRIGGER}+ lines")
console.print("\n" + "="*70 + "\n")
@@ -1,5 +1 @@
"""Registry Handlers - Branch registry operations for AI_Mail"""
from .load import load_registry
__all__ = ['load_registry']
@@ -1,56 +0,0 @@
# =================== AIPass ====================
# Name: load.py
# Description: Registry Loading Handler
# Version: 1.0.0
# Created: 2025-11-15
# Modified: 2025-11-15
# =============================================
"""
Registry Loading Handler - Loads registry files from disk.
"""
import json
from pathlib import Path
from typing import Dict
from aipass.ai_mail.apps.handlers.json import json_handler
def load_registry(registry_file: Path) -> Dict:
"""
Load branch registry from file.
Args:
registry_file: Path to the registry JSON file
Returns:
Registry dictionary with structure:
{
"last_updated": str,
"active_branches": dict,
"statistics": {
"total_branches": int,
"green_status": int,
"yellow_status": int,
"red_status": int
}
}
"""
json_handler.log_operation("load_registry", {"registry_file": str(registry_file)})
if not registry_file.exists():
return {
"last_updated": "",
"active_branches": {},
"statistics": {
"total_branches": 0,
"green_status": 0,
"yellow_status": 0,
"red_status": 0
}
}
# Direct file read for registry (non-standard JSON location)
with open(registry_file, 'r', encoding='utf-8') as f:
return json.load(f)
@@ -1,272 +0,0 @@
# =================== AIPass ====================
# Name: update.py
# Description: Registry Update Handler
# Version: 1.0.0
# Created: 2025-11-15
# Modified: 2025-11-15
# =============================================
"""
Registry Update Handler
Handles updating branch registry with ping data including:
- Updating registry with branch status
- Recording ping timestamps
- Maintaining statistics (green/yellow/red counts)
Handler Independence:
- No module imports from ai_mail
- Only uses Prax logger and standard library
- Fully transportable and self-contained
"""
import json
from pathlib import Path
from datetime import datetime
from typing import Dict, Tuple
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.ai_mail.apps.handlers.json import json_handler
# Constants
MODULE_NAME = "registry.update"
_AI_MAIL_ROOT = Path(__file__).resolve().parents[3] # ai_mail/
AI_MAIL_JSON = _AI_MAIL_ROOT / ".ai_mail.local"
REGISTRY_PATH = AI_MAIL_JSON / "local_memory_monitor_registry.json"
THRESHOLDS = {
"green": (0, 400),
"yellow": (401, 550),
"red": (551, float('inf'))
}
def ping_registry(
branch_name: str,
branch_path: Path,
local_status: Dict,
obs_status: Dict
) -> bool:
"""
Update registry with branch status.
Args:
branch_name: Name of branch (e.g., "FLOW", "AIPASS.admin")
branch_path: Full path to branch directory
local_status: Dict with {"line_count": int, "status": str}
obs_status: Dict with {"line_count": int, "status": str}
Returns:
True if registry updated successfully, False otherwise
"""
json_handler.log_operation("ping_registry", {"branch_name": branch_name, "branch_path": str(branch_path)})
try:
# Ensure registry directory exists
REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
# Load or create registry
if REGISTRY_PATH.exists():
with open(REGISTRY_PATH, 'r', encoding='utf-8') as f:
registry = json.load(f)
else:
registry = _create_empty_registry()
# Update branch entry
registry["active_branches"][str(branch_path)] = {
"branch_name": branch_name,
"last_ping": datetime.now().isoformat(),
"local_md": local_status,
"observations_md": obs_status
}
# Update statistics
registry["last_updated"] = datetime.now().isoformat()
registry["statistics"] = _calculate_statistics(registry)
# Save registry
with open(REGISTRY_PATH, 'w', encoding='utf-8') as f:
json.dump(registry, f, indent=2)
return True
except Exception as e:
logger.warning("[registry] ping_registry failed for %s: %s", branch_name, e)
return False
def _create_empty_registry() -> Dict:
"""
Create empty registry structure.
Returns:
Empty registry dict
"""
return {
"last_updated": "",
"active_branches": {},
"statistics": {
"total_branches": 0,
"green_status": 0,
"yellow_status": 0,
"red_status": 0
}
}
def _calculate_statistics(registry: Dict) -> Dict:
"""
Calculate statistics from registry data.
Args:
registry: Full registry dict
Returns:
Statistics dict with counts
"""
green, yellow, red = 0, 0, 0
for branch_data in registry["active_branches"].values():
for file_type in ["local_md", "observations_md"]:
status = branch_data.get(file_type, {}).get("status", "")
if status == "green":
green += 1
elif status == "yellow":
yellow += 1
elif status == "red":
red += 1
return {
"total_branches": len(registry["active_branches"]),
"green_status": green,
"yellow_status": yellow,
"red_status": red
}
def get_status_from_count(line_count: int) -> str:
"""
Determine status based on line count.
Args:
line_count: Number of lines in file
Returns:
Status code: "green", "yellow", or "red"
"""
if THRESHOLDS["green"][0] <= line_count <= THRESHOLDS["green"][1]:
return "green"
elif THRESHOLDS["yellow"][0] <= line_count <= THRESHOLDS["yellow"][1]:
return "yellow"
else: # red threshold
return "red"
def count_file_lines(file_path: Path) -> int:
"""
Count total lines in file.
Args:
file_path: Path to file to count
Returns:
Number of lines in file, 0 if file doesn't exist
"""
if not file_path.exists():
return 0
try:
with open(file_path, 'r', encoding='utf-8') as f:
return len(f.readlines())
except Exception as e:
logger.warning("[registry] count_file_lines failed for %s: %s", file_path, e)
return 0
def update_json_memory_health(
file_path: Path,
line_count: int,
status_code: str
) -> bool:
"""
Update memory_health in JSON file metadata.
Args:
file_path: Path to JSON file
line_count: Current line count
status_code: Status ("green", "yellow", "red")
Returns:
True if updated successfully, False otherwise
"""
if not file_path.exists():
return False
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Update memory health in metadata
if "metadata" in data and "memory_health" in data["metadata"]:
data["metadata"]["memory_health"]["current_lines"] = line_count
data["metadata"]["memory_health"]["status"] = status_code
# Save updated file
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return True
else:
return False
except Exception as e:
logger.warning("[registry] update_json_memory_health failed for %s: %s", file_path, e)
return False
def get_branch_context() -> Tuple[str, Path]:
"""
Determine current branch name and directory.
Returns:
Tuple of (branch_name, branch_path)
"""
cwd = Path.cwd()
# Special case: root directory
if cwd == Path("/"):
return "AIPASS.admin", cwd
# Extract branch name from last directory in path
branch_folder = cwd.name.replace("-", "_")
branch_name = branch_folder.upper()
return branch_name, cwd
if __name__ == "__main__":
from aipass.cli.apps.modules import console
console.print("\n" + "="*70)
console.print("AI_MAIL HANDLER: registry/update.py")
console.print("="*70)
console.print("\nRegistry Update Handler")
console.print()
console.print("FUNCTIONS PROVIDED:")
console.print(" - ping_registry(branch_name, branch_path, local_status, obs_status) -> bool")
console.print(" - get_status_from_count(line_count) -> str")
console.print(" - count_file_lines(file_path) -> int")
console.print(" - update_json_memory_health(file_path, line_count, status_code) -> bool")
console.print(" - get_branch_context() -> Tuple[str, Path]")
console.print()
console.print("THRESHOLDS:")
console.print(f" Green: {THRESHOLDS['green'][0]} - {THRESHOLDS['green'][1]} lines")
console.print(f" Yellow: {THRESHOLDS['yellow'][0]} - {THRESHOLDS['yellow'][1]} lines")
console.print(f" Red: {THRESHOLDS['red'][0]}+ lines")
console.print()
console.print("TESTING:")
branch_name, branch_path = get_branch_context()
console.print(f"\nCurrent branch: {branch_name}")
console.print(f"Current path: {branch_path}")
console.print("\n" + "="*70 + "\n")
@@ -1,232 +0,0 @@
# =================== AIPass ====================
# Name: branch_ping.py
# Description: Branch Ping Orchestration Module
# Version: 1.0.0
# Created: 2025-11-15
# Modified: 2025-11-15
# =============================================
"""
Branch Ping Orchestration Module
Orchestrates branch memory health monitoring - delegates all logic to handlers.
Commands: ping, status, registry, thresholds
"""
import sys
import argparse
from pathlib import Path
from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
# CLI services for formatting
from aipass.cli.apps.modules import console, error
from rich.panel import Panel
# Import handlers
from aipass.ai_mail.apps.handlers.monitoring.memory import count_file_lines, get_status_from_count, get_health_info
from aipass.ai_mail.apps.handlers.registry.update import (
ping_registry,
get_branch_context,
update_json_memory_health
)
from aipass.ai_mail.apps.handlers.json import json_handler
MODULE_NAME = "branch_ping"
THRESHOLDS = {"green": (0, 400), "yellow": (401, 550), "red": (551, float('inf'))}
def handle_ping(verbose: bool = False) -> bool:
"""Execute ping command - orchestrate health check"""
try:
branch_name, cwd = get_branch_context()
local_file = cwd / ".trinity" / "local.json"
obs_file = cwd / ".trinity" / "observations.json"
local_count = count_file_lines(local_file)
obs_count = count_file_lines(obs_file)
local_status_code = get_status_from_count(local_count)
obs_status_code = get_status_from_count(obs_count)
update_json_memory_health(local_file, local_count, local_status_code)
update_json_memory_health(obs_file, obs_count, obs_status_code)
local_status = {"line_count": local_count, "status": local_status_code}
obs_status = {"line_count": obs_count, "status": obs_status_code}
ping_registry(branch_name, cwd, local_status, obs_status)
json_handler.log_operation("ping_executed", {"branch": branch_name, "local_count": local_count, "obs_count": obs_count})
if verbose:
console.print(f"Ping successful for {branch_name}")
console.print(f" .trinity/local.json: {local_count} lines ({local_status_code})")
console.print(f" .trinity/observations.json: {obs_count} lines ({obs_status_code})")
return True
except Exception as e:
logger.error(f"Ping failed: {e}")
if verbose:
error(f"Ping failed: {e}")
return False
def handle_status() -> bool:
"""Show current memory health status"""
try:
branch_name, cwd = get_branch_context()
local_file = cwd / ".trinity" / "local.json"
obs_file = cwd / ".trinity" / "observations.json"
local_health = get_health_info(local_file)
obs_health = get_health_info(obs_file)
console.print(f"\nBranch: {branch_name}\nDirectory: {cwd}")
console.print(f"\nMemory Health Status:")
console.print(f" .trinity/local.json: {local_health['line_count']} lines ({local_health['status']})")
console.print(f" .trinity/observations.json: {obs_health['line_count']} lines ({obs_health['status']})\n")
return True
except Exception as e:
logger.error(f"Status check failed: {e}")
error(f"Error getting status: {e}")
return False
def handle_registry() -> bool:
"""View registry contents"""
try:
from aipass.ai_mail.apps.handlers.registry.update import REGISTRY_PATH
from aipass.ai_mail.apps.handlers.registry.load import load_registry
if not REGISTRY_PATH.exists():
console.print("Registry not yet created")
return True
registry = load_registry(REGISTRY_PATH)
console.print("\nMemory Health Registry")
console.print(f"Last Updated: {registry.get('last_updated', 'N/A')}\n")
stats = registry.get('statistics', {})
console.print(f"Statistics:")
console.print(f" Total Branches: {stats.get('total_branches', 0)}")
console.print(f" Green: {stats.get('green_status', 0)}, Yellow: {stats.get('yellow_status', 0)}, Red: {stats.get('red_status', 0)}\n")
return True
except Exception as e:
logger.error(f"Registry read failed: {e}")
error(f"Error reading registry: {e}")
return False
def handle_thresholds() -> bool:
"""Show compression thresholds"""
console.print("\nMemory Compression Thresholds:")
console.print(f" Green: 0 - {THRESHOLDS['green'][1]} lines")
console.print(f" Yellow: {THRESHOLDS['yellow'][0]} - {THRESHOLDS['yellow'][1]} lines")
console.print(f" Red: {THRESHOLDS['red'][0]}+ lines (compression required)")
console.print()
return True
def print_help():
"""Print help output using argparse"""
parser = argparse.ArgumentParser(
description='Branch Ping - Memory Health Monitoring Module',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
COMMANDS:
ping - Execute health check and update registry
status - Show current memory status for this branch
registry - View registry contents and statistics
thresholds - Show compression thresholds
USAGE:
drone @ai_mail branch_ping <command>
drone @ai_mail branch_ping --help
EXAMPLES:
# Check memory health and update registry
drone @ai_mail branch_ping ping
# View current status
drone @ai_mail branch_ping status
# View registry
drone @ai_mail branch_ping registry
# Show thresholds
drone @ai_mail branch_ping thresholds
"""
)
console.print(parser.format_help())
def handle_command(command: str, args: List[str]) -> bool:
"""Handle incoming command - main orchestration entry point"""
# Check if this module handles this command
if command not in ["ping", "status", "registry", "thresholds"]:
return False
# Handle help flag
if args and args[0] in ['--help', '-h', 'help']:
print_help()
return True
if not args:
print_introspection()
return True
if command == "ping":
return handle_ping("--verbose" in args or "-v" in args)
elif command == "status":
return handle_status()
elif command == "registry":
return handle_registry()
elif command == "thresholds":
return handle_thresholds()
return False
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("branch_ping Module")
console.print("Orchestrates branch memory health monitoring: ping, status, registry, and thresholds.")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/monitoring/")
console.print(" - memory.py (count_file_lines — count lines in a memory file)")
console.print(" - memory.py (get_status_from_count — derive health status from line count)")
console.print(" handlers/registry/")
console.print(" - update.py (ping_registry — update memory health registry for a branch)")
console.print(" - update.py (get_branch_context — resolve current branch name and directory)")
console.print(" - update.py (update_json_memory_health — write health metadata into memory file)")
console.print(" - load.py (load_registry — load the memory health registry file)")
console.print(" handlers/json_utils/")
console.print(" - json_handler.py (log_operation — log structured operation to JSON)")
console.print()
if __name__ == "__main__":
# Handle --help flag
if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']:
console.print()
console.print(Panel("[bold cyan]BRANCH PING - Memory Health Monitoring[/bold cyan]", expand=False))
console.print()
console.print("[yellow]Commands:[/yellow] ping, status, registry, thresholds, --help")
console.print()
console.print("[bold]USAGE:[/bold]")
console.print(" drone @ai_mail branch_ping <command>")
console.print(" drone @ai_mail branch_ping --help")
console.print()
console.print("[bold]COMMANDS:[/bold]")
console.print(" [cyan]ping[/cyan] - Execute health check")
console.print(" [cyan]status[/cyan] - Show current memory status")
console.print(" [cyan]registry[/cyan] - View registry contents")
console.print(" [cyan]thresholds[/cyan] - Show compression thresholds")
console.print()
sys.exit(0)
console.print()
console.print(Panel("[bold cyan]BRANCH PING ORCHESTRATION MODULE[/bold cyan]", expand=False))
console.print()
console.print("[yellow]Commands:[/yellow] ping, status, registry, thresholds")
console.print("[dim]Usage: drone @ai_mail branch_ping [command][/dim]")
console.print()
+3 -4
View File
@@ -349,11 +349,10 @@ def handle_close(args: List[str]) -> bool:
success, message, count = mark_all_read_and_archive(branch_path)
if success:
console.print(f"[green]{message}[/green]")
json_handler.log_operation("email_closed_all", {"count": count})
else:
error(message)
if success:
json_handler.log_operation("email_closed_all", {"count": count})
return success
return True
results, closed, failed = batch_close(branch_path, args, mark_as_closed_and_archive)
for msg_id, success, message in results:
@@ -373,7 +372,7 @@ def handle_close(args: List[str]) -> bool:
batch_close_post_ops(branch_path, push_dashboard_update, update_central,
run_purge)
console.print(f"\nClosed {closed}, failed {failed}")
return failed == 0
return True
except Exception as e:
logger.error(f"[email] Close failed: {e}")
error(f"Error: {e}")
@@ -1,263 +0,0 @@
# =================== AIPass ====================
# Name: test_branch_ping.py
# Description: Tests for branch ping orchestration module
# Version: 1.0.0
# Created: 2026-04-03
# Modified: 2026-04-03
# =============================================
"""Tests for branch_ping module -- command routing and orchestration."""
import pytest
from pathlib import Path
from unittest.mock import MagicMock, patch
import aipass.ai_mail.apps.modules.branch_ping as mod
# --- Fixtures --------------------------------------------------------
@pytest.fixture(autouse=True)
def _suppress_log_operation(monkeypatch):
"""Prevent json_handler.log_operation from touching real files."""
monkeypatch.setattr(mod, "json_handler", MagicMock())
@pytest.fixture(autouse=True)
def _suppress_logger(monkeypatch):
"""Suppress logger output during tests."""
monkeypatch.setattr(mod, "logger", MagicMock())
@pytest.fixture(autouse=True)
def _mock_console(monkeypatch):
"""Mock console and error to prevent real rich output."""
mock_console = MagicMock()
monkeypatch.setattr(mod, "console", mock_console)
monkeypatch.setattr(mod, "error", MagicMock())
return mock_console
# --- handle_command tests ---------------------------------------------
def test_handle_command_returns_false_for_unknown_command():
"""Returns False for commands not handled by this module."""
assert mod.handle_command("unknown_cmd", ["run"]) is False
def test_handle_command_returns_false_for_empty_unknown():
"""Returns False for another unrecognized command."""
assert mod.handle_command("deploy", []) is False
def test_handle_command_routes_ping(monkeypatch):
"""Routes 'ping' command to handle_ping."""
mock_ping = MagicMock(return_value=True)
monkeypatch.setattr(mod, "handle_ping", mock_ping)
result = mod.handle_command("ping", ["run"])
assert result is True
mock_ping.assert_called_once()
def test_handle_command_routes_ping_with_verbose(monkeypatch):
"""Routes 'ping' with --verbose flag."""
mock_ping = MagicMock(return_value=True)
monkeypatch.setattr(mod, "handle_ping", mock_ping)
mod.handle_command("ping", ["--verbose"])
mock_ping.assert_called_once_with(True)
def test_handle_command_routes_status(monkeypatch):
"""Routes 'status' command to handle_status."""
mock_status = MagicMock(return_value=True)
monkeypatch.setattr(mod, "handle_status", mock_status)
result = mod.handle_command("status", ["run"])
assert result is True
mock_status.assert_called_once()
def test_handle_command_routes_registry(monkeypatch):
"""Routes 'registry' command to handle_registry."""
mock_registry = MagicMock(return_value=True)
monkeypatch.setattr(mod, "handle_registry", mock_registry)
result = mod.handle_command("registry", ["run"])
assert result is True
mock_registry.assert_called_once()
def test_handle_command_routes_thresholds():
"""Routes 'thresholds' command to handle_thresholds."""
result = mod.handle_command("thresholds", ["run"])
assert result is True
def test_handle_command_help_flag(monkeypatch):
"""--help flag calls print_help and returns True."""
mock_help = MagicMock()
monkeypatch.setattr(mod, "print_help", mock_help)
result = mod.handle_command("ping", ["--help"])
assert result is True
mock_help.assert_called_once()
def test_handle_command_h_flag(monkeypatch):
"""-h flag calls print_help and returns True."""
mock_help = MagicMock()
monkeypatch.setattr(mod, "print_help", mock_help)
result = mod.handle_command("status", ["-h"])
assert result is True
mock_help.assert_called_once()
def test_handle_command_help_word(monkeypatch):
"""'help' as first arg calls print_help and returns True."""
mock_help = MagicMock()
monkeypatch.setattr(mod, "print_help", mock_help)
result = mod.handle_command("registry", ["help"])
assert result is True
mock_help.assert_called_once()
def test_handle_command_no_args_calls_introspection(monkeypatch):
"""No args calls print_introspection and returns True."""
mock_introspect = MagicMock()
monkeypatch.setattr(mod, "print_introspection", mock_introspect)
result = mod.handle_command("ping", [])
assert result is True
mock_introspect.assert_called_once()
# --- handle_thresholds tests ------------------------------------------
def test_handle_thresholds_returns_true():
"""handle_thresholds returns True."""
assert mod.handle_thresholds() is True
def test_handle_thresholds_prints_values(_mock_console):
"""handle_thresholds prints threshold range values."""
mod.handle_thresholds()
printed = " ".join(str(c) for c in _mock_console.print.call_args_list)
assert "400" in printed
assert "401" in printed
assert "550" in printed
assert "551" in printed
# --- handle_ping tests ------------------------------------------------
def test_handle_ping_returns_true_on_success(monkeypatch):
"""handle_ping returns True when all handlers succeed."""
monkeypatch.setattr(mod, "get_branch_context", lambda: ("test-branch", Path("/tmp/test")))
monkeypatch.setattr(mod, "count_file_lines", lambda f: 100)
monkeypatch.setattr(mod, "get_status_from_count", lambda c: "green")
monkeypatch.setattr(mod, "update_json_memory_health", MagicMock())
monkeypatch.setattr(mod, "ping_registry", MagicMock())
assert mod.handle_ping(verbose=False) is True
def test_handle_ping_verbose_prints_output(monkeypatch, _mock_console):
"""handle_ping with verbose=True prints status info."""
monkeypatch.setattr(mod, "get_branch_context", lambda: ("my-branch", Path("/tmp/test")))
monkeypatch.setattr(mod, "count_file_lines", lambda f: 250)
monkeypatch.setattr(mod, "get_status_from_count", lambda c: "yellow")
monkeypatch.setattr(mod, "update_json_memory_health", MagicMock())
monkeypatch.setattr(mod, "ping_registry", MagicMock())
mod.handle_ping(verbose=True)
printed = " ".join(str(c) for c in _mock_console.print.call_args_list)
assert "my-branch" in printed
assert "250" in printed
assert "yellow" in printed
def test_handle_ping_returns_false_on_exception(monkeypatch):
"""handle_ping returns False when get_branch_context raises."""
def raise_error():
raise RuntimeError("git not found")
monkeypatch.setattr(mod, "get_branch_context", raise_error)
assert mod.handle_ping(verbose=False) is False
def test_handle_ping_calls_ping_registry(monkeypatch):
"""handle_ping calls ping_registry with correct arguments."""
mock_ping_reg = MagicMock()
monkeypatch.setattr(mod, "get_branch_context", lambda: ("feat-x", Path("/tmp/feat")))
monkeypatch.setattr(mod, "count_file_lines", lambda f: 50)
monkeypatch.setattr(mod, "get_status_from_count", lambda c: "green")
monkeypatch.setattr(mod, "update_json_memory_health", MagicMock())
monkeypatch.setattr(mod, "ping_registry", mock_ping_reg)
mod.handle_ping(verbose=False)
mock_ping_reg.assert_called_once_with(
"feat-x",
Path("/tmp/feat"),
{"line_count": 50, "status": "green"},
{"line_count": 50, "status": "green"},
)
# --- handle_status tests ---------------------------------------------
def test_handle_status_returns_true_on_success(monkeypatch):
"""handle_status returns True when handlers succeed."""
monkeypatch.setattr(mod, "get_branch_context", lambda: ("main", Path("/tmp/main")))
monkeypatch.setattr(mod, "get_health_info", lambda f: {"line_count": 100, "status": "green"})
assert mod.handle_status() is True
def test_handle_status_returns_false_on_exception(monkeypatch):
"""handle_status returns False when an exception is raised."""
def raise_error():
raise RuntimeError("file not found")
monkeypatch.setattr(mod, "get_branch_context", raise_error)
assert mod.handle_status() is False
# --- print_introspection tests ----------------------------------------
def test_print_introspection_prints_module_info(_mock_console):
"""print_introspection prints module name and handler info."""
mod.print_introspection()
printed = " ".join(str(c) for c in _mock_console.print.call_args_list)
assert "branch_ping" in printed
assert "handlers/monitoring/" in printed
assert "handlers/registry/" in printed
# --- print_help tests -------------------------------------------------
def test_print_help_prints_command_list(_mock_console):
"""print_help prints available commands."""
mod.print_help()
printed = " ".join(str(c) for c in _mock_console.print.call_args_list)
assert "ping" in printed
assert "status" in printed
assert "registry" in printed
assert "thresholds" in printed
@@ -1,196 +0,0 @@
# =================== AIPass ====================
# Name: test_monitoring_memory.py
# Description: Tests for memory health handler
# Version: 1.0.0
# Created: 2026-03-24
# Modified: 2026-03-24
# =============================================
"""Tests for memory health handler -- line counting and health status."""
import pytest
from pathlib import Path
from unittest.mock import patch
from aipass.ai_mail.apps.handlers.monitoring.memory import (
count_file_lines,
get_status_from_count,
should_send_email,
get_health_info,
THRESHOLD_GREEN_MAX,
THRESHOLD_YELLOW_MIN,
THRESHOLD_YELLOW_MAX,
THRESHOLD_EMAIL_TRIGGER,
STATUS_GREEN,
STATUS_YELLOW,
STATUS_RED,
)
# ---- Fixtures ------------------------------------------------
@pytest.fixture(autouse=True)
def _silence_json_handler():
"""Prevent log_operation from writing real JSON files during tests."""
with patch("aipass.ai_mail.apps.handlers.monitoring.memory.json_handler") as mock_jh:
mock_jh.log_operation.return_value = True
yield mock_jh
# ---- count_file_lines tests ----------------------------------
def test_count_file_lines_missing(tmp_path):
"""Nonexistent file returns 0."""
result = count_file_lines(tmp_path / "nonexistent_file.txt")
assert isinstance(result, int)
assert result == 0
def test_count_file_lines_empty(tmp_path):
"""Empty file returns 0."""
empty_file = tmp_path / "empty.txt"
empty_file.write_text("", encoding="utf-8")
result = count_file_lines(empty_file)
assert isinstance(result, int)
assert result == 0
def test_count_file_lines_content(tmp_path):
"""File with 5 lines returns 5."""
file_path = tmp_path / "five_lines.txt"
file_path.write_text("line1\nline2\nline3\nline4\nline5\n", encoding="utf-8")
result = count_file_lines(file_path)
assert isinstance(result, int)
assert result == 5
# ---- get_status_from_count tests -----------------------------
def test_status_green():
"""100 lines is well within green threshold -- returns STATUS_GREEN."""
assert 100 <= THRESHOLD_GREEN_MAX, "Precondition: 100 must be in green range"
result = get_status_from_count(100)
assert isinstance(result, str)
assert result == STATUS_GREEN
def test_status_yellow():
"""450 lines falls in yellow range -- returns STATUS_YELLOW."""
assert THRESHOLD_YELLOW_MIN <= 450 <= THRESHOLD_YELLOW_MAX, "Precondition: 450 must be in yellow range"
result = get_status_from_count(450)
assert isinstance(result, str)
assert result == STATUS_YELLOW
def test_status_red():
"""600 lines is in red zone -- returns STATUS_RED."""
assert 600 > THRESHOLD_YELLOW_MAX, "Precondition: 600 must be above yellow range"
result = get_status_from_count(600)
assert isinstance(result, str)
assert result == STATUS_RED
def test_status_green_at_boundary():
"""Exactly THRESHOLD_GREEN_MAX (400) should still be green."""
result = get_status_from_count(THRESHOLD_GREEN_MAX)
assert result == STATUS_GREEN
def test_status_yellow_at_lower_boundary():
"""Exactly THRESHOLD_YELLOW_MIN (401) should be yellow."""
result = get_status_from_count(THRESHOLD_YELLOW_MIN)
assert result == STATUS_YELLOW
def test_status_yellow_at_upper_boundary():
"""Exactly THRESHOLD_YELLOW_MAX (550) should still be yellow."""
result = get_status_from_count(THRESHOLD_YELLOW_MAX)
assert result == STATUS_YELLOW
def test_status_red_at_boundary():
"""One above THRESHOLD_YELLOW_MAX (551) should be red."""
result = get_status_from_count(THRESHOLD_YELLOW_MAX + 1)
assert result == STATUS_RED
def test_status_zero_lines():
"""Zero lines should be green."""
result = get_status_from_count(0)
assert result == STATUS_GREEN
# ---- should_send_email tests ---------------------------------
def test_should_send_email_below():
"""500 is below THRESHOLD_EMAIL_TRIGGER (600) -- no email."""
assert 500 < THRESHOLD_EMAIL_TRIGGER, "Precondition: 500 must be below trigger"
result = should_send_email(500)
assert isinstance(result, bool)
assert result is False
def test_should_send_email_one_below_threshold():
"""599 is one below THRESHOLD_EMAIL_TRIGGER -- no email."""
result = should_send_email(THRESHOLD_EMAIL_TRIGGER - 1)
assert isinstance(result, bool)
assert result is False
def test_should_send_email_at_threshold():
"""Exactly at THRESHOLD_EMAIL_TRIGGER -- email should be sent."""
result = should_send_email(THRESHOLD_EMAIL_TRIGGER)
assert isinstance(result, bool)
assert result is True
def test_should_send_email_above_threshold():
"""Well above THRESHOLD_EMAIL_TRIGGER -- email should be sent."""
result = should_send_email(THRESHOLD_EMAIL_TRIGGER + 100)
assert isinstance(result, bool)
assert result is True
# ---- get_health_info tests -----------------------------------
def test_get_health_info(tmp_path):
"""10-line file returns correct health dict."""
file_path = tmp_path / "ten_lines.txt"
lines = "\n".join(f"line {i}" for i in range(1, 11)) + "\n"
file_path.write_text(lines, encoding="utf-8")
result = get_health_info(file_path)
assert isinstance(result, dict)
# Verify exactly these keys exist -- no more, no fewer
assert set(result.keys()) == {"line_count", "status", "needs_email", "file_path"}
# Verify exact values and types
assert result["line_count"] == 10
assert isinstance(result["line_count"], int)
assert result["status"] == STATUS_GREEN
assert isinstance(result["status"], str)
assert result["needs_email"] is False
assert isinstance(result["needs_email"], bool)
assert result["file_path"] == str(file_path)
@@ -60,9 +60,9 @@ drone @flow list open # Active plans
drone systems # All branches
```
## Branches (15)
## Branches (10 core)
drone, seedgo, prax, cli, ai_mail, flow, spawn, trigger, api, backup, daemon, memory, commons (`src/commons/`), skills (`src/skills/`), devpulse (you — no apps/, coordinates via dispatch + agents)
drone, seedgo, prax, cli, ai_mail, flow, spawn, trigger, memory, devpulse (you — no apps/, coordinates via dispatch + agents)
## Your Project
-1
View File
@@ -146,7 +146,6 @@ Commands in the interactive tuple bypass capture and inherit the terminal direct
| Branch | Reason |
|----------|-----------------------------------------------|
| `cli` | User-facing CLI with Rich formatted output |
| `backup` | Heavy-IO branch (file scanning, Google APIs) |
To add: edit `interactive_commands` or `interactive_branches` in `_handle_target()` in `apps/drone.py`.
+2 -2
View File
@@ -288,7 +288,7 @@ def _handle_custom_command(args: list[str]) -> int:
# Interactive detection -- same logic as _handle_target
interactive_commands = ("monitor", "audit")
interactive_branches = ("cli", "backup")
interactive_branches = ("cli",)
interactive = command in interactive_commands or module_name in interactive_branches
try:
@@ -322,7 +322,7 @@ def _handle_target(args: List[str]) -> int:
# Per-command: specific commands that need live terminal (progress bars, TUI).
# Per-branch: all commands from that branch get interactive mode (Rich CLI).
interactive_commands = ("monitor", "audit")
interactive_branches = ("cli", "backup")
interactive_branches = ("cli",)
first_cmd = rest[0] if rest and rest[0] != "--help" else None
needs_interactive = (
first_cmd in interactive_commands or module_name in interactive_branches
+4 -2
View File
@@ -52,7 +52,8 @@ def handle_command(command: Optional[str] = None, args: Optional[List[str]] = No
return False
try:
path = resolve_branch(args[0])
except BranchNotFoundError:
except BranchNotFoundError as exc:
logger.warning("resolver resolve: branch '%s' not found: %s", args[0], exc)
err_console.print(f"resolver: branch '{args[0]}' not found")
return False
console.print(f"{args[0]} -> {path}")
@@ -69,7 +70,8 @@ def handle_command(command: Optional[str] = None, args: Optional[List[str]] = No
return False
try:
info = get_branch_info(args[0])
except BranchNotFoundError:
except BranchNotFoundError as exc:
logger.warning("resolver info: branch '%s' not found: %s", args[0], exc)
err_console.print(f"resolver: branch '{args[0]}' not found")
return False
console.print(f"Branch info: {info}")
+2 -2
View File
@@ -14,7 +14,7 @@
### What I Do
- Create numbered plans from type-specific templates via `templates/` plugins
- Unified create/close/list commands for all plan types (FPLAN, DPLAN, RPLAN, TDPLAN, ...)
- Close plans with foreground archival to `backup/processed_plans/`
- Close plans with foreground archival to `.backup/processed_plans/`
- Vector intake on close via `drone @memory process-plans` with chroma verification
- List and filter plans across branches and plan types
- Restore plans from backups
@@ -114,7 +114,7 @@ Plans follow the convention `{PREFIX}-{NNNN}_topic_slug_YYYY-MM-DD.md` where NNN
On `drone @flow close`:
1. Template check (fast-delete empty templates)
2. Mark as closed in registry
3. Archive to `backup/processed_plans/` (foreground, sets processed/cleanup flags atomically)
3. Archive to `.backup/processed_plans/` (foreground, sets processed/cleanup flags atomically)
4. Vector intake: `drone @memory process-plans` + `is_plan_vectorized()` verification
5. Dashboard updates (local + central + branch)
6. Append to `CLOSED_PLANS.local.json`
@@ -52,7 +52,7 @@ def _find_repo_root() -> Path:
_REPO_ROOT = _find_repo_root()
MEMORY_PATH = _REPO_ROOT / "src" / "aipass" / "memory"
PROCESSED_PLANS_DIR = _PKG_ROOT / "backup" / "processed_plans"
PROCESSED_PLANS_DIR = _REPO_ROOT / ".backup" / "processed_plans"
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
# =============================================
@@ -33,7 +33,16 @@ from aipass.flow.apps.handlers.json import json_handler
_PKG_ROOT = Path(__file__).resolve().parents[4] # handlers/plan/ -> handlers/ -> apps/ -> flow/ -> aipass/
FLOW_ROOT = _PKG_ROOT / "flow"
PROCESSED_PLANS_DIR = _PKG_ROOT / "backup" / "processed_plans"
def _find_repo_root() -> Path:
"""Walk up to find the repo root (contains AIPASS_REGISTRY.json)."""
current = Path(__file__).resolve().parent
for parent in [current] + list(current.parents):
if (parent / "AIPASS_REGISTRY.json").exists():
return parent
return Path.cwd()
PROCESSED_PLANS_DIR = _find_repo_root() / ".backup" / "processed_plans"
MODULE_NAME = "restore_plan"
@@ -179,6 +179,59 @@ def load_registry() -> Dict[str, Any]:
)
save_registry(data)
# Auto-register: detect new template directories and register them
templates_dir = FLOW_ROOT / "templates"
if templates_dir.is_dir():
registered = set(data["types"].keys())
used_prefixes = {
entry.get("prefix", "").upper()
for entry in data["types"].values()
}
changed = False
for child in sorted(templates_dir.iterdir()):
if not child.is_dir() or child.name.startswith(("_", ".")) or child.name == "__pycache__":
continue
if child.name in registered:
continue
md_files = list(child.glob("*.md"))
if not md_files:
continue
# Derive prefix: first letter of first word + "PLAN"
first_word = child.name.split("_")[0]
prefix = (first_word[0].upper() + "PLAN") if first_word else "XPLAN"
# Avoid collisions — append second letter if needed
if prefix in used_prefixes and len(first_word) > 1:
prefix = (first_word[:2].upper() + "PLAN")
if prefix in used_prefixes:
continue # Can't auto-assign — needs manual registration
shorthand = prefix.lower()
data["types"][child.name] = {
"prefix": prefix,
"shorthand": shorthand,
"created": _today(),
"registered_by": "auto",
}
used_prefixes.add(prefix)
registered.add(child.name)
changed = True
logger.info(
"[%s] Auto-registered new type '%s' with prefix %s",
MODULE_NAME,
child.name,
prefix,
)
# Create empty plan registry for new type
plan_reg = FLOW_ROOT / "flow_json" / f"{shorthand}_registry.json"
if not plan_reg.exists():
try:
plan_reg.parent.mkdir(parents=True, exist_ok=True)
with open(plan_reg, "w", encoding="utf-8") as fh:
json.dump({"next_number": 1, "plans": {}, "last_updated": _today()}, fh, indent=2)
except OSError as exc:
logger.warning("[%s] Failed to create plan registry for %s: %s", MODULE_NAME, child.name, exc)
if changed:
save_registry(data)
return data
@@ -57,7 +57,7 @@ def handle_command(command: str, args: list) -> bool:
Returns:
True if command was handled, False otherwise
"""
if command != "post_close":
if command not in ("post", "post_close_runner"):
return False
if not args:
@@ -204,7 +204,8 @@ def handle_command(command: str, args: List[str]) -> bool:
console.print(f" • Renumbered: {len(result['renumbered'])}")
if result['healing_performed']:
warning(f"Registry healed - {len(result['added']) + len(result['updated']) + len(result['removed'])} changes")
change_count = len(result['added']) + len(result['updated']) + len(result['removed'])
warning(f"Registry scan found {change_count} mismatch(es) — trigger event handlers not wired, no changes applied")
else:
console.print(f"\n[dim]No changes needed - registry is healthy[/dim]")
@@ -119,7 +119,10 @@ def _get_memory_file_path(branch: Dict, memory_type: str) -> Path | None:
Returns:
Path to memory file, or None if not found
"""
branch_path = Path(branch.get('path', ''))
raw_path = branch.get('path', '')
if not raw_path:
return None
branch_path = Path(raw_path)
if not branch_path.exists():
return None
@@ -554,7 +554,7 @@ def start_memory_watcher() -> Dict[str, Any]:
watched_paths = []
for branch_path in branch_paths:
try:
new_observer.schedule(watcher, str(branch_path), recursive=False)
new_observer.schedule(watcher, str(branch_path), recursive=True)
watched_paths.append(str(branch_path))
except Exception as e:
logger.warning(f"[memory_watcher] Failed to schedule watcher for {branch_path}: {e}")
@@ -61,7 +61,11 @@ def create_rollover_backup(file_path: Path) -> Dict[str, Any]:
"""
try:
# Create .backup directory in branch root
backup_dir = file_path.parent / '.backup'
# For .trinity/ files, go up to branch root; otherwise use file's parent
if file_path.parent.name == '.trinity':
backup_dir = file_path.parent.parent / '.backup'
else:
backup_dir = file_path.parent / '.backup'
backup_dir.mkdir(exist_ok=True)
# Backup filename: rollover_backup.json (always overwrites)
@@ -98,7 +102,11 @@ def restore_from_backup(file_path: Path) -> Dict[str, Any]:
Dict with restore status
"""
try:
backup_dir = file_path.parent / '.backup'
# Match backup location from create_rollover_backup
if file_path.parent.name == '.trinity':
backup_dir = file_path.parent.parent / '.backup'
else:
backup_dir = file_path.parent / '.backup'
backup_name = f'rollover_backup_{file_path.name}'
backup_path = backup_dir / backup_name
@@ -34,7 +34,7 @@ from aipass.memory.apps.handlers.json import json_handler
# PATH SETUP
# =============================================================================
MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent # handlers/templates/differ.py -> memory/
MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent.parent # handlers/templates/differ.py -> apps -> handlers -> templates -> memory/
# =============================================================================
# CONSTANTS
@@ -42,7 +42,7 @@ from aipass.memory.apps.handlers.json.memory_files import (
# PATH SETUP
# =============================================================================
MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent # handlers/templates/pusher.py -> memory/
MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent.parent # handlers/templates/pusher.py -> apps -> handlers -> templates -> memory/
def _find_repo_root() -> Path:
@@ -216,62 +216,67 @@ class MonitoringFileHandler(FileSystemEventHandler):
return parts[idx + 1].upper()
return None
def _read_codex_cwd(self, file_path) -> Optional[str]:
"""Read CWD from the first line (session_meta) of a Codex JSONL file."""
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
first_line = f.readline().strip()
if not first_line:
return None
meta = _json.loads(first_line)
if meta.get('type') != 'session_meta':
return None
return meta.get('payload', {}).get('cwd', '')
except (OSError, _json.JSONDecodeError) as e:
logger.info(f"[monitor] Could not read Codex session_meta: {e}")
return None
def _get_codex_branch(self, file_path, path_key: str) -> str:
"""Get branch for a Codex session, reading session_meta if needed."""
if path_key in self._session_branches:
return self._session_branches[path_key]
# Read first line of JSONL for session_meta
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
first_line = f.readline().strip()
if first_line:
meta = _json.loads(first_line)
if meta.get('type') == 'session_meta':
cwd = meta.get('payload', {}).get('cwd', '')
branch = self._branch_from_cwd(cwd)
if branch:
self._session_branches[path_key] = branch
return branch
except (OSError, _json.JSONDecodeError) as e:
if path_key not in self._branch_warn_logged:
logger.info(f"[monitor] Could not read Codex session_meta: {e}")
self._branch_warn_logged.add(path_key)
cwd = self._read_codex_cwd(file_path)
branch = self._branch_from_cwd(cwd) if cwd else None
result = branch or 'CODEX'
self._session_branches[path_key] = result
return result
self._session_branches[path_key] = 'CODEX'
return 'CODEX'
def _resolve_gemini_slug(self, slug: str) -> Optional[str]:
"""Resolve a Gemini project slug to a branch name via projects.json."""
projects_file = Path.home() / '.gemini' / 'projects.json'
if not projects_file.exists():
return None
try:
data = _json.loads(projects_file.read_text())
except (OSError, _json.JSONDecodeError) as e:
logger.info(f"[monitor] Could not read Gemini projects.json: {e}")
return None
for project_path, project_slug in data.get('projects', {}).items():
if project_slug == slug:
return self._branch_from_cwd(project_path)
return None
def _get_gemini_branch(self, file_path, path_key: str) -> str:
"""Get branch for a Gemini session from project slug in path."""
if path_key in self._session_branches:
return self._session_branches[path_key]
# Path format: ~/.gemini/tmp/<project-slug>/chats/session-*.json
# Project slug maps to a directory via ~/.gemini/projects.json
# Path: ~/.gemini/tmp/<project-slug>/chats/session-*.json
parts = Path(file_path).parts
slug = None
if 'tmp' in parts:
idx = parts.index('tmp')
if idx + 1 < len(parts):
slug = parts[idx + 1]
# Try to resolve slug to real branch via projects.json
projects_file = Path.home() / '.gemini' / 'projects.json'
if projects_file.exists():
try:
data = _json.loads(projects_file.read_text())
for project_path, project_slug in data.get('projects', {}).items():
if project_slug == slug:
branch = self._branch_from_cwd(project_path)
if branch:
self._session_branches[path_key] = branch
return branch
except (OSError, _json.JSONDecodeError):
pass
# Fallback: use slug as branch name
self._session_branches[path_key] = slug.upper()
return slug.upper()
self._session_branches[path_key] = 'GEMINI'
return 'GEMINI'
if not slug:
self._session_branches[path_key] = 'GEMINI'
return 'GEMINI'
branch = self._resolve_gemini_slug(slug) or slug.upper()
self._session_branches[path_key] = branch
return branch
# =========================================================================
# MODEL TAG HELPERS
@@ -338,35 +343,32 @@ class MonitoringFileHandler(FileSystemEventHandler):
return None
if entry_type == 'response_item':
item = payload.get('item', payload)
item_type = item.get('type', '')
if item_type == 'function_call':
name = item.get('name', 'tool')
args = item.get('arguments', '')
if isinstance(args, str) and len(args) > 80:
args = args[:80] + '...'
return f"🔧 {name}"
if item_type == 'function_call_output':
return None # Skip output events
if item_type == 'message':
content = item.get('content', [])
if isinstance(content, list):
for part in content:
if isinstance(part, dict):
text = part.get('text', '')
if text:
return f"💬 {text[:120]}"
return '💬 Agent response'
return MonitoringFileHandler._parse_codex_response_item(payload)
if entry_type in ('session_meta', 'turn_context'):
return None
if entry_type == 'session_meta':
return None # Skip metadata
if entry_type == 'turn_context':
return None # Skip context snapshots
return None
@staticmethod
def _parse_codex_response_item(payload: dict) -> Optional[str]:
"""Parse a Codex response_item payload into a display string."""
item = payload.get('item', payload)
item_type = item.get('type', '')
if item_type == 'function_call':
return f"🔧 {item.get('name', 'tool')}"
if item_type == 'function_call_output':
return None
if item_type != 'message':
return None
content = item.get('content', [])
if not isinstance(content, list):
return '💬 Agent response'
for part in content:
if isinstance(part, dict) and part.get('text'):
return f"💬 {part['text'][:120]}"
return '💬 Agent response'
# =========================================================================
# GEMINI AGENT ACTIVITY PARSING (full JSON sessions)
# =========================================================================
@@ -375,37 +377,31 @@ class MonitoringFileHandler(FileSystemEventHandler):
def _extract_gemini_action(message: dict) -> Optional[str]:
"""Extract a display action string from a Gemini session message."""
msg_type = message.get('type', '')
if msg_type == 'user':
return '📩 User message'
if msg_type != 'gemini':
return None
if msg_type == 'gemini':
# Check for tool calls first
tool_calls = message.get('toolCalls', [])
if tool_calls:
last_tool = tool_calls[-1]
name = last_tool.get('displayName', last_tool.get('name', 'tool'))
return f"🔧 {name}"
tool_calls = message.get('toolCalls', [])
if tool_calls:
last_tool = tool_calls[-1]
return f"🔧 {last_tool.get('displayName', last_tool.get('name', 'tool'))}"
# Check for thoughts
thoughts = message.get('thoughts', [])
if thoughts:
return '💭 Thinking'
if message.get('thoughts'):
return '💭 Thinking'
# Text response
content = message.get('content', [])
if isinstance(content, list) and content:
for part in content:
if isinstance(part, dict):
text = part.get('text', '').strip()
if text:
return f"💬 {text[:120]}"
elif isinstance(content, str) and content.strip():
return f"💬 {content.strip()[:120]}"
return MonitoringFileHandler._extract_text_from_content(message.get('content', []))
return '💬 Agent response'
return None
@staticmethod
def _extract_text_from_content(content) -> str:
"""Extract first text snippet from a content field (list or string)."""
if isinstance(content, str) and content.strip():
return f"💬 {content.strip()[:120]}"
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get('text', '').strip():
return f"💬 {part['text'].strip()[:120]}"
return '💬 Agent response'
def _parse_gemini_activity(self, file_path, branch):
"""Parse Gemini session JSON to show agent actions.
@@ -458,64 +454,70 @@ class MonitoringFileHandler(FileSystemEventHandler):
logger.info(f"[monitor] Gemini parse error for {file_path.name}: {e}")
return False
def _parse_codex_activity(self, file_path, branch):
"""Parse Codex session JSONL to show agent actions.
def _read_new_jsonl_lines(self, file_path, path_key: str) -> Optional[list]:
"""Read new lines from a JSONL file since last position. Returns None if no new data."""
current_size = file_path.stat().st_size
last_pos = self._jsonl_positions.get(path_key, 0)
if current_size < last_pos:
last_pos = 0
if current_size <= last_pos:
return None
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
f.seek(last_pos)
new_data = f.read()
self._jsonl_positions[path_key] = f.tell()
lines = [l for l in new_data.strip().split('\n') if l.strip()]
return lines if lines else None
Same JSONL tail-and-parse approach as Claude Code, but uses
_extract_codex_action() for Codex-specific event types.
"""
def _safe_parse_json(self, line: str, label: str) -> Optional[dict]:
"""Parse a JSON line, logging errors. Returns None on failure."""
try:
return _json.loads(line)
except _json.JSONDecodeError as e:
logger.info(f"[monitor] Skipping malformed {label} JSONL: {e}")
return None
def _emit_agent_event(self, path_key: str, branch: str, action_text: str) -> bool:
"""Emit an agent activity event, deduplicating against last action."""
if self._last_agent_action.get(path_key) == action_text:
return True
self._last_agent_action[path_key] = action_text
tagged_branch = self._tag_branch_with_model(path_key, branch)
evt = MonitoringEvent(
priority=1, event_type='agent', branch=tagged_branch,
action='activity', message=action_text, level='info',
)
if self._event_queue:
self._event_queue.enqueue(evt)
return True
def _extract_codex_model(self, entry: dict, path_key: str) -> None:
"""Extract model from a Codex JSONL entry if present."""
entry_type = entry.get('type', '')
payload = entry.get('payload', {})
model = ''
if entry_type == 'turn_context':
model = payload.get('model', '')
if model:
self._session_models[path_key] = self._shorten_model(model)
def _parse_codex_activity(self, file_path, branch):
"""Parse Codex session JSONL to show agent actions."""
try:
path_key = str(file_path)
current_size = file_path.stat().st_size
last_pos = self._jsonl_positions.get(path_key, 0)
if current_size < last_pos:
last_pos = 0
if current_size <= last_pos:
return True
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
f.seek(last_pos)
new_data = f.read()
self._jsonl_positions[path_key] = f.tell()
lines = [l for l in new_data.strip().split('\n') if l.strip()]
if not lines:
lines = self._read_new_jsonl_lines(file_path, path_key)
if lines is None:
return True
for line in reversed(lines):
try:
entry = _json.loads(line)
except _json.JSONDecodeError as e:
logger.info(f"[monitor] Skipping malformed Codex JSONL: {e}")
entry = self._safe_parse_json(line, "Codex")
if entry is None:
continue
# Extract model from session_meta or response_item
if entry.get('type') == 'session_meta':
model = entry.get('payload', {}).get('model', '')
if model:
self._session_models[path_key] = self._shorten_model(model)
elif entry.get('type') == 'response_item':
model = entry.get('payload', {}).get('item', {}).get('model', '')
if model:
self._session_models[path_key] = self._shorten_model(model)
self._extract_codex_model(entry, path_key)
action_text = self._extract_codex_action(entry)
if not action_text:
continue
if self._last_agent_action.get(path_key) == action_text:
return True
self._last_agent_action[path_key] = action_text
tagged_branch = self._tag_branch_with_model(path_key, branch)
evt = MonitoringEvent(
priority=1, event_type='agent', branch=tagged_branch,
action='activity', message=action_text, level='info',
)
if self._event_queue:
self._event_queue.enqueue(evt)
return True
return self._emit_agent_event(path_key, branch, action_text)
return True
except Exception as e:
+11 -1
View File
@@ -202,11 +202,21 @@ def print_help() -> None:
console.print(" [green]drone @seedgo diagnostics @flow[/green] [dim]# Single branch diagnostics[/dim]")
console.print()
console.print("[yellow]Proof (Self-Check):[/yellow]")
console.print(" [green]drone @seedgo proof[/green] [dim]# List available proof packs[/dim]")
console.print(" [green]drone @seedgo proof aipass[/green] [dim]# Run all proofs for aipass pack[/dim]")
console.print(" [green]drone @seedgo proof_query[/green] [dim]# Query proof pack content[/dim]")
console.print()
console.print("[yellow]Test Map:[/yellow]")
console.print(" [green]drone @seedgo test_map @flow[/green] [dim]# Function test coverage map for branch[/dim]")
console.print()
console.print("─" * 70)
console.print()
# Commands line for drone discovery
console.print("[dim]Commands: audit, standards_audit, standards_query, checklist, diagnostics, diagnostics_audit, readme, readme_update, --help[/dim]")
console.print("[dim]Commands: audit, standards_audit, standards_query, checklist, diagnostics, diagnostics_audit, proof, proof_query, test_map, readme, readme_update, --help[/dim]")
console.print()
+9
View File
@@ -75,6 +75,15 @@ def handle_delete(args: list[str]) -> int:
Returns exit code (0=success, 1=failure).
"""
# Intercept --help before processing (argparse has add_help=False)
if "--help" in args or "-h" in args:
warning("Usage: drone @spawn delete <@branch> [--yes] [--dry-run]")
console.print()
console.print(" [green]@branch[/green] Branch to archive and deregister")
console.print(" [green]--yes[/green] Skip confirmation prompt")
console.print(" [green]--dry-run[/green] Preview what would happen without changes")
return 0
if not args:
warning("Usage: drone @spawn delete <@branch> [--yes] [--dry-run]")
console.print()
+11
View File
@@ -82,6 +82,17 @@ def handle_update(args: list[str]) -> int:
Returns exit code (0=success, 1=failure).
"""
# Intercept --help before processing (argparse has add_help=False)
if "--help" in args or "-h" in args:
warning("Usage: drone @spawn update <@branch|class --all> [--dry-run] [--trace]")
console.print()
console.print(" [green]@branch[/green] Update a single branch (uses its own class)")
console.print(" [green]builder --all[/green] Update all builder-class branches")
console.print(" [green]birthright --all[/green] Update all birthright-class branches")
console.print(" [green]--dry-run[/green] Preview changes without modifying files")
console.print(" [green]--trace[/green] Enable verbose logging")
return 0
if not args:
warning("Usage: drone @spawn update <@branch|class --all> [--dry-run] [--trace]")
console.print()