Merge pull request #247 from AIOSAI/system/devpulse-s86-memory-bypass-update-trigger-dead-code-removal

feat(system): S86: memory bypass update + trigger dead code removal (10 orphaned functions from DPLAN-0112)
This commit is contained in:
AIPass
2026-04-10 21:43:17 -07:00
committed by GitHub
76 changed files with 1454 additions and 1219 deletions
+1 -15
View File
@@ -1,24 +1,10 @@
# Pre-commit hooks for AIPass
# Install: pip install pre-commit && pre-commit install
<<<<<<< HEAD
# Manual run: pre-commit run --all-files
repos:
# Gitleaks — secret detection
=======
# Run manually: pre-commit run --all-files
repos:
>>>>>>> 190d7a4 (feat(system): SECURITY: gitleaks config + pre-commit for API key leak prevention)
# Gitleaks — secret detection
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks
<<<<<<< HEAD
=======
name: gitleaks (secret detection)
description: Detect hardcoded secrets using gitleaks
entry: gitleaks protect --verbose --redact --staged
language: golang
pass_filenames: false
>>>>>>> 190d7a4 (feat(system): SECURITY: gitleaks config + pre-commit for API key leak prevention)
+25 -9
View File
@@ -4,7 +4,7 @@
> The living record. What happened, what's changing, what matters.
**Last updated:** 2026-04-10 | **Session:** 84 | **PRs merged:** 185
**Last updated:** 2026-04-10 | **Session:** 86 | **PRs merged:** 230+
---
@@ -12,8 +12,8 @@
- **11 core agents** operational (streamlined from 15 — backup, daemon, commons, skills split to standalone projects; api reinstated)
- **3,500+ tests** system-wide
- **185 PRs** merged
- **84 sessions** of development
- **230+ PRs** merged
- **86 sessions** of development
- **Multi-CLI support** — Claude Code, Codex (GPT-5.4), Gemini all integrated with hooks, identity, skills
- **Vera Studio** — standalone AI-driven brand studio for promotion (separate from devpulse construction)
- **aipass init v2** — real templates, next steps, `aipass init agent` command
@@ -22,8 +22,20 @@
## Recent Sessions
### S82 — Core 10 Split: Dependency Audit + README Update (2026-04-09)
Decided to split 5 agents to standalone projects (backup, daemon, api, commons, skills). 6 parallel agents verified zero code dependencies from core 10 → split 5. Updated README from 15 → 10 agents. All agent tables, tree diagrams, TOC, metrics updated. CLI dispatched for src/ directory in init + CWD-aware sync-registry for spawn.
### S86 — Autonomous APLAN Fix Sweep (2026-04-10)
6-hour autonomous session (DPLAN-0111). 11 CRITICALs resolved, 35+ BUGs fixed across flow, api, cli, trigger, drone. Logger catch-all added to all 10 entry points. Seedgo full audit dispatched. Spawn 12 BUGs dispatched.
### S85 — Trigger Medic v2 Self-Healing Pipeline (2026-04-10)
Full autonomous error cycle proven: detect → dispatch → wake → fix → report. Trigger Medic v2 fixed (log_watcher dedup, count gate, wake_branch). Drone git fix/sync upgraded (merge not reset). OSS Health badge. 13 stale plans closed. PRs #229-230.
### S84 — API Key Incident Recovery + Branch Purge (2026-04-09)
Watchdog fixed. Night shift: 4 DPLANs (0107-0110), 10 branches dispatched, PRs #214-226. API key incident recovery. 28 stale branches purged.
### S83 — Adversarial Audit (2026-04-09)
22 Opus adversarial audit agents: 350+ findings across all 11 branches, 11 CRITICALs identified. All 11 APLANs updated by builder agents. Hook-sounds plugin built. PRs #211-212.
### S82 — Core Split: Dependency Audit + README Update (2026-04-09)
Decided to split 4 agents to standalone projects (backup, daemon, commons, skills); api reinstated as infrastructure. 6 parallel agents verified zero code dependencies. Updated README from 15 → 11 agents. All agent tables, tree diagrams, TOC, metrics updated. CLI dispatched for src/ directory in init + CWD-aware sync-registry for spawn.
### S81 — aipass init v2 + Vera Studio (2026-04-08)
TDPLAN-0002: Complete init overhaul. CLI, spawn, drone worked in parallel. Init now creates 10 items with real content (CLAUDE.md, AGENTS.md, GEMINI.md, global prompt, README, .gitignore, hooks, settings). `aipass init agent` routes to spawn. Spawn added --template flag + CLAUDE.md to builder template. Drone added spawn to routing_config.json. Prax fixed watchdog with --daemon mode + statusline indicator. Vera Studio project created. Patrick testing as real first-time user — found .trinity shouldn't be in project root, local prompt is agent-level only. CLI fixed both. DPLAN-0105 (promotion prep), DPLAN-0106 (watchdog v2). PRs #204-205.
@@ -101,6 +113,10 @@ Every branch, every standard, 100%. PR #137 (167 files, +12,843 lines).
| Date | Milestone |
|------|-----------|
| 2026-04-10 | S86: Autonomous APLAN fix sweep — 11 CRITICALs resolved, 35+ BUGs fixed |
| 2026-04-10 | S85: Trigger Medic v2 self-healing pipeline live |
| 2026-04-09 | S84: API key incident recovery + 28 stale branches purged |
| 2026-04-09 | S83: Adversarial audit (22 agents, 350+ findings, 11 CRITICALs) |
| 2026-04-05 | Multi-CLI integration — Codex + Gemini fully integrated with hooks, skills, prax model tags |
| 2026-04-05 | README overhaul — grouped branch tables, compliance section, navigation |
| 2026-04-03 | S73 night shift — 1,120 new tests, 7 branches at 100% module coverage |
@@ -124,10 +140,10 @@ Every branch, every standard, 100%. PR #137 (167 files, +12,843 lines).
```
Branches: 11
Standards: 33
Tests: 3,500+
PRs merged: 185
Sessions: 84
Diagnostic tools: 21
Tests: ~3,500
PRs merged: 230+
Sessions: 86
Diagnostic tools: 26
CLIs supported: 3 (Claude Code, Codex, Gemini)
```
+5
View File
@@ -130,6 +130,11 @@
"standard": "naming",
"reason": "False positive — _inbox_lock is a lazy-import function reference, not a module-level constant."
},
{
"file": "apps/handlers/email/inbox_ops.py",
"standard": "naming",
"reason": "False positive — _inbox_lock is a lazy-import function reference, not a module-level constant."
},
{
"file": "apps/handlers/email/inbox_resolve.py",
"standard": "naming",
+30 -26
View File
@@ -206,39 +206,43 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
def main():
"""Main entry point - routes commands to modules"""
try:
# Parse arguments
args = sys.argv[1:]
# Parse arguments
args = sys.argv[1:]
# Show introspection when run without arguments
if len(args) == 0:
print_introspection()
return 0
# Show introspection when run without arguments
if len(args) == 0:
print_introspection()
return 0
# Show version
if args[0] in ['--version', '-V']:
console.print("AI_MAIL v1.0.0")
return 0
# Show version
if args[0] in ['--version', '-V']:
console.print("AI_MAIL v1.0.0")
return 0
# Show help for explicit help flags
if args[0] in ['--help', '-h', 'help']:
print_help()
return 0
# Show help for explicit help flags
if args[0] in ['--help', '-h', 'help']:
print_help()
return 0
# Command provided - try to route to modules
modules = discover_modules()
command = args[0]
remaining_args = args[1:] if len(args) > 1 else []
# Command provided - try to route to modules
modules = discover_modules()
command = args[0]
remaining_args = args[1:] if len(args) > 1 else []
if not modules:
error("No modules found")
return 1
if not modules:
error("No modules found")
return 1
# Route command
if route_command(command, remaining_args, modules):
return 0
else:
error(f"Unknown command: {command}")
return 1
# Route command
if route_command(command, remaining_args, modules):
return 0
else:
error(f"Unknown command: {command}")
except Exception as exc:
logger.error('[ai_mail] Unhandled error in main: %s', exc)
return 1
if __name__ == "__main__":
@@ -98,7 +98,8 @@ def _check_rate_limited(stderr_log: str) -> bool:
lower = content.lower()
return ("rate_limit" in lower or "429" in content or
"overloaded" in lower or "529" in content)
except OSError:
except OSError as e:
logger.warning("[monitor] _check_rate_limited failed reading %s: %s", stderr_log, e)
return False
@@ -122,10 +123,10 @@ def _snapshot_jsonl_sizes(projects_dir: Path) -> dict:
for f in projects_dir.glob("*.jsonl"):
try:
sizes[f.name] = f.stat().st_size
except OSError:
pass
except OSError:
pass
except OSError as e:
logger.warning("[monitor] _snapshot_jsonl_sizes stat failed for %s: %s", f.name, e)
except OSError as e:
logger.warning("[monitor] _snapshot_jsonl_sizes glob failed for %s: %s", projects_dir, e)
return sizes
@@ -145,10 +146,10 @@ def _check_jsonl_activity(projects_dir: Path, initial_sizes: dict) -> bool:
elif current_size > initial_sizes[name]:
# Existing file grew
return True
except OSError:
pass
except OSError:
pass
except OSError as e:
logger.warning("[monitor] _check_jsonl_activity stat failed for %s: %s", f.name, e)
except OSError as e:
logger.warning("[monitor] _check_jsonl_activity glob failed for %s: %s", projects_dir, e)
return False
-2
View File
@@ -123,8 +123,6 @@ api/
### Provides To
- All branches -- authenticated external API clients
- `@backup` -- Google Drive service (migration from self-contained auth)
- `@skills` -- future external API integrations (Telegram, Google services)
- System-wide API key management and credential validation
### Credentials
+40 -36
View File
@@ -235,51 +235,55 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
def main():
"""Main entry point - routes commands to modules"""
try:
# Parse arguments directly from sys.argv
args = sys.argv[1:]
# Parse arguments directly from sys.argv
args = sys.argv[1:]
# Show introspection when run without arguments
if len(args) == 0:
print_introspection()
json_handler.log_operation("api_introspection_displayed", {"trigger": "no_args"})
return 0
# Show introspection when run without arguments
if len(args) == 0:
print_introspection()
json_handler.log_operation("api_introspection_displayed", {"trigger": "no_args"})
return 0
# Show version
if args[0] in ['--version', '-V']:
console.print("API v1.0.0")
return 0
# Show version
if args[0] in ['--version', '-V']:
console.print("API v1.0.0")
return 0
# Show help for explicit help flags
if args[0] in ['--help', '-h', 'help']:
print_help()
json_handler.log_operation("api_help_displayed", {"trigger": args[0]})
return 0
# Show help for explicit help flags
if args[0] in ['--help', '-h', 'help']:
print_help()
json_handler.log_operation("api_help_displayed", {"trigger": args[0]})
return 0
# Discover modules
modules = discover_modules()
# Discover modules
modules = discover_modules()
if not modules:
logger.error("No modules found")
error("No modules found")
return 1
if not modules:
logger.error("No modules found")
error("No modules found")
return 1
# Extract command and remaining args (matching seedgo pattern)
command = args[0]
remaining_args = args[1:] if len(args) > 1 else []
# Extract command and remaining args (matching seedgo pattern)
command = args[0]
remaining_args = args[1:] if len(args) > 1 else []
# Log api command attempt
json_handler.log_operation(
"api_command_attempted",
{"command": command, "modules_discovered": len(modules)}
)
# Log api command attempt
json_handler.log_operation(
"api_command_attempted",
{"command": command, "modules_discovered": len(modules)}
)
# Route command to modules
if route_command(command, remaining_args, modules):
return 0
else:
logger.warning(f"Unknown command: {command}")
error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands")
return 1
# Route command to modules
if route_command(command, remaining_args, modules):
return 0
else:
logger.warning(f"Unknown command: {command}")
error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands")
except Exception as exc:
logger.error("[api] Unhandled error in main: %s", exc)
return 1
if __name__ == "__main__":
@@ -42,7 +42,6 @@ MODULE_VERSION = "1.0.0"
CALLER_PATTERNS = {
"flow": "flow_json",
"prax": "prax_json",
"skills": "{category}_json",
}
# =============================================
@@ -70,11 +69,6 @@ def get_caller_info() -> Optional[Dict[str, Any]]:
result = _detect_prax_caller(frame_path)
json_handler.log_operation("caller_detected", {"caller": result.get("caller_name"), "category": "prax"})
return result
elif any("skills" in part for part in frame_path.parts):
result = _detect_skills_caller(frame_path)
json_handler.log_operation("caller_detected", {"caller": result.get("caller_name"), "category": "skills"})
return result
logger.info(f"[{MODULE_NAME}] Could not detect caller from stack trace")
return None
@@ -105,8 +99,6 @@ def detect_caller_category(caller_path: Path) -> str:
return "flow"
elif "prax" in path_parts:
return "prax"
elif any("skills" in part for part in path_parts):
return "skills"
else:
return "unknown"
@@ -165,38 +157,6 @@ def _detect_prax_caller(frame_path: Path) -> Dict[str, Any]:
return _create_fallback_info(frame_path)
def _detect_skills_caller(frame_path: Path) -> Dict[str, Any]:
"""
Detect skills module caller from stack frame path.
Skills have category subdirectories (e.g., /skills/skills_api/skill.py)
"""
try:
for i, part in enumerate(frame_path.parts):
if "skills" in part:
skills_path = Path(*frame_path.parts[:i + 2])
category = frame_path.parts[i + 1] if i + 1 < len(frame_path.parts) else "skills_api"
json_folder_path = skills_path / f"{category}_json"
caller_name = frame_path.stem
logger.info(f"[{MODULE_NAME}] Detected skills caller: {caller_name} (category: {category})")
return {
"caller_name": caller_name,
"caller_path": frame_path,
"json_folder": json_folder_path,
"category": "skills",
"skills_category": category,
"detection_method": "stack"
}
logger.info(f"[{MODULE_NAME}] Could not find skills directory in path: {frame_path}")
return _create_fallback_info(frame_path)
except Exception as e:
logger.error(f"Failed to detect skills caller: {e}")
return _create_fallback_info(frame_path)
def _create_fallback_info(frame_path: Path) -> Dict[str, Any]:
"""Create fallback caller info when detection fails."""
caller_name = frame_path.stem
@@ -256,13 +256,17 @@ def extract_response(response: Any) -> Optional[Dict[str, Any]]:
return None
# Extract metadata
finish_reason = response.choices[0].finish_reason if hasattr(response.choices[0], 'finish_reason') else None
result = {
"content": content,
"id": response.id if hasattr(response, 'id') else None,
"model": response.model if hasattr(response, 'model') else None,
"finish_reason": response.choices[0].finish_reason if hasattr(response.choices[0], 'finish_reason') else None
"finish_reason": finish_reason
}
if finish_reason == "content_filter":
logger.warning("Response was truncated or blocked by content filter")
logger.info(f"Extracted response - length: {len(content)} chars, id: {result['id']}")
return result
@@ -172,9 +172,9 @@ def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]:
Create new caller configuration with defaults
Creates complete 3-file JSON structure:
- openrouter_skill_config.json (API settings)
- openrouter_skill_data.json (usage tracking)
- openrouter_skill_log.json (operation log)
- openrouter_config.json (API settings)
- openrouter_data.json (usage tracking)
- openrouter_log.json (operation log)
Args:
caller: Name of calling module
@@ -189,7 +189,7 @@ def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]:
return {}
# Create config file
config_file = json_folder / "openrouter_skill_config.json"
config_file = json_folder / "openrouter_config.json"
config = get_default_caller_config()
if not write_json(config_file, config):
@@ -199,7 +199,7 @@ def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]:
logger.info(f"Created config: {config_file.name}")
# Create data file
data_file = json_folder / "openrouter_skill_data.json"
data_file = json_folder / "openrouter_data.json"
data = get_default_caller_data()
if write_json(data_file, data):
@@ -207,7 +207,7 @@ def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]:
logger.info(f"Created data: {data_file.name}")
# Create log file
log_file = json_folder / "openrouter_skill_log.json"
log_file = json_folder / "openrouter_log.json"
log_data = get_default_caller_log()
if write_json(log_file, log_data):
@@ -258,7 +258,7 @@ def ensure_caller_config(caller: str | None = None) -> Dict[str, Any]:
return {}
# Check if config already exists
config_file = json_folder / "openrouter_skill_config.json"
config_file = json_folder / "openrouter_config.json"
if config_file.exists():
config = read_json(config_file)
@@ -101,7 +101,7 @@ def cleanup_old_data(data_file_path: Path, retention_days: int = DEFAULT_RETENTI
except Exception as e:
logger.error(f"Cleanup failed: {e}")
raise
return 0
def _identify_old_generations(generation_tracking: Dict, cutoff_date: datetime) -> List[str]:
+28 -7
View File
@@ -49,6 +49,7 @@ GENERATION_ENDPOINT = f"{OPENROUTER_BASE_URL}/generation"
# Default configuration values
DEFAULT_GENERATION_CHECK_DELAY = 2 # seconds to wait before querying metrics
DEFAULT_REQUEST_TIMEOUT = 30 # seconds for HTTP request timeout
MAX_GENERATION_TRACKING = 500 # Maximum entries in generation_tracking before trimming
# =============================================
@@ -161,15 +162,15 @@ def get_generation_metrics(generation_id: str, api_key: str) -> Optional[Dict[st
logger.warning(f"[{MODULE_NAME}] Invalid response structure from OpenRouter for generation {generation_id}")
return None
# Extract metrics from response
# Extract metrics from response (use `or 0` to handle explicit None values)
metrics = data["data"]
result = {
"total_cost": float(metrics.get("total_cost", 0)),
"tokens_prompt": int(metrics.get("tokens_prompt", 0)),
"tokens_completion": int(metrics.get("tokens_completion", 0)),
"generation_time": int(metrics.get("generation_time", 0)),
"latency": int(metrics.get("latency", 0)),
"provider_name": metrics.get("provider_name", "unknown")
"total_cost": float(metrics.get("total_cost") or 0),
"tokens_prompt": int(metrics.get("tokens_prompt") or 0),
"tokens_completion": int(metrics.get("tokens_completion") or 0),
"generation_time": int(metrics.get("generation_time") or 0),
"latency": int(metrics.get("latency") or 0),
"provider_name": metrics.get("provider_name") or "unknown"
}
# Retrieved metrics for generation_id
@@ -281,6 +282,20 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[
current_data["daily_totals"][today]["cost"] += metrics["total_cost"]
current_data["daily_totals"][today]["tokens"] += total_tokens
# Update monthly totals
month = today[:7] # YYYY-MM
if "monthly_totals" not in current_data:
current_data["monthly_totals"] = {}
if month not in current_data["monthly_totals"]:
current_data["monthly_totals"][month] = {
"requests": 0,
"cost": 0.0,
"tokens": 0
}
current_data["monthly_totals"][month]["requests"] += 1
current_data["monthly_totals"][month]["cost"] += metrics["total_cost"]
current_data["monthly_totals"][month]["tokens"] += total_tokens
# Store generation details with newest-first ordering
new_entry = {
"timestamp": datetime.now().isoformat(),
@@ -293,6 +308,12 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[
current_tracking = current_data["generation_tracking"]
current_data["generation_tracking"] = {generation_id: new_entry, **current_tracking}
# Trim oldest entries if over the size cap
if len(current_data["generation_tracking"]) > MAX_GENERATION_TRACKING:
keys = list(current_data["generation_tracking"].keys())
for old_key in keys[MAX_GENERATION_TRACKING:]:
del current_data["generation_tracking"][old_key]
# Save updated data with proper wrapper structure
data_wrapper = {
"module_name": "api_usage",
+8 -15
View File
@@ -12,9 +12,7 @@ Tests for openrouter.caller — caller detection handler.
Tests:
- detect_caller_category for flow paths
- detect_caller_category for prax paths
- detect_caller_category for skills paths
- detect_caller_category for unknown paths
- detect_caller_category with 'skills' as substring in a part
- detect_caller_category for unknown paths (including former skills paths)
"""
from unittest.mock import patch, MagicMock
@@ -43,18 +41,13 @@ class TestDetectCallerCategory:
path = Path("/home/user/projects/aipass/src/aipass/prax/monitor.py")
assert detect_caller_category(path) == "prax"
def test_skills_exact_part_returns_skills(self):
"""Path with exact 'skills' directory should return 'skills'."""
def test_skills_path_returns_unknown(self):
"""Skills branch was removed — skills paths now return 'unknown'."""
path = Path("/home/user/projects/aipass/src/aipass/skills/skills_api/tool.py")
assert detect_caller_category(path) == "skills"
def test_skills_substring_returns_skills(self):
"""Path with 'skills' as substring in a part (e.g., 'skills_api') should return 'skills'."""
path = Path("/home/user/projects/aipass/src/aipass/modules/skills_custom/handler.py")
assert detect_caller_category(path) == "skills"
assert detect_caller_category(path) == "unknown"
def test_unknown_path_returns_unknown(self):
"""Path without flow, prax, or skills should return 'unknown'."""
"""Path without flow or prax should return 'unknown'."""
path = Path("/home/user/projects/aipass/src/aipass/api/apps/handler.py")
assert detect_caller_category(path) == "unknown"
@@ -63,9 +56,9 @@ class TestDetectCallerCategory:
path = Path("/home/user/flow/prax/script.py")
assert detect_caller_category(path) == "flow"
def test_prax_takes_priority_over_skills(self):
"""If 'prax' appears before a skills part, should return 'prax'."""
path = Path("/home/user/prax/skills_module/script.py")
def test_prax_in_mixed_path(self):
"""'prax' in path should return 'prax' regardless of other parts."""
path = Path("/home/user/prax/other_module/script.py")
assert detect_caller_category(path) == "prax"
def test_root_path_returns_unknown(self):
+10 -10
View File
@@ -46,9 +46,9 @@ def test_create_caller_config_creates_three_files(tmp_path: Path):
result = create_caller_config("test_caller", json_folder)
assert result != {}
assert (json_folder / "openrouter_skill_config.json").exists()
assert (json_folder / "openrouter_skill_data.json").exists()
assert (json_folder / "openrouter_skill_log.json").exists()
assert (json_folder / "openrouter_config.json").exists()
assert (json_folder / "openrouter_data.json").exists()
assert (json_folder / "openrouter_log.json").exists()
def test_create_caller_config_defaults(tmp_path: Path):
@@ -57,7 +57,7 @@ def test_create_caller_config_defaults(tmp_path: Path):
create_caller_config("test_caller", json_folder)
config = read_json(json_folder / "openrouter_skill_config.json")
config = read_json(json_folder / "openrouter_config.json")
assert config is not None
assert config["config"]["ai_temperature"] == 0.7
assert config["config"]["ai_max_tokens"] == 4000
@@ -72,7 +72,7 @@ def test_create_caller_config_data_defaults(tmp_path: Path):
create_caller_config("test_caller", json_folder)
data = read_json(json_folder / "openrouter_skill_data.json")
data = read_json(json_folder / "openrouter_data.json")
assert data is not None
assert data["data"]["total_requests"] == 0
assert data["data"]["successful_requests"] == 0
@@ -87,7 +87,7 @@ def test_create_caller_config_log_defaults(tmp_path: Path):
create_caller_config("test_caller", json_folder)
log = read_json(json_folder / "openrouter_skill_log.json")
log = read_json(json_folder / "openrouter_log.json")
assert log is not None
assert log["logs"] == []
assert log["module_name"] == "openrouter"
@@ -143,9 +143,9 @@ def test_ensure_caller_config_provisions_new(mock_detect, tmp_path: Path):
result = ensure_caller_config("test_caller")
assert result != {}
assert (json_folder / "openrouter_skill_config.json").exists()
assert (json_folder / "openrouter_skill_data.json").exists()
assert (json_folder / "openrouter_skill_log.json").exists()
assert (json_folder / "openrouter_config.json").exists()
assert (json_folder / "openrouter_data.json").exists()
assert (json_folder / "openrouter_log.json").exists()
@patch("aipass.api.apps.handlers.openrouter.provision.detect_caller_from_stack")
@@ -159,7 +159,7 @@ def test_ensure_caller_config_returns_existing(mock_detect, tmp_path: Path):
assert first_result != {}
# Read the created config and modify it to detect overwrites
config_path = json_folder / "openrouter_skill_config.json"
config_path = json_folder / "openrouter_config.json"
config = read_json(config_path)
assert config is not None
config["config"]["ai_model"] = "test/modified-model"
+5 -4
View File
@@ -5,8 +5,8 @@
**Purpose:** Display and output formatting service for AIPass modules. Provides consistent terminal output — headers, success/error/warning messages, section breaks, and operation templates — so every module looks the same without duplicating Rich formatting code.
**Module:** `aipass.cli`
**Seedgo:** 100%
**Tests:** 138 passing (6 files, 5/5 modules covered)
**Last Updated:** 2026-04-07
**Tests:** 142 passing (6 files, 5/5 modules covered)
**Last Updated:** 2026-04-10
## Usage
@@ -66,7 +66,7 @@ cli/
├── cli_json/ # Auto-created JSON output (three-file pattern)
├── dropbox/ # Inbound file drop
├── logs/ # Branch-level logs
├── tests/ # 138 tests across 6 files
├── tests/ # 142 tests across 6 files
│ ├── test_bootstrap.py # bootstrap.py handler tests
│ ├── test_json_handler.py # json_handler tests
│ ├── test_display.py # display module tests
@@ -90,6 +90,7 @@ drone @cli aipass # Show aipass subcommands
drone @cli aipass init # Bootstrap AIPass project in current dir
drone @cli aipass init /path # Bootstrap in target directory
drone @cli aipass init /path MyProject # Bootstrap with custom name
drone @cli aipass init agent <name> # Create agent in project (routes to spawn)
drone @cli aipass init --help # Detailed init usage
drone @cli display # Display module introspection
drone @cli display demo # Run display function showcase
@@ -119,7 +120,7 @@ aipass --help # Via console_scripts entry point
---
*Last Updated: 2026-04-07*
*Last Updated: 2026-04-10*
---
[← Back to AIPass](../../../README.md)
+4 -2
View File
@@ -14,6 +14,7 @@ Provides display functions for all branches:
- success() - Green checkmark + message
- error() - Red X + error message
- warning() - Yellow warning + message
- fatal() - Error + sys.exit(1)
- section() - Visual section breaks
Uses Rich library for beautiful terminal output.
@@ -97,7 +98,7 @@ def print_help():
CONSOLE.print("Display is the [bold]CLI's universal output service[/bold] that provides:")
# RICH FORMATTING TIP: Use [green]✓[/green] for checkmarks in lists
CONSOLE.print(" [green]✓[/green] Consistent Rich-formatted output across all branches")
CONSOLE.print(" [green]✓[/green] Five core display functions ([green]header, success, error, warning, section[/green])")
CONSOLE.print(" [green]✓[/green] Six core display functions ([green]header, success, error, warning, fatal, section[/green])")
CONSOLE.print(" [green]✓[/green] Beautiful terminal output with colors, panels, and formatting")
CONSOLE.print(" [green]✓[/green] Integration with CLI error handler for advanced error display")
CONSOLE.print()
@@ -106,7 +107,7 @@ def print_help():
# RICH FORMATTING TIP: Tables are powerful for structured data
# Create with Table(), add columns, add rows, then print
# =========================================================================
CONSOLE.print("[bold cyan]PUBLIC API FUNCTIONS (5 total):[/bold cyan]")
CONSOLE.print("[bold cyan]PUBLIC API FUNCTIONS (6 total):[/bold cyan]")
CONSOLE.print()
# RICH FORMATTING TIP: Table styling - show_header, header_style, border_style
@@ -119,6 +120,7 @@ def print_help():
table.add_row("success()", "message, **kwargs", "Success messages with green checkmark + optional details")
table.add_row("error()", "message, suggestion=None", "Error messages with red X + optional suggestion")
table.add_row("warning()", "message, details=None", "Warning messages with yellow symbol + optional details")
table.add_row("fatal()", "message, suggestion=None", "Error message + sys.exit(1) for unrecoverable failures")
table.add_row("section()", "title", "Visual section separators with title and line")
# RICH FORMATTING TIP: Print the table after adding all rows
+9 -6
View File
@@ -201,7 +201,12 @@ def _handle_init(args: List[str]) -> bool:
# Parse positional args: [target_dir] [project_name]
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", os.getcwd())
target = Path(args[0]) if args else Path(caller_cwd)
if args:
target = Path(args[0])
if not target.is_absolute():
target = Path(caller_cwd) / target
else:
target = Path(caller_cwd)
project_name = args[1] if len(args) > 1 else None
try:
@@ -210,10 +215,6 @@ def _handle_init(args: List[str]) -> bool:
logger.warning("Init validation error: %s", exc)
error(str(exc), suggestion="Pass a project name explicitly")
sys.exit(1)
except FileExistsError as exc:
logger.warning("Init target already exists: %s", exc)
error(str(exc))
sys.exit(1)
except OSError as exc:
logger.error("Init filesystem error: %s", exc)
error(f"Filesystem error: {exc}")
@@ -283,11 +284,12 @@ def _handle_init_agent(args: List[str]) -> bool:
agent_name = args[0]
agent_path = f"src/{agent_name}"
extra_flags = args[1:]
logger.info("Routing 'init agent %s' to drone @spawn create %s", agent_name, agent_path)
try:
result = subprocess.run(
["drone", "@spawn", "create", agent_path],
["drone", "@spawn", "create", agent_path] + extra_flags,
check=False,
)
if result.returncode != 0:
@@ -297,6 +299,7 @@ def _handle_init_agent(args: List[str]) -> bool:
)
return True
except FileNotFoundError:
logger.warning("drone command not found on PATH")
error(
"drone command not found",
suggestion="Ensure AIPass is installed and drone is in PATH",
+3 -4
View File
@@ -60,7 +60,7 @@ def print_help():
CONSOLE.print(" Standard operation header with Rich styling")
CONSOLE.print(" Example: operation_start('Creating branch', target='/path', type='module')")
CONSOLE.print()
CONSOLE.print(" [yellow]operation_complete(success=None, results=None, **summary)[/yellow]")
CONSOLE.print(" [yellow]operation_complete(**summary)[/yellow]")
CONSOLE.print(" Standard completion summary with Rich styling")
CONSOLE.print(" Example: operation_complete(created=5, skipped=2, time='3.2s')")
CONSOLE.print()
@@ -174,16 +174,15 @@ def operation_start(operation: str, **details) -> None:
CONSOLE.print()
def operation_complete(success: bool | None = None, **summary) -> None:
def operation_complete(**summary) -> None:
"""
Display standard operation completion template with Rich styling
Args:
success: True if successful, False if errors
**summary: Summary statistics
Example:
operation_complete(True, created=5, skipped=2, time='3.2s')
operation_complete(created=5, skipped=2, time='3.2s')
"""
CONSOLE.print()
CONSOLE.print("─" * 50)
+18
View File
@@ -377,6 +377,24 @@ def test_init_project_skips_existing_optional_files(tmp_path):
assert md_content == "# Custom CLAUDE\n"
def test_init_project_no_overwrite(tmp_path):
"""Init does not overwrite existing files — re-runnable safety."""
target = tmp_path / "proj"
target.mkdir()
# First run creates files
result1 = init_project(target, project_name="safe")
assert len(result1["created_files"]) > 0
# Second run creates nothing — all files skipped
result2 = init_project(target, project_name="safe")
assert len(result2["created_files"]) == 0
# Content from first run is preserved
claude_md = (target / "CLAUDE.md").read_text(encoding="utf-8")
assert "SAFE" in claude_md
def test_init_project_returns_dict(tmp_path):
"""init_project return value is a dict."""
target = tmp_path / "proj"
@@ -13,7 +13,7 @@ You are DEVPULSE — orchestration hub. Manager, not builder. Coordinate, plan,
- Never block waiting on agents. Never burn context reading code across branches.
- Use `drone @branch --help` for command syntax. Use `drone systems` for branch list.
- **ALWAYS WAKE after sending dispatch emails.** Send email → wake. Every time. No asking. If the user wants something different, they will say so.
- **START WATCHDOG after any dispatch.** Run `python3 src/aipass/prax/tools/inbox_watchdog.py src/aipass/devpulse/.ai_mail.local/inbox.json --interval 30 &` after dispatching. Don't wait for Patrick to ask.
- **START WATCHDOG after any dispatch.** Use the watchdog one-liner (see Watchdog section below) with `run_in_background: true`. Don't wait for Patrick to ask.
## Dispatch, Don't Do
@@ -66,7 +66,7 @@ drone, seedgo, prax, cli, ai_mail, api, flow, spawn, trigger, memory, devpulse (
## Your Project
You have a personal project: **Compass** at `~/Projects/compass/`. It's a vector-based thinking engine for growing autonomous decision-making. The judgment library at `~/Projects/decisions.md` (27 entries) is the prototype data. Compass vectorizes these decisions into ChromaDB fragments so you can query past judgment patterns, feedback, and outcomes when facing new decisions. Own venv, own registry, own orchestration AI (eventually). Work on this when you have autonomy time.
You have a personal project: **Compass** at `~/Projects/compass/`. It's a vector-based thinking engine for growing autonomous decision-making. The judgment library at `~/Projects/decisions.md` (27 entries) is the prototype data. Compass vectorizes these decisions into ChromaDB fragments so you can query past judgment patterns, feedback, and outcomes when facing new decisions. Own venv, own registry, own orchestration AI (eventually). Work on this when you have autonomy time. Compass is part of the larger Nexus vision. See decisions.md and key_learning nexus_is_the_answer.
## Working Habits
@@ -74,7 +74,7 @@ You have a personal project: **Compass** at `~/Projects/compass/`. It's a vector
- **Use memories freely.** Don't hoard or stress about capacity — rollover to @memory is by design. Update `.trinity/` often. More is better.
- **STATUS.local.md for friction notes.** When something feels off or could be improved, drop a quick note in the Notepad section. Address in batches later.
- **Know your limits.** You're great at planning, coordinating, seeing the big picture. You're bad at hands-on branch-level code tasks. Dispatch, don't do.
- **Git awareness as a natural habit.** After completing a feature, merging something, or wrapping up a chunk of work — take a moment to think: "we've been working for a while, what's changed?" Run `git status`, see what's accumulated. If it looks like a coherent set of changes (an upgrade, a fix cycle, a config update), suggest a commit or PR. Don't force it every turn, but don't let 60+ files pile up silently either. Think of it like tidying your desk at the end of a work session — not obsessive, just mindful.
- **Git awareness as a natural habit.** After completing a feature or wrapping up a chunk of work, run `git status`. If changes look coherent (upgrade, fix cycle, config update), suggest a commit or PR. Don't force it every turn, but don't let files pile up silently either.
## Watchdog — Autonomous Mail Wait
After dispatching branches, use a background bash wait that exits when mail arrives. This wakes you like a sub-agent completing.
@@ -84,19 +84,19 @@ After dispatching branches, use a background bash wait that exits when mail arri
# 1. Dispatch work
drone @ai_mail dispatch @target "Subject" "Body"
# 2. Clear inbox first, then arm watchdog (run_in_background: true, timeout: 600000)
drone @ai_mail close all
INBOX="path/to/inbox.json"; while true; do sleep 10; UNREAD=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); if [ "$UNREAD" -gt "0" ]; then echo "WOKE: $UNREAD unread"; exit 0; fi; done
# 2. Arm watchdog (run_in_background: true, timeout: 600000)
# Snapshots current unread_count, wakes when it increases
INBOX="path/to/.ai_mail.local/inbox.json"; INITIAL=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); C=0; while [ $C -lt 60 ]; do sleep 10; C=$((C+1)); CURRENT=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); if [ "$CURRENT" -gt "$INITIAL" ]; then echo "WOKE: new mail ($INITIAL→$CURRENT)"; exit 0; fi; done; echo "TIMEOUT"
# 3. Stop — do nothing until notified
# 4. Wake notification arrives → read mail → process → dispatch next → repeat
```
**Key:** Use `unread_count > 0` (not total_messages — close resets totals). Always `close all` before arming. 10s poll interval. `run_in_background: true` so the completion notification wakes you. On timeout, wake anyway to check if agent crashed — then either restart watchdog or re-dispatch.
**Key:** Snapshot unread_count BEFORE arming, then wake when it increases. Don't require empty inbox — works with existing mail. 10s poll interval. `run_in_background: true` so the completion notification wakes you. On timeout, wake anyway to check if agent crashed.
**Watchdog one-liner (copy-paste ready):**
```
INBOX="/home/patrick/Projects/AIPass/src/aipass/devpulse/.ai_mail.local/inbox.json"; C=0; while [ $C -lt 60 ]; do sleep 10; C=$((C+1)); UNREAD=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); if [ "$UNREAD" -gt "0" ]; then echo "WOKE: $UNREAD unread"; exit 0; fi; done; echo "TIMEOUT: 10min no reply — check if agent crashed"; exit 0
INBOX="/home/patrick/Projects/AIPass/src/aipass/devpulse/.ai_mail.local/inbox.json"; INITIAL=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); C=0; while [ $C -lt 60 ]; do sleep 10; C=$((C+1)); CURRENT=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); if [ "$CURRENT" -gt "$INITIAL" ]; then echo "WOKE: new mail ($INITIAL→$CURRENT)"; exit 0; fi; done; echo "TIMEOUT: 10min no new mail"; exit 0
```
## Memory & Tracking
+49 -6
View File
@@ -5,7 +5,7 @@
**Purpose:** Orchestration hub for the AIPass ecosystem
**Module:** `aipass.devpulse`
**Status:** Active
**Last Updated:** 2026-03-22
**Last Updated:** 2026-04-10
---
@@ -56,21 +56,25 @@ devpulse/
├── .aipass/ # AI context
│ └── aipass_local_prompt.md
├── .spawn/ # Spawn metadata
├── tools/ # Diagnostic scanner suite (20 tools)
├── apps/ # Entry point scaffold (minimal — devpulse.py + stubs)
├── tools/ # Diagnostic scanner suite (26 tools)
├── branch_audits _only/ # Living audit plans for all 11 branches + patrick
├── dropbox/ # Incoming files from other branches/users
├── docs/ # Tracked documentation
├── docs.local/ # Working files (gitignored)
├── tests/
├── DPLAN-*.md # Active design plans (8-10 at any time)
├── CLOSED_PLANS.local.json # Archive of completed plans
├── STATUS.local.md # Current work, issues, todos
└── README.md
```
DevPulse has no `apps/` directory — it's a **manager** branch, not a builder. It coordinates via dispatch and sub-agents rather than implementing code.
DevPulse has a minimal `apps/` scaffold but is primarily a **manager** branch, not a builder. It coordinates via dispatch and sub-agents rather than implementing code.
---
## Diagnostic Tools
DevPulse maintains a suite of 20 standalone diagnostic scanners in `tools/`. Each follows the `{concern}_scanner_v1.py` naming convention and supports `@branch`, `--all`, and `--summary` flags.
DevPulse maintains a suite of 26 standalone diagnostic scanners in `tools/`. Each follows the `{concern}_scanner_v1.py` naming convention and supports `@branch`, `--all`, and `--summary` flags.
### Code Quality
| Tool | What it checks |
@@ -104,16 +108,53 @@ DevPulse maintains a suite of 20 standalone diagnostic scanners in `tools/`. Eac
| `magic_number_scanner_v1.py` | Hardcoded numbers (cross-file consistency) |
| `stale_scanner_v1.py` | Outdated terminology (17 tracked keywords) |
### Infrastructure
| Tool | What it checks |
|------|---------------|
| `log_scanner_v1.py` | Prax log files for errors/warnings |
| `key_exposure_scanner_v1.py` | API key exposure patterns in code |
| `meta_header_scanner_v1.py` | Module metadata headers compliance |
| `scanner_v1.py` | Base scanner framework |
### Utilities
| Tool | What it does |
|------|-------------|
| `dev_central_to_devpulse.py` | Rename stale terms across files |
| `verify_branch.py` | Verify branch structure compliance |
| `git_lock_tool.py` | Manage git lock state for PRs |
**Tool classification:** Hard checks (pass/fail) vs Advisory (flag for investigation). See DPLAN-0030 for full details.
---
## Branch Audits
DevPulse maintains living audit plans for every branch in `branch_audits _only/`. These are comprehensive health assessments created during adversarial audit sessions (S83 Wave 1-3) and continuously updated.
Each audit tracks:
- **CRITICAL** — Must-fix issues (security, data loss, broken core features)
- **BUG** — Functional defects
- **STALE/DEAD** — Outdated references, dead code
- **QUALITY** — Performance, thread safety, architectural debt
- **DOCS** — Documentation gaps and inaccuracies
| Branch | Audit File | Health |
|--------|-----------|--------|
| drone | DPLAN-0053 | YELLOW (23 BUG) |
| seedgo | DPLAN-0084 | GREEN |
| prax | DPLAN-0039 | GREEN |
| cli | DPLAN-0074 | YELLOW (2 CRITICAL) |
| flow | DPLAN-0082 | YELLOW (5 CRITICAL) |
| ai_mail | DPLAN-0036 | GREEN |
| api | DPLAN-0029 | YELLOW (3 CRITICAL) |
| trigger | DPLAN-0075 | YELLOW (1 CRITICAL) |
| spawn | DPLAN-0035 | YELLOW (12 BUG) |
| memory | DPLAN-0038 | GREEN |
| devpulse | DPLAN-0037 | RED |
| patrick | DPLAN-0086 | N/A |
---
## Commands
```bash
@@ -160,7 +201,9 @@ drone @spawn delete @branch # Archive + deregister branch
DevPulse is a **manager** branch, not a builder. It delegates code tasks to sub-agents and branch agents. Its context window is reserved for coordination, planning, and architecture — not for reading and editing files across the codebase.
The `tools/` directory is DevPulse's "tool shed" — standalone diagnostic scripts for investigating code quality across all branches. These tools surface patterns and create conversations. They're built for AI consumption: run a scanner, get instant visibility, decide what matters.
The `tools/` directory is DevPulse's "tool shed" — 26 standalone diagnostic scripts for investigating code quality across all branches. These tools surface patterns and create conversations. They're built for AI consumption: run a scanner, get instant visibility, decide what matters.
The `branch_audits _only/` directory is DevPulse's health dashboard — living audit documents for every branch, updated during adversarial audit sessions and fix sweeps. Each audit tracks CRITICALs, BUGs, quality debt, and documentation gaps with current health ratings.
---
[← Back to AIPass](../../../README.md)
+3 -3
View File
@@ -16,7 +16,7 @@ Auto-discovery architecture:
import sys
import importlib
from pathlib import Path
from typing import List, Any
from typing import Any
from aipass.prax import logger
@@ -31,7 +31,7 @@ console = Console(stderr=True)
MODULES_DIR = Path(__file__).parent / "modules"
def discover_modules() -> List[Any]:
def discover_modules() -> list[Any]:
"""Auto-discover modules in modules/ directory."""
modules = []
@@ -65,7 +65,7 @@ def print_introspection():
console.print(f" {name:20} {desc}")
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
"""Route command to appropriate module."""
for module in modules:
try:
+1 -3
View File
@@ -1,5 +1,3 @@
# Plugins
Scheduled tasks and extensions for `DEVPULSE`.
Plugins are standalone units of work that can be scheduled. Each plugin handles one specific recurring task.
Plugins extend devpulse functionality.
+3 -3
View File
@@ -104,7 +104,7 @@ def animate_startup():
("Connecting AI Mail network", 0.3),
("Mounting .trinity/ memories", 0.4),
("Starting Prax logging", 0.2),
("Seedgo standards: 21 loaded", 0.3),
("Seedgo standards: 33 loaded", 0.3),
("System ready", 0.1),
]
@@ -199,8 +199,8 @@ def show_stats():
"""Final stats box."""
stats = [
f"{WHITE}Branches: {CYAN}11 registered, 11 operational{RESET}",
f"{WHITE}Standards: {GREEN}21 seedgo checks active{RESET}",
f"{WHITE}Sessions: {YELLOW}32 completed (devpulse alone){RESET}",
f"{WHITE}Standards: {GREEN}33 seedgo checks active{RESET}",
f"{WHITE}Sessions: {YELLOW}86 completed (devpulse alone){RESET}",
f"{WHITE}Architecture:{MAGENTA} Citizens + Agents + Dispatch{RESET}",
f"{WHITE}Built with: {CYAN}Python, Rich, Claude Code{RESET}",
f"{WHITE}Memory: {GREEN}.trinity/ — persistent identity{RESET}",
+15
View File
@@ -107,6 +107,21 @@
"file": "apps/plugins/devpulse_ops/fix_plugin.py",
"standard": "encapsulation",
"reason": "Plugin imports lock_handler.find_repo_root() — drone-internal plugin, same branch."
},
{
"file": "apps/plugins/hook_sounds/hook_sounds_plugin.py",
"standard": "architecture",
"reason": "Plugin lives in apps/plugins/ — deliberately outside 3-layer structure (same pattern as devpulse_ops)."
},
{
"file": "apps/plugins/hook_sounds/hook_sounds_plugin.py",
"standard": "naming",
"reason": "Plugin filename convention: {plugin_name}_plugin.py inside {plugin_name}/ directory."
},
{
"file": "apps/plugins/hook_sounds/hook_sounds_plugin.py",
"standard": "trigger",
"reason": "Simple toggle plugin — creates/removes a flag file. No state changes that warrant trigger events."
}
],
"notes": {
+8 -2
View File
@@ -136,10 +136,12 @@ def show_introspection() -> None:
# COMMAND HANDLERS
# =============================================================================
def _cwd_has_registry() -> bool:
def _cwd_has_registry(max_depth: int = 10) -> bool:
"""Check if CWD is within a project that has a *_REGISTRY.json."""
cwd = Path.cwd()
for parent in [cwd] + list(cwd.parents):
for i, parent in enumerate([cwd] + list(cwd.parents)):
if i >= max_depth:
break
if list(parent.glob("*_REGISTRY.json")):
return True
return False
@@ -431,6 +433,10 @@ def main() -> int:
logger.warning("Registry error during systems listing: %s", exc)
err_console.print(f"drone: {exc}")
return 1
except Exception as exc:
logger.error("[drone] Unhandled error in systems: %s", exc)
err_console.print(f"drone: unexpected error: {exc}")
return 1
# scan — discover available commands in a branch
if command == "scan":
@@ -18,11 +18,14 @@ from __future__ import annotations
import importlib
import sys
import threading
from io import StringIO
from aipass.prax import logger
from aipass.drone.apps.handlers.json import json_handler
_capture_lock = threading.Lock()
def capture_main(
entry_point_module: str,
@@ -54,33 +57,35 @@ def capture_main(
argv_parts.append(command)
argv_parts.extend(args)
original_argv = sys.argv
old_stdout = sys.stdout
old_stderr = sys.stderr
captured_out = StringIO()
captured_err = StringIO()
try:
sys.argv = argv_parts
sys.stdout = captured_out
sys.stderr = captured_err
with _capture_lock:
original_argv = sys.argv
old_stdout = sys.stdout
old_stderr = sys.stderr
mod = importlib.import_module(entry_point_module)
main_fn = getattr(mod, "main")
exit_code = main_fn()
except SystemExit as exc:
exit_code = exc.code if exc.code is not None else 0
logger.info("capture_main: SystemExit(%s) from '%s'", exit_code, entry_point_module)
except Exception as exc:
captured_err.write(str(exc))
exit_code = 1
logger.warning(
"capture_main: exception from '%s': %s", entry_point_module, exc
)
finally:
sys.argv = original_argv
sys.stdout = old_stdout
sys.stderr = old_stderr
try:
sys.argv = argv_parts
sys.stdout = captured_out
sys.stderr = captured_err
mod = importlib.import_module(entry_point_module)
main_fn = getattr(mod, "main")
exit_code = main_fn()
except SystemExit as exc:
exit_code = exc.code if exc.code is not None else 0
logger.info("capture_main: SystemExit(%s) from '%s'", exit_code, entry_point_module)
except Exception as exc:
captured_err.write(str(exc))
exit_code = 1
logger.warning(
"capture_main: exception from '%s': %s", entry_point_module, exc
)
finally:
sys.argv = original_argv
sys.stdout = old_stdout
sys.stderr = old_stderr
result = {
"stdout": captured_out.getvalue(),
@@ -82,6 +82,15 @@ def _load_external_modules() -> dict[str, _ExternalModuleConfig]:
_EXTERNAL_MODULES: dict[str, _ExternalModuleConfig] = _load_external_modules()
def refresh_external_modules() -> None:
"""Reload external module declarations from routing_config.json.
Call after modifying routing_config.json at runtime.
"""
global _EXTERNAL_MODULES
_EXTERNAL_MODULES = _load_external_modules()
@dataclass
class ModuleInfo:
"""Metadata about a registered module."""
@@ -163,6 +172,9 @@ def route_module_command(
mod = importlib.import_module(adapter_path)
handler = getattr(mod, "handle_command")
result = handler(command, args)
# Internal modules may return bool (standard) instead of dict (adapter)
if isinstance(result, bool):
result = {"stdout": "", "stderr": "", "exit_code": 0 if result else 1}
json_handler.log_operation(
"route_module_command", {"module": name, "command": command}
)
@@ -27,6 +27,7 @@ from aipass.drone.apps.handlers.module_registry_handler import (
get_module_help,
get_module_introspective,
register_module,
refresh_external_modules,
)
__all__ = [
@@ -38,6 +39,7 @@ __all__ = [
"get_module_help",
"get_module_introspective",
"register_module",
"refresh_external_modules",
]
@@ -23,6 +23,61 @@ from aipass.drone.apps.handlers.json import json_handler
from aipass.drone.apps.handlers.git.lock_handler import find_repo_root
def _fix_divergence(repo_root: Path, actions: list[str]) -> None:
"""Fetch origin and merge if local main has diverged."""
fetch = subprocess.run(
["git", "fetch", "origin"],
capture_output=True, text=True, cwd=str(repo_root),
)
if fetch.returncode != 0:
actions.append(f"Fetch failed: {fetch.stderr.strip()}")
logger.error("fix_git_state: fetch failed: %s", fetch.stderr.strip())
return
rev_list = subprocess.run(
["git", "rev-list", "--left-right", "--count", "main...origin/main"],
capture_output=True, text=True, cwd=str(repo_root),
)
if rev_list.returncode != 0:
return
parts = rev_list.stdout.strip().split()
ahead = int(parts[0]) if len(parts) >= 1 else 0
behind = int(parts[1]) if len(parts) >= 2 else 0
if ahead == 0 or behind == 0:
return
merge = subprocess.run(
["git", "merge", "origin/main", "--no-edit"],
capture_output=True, text=True, cwd=str(repo_root),
)
if merge.returncode == 0:
actions.append(f"Merged origin/main (was ahead={ahead}, behind={behind})")
logger.info("fix_git_state: merged origin/main ahead=%d behind=%d", ahead, behind)
return
# Merge failed — report conflict files and abort
diff = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=U"],
capture_output=True, text=True, cwd=str(repo_root),
)
conflict_files = diff.stdout.strip().splitlines() if diff.stdout.strip() else []
subprocess.run(
["git", "merge", "--abort"],
capture_output=True, text=True, cwd=str(repo_root),
)
if conflict_files:
actions.append(
f"Merge conflict (ahead={ahead}, behind={behind}). "
f"Conflicting files: {', '.join(conflict_files)}"
)
else:
actions.append(
f"Merge failed (ahead={ahead}, behind={behind}): {merge.stderr.strip()}"
)
logger.warning("fix_git_state: merge conflict ahead=%d behind=%d", ahead, behind)
def fix_git_state(caller: str) -> dict:
"""Detect and fix common broken git states.
@@ -78,55 +133,7 @@ def fix_git_state(caller: str) -> dict:
logger.error("fix_git_state: checkout main failed: %s", checkout.stderr.strip())
# Check 3: Diverged from origin — fetch + merge
fetch = subprocess.run(
["git", "fetch", "origin"],
capture_output=True, text=True, cwd=str(repo_root),
)
if fetch.returncode != 0:
actions.append(f"Fetch failed: {fetch.stderr.strip()}")
logger.error("fix_git_state: fetch failed: %s", fetch.stderr.strip())
else:
rev_list = subprocess.run(
["git", "rev-list", "--left-right", "--count", "main...origin/main"],
capture_output=True, text=True, cwd=str(repo_root),
)
if rev_list.returncode == 0:
parts = rev_list.stdout.strip().split()
ahead = int(parts[0]) if len(parts) >= 1 else 0
behind = int(parts[1]) if len(parts) >= 2 else 0
if ahead > 0 and behind > 0:
merge = subprocess.run(
["git", "merge", "origin/main", "--no-edit"],
capture_output=True, text=True, cwd=str(repo_root),
)
if merge.returncode == 0:
actions.append(
f"Merged origin/main (was ahead={ahead}, behind={behind})"
)
logger.info("fix_git_state: merged origin/main ahead=%d behind=%d", ahead, behind)
else:
# Report conflict files
diff = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=U"],
capture_output=True, text=True, cwd=str(repo_root),
)
conflict_files = diff.stdout.strip().splitlines() if diff.stdout.strip() else []
# Abort the failed merge
subprocess.run(
["git", "merge", "--abort"],
capture_output=True, text=True, cwd=str(repo_root),
)
if conflict_files:
actions.append(
f"Merge conflict (ahead={ahead}, behind={behind}). "
f"Conflicting files: {', '.join(conflict_files)}"
)
else:
actions.append(
f"Merge failed (ahead={ahead}, behind={behind}): "
f"{merge.stderr.strip()}"
)
logger.warning("fix_git_state: merge conflict ahead=%d behind=%d", ahead, behind)
_fix_divergence(repo_root, actions)
# Check 4: Dirty index with no intent
cached = subprocess.run(
@@ -18,6 +18,7 @@ from __future__ import annotations
from pathlib import Path
from aipass.prax import logger
from aipass.cli.apps.modules import console
from aipass.drone.apps.handlers.json import json_handler
MUTE_FLAG = Path("/tmp/aipass-hooks-muted")
@@ -56,19 +57,19 @@ def handle_command(command: str | None = None, args: list[str] | None = None) ->
if command == "off":
mute()
print("Hook sounds: MUTED")
console.print("Hook sounds: MUTED")
return True
if command == "on":
unmute()
print("Hook sounds: ACTIVE")
console.print("Hook sounds: ACTIVE")
return True
# No command = show status
if is_muted():
print("Hook sounds: MUTED (off)")
print(f" Flag: {MUTE_FLAG}")
console.print("Hook sounds: MUTED (off)")
console.print(f" Flag: {MUTE_FLAG}")
else:
print("Hook sounds: ACTIVE (on)")
console.print("Hook sounds: ACTIVE (on)")
return True
+10
View File
@@ -108,6 +108,16 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
def main():
"""Main entry point - routes commands or shows help"""
try:
return _main_impl()
except Exception as exc:
logger.error("[flow] Unhandled error in main: %s", exc)
error(f"Unexpected error: {exc}")
return 1
def _main_impl():
"""Internal implementation of main — separated for catch-all handler."""
# Discover available modules
modules = discover_modules()
@@ -54,8 +54,9 @@ from aipass.prax.apps.modules.logger import system_logger as logger
_PKG_ROOT = Path(__file__).resolve().parents[4]
FLOW_ROOT = _PKG_ROOT / "flow"
# Registry location
REGISTRY_FILE = FLOW_ROOT / "flow_json" / "fplan_registry.json"
# Registry location (fallback default)
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
# Dashboard template path (package-relative)
DASHBOARD_TEMPLATE_FILE = _PKG_ROOT / "devpulse" / "templates" / "DASHBOARD.template.json"
@@ -194,25 +195,56 @@ def _calculate_quick_status(sections: Dict[str, Any]) -> Dict[str, Any]:
}
# =============================================
# PLAN TYPE HELPERS
# =============================================
def _get_all_registry_files() -> List[str]:
"""Return per-type registry filenames via plan-type discovery."""
try:
from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types
files: List[str] = []
for _key, config in discover_plan_types().items():
rf = config.get("registry_file")
if rf and rf not in files:
files.append(rf)
if files:
return files
except Exception as exc:
logger.warning("[push_branch_dashboard] Failed to discover plan types, falling back to default registry: %s", exc)
return [REGISTRY_FILE.name]
# =============================================
# HELPER FUNCTIONS
# =============================================
def _load_registry() -> Dict[str, Any]:
"""
Load fplan_registry.json.
Load all per-type plan registries and merge into a single dict.
Returns:
Registry dict or empty structure if unavailable
Merged registry dict or empty structure if unavailable
"""
try:
if not REGISTRY_FILE.exists():
return {"plans": {}, "next_number": 1}
with open(REGISTRY_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as exc:
logger.warning("Failed to load fplan registry '%s': %s", REGISTRY_FILE, exc)
return {"plans": {}, "next_number": 1}
merged: Dict[str, Any] = {"plans": {}, "next_number": 1}
for registry_file in _get_all_registry_files():
target = FLOW_JSON_DIR / registry_file
try:
if not target.exists():
continue
with open(target, 'r', encoding='utf-8') as f:
data = json.load(f)
for plan_num, plan_data in data.get("plans", {}).items():
merged["plans"][plan_num] = plan_data
# Keep the highest next_number across registries
nn = data.get("next_number", 1)
if nn > merged["next_number"]:
merged["next_number"] = nn
except Exception as exc:
logger.warning("Failed to load registry '%s': %s", target, exc)
return merged
def _filter_branch_plans(
@@ -49,6 +49,8 @@ from aipass.flow.apps.handlers.plan.aggregate_ops import aggregate_central_impl
MODULE_NAME = "push_central"
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
def _find_repo_root() -> Path:
"""Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json)."""
current = Path(__file__).resolve().parent
@@ -62,25 +64,54 @@ _REPO_ROOT = _find_repo_root()
AI_CENTRAL_DIR = _REPO_ROOT / ".ai_central"
CENTRAL_FILE = AI_CENTRAL_DIR / "PLANS.central.json"
# =============================================
# PLAN TYPE HELPERS
# =============================================
def _get_all_registry_files() -> List[str]:
"""Return per-type registry filenames via plan-type discovery."""
try:
from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types
files: List[str] = []
for _key, config in discover_plan_types().items():
rf = config.get("registry_file")
if rf and rf not in files:
files.append(rf)
if files:
return files
except Exception as exc:
logger.warning("[push_central] Failed to discover plan types, falling back to default registry: %s", exc)
return [REGISTRY_FILE.name]
# =============================================
# HELPER FUNCTIONS
# =============================================
def _load_registry() -> Dict[str, Any]:
"""Load fplan_registry.json
"""Load all per-type plan registries and merge into a single dict.
Returns:
Registry dict or empty structure if file doesn't exist
Merged registry dict or empty structure if no files found
"""
if not REGISTRY_FILE.exists():
return {"plans": {}, "next_number": 1}
try:
with open(REGISTRY_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as exc:
logger.warning("Failed to load fplan registry '%s': %s", REGISTRY_FILE, exc)
return {"plans": {}, "next_number": 1}
merged: Dict[str, Any] = {"plans": {}, "next_number": 1}
for registry_file in _get_all_registry_files():
target = FLOW_JSON_DIR / registry_file
try:
if not target.exists():
continue
with open(target, 'r', encoding='utf-8') as f:
data = json.load(f)
for plan_num, plan_data in data.get("plans", {}).items():
merged["plans"][plan_num] = plan_data
nn = data.get("next_number", 1)
if nn > merged["next_number"]:
merged["next_number"] = nn
except Exception as exc:
logger.warning("Failed to load registry '%s': %s", target, exc)
return merged
def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict], List[Dict]]:
@@ -82,28 +82,64 @@ FLOW_ROOT = _PKG_ROOT / "flow"
# CONFIGURATION
# =============================================
REGISTRY_FILE = FLOW_ROOT / "flow_json" / "fplan_registry.json"
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
DASHBOARD_FILE = FLOW_ROOT / "DASHBOARD.local.json"
# =============================================
# PLAN TYPE HELPERS
# =============================================
def _get_all_registry_files() -> List[str]:
"""Return per-type registry filenames via plan-type discovery."""
try:
from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types
files: List[str] = []
for _key, config in discover_plan_types().items():
rf = config.get("registry_file")
if rf and rf not in files:
files.append(rf)
if files:
return files
except Exception as exc:
logger.warning("[update_local] Failed to discover plan types, falling back to default registry: %s", exc)
return [REGISTRY_FILE.name]
# =============================================
# HELPER FUNCTIONS
# =============================================
def _read_registry() -> Optional[Dict[str, Any]]:
"""
Read fplan_registry.json.
Read all per-type plan registries and merge into a single dict.
Returns:
Registry dict or None if error
Merged registry dict or None if no registries found
"""
try:
if not REGISTRY_FILE.exists():
return None
with open(REGISTRY_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as exc:
logger.warning("Failed to read fplan registry '%s': %s", REGISTRY_FILE, exc)
merged: Dict[str, Any] = {"plans": {}, "next_number": 1}
found_any = False
for registry_file in _get_all_registry_files():
target = FLOW_JSON_DIR / registry_file
try:
if not target.exists():
continue
with open(target, 'r', encoding='utf-8') as f:
data = json.load(f)
found_any = True
for plan_num, plan_data in data.get("plans", {}).items():
merged["plans"][plan_num] = plan_data
nn = data.get("next_number", 1)
if nn > merged["next_number"]:
merged["next_number"] = nn
except Exception as exc:
logger.warning("Failed to read registry '%s': %s", target, exc)
if not found_any:
return None
return merged
def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
@@ -110,17 +110,17 @@ def save_branch_registry(registry_path: Path, registry: Dict[str, Any]) -> bool:
def extract_plan_number(plan_id: str) -> Optional[str]:
"""Extract plan number from plan_id (e.g., 'FPLAN-0148' -> '0148')
"""Extract plan number from plan_id (e.g., 'FPLAN-0148' -> '0148', 'DPLAN-0004' -> '0004')
Args:
plan_id: Plan ID string (e.g., 'FPLAN-0148')
plan_id: Plan ID string with any prefix (e.g., 'FPLAN-0148', 'DPLAN-0004')
Returns:
Plan number string or None if invalid format
"""
if not plan_id or not plan_id.startswith("FPLAN-"):
if not plan_id or '-' not in plan_id:
return None
return plan_id[6:] # Skip 'FPLAN-' prefix
return plan_id.split('-', 1)[1]
def auto_close_plan(registry_path: Path, plan_id: str, branch_name: str) -> bool:
@@ -14,6 +14,7 @@ Creates the file if it doesn't exist.
"""
import json
import re
from pathlib import Path
# INFRASTRUCTURE IMPORT PATTERN
@@ -40,7 +41,12 @@ def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path)
True on success, False on failure
"""
try:
plan_id = f"FPLAN-{plan_key}"
# Extract prefix from plan_info's file_path (e.g., FPLAN, DPLAN)
file_path = plan_info.get("file_path", "")
filename = Path(file_path).name if file_path else ""
prefix_match = re.match(r'^([A-Z]+PLAN)', filename)
prefix = prefix_match.group(1) if prefix_match else "FPLAN"
plan_id = f"{prefix}-{plan_key}"
# Extract date (YYYY-MM-DD) from the closed ISO timestamp
closed_raw = plan_info.get("closed", "")
@@ -49,7 +55,7 @@ def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path)
# Build the entry
entry = {
"plan_id": plan_id,
"type": "FPLAN",
"type": prefix,
"subject": plan_info.get("subject", ""),
"date_closed": date_closed,
"location": plan_info.get("relative_path", "")
@@ -19,6 +19,8 @@ Usage:
from pathlib import Path
from typing import List, Tuple, Dict, Any
from aipass.prax.apps.modules.logger import system_logger as logger
# INFRASTRUCTURE IMPORT PATTERN
_PKG_ROOT = Path(__file__).resolve().parents[4]
@@ -26,13 +28,41 @@ _PKG_ROOT = Path(__file__).resolve().parents[4]
from aipass.flow.apps.handlers.registry.load_registry import load_registry
from aipass.flow.apps.handlers.json import json_handler
# =============================================
# MULTI-REGISTRY DISCOVERY
# =============================================
MODULE_NAME = "get_closed_plans"
def _get_all_registry_files() -> List[str]:
"""Return per-type registry filenames via plan-type discovery.
Uses the same pattern as mbank/process.py to discover all plan-type
registries (e.g. fplan_registry.json, dplan_registry.json).
Falls back to the default fplan_registry.json if discovery fails.
"""
try:
from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types
files: List[str] = []
for _key, config in discover_plan_types().items():
rf = config.get("registry_file")
if rf and rf not in files:
files.append(rf)
if files:
return files
except Exception as exc:
logger.warning("[%s] Failed to discover plan types, falling back to default registry: %s", MODULE_NAME, exc)
return ["fplan_registry.json"]
# =============================================
# HANDLER FUNCTION
# =============================================
def get_closed_plans() -> List[Tuple[str, Dict[str, Any]]]:
"""
Get all closed plans from registry
Get all closed plans from ALL discovered registries
Returns:
List of tuples: [(plan_num, plan_info), ...]
@@ -43,15 +73,18 @@ def get_closed_plans() -> List[Tuple[str, Dict[str, Any]]]:
>>> for plan_num, plan_info in plans:
... print(f"PLAN{plan_num}: {plan_info['subject']}")
"""
# Load registry
registry = load_registry()
closed_plans: List[Tuple[str, Dict[str, Any]]] = []
# Filter for closed plans
closed_plans = [
(plan_num, plan_info)
for plan_num, plan_info in registry.get("plans", {}).items()
if plan_info.get("status") == "closed"
]
for reg_file in _get_all_registry_files():
try:
registry = load_registry(registry_file=reg_file)
except Exception as exc:
logger.warning("[%s] Failed to load registry '%s': %s", MODULE_NAME, reg_file, exc)
continue
for plan_num, plan_info in registry.get("plans", {}).items():
if plan_info.get("status") == "closed":
closed_plans.append((plan_num, plan_info))
json_handler.log_operation("closed_plans_retrieved", {"count": len(closed_plans)})
return closed_plans
@@ -71,12 +71,12 @@ def recover_plan_from_backup(plan_key: str, load_registry: Any = None, save_regi
# Search for any prefix matching the plan key (FPLAN-, DPLAN-, etc.)
variants = list(processed_plans.glob(f"*-{plan_key}*.md")) if processed_plans.exists() else []
plan_file = processed_plans / f"FPLAN-{plan_key}.md" # fallback default
plan_file = None # No default -- use variant search
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():
if plan_file is None or not plan_file.exists():
return False, f"Plan {plan_key} not found in backups"
# Read plan file to extract original location from header
@@ -209,7 +209,7 @@ def restore_plan_impl(
exists, error_msg = validate_plan_exists(plan_key, registry)
if not exists:
# AUTO-RECOVERY: Try to recover from processed_plans
messages.append({"type": "warning", "text": f"FPLAN-{plan_key} not in registry - attempting recovery..."})
messages.append({"type": "warning", "text": f"PLAN-{plan_key} not in registry - attempting recovery..."})
recovered, recovery_msg = recover_plan_from_backup_fn(plan_key)
if recovered:
@@ -234,7 +234,7 @@ def restore_plan_impl(
# 4. VALIDATE: Check plan is closed
if plan_info.get("status") != "closed":
logger.warning(f"[{MODULE_NAME}] FPLAN-{plan_key} is already open")
logger.warning(f"[{MODULE_NAME}] Plan {plan_key} is already open")
messages.append({"type": "error", "error_type": "already_open", "plan_key": plan_key})
return {
"success": False,
@@ -268,7 +268,7 @@ def restore_plan_impl(
plan_info.pop('memory_file', None)
save_registry(registry)
logger.info(f"[{MODULE_NAME}] Restored FPLAN-{plan_key} to open status")
logger.info(f"[{MODULE_NAME}] Restored plan {plan_key} to open status")
# 8. UPDATE DASHBOARDS: Sync dashboard files (handlers)
dashboard_success = update_dashboard_local()
@@ -147,94 +147,98 @@ def load_registry() -> Dict[str, Any]:
"type_count": len(data["types"]),
}
# Auto-heal: prune orphaned types (directory deleted but registry entry remains)
templates_dir = FLOW_ROOT / "templates"
orphaned = [
dir_name
for dir_name in data["types"]
if dir_name not in _PROTECTED_TYPES
and not (templates_dir / dir_name).is_dir()
]
if orphaned:
plan_registry_dir = FLOW_ROOT / "flow_json"
for dir_name in orphaned:
entry = data["types"][dir_name]
shorthand = entry.get("shorthand", entry.get("prefix", "").lower())
logger.info(
"[%s] Auto-pruning orphaned type '%s' (directory missing)",
MODULE_NAME,
dir_name,
)
del data["types"][dir_name]
# Clean up the per-type plan registry JSON
if shorthand:
plan_reg = plan_registry_dir / f"{shorthand}_registry.json"
if plan_reg.exists():
plan_reg.unlink()
logger.info(
"[%s] Removed orphaned plan registry: %s",
MODULE_NAME,
plan_reg.name,
)
# Auto-heal: prune orphans + register new dirs
if _prune_orphaned_types(data):
save_registry(data)
if _auto_register_new_types(data):
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
def _prune_orphaned_types(data: Dict[str, Any]) -> bool:
"""Remove registry entries whose template directory no longer exists.
Returns True if any entries were pruned.
"""
templates_dir = FLOW_ROOT / "templates"
orphaned = [
d for d in data["types"]
if d not in _PROTECTED_TYPES and not (templates_dir / d).is_dir()
]
if not orphaned:
return False
plan_registry_dir = FLOW_ROOT / "flow_json"
for dir_name in orphaned:
entry = data["types"][dir_name]
shorthand = entry.get("shorthand", entry.get("prefix", "").lower())
logger.info("[%s] Auto-pruning orphaned type '%s' (directory missing)", MODULE_NAME, dir_name)
del data["types"][dir_name]
if shorthand:
plan_reg = plan_registry_dir / f"{shorthand}_registry.json"
if plan_reg.exists():
plan_reg.unlink()
logger.info("[%s] Removed orphaned plan registry: %s", MODULE_NAME, plan_reg.name)
return True
def _auto_register_new_types(data: Dict[str, Any]) -> bool:
"""Detect unregistered template directories and register them.
Returns True if any new types were registered.
"""
templates_dir = FLOW_ROOT / "templates"
if not templates_dir.is_dir():
return False
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 or not list(child.glob("*.md")):
continue
prefix = _derive_prefix(child.name, used_prefixes)
if prefix is None:
continue # Collision — 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_plan_registry(shorthand)
return changed
def _derive_prefix(dir_name: str, used: set) -> str | None:
"""Derive a unique prefix from a directory name, or None if collision."""
first_word = dir_name.split("_")[0]
prefix = (first_word[0].upper() + "PLAN") if first_word else "XPLAN"
if prefix in used and len(first_word) > 1:
prefix = first_word[:2].upper() + "PLAN"
return None if prefix in used else prefix
def _create_plan_registry(shorthand: str) -> None:
"""Create an empty plan registry JSON for a new type."""
plan_reg = FLOW_ROOT / "flow_json" / f"{shorthand}_registry.json"
if plan_reg.exists():
return
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, shorthand, exc)
def save_registry(data: Dict[str, Any]) -> bool:
"""Save the template registry to disk.
@@ -92,18 +92,53 @@ def handle_command(command: str, args: list) -> bool:
return True
def _try_create_lock() -> bool:
"""Atomically create lock file with current PID. Returns True on success."""
try:
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
except FileExistsError:
logger.info("[%s] Lock file already exists, cannot acquire", MODULE_NAME)
return False
def _is_lock_stale() -> bool:
"""Check if existing lock file belongs to a dead process."""
try:
pid = int(LOCK_FILE.read_text(encoding="utf-8").strip())
os.kill(pid, 0)
logger.info("[%s] Another instance running (PID %d), exiting", MODULE_NAME, pid)
return False # Process alive — lock is valid
except (ValueError, ProcessLookupError, PermissionError):
logger.info("[%s] Stale lock found, taking over", MODULE_NAME)
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():
try:
pid = int(LOCK_FILE.read_text().strip())
os.kill(pid, 0) # Signal 0 = check if process exists
logger.info(f"[{MODULE_NAME}] Another instance running (PID {pid}), exiting")
return False
except (ValueError, ProcessLookupError, PermissionError):
logger.info(f"[{MODULE_NAME}] Stale lock found, taking over")
"""Try to acquire lock file. Returns True if acquired, False if another instance is running.
Uses atomic O_CREAT | O_EXCL to avoid TOCTOU race between existence check and write.
"""
if _try_create_lock():
return True
# Lock exists — check if stale
if not _is_lock_stale():
return False
# Remove stale lock and retry
try:
LOCK_FILE.unlink()
except OSError as exc:
logger.warning("[%s] Failed to remove stale lock: %s", MODULE_NAME, exc)
return False
if not _try_create_lock():
logger.info("[%s] Another process grabbed lock during retry", MODULE_NAME)
return False
LOCK_FILE.write_text(str(os.getpid()))
return True
+3 -2
View File
@@ -152,9 +152,10 @@ class TestExtractPlanNumber:
extract_plan_number = _import("extract_plan_number")
assert extract_plan_number(None) is None
def test_wrong_prefix(self):
def test_other_prefix(self):
"""Any PREFIX-number pattern is accepted (DPLAN, FPLAN, etc.)"""
extract_plan_number = _import("extract_plan_number")
assert extract_plan_number("DPLAN-0001") is None
assert extract_plan_number("DPLAN-0001") == "0001"
def test_no_dash(self):
extract_plan_number = _import("extract_plan_number")
+14 -20
View File
@@ -393,12 +393,14 @@ class TestAutoCloseOrphanedPlans:
class TestGetClosedPlans:
"""Tests for get_closed_plans()."""
_SINGLE_REG = ["fplan_registry.json"]
_DISCOVERY_PATH = "aipass.flow.apps.handlers.plan.get_closed_plans._get_all_registry_files"
_LOAD_PATH = "aipass.flow.apps.handlers.plan.get_closed_plans.load_registry"
def test_returns_only_closed_plans(self, mock_registry):
_, registry = mock_registry
with patch(
"aipass.flow.apps.handlers.plan.get_closed_plans.load_registry",
return_value=registry,
):
with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \
patch(self._LOAD_PATH, return_value=registry):
result = get_closed_plans()
assert len(result) == 1
@@ -412,19 +414,15 @@ class TestGetClosedPlans:
"1": {"status": "open", "subject": "active"},
}
}
with patch(
"aipass.flow.apps.handlers.plan.get_closed_plans.load_registry",
return_value=registry,
):
with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \
patch(self._LOAD_PATH, return_value=registry):
result = get_closed_plans()
assert result == []
def test_returns_empty_on_empty_registry(self):
with patch(
"aipass.flow.apps.handlers.plan.get_closed_plans.load_registry",
return_value={"plans": {}},
):
with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \
patch(self._LOAD_PATH, return_value={"plans": {}}):
result = get_closed_plans()
assert result == []
@@ -437,10 +435,8 @@ class TestGetClosedPlans:
"3": {"status": "open", "subject": "still going"},
}
}
with patch(
"aipass.flow.apps.handlers.plan.get_closed_plans.load_registry",
return_value=registry,
):
with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \
patch(self._LOAD_PATH, return_value=registry):
result = get_closed_plans()
assert len(result) == 2
@@ -449,10 +445,8 @@ class TestGetClosedPlans:
def test_result_tuples_contain_plan_num_and_info(self, mock_registry):
_, registry = mock_registry
with patch(
"aipass.flow.apps.handlers.plan.get_closed_plans.load_registry",
return_value=registry,
):
with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \
patch(self._LOAD_PATH, return_value=registry):
result = get_closed_plans()
for plan_num, plan_info in result:
+22 -2
View File
@@ -491,6 +491,26 @@
"standard": "deep_nesting",
"reason": "Vector search with nested result filtering and similarity scoring."
},
{
"file": "apps/handlers/symbolic/deduplicator.py",
"standard": "encapsulation",
"reason": "Cross-branch service call to api branch key management. api has no modules-level get_api_key() for external callers."
},
{
"file": "apps/handlers/symbolic/extractor.py",
"standard": "encapsulation",
"reason": "Cross-branch service call to api branch key management. api has no modules-level get_api_key() for external callers."
},
{
"file": "apps/handlers/symbolic/deduplicator.py",
"standard": "handlers",
"reason": "Cross-branch handler import for api key retrieval — api branch's canonical key interface is at handler level."
},
{
"file": "apps/handlers/symbolic/extractor.py",
"standard": "handlers",
"reason": "Cross-branch handler import for api key retrieval — api branch's canonical key interface is at handler level."
},
{
"file": "apps/handlers/symbolic/deduplicator.py",
"standard": "deep_nesting",
@@ -519,7 +539,7 @@
{
"file": "apps/handlers/learnings/manager.py",
"standard": "unused_function",
"lines": [764],
"lines": [773],
"reason": "Public API for learning extraction pipeline — called dynamically by symbolic extraction."
},
{
@@ -543,7 +563,7 @@
{
"file": "apps/handlers/symbolic/extractor.py",
"standard": "unused_function",
"lines": [437],
"lines": [427],
"reason": "LLM-based extraction function — called conditionally when API key is available."
}
],
@@ -113,7 +113,8 @@ def deduplicate_fragment(
try:
from aipass.api.apps.handlers.auth.keys import get_api_key
api_key = get_api_key("openrouter")
except ImportError:
except ImportError as e:
logger.warning(f"[deduplicator] api branch not available for key loading: {e}")
api_key = None
if not api_key:
@@ -356,7 +356,8 @@ def extract_fragments_llm(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]:
try:
from aipass.api.apps.handlers.auth.keys import get_api_key
api_key = get_api_key("openrouter")
except ImportError:
except ImportError as e:
logger.warning(f"[extractor] api branch not available for key loading: {e}")
api_key = None
if not api_key:
+7
View File
@@ -55,6 +55,13 @@
"subject": "DPLAN-0039 prax audit work items — test coverage, JSON corruption, stale cleanup",
"date_closed": "2026-03-29",
"location": "prax"
},
{
"plan_id": "FPLAN-0097",
"type": "FPLAN",
"subject": "Codex + Gemini log monitoring adapters for prax monitor",
"date_closed": "2026-04-10",
"location": "prax"
}
]
}
@@ -124,7 +124,7 @@ def detect_branch_from_path(module_path: str) -> Optional[str]:
# relative is like: flow/apps/module.py → parts[0] = "flow"
if len(relative.parts) >= 2:
branch = relative.parts[0]
json_handler.log_operation("introspection_resolved", {"module_path": module_path, "branch": branch})
json_handler.log_operation("introspection_resolved", {"module_path": module_path, "branch": branch}, module_name="prax_introspection")
return branch
except ValueError:
logger.info("Path %s is not relative to aipass package root", module_path)
@@ -134,7 +134,7 @@ def detect_branch_from_path(module_path: str) -> Optional[str]:
relative = path.relative_to(_SRC_ROOT)
if len(relative.parts) >= 2 and relative.parts[0] != "aipass":
branch = relative.parts[0]
json_handler.log_operation("introspection_resolved", {"module_path": module_path, "branch": branch, "outside_aipass": True})
json_handler.log_operation("introspection_resolved", {"module_path": module_path, "branch": branch, "outside_aipass": True}, module_name="prax_introspection")
return branch
except ValueError:
logger.info("Path %s is not relative to src root", module_path)
+5 -1
View File
@@ -225,4 +225,8 @@ Examples:
return 1
if __name__ == "__main__":
sys.exit(main())
try:
sys.exit(main())
except Exception as exc:
logger.error("Unhandled error in main: %s", exc)
sys.exit(1)
+24 -20
View File
@@ -226,32 +226,36 @@ def print_help() -> None:
def main() -> int:
"""Main entry point - routes to modules."""
modules = discover_modules()
args = sys.argv[1:]
try:
modules = discover_modules()
args = sys.argv[1:]
# No args → introspection (discovery mode)
if not args:
print_introspection()
return 0
# No args → introspection (discovery mode)
if not args:
print_introspection()
return 0
# Help flag → full help with usage
if args[0] in ["--help", "-h", "help"]:
print_help()
return 0
# Help flag → full help with usage
if args[0] in ["--help", "-h", "help"]:
print_help()
return 0
if args[0] in ["--version", "-V"]:
console.print(f"seedgo v{VERSION}")
return 0
if args[0] in ["--version", "-V"]:
console.print(f"seedgo v{VERSION}")
return 0
command = args[0]
remaining = args[1:] if len(args) > 1 else []
command = args[0]
remaining = args[1:] if len(args) > 1 else []
# Route to modules
if route_command(command, remaining, modules):
return 0
# Route to modules
if route_command(command, remaining, modules):
return 0
error(f"Unknown command: {command}", suggestion="Run 'seedgo --help' for usage")
return 1
error(f"Unknown command: {command}", suggestion="Run 'seedgo --help' for usage")
return 1
except Exception as exc:
logger.error('[seedgo] Unhandled error in main: %s', exc)
raise
if __name__ == "__main__":
+94 -100
View File
@@ -26,9 +26,6 @@ from aipass.spawn.apps.handlers.registry import (
)
from aipass.spawn.apps.handlers.json import json_handler
# Repo root — resolved from spawn package location
_REPO_ROOT = Path(__file__).parents[5] # handlers/apps/spawn/aipass/src/AIPass
# Branches that cannot be deleted (critical infrastructure)
_PROTECTED_BRANCHES = {"spawn", "devpulse", "drone"}
@@ -37,6 +34,81 @@ _PROTECTED_BRANCHES = {"spawn", "devpulse", "drone"}
# PUBLIC API
# =============================================================================
def _resolve_branch_dir(branch_name, registry_path, registry):
"""Find branch entry and directory from registry.
Returns:
Tuple of (branch_entry, branch_dir) or (None, None) if not found.
"""
project_root = registry_path.parent
for entry in _branches_as_list(registry.get("branches", [])):
if entry.get("name", "").lower() == branch_name.lower():
rel_path = entry.get("path", "")
branch_dir = (project_root / rel_path).resolve() if rel_path else None
return entry, branch_dir
return None, None
def _archive_branch(branch_dir, archive_dir):
"""Copy branch to archive directory. Cleans up on failure.
Returns:
Error message string, or None on success.
"""
archive_dir.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.copytree(str(branch_dir), str(archive_dir))
logger.info(f"[delete] Archived to {archive_dir}")
return None
except Exception as exc:
msg = f"Failed to create archive: {exc}"
logger.error(f"[delete] {msg}")
_cleanup_partial_archive(archive_dir)
return msg
def _cleanup_partial_archive(archive_dir):
"""Remove partially-created archive directory."""
if not archive_dir.exists():
return
try:
shutil.rmtree(str(archive_dir))
except Exception as cleanup_exc:
logger.warning("[delete] Failed to clean up partial archive %s: %s", archive_dir, cleanup_exc)
def _remove_from_registry(registry, branch_name, registry_path):
"""Remove branch from registry and save.
Returns:
True if registry was updated successfully.
"""
branches = registry.get("branches", [])
if isinstance(branches, dict):
for key in list(branches.keys()):
if key.lower() == branch_name.lower() or branches[key].get("name", "").lower() == branch_name.lower():
del branches[key]
registry["branches"] = branches
else:
registry["branches"] = [
b for b in branches
if b.get("name", "").lower() != branch_name.lower()
]
registry["metadata"]["total_branches"] = len(_branches_as_list(registry["branches"]))
return save_registry(registry_path, registry)
def _error_result(branch_name, msg, archive_path="", registry_updated=False):
"""Build a failure result dict."""
return {
"branch": branch_name,
"success": False,
"archive_path": archive_path,
"registry_updated": registry_updated,
"error": msg,
}
def delete_branch(
branch_name: str,
confirm: bool = True,
@@ -44,22 +116,8 @@ def delete_branch(
) -> dict:
"""Archive and deregister a branch.
Workflow:
1. Resolve branch path from AIPASS_REGISTRY.json
2. Verify branch exists on filesystem
3. If confirm=True -> print what will happen, prompt "Are you sure? (y/N)"
4. If dry_run -> print summary, return
5. Create archive directory: {repo_root}/.archive/deleted_branches/{name}_{timestamp}/
6. Copy entire branch directory to archive (shutil.copytree)
7. Remove branch from AIPASS_REGISTRY.json
8. Remove branch directory (shutil.rmtree)
9. Log and return summary
SAFETY:
- Cannot delete spawn (self-protection)
- Cannot delete devpulse (orchestration hub protection)
- Cannot delete drone (routing infrastructure protection)
- Archive ALWAYS created before deletion
SAFETY: Cannot delete spawn, devpulse, or drone (protected).
Archive ALWAYS created before deletion.
Returns:
Dict with deletion results.
@@ -68,52 +126,25 @@ def delete_branch(
if branch_name.lower() in _PROTECTED_BRANCHES:
msg = f"Cannot delete '{branch_name}' — protected branch ({', '.join(sorted(_PROTECTED_BRANCHES))})"
logger.warning(f"[delete] {msg}")
return {
"branch": branch_name,
"success": False,
"archive_path": "",
"registry_updated": False,
"error": msg,
}
return _error_result(branch_name, msg)
# 1. Resolve branch path from registry
registry_path = find_registry()
project_root = registry_path.parent
registry = load_registry(registry_path)
branch_entry = None
branch_dir = None
for entry in _branches_as_list(registry.get("branches", [])):
if entry.get("name", "").lower() == branch_name.lower():
branch_entry = entry
rel_path = entry.get("path", "")
if rel_path:
branch_dir = (_REPO_ROOT / rel_path).resolve()
break
branch_entry, branch_dir = _resolve_branch_dir(branch_name, registry_path, registry)
if branch_entry is None:
msg = f"Branch '{branch_name}' not found in registry"
logger.warning(f"[delete] {msg}")
return {
"branch": branch_name,
"success": False,
"archive_path": "",
"registry_updated": False,
"error": msg,
}
return _error_result(branch_name, msg)
# 2. Verify branch exists on filesystem
if branch_dir is None or not branch_dir.is_dir():
msg = f"Branch directory does not exist: {branch_dir}"
logger.warning(f"[delete] {msg}")
return {
"branch": branch_name,
"success": False,
"archive_path": "",
"registry_updated": False,
"error": msg,
}
return _error_result(branch_name, msg)
# 3. Confirmation prompt
# 2. Confirmation prompt
if confirm and not dry_run:
try:
answer = input("Are you sure? (y/N): ").strip().lower()
@@ -121,17 +152,11 @@ def delete_branch(
logger.warning("Delete confirmation prompt interrupted: %s", e)
answer = ""
if answer != "y":
return {
"branch": branch_name,
"success": False,
"archive_path": "",
"registry_updated": False,
"error": "Cancelled by user",
}
return _error_result(branch_name, "Cancelled by user")
# 4. Dry run — report and return
# 3. Dry run — report and return
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
archive_dir = _REPO_ROOT / ".archive" / "deleted_branches" / f"{branch_name}_{timestamp}"
archive_dir = project_root / ".archive" / "deleted_branches" / f"{branch_name}_{timestamp}"
if dry_run:
logger.info(f"[delete] Dry run: would archive {branch_name} to {archive_dir}")
@@ -143,57 +168,26 @@ def delete_branch(
"dry_run": True,
}
# 5. Create archive
archive_dir.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.copytree(str(branch_dir), str(archive_dir))
logger.info(f"[delete] Archived {branch_name} to {archive_dir}")
except Exception as exc:
msg = f"Failed to create archive: {exc}"
logger.error(f"[delete] {msg}")
return {
"branch": branch_name,
"success": False,
"archive_path": "",
"registry_updated": False,
"error": msg,
}
# 6. Remove from registry
branches = registry.get("branches", [])
if isinstance(branches, dict):
# Dict format: remove by key (try both cases)
for key in list(branches.keys()):
if key.lower() == branch_name.lower() or branches[key].get("name", "").lower() == branch_name.lower():
del branches[key]
registry["branches"] = branches
else:
registry["branches"] = [
b for b in branches
if b.get("name", "").lower() != branch_name.lower()
]
registry["metadata"]["total_branches"] = len(_branches_as_list(registry["branches"]))
registry_updated = save_registry(registry_path, registry)
# 4. Create archive
archive_error = _archive_branch(branch_dir, archive_dir)
if archive_error:
return _error_result(branch_name, archive_error)
# 5. Remove from registry
registry_updated = _remove_from_registry(registry, branch_name, registry_path)
if registry_updated:
logger.info(f"[delete] Removed {branch_name} from registry")
else:
logger.error(f"[delete] Failed to update registry after removing {branch_name}")
# 7. Remove branch directory
# 6. Remove branch directory
try:
shutil.rmtree(str(branch_dir))
logger.info(f"[delete] Removed branch directory: {branch_dir}")
except Exception as exc:
msg = f"Archive created but failed to remove directory: {exc}"
logger.error(f"[delete] {msg}")
return {
"branch": branch_name,
"success": False,
"archive_path": str(archive_dir),
"registry_updated": registry_updated,
"error": msg,
}
return _error_result(branch_name, msg, str(archive_dir), registry_updated)
json_handler.log_operation("delete_executed", data={"branch": branch_name})
@@ -23,6 +23,8 @@ SKIP_NAMES = {"__pycache__", ".git", ".template_registry.json"}
def ensure_directory(path):
"""Create directory and parents if they don't exist."""
if path is None:
raise ValueError("ensure_directory received None path — caller passed invalid target")
Path(path).mkdir(parents=True, exist_ok=True)
@@ -98,9 +98,14 @@ def grant_passport(
# Regenerate template registry
regenerate_template_registry(target)
# Register in AIPASS_REGISTRY.json
# Register in AIPASS_REGISTRY.json (store relative path for portability)
try:
registry_branch_path = str(target.relative_to(reg_path.parent))
except ValueError:
logger.warning("[passport] Cannot relativize path %s to registry %s, storing absolute", target, reg_path.parent)
registry_branch_path = str(target)
registry_updated = add_to_registry(
reg_path, branch_upper, str(target), detected_profile,
reg_path, branch_upper, registry_branch_path, detected_profile,
f"@{branch_lower}", purpose or "Birthright citizen - purpose TBD",
)
+3 -4
View File
@@ -36,8 +36,6 @@ from aipass.spawn.apps.handlers.placeholders import build_replacements_dict, rep
from aipass.spawn.apps.handlers.registry import find_registry, load_registry, _branches_as_list
from aipass.spawn.apps.handlers.json import json_handler
# Repo root — resolved from spawn package location
_REPO_ROOT = Path(__file__).parents[5] # handlers/apps/spawn/aipass/src/AIPass
# =============================================================================
@@ -347,9 +345,10 @@ def _resolve_branch_path(branch_name: str) -> Path | None:
"""Resolve a branch name to its absolute directory path via the registry.
Tries both the exact name and common case variants.
Registry paths are relative to repo root.
Registry paths are relative to registry parent (project root).
"""
registry_path = find_registry()
project_root = registry_path.parent
registry = load_registry(registry_path)
for branch in _branches_as_list(registry.get("branches", [])):
@@ -357,7 +356,7 @@ def _resolve_branch_path(branch_name: str) -> Path | None:
if reg_name.lower() == branch_name.lower():
rel_path = branch.get("path", "")
if rel_path:
return (_REPO_ROOT / rel_path).resolve()
return (project_root / rel_path).resolve()
return None
+16 -7
View File
@@ -61,6 +61,19 @@ def handle_command(command: str, args: list) -> bool:
return handle_passport(args) == 0
def _search_src_subdirs(src_dir: Path, dirname: str) -> Path | None:
"""Search src/*/dirname for an existing directory."""
if not src_dir.is_dir():
return None
for sub in src_dir.iterdir():
if not sub.is_dir():
continue
candidate = sub / dirname
if candidate.exists():
return candidate
return None
def _resolve_target(dirname: str) -> Path:
"""Resolve a @dirname to a filesystem path.
@@ -87,13 +100,9 @@ def _resolve_target(dirname: str) -> Path:
if candidate.exists():
return candidate
# Search src/*/dirname (e.g., src/aipass/target)
src_dir = project_root / "src"
if src_dir.is_dir():
for sub in src_dir.iterdir():
if sub.is_dir():
candidate = sub / dirname
if candidate.exists():
return candidate
found = _search_src_subdirs(project_root / "src", dirname)
if found:
return found
except Exception as exc:
logger.warning("[passport] Registry lookup failed during target resolution: %s", exc)
@@ -1,350 +1,390 @@
{
"metadata": {
"description": "Template registry for tracking files",
"generated": true
"version": "1.0.0",
"last_updated": "2026-04-10",
"description": "Template file tracking registry for ID-based updates"
},
"files": {
"f001": {
"path": ".ai_mail.local/README.md",
"name": "README.md",
"content_hash": "49299c242a018d73",
"has_branch_placeholder": true
"content_hash": "49299c242a01",
"has_branch_placeholder": false
},
"f002": {
"path": ".ai_mail.local/inbox.json",
"name": "inbox.json",
"content_hash": "c9702fe2cc21b748",
"content_hash": "c9702fe2cc21",
"has_branch_placeholder": false
},
"f003": {
"path": ".aipass/README.md",
"name": "README.md",
"content_hash": "f42d87684fdfb1af",
"has_branch_placeholder": true
"content_hash": "f42d87684fdf",
"has_branch_placeholder": false
},
"f004": {
"path": ".aipass/aipass_local_prompt.md",
"name": "aipass_local_prompt.md",
"content_hash": "5f8e2a3859a99906",
"has_branch_placeholder": true
"content_hash": "5f8e2a3859a9",
"has_branch_placeholder": false
},
"f005": {
"path": ".archive/README.md",
"name": "README.md",
"content_hash": "93d3fcb74f234b1d",
"has_branch_placeholder": true
"content_hash": "93d3fcb74f23",
"has_branch_placeholder": false
},
"f006": {
"path": ".claude/README.md",
"name": "README.md",
"content_hash": "adb0ce8c53c1da2d",
"has_branch_placeholder": true
"content_hash": "adb0ce8c53c1",
"has_branch_placeholder": false
},
"f007": {
"path": ".claude/settings.local.json",
"name": "settings.local.json",
"content_hash": "eacf065629cd9c03",
"content_hash": "eacf065629cd",
"has_branch_placeholder": false
},
"f008": {
"path": ".gitignore",
"name": ".gitignore",
"content_hash": "841dedb922da7ddb",
"content_hash": "841dedb922da",
"has_branch_placeholder": false
},
"f009": {
"path": ".seedgo/README.md",
"name": "README.md",
"content_hash": "ea03468bbf16f248",
"has_branch_placeholder": true
"content_hash": "ea03468bbf16",
"has_branch_placeholder": false
},
"f010": {
"path": ".seedgo/bypass.json",
"name": "bypass.json",
"content_hash": "0ac90a35515b35c2",
"has_branch_placeholder": true
"content_hash": "0ac90a35515b",
"has_branch_placeholder": false
},
"f024": {
"path": ".spawn/.registry_ignore.json",
"name": ".registry_ignore.json",
"content_hash": "34f5e7ff7e01",
"has_branch_placeholder": false
},
"f043": {
"path": ".spawn/README.md",
"name": "README.md",
"content_hash": "e22ad5337efd",
"has_branch_placeholder": false
},
"f011": {
"path": ".trinity/README.md",
"name": "README.md",
"content_hash": "f461c9b16fc566a0",
"has_branch_placeholder": true
"content_hash": "f461c9b16fc5",
"has_branch_placeholder": false
},
"f012": {
"path": ".trinity/local.json",
"name": "local.json",
"content_hash": "f4e62d2d12bada2e",
"has_branch_placeholder": true
"content_hash": "f4e62d2d12ba",
"has_branch_placeholder": false
},
"f013": {
"path": ".trinity/observations.json",
"name": "observations.json",
"content_hash": "25b957960a5939a3",
"has_branch_placeholder": true
"content_hash": "25b957960a59",
"has_branch_placeholder": false
},
"f014": {
"path": ".trinity/passport.json",
"name": "passport.json",
"content_hash": "04c2ead2a4284027",
"has_branch_placeholder": true
"content_hash": "9b1d3a691272",
"has_branch_placeholder": false
},
"f015": {
"path": "CLAUDE.md",
"name": "CLAUDE.md",
"content_hash": "e24c75348c4faa57",
"has_branch_placeholder": true
"content_hash": "e24c75348c4f",
"has_branch_placeholder": false
},
"f016": {
"path": "DASHBOARD.local.json",
"name": "DASHBOARD.local.json",
"content_hash": "88360d943d4bacb6",
"has_branch_placeholder": true
"content_hash": "88360d943d4b",
"has_branch_placeholder": false
},
"f017": {
"path": "README.md",
"name": "README.md",
"content_hash": "ad99517a50f96ba7",
"has_branch_placeholder": true
"content_hash": "ad99517a50f9",
"has_branch_placeholder": false
},
"f018": {
"path": "STATUS.local.md",
"name": "STATUS.local.md",
"content_hash": "059295ad4d4e62eb",
"has_branch_placeholder": true
"content_hash": "059295ad4d4e",
"has_branch_placeholder": false
},
"f019": {
"path": "apps/README.md",
"name": "README.md",
"content_hash": "92a956009e0e22dc",
"has_branch_placeholder": true
"content_hash": "92a956009e0e",
"has_branch_placeholder": false
},
"f020": {
"path": "apps/__init__.py",
"name": "__init__.py",
"content_hash": "41b011f487af14dc",
"has_branch_placeholder": true
"content_hash": "41b011f487af",
"has_branch_placeholder": false
},
"f021": {
"path": "apps/handlers/README.md",
"name": "README.md",
"content_hash": "2e4f4a0c1b474ffa",
"has_branch_placeholder": true
"content_hash": "2e4f4a0c1b47",
"has_branch_placeholder": false
},
"f022": {
"path": "apps/handlers/__init__.py",
"name": "__init__.py",
"content_hash": "55afc5eddbe04134",
"has_branch_placeholder": true
"content_hash": "55afc5eddbe0",
"has_branch_placeholder": false
},
"f023": {
"path": "apps/modules/README.md",
"name": "README.md",
"content_hash": "a4cf0a8e3b4ff993",
"has_branch_placeholder": true
"content_hash": "a4cf0a8e3b4f",
"has_branch_placeholder": false
},
"f024": {
"f044": {
"path": "apps/modules/__init__.py",
"name": "__init__.py",
"content_hash": "e3b0c44298fc1c14",
"content_hash": "e3b0c44298fc",
"has_branch_placeholder": false
},
"f025": {
"path": "apps/plugins/README.md",
"name": "README.md",
"content_hash": "d1e4e2b98c384ca2",
"has_branch_placeholder": true
},
"f026": {
"path": "apps/plugins/__init__.py",
"name": "__init__.py",
"content_hash": "e3b0c44298fc1c14",
"content_hash": "d1e4e2b98c38",
"has_branch_placeholder": false
},
"f027": {
"path": "apps/{{BRANCH}}.py",
"name": "{{BRANCH}}.py",
"content_hash": "fc71b424c10bb172",
"content_hash": "e3893ff95aa5",
"has_branch_placeholder": true
},
"f028": {
"path": "artifacts/README.md",
"name": "README.md",
"content_hash": "de20d11e5cfd3586",
"has_branch_placeholder": true
},
"f029": {
"path": "artifacts/birth_certificate.json",
"name": "birth_certificate.json",
"content_hash": "1ec401f4e397eec7",
"has_branch_placeholder": true
"content_hash": "de20d11e5cfd",
"has_branch_placeholder": false
},
"f030": {
"path": "docs/README.md",
"name": "README.md",
"content_hash": "2434da568727499d",
"has_branch_placeholder": true
"content_hash": "2434da568727",
"has_branch_placeholder": false
},
"f031": {
"path": "docs.local/README.md",
"name": "README.md",
"content_hash": "c19d8872ea2c2c25",
"has_branch_placeholder": true
"content_hash": "c19d8872ea2c",
"has_branch_placeholder": false
},
"f032": {
"path": "docs.local/sub_agent_drops/README.md",
"name": "README.md",
"content_hash": "e3e5a6b9c9c51817",
"content_hash": "e3e5a6b9c9c5",
"has_branch_placeholder": false
},
"f033": {
"path": "dropbox/README.md",
"name": "README.md",
"content_hash": "9e1e9b71f93b4cde",
"has_branch_placeholder": true
"content_hash": "9e1e9b71f93b",
"has_branch_placeholder": false
},
"f034": {
"path": "logs/README.md",
"name": "README.md",
"content_hash": "4ca207af6bd3a648",
"has_branch_placeholder": true
"content_hash": "4ca207af6bd3",
"has_branch_placeholder": false
},
"f035": {
"path": "pytest.ini",
"name": "pytest.ini",
"content_hash": "7b39ba7bca4025c5",
"content_hash": "7b39ba7bca40",
"has_branch_placeholder": false
},
"f036": {
"path": "templates/README.md",
"name": "README.md",
"content_hash": "73b020b003f900e5",
"has_branch_placeholder": true
"content_hash": "73b020b003f9",
"has_branch_placeholder": false
},
"f037": {
"path": "tests/README.md",
"name": "README.md",
"content_hash": "c157895c9b27d3ba",
"has_branch_placeholder": true
"content_hash": "c157895c9b27",
"has_branch_placeholder": false
},
"f038": {
"path": "tests/__init__.py",
"name": "__init__.py",
"content_hash": "881f06bb6574d2f7",
"has_branch_placeholder": true
"content_hash": "881f06bb6574",
"has_branch_placeholder": false
},
"f039": {
"path": "tests/conftest.py",
"name": "conftest.py",
"content_hash": "5d98b049957b24a4",
"content_hash": "5d98b049957b",
"has_branch_placeholder": false
},
"f040": {
"path": "tools/README.md",
"name": "README.md",
"content_hash": "3c7eaedb16ac2695",
"has_branch_placeholder": true
"content_hash": "3c7eaedb16ac",
"has_branch_placeholder": false
},
"f041": {
"path": "{{BRANCH}}_json/README.md",
"name": "README.md",
"content_hash": "e64fa555e7b8e20f",
"has_branch_placeholder": true
"content_hash": "e64fa555e7b8",
"has_branch_placeholder": false
},
"f042": {
"path": "{{BRANCH}}_json/custom_config/README.md",
"name": "README.md",
"content_hash": "28e9ae3735636611",
"has_branch_placeholder": true
"content_hash": "28e9ae373563",
"has_branch_placeholder": false
},
"f029": {
"path": "artifacts/birth_certificate.json",
"name": "birth_certificate.json",
"content_hash": "0b6e4319781e",
"has_branch_placeholder": false
},
"f026": {
"path": "apps/plugins/__init__.py",
"name": "__init__.py",
"content_hash": "e3b0c44298fc",
"has_branch_placeholder": false
}
},
"directories": {
"d001": {
"path": ".ai_mail.local",
"name": ".ai_mail.local"
"name": ".ai_mail.local",
"has_branch_placeholder": false
},
"d002": {
"path": ".ai_mail.local/sent",
"name": "sent"
"name": "sent",
"has_branch_placeholder": false
},
"d003": {
"path": ".aipass",
"name": ".aipass"
"name": ".aipass",
"has_branch_placeholder": false
},
"d004": {
"path": ".archive",
"name": ".archive"
"name": ".archive",
"has_branch_placeholder": false
},
"d005": {
"path": ".claude",
"name": ".claude"
"name": ".claude",
"has_branch_placeholder": false
},
"d006": {
"path": ".seedgo",
"name": ".seedgo"
"name": ".seedgo",
"has_branch_placeholder": false
},
"d023": {
"path": ".spawn",
"name": ".spawn",
"has_branch_placeholder": false
},
"d007": {
"path": ".trinity",
"name": ".trinity"
"name": ".trinity",
"has_branch_placeholder": false
},
"d008": {
"path": "apps",
"name": "apps"
"name": "apps",
"has_branch_placeholder": false
},
"d009": {
"path": "apps/handlers",
"name": "handlers"
"name": "handlers",
"has_branch_placeholder": false
},
"d010": {
"path": "apps/modules",
"name": "modules"
"name": "modules",
"has_branch_placeholder": false
},
"d011": {
"path": "apps/plugins",
"name": "plugins"
"name": "plugins",
"has_branch_placeholder": false
},
"d012": {
"path": "artifacts",
"name": "artifacts"
"name": "artifacts",
"has_branch_placeholder": false
},
"d013": {
"path": "docs",
"name": "docs"
"name": "docs",
"has_branch_placeholder": false
},
"d014": {
"path": "docs.local",
"name": "docs.local"
"name": "docs.local",
"has_branch_placeholder": false
},
"d015": {
"path": "docs.local/sub_agent_drops",
"name": "sub_agent_drops"
"name": "sub_agent_drops",
"has_branch_placeholder": false
},
"d016": {
"path": "dropbox",
"name": "dropbox"
"name": "dropbox",
"has_branch_placeholder": false
},
"d017": {
"path": "logs",
"name": "logs"
"name": "logs",
"has_branch_placeholder": false
},
"d018": {
"path": "templates",
"name": "templates"
"name": "templates",
"has_branch_placeholder": false
},
"d019": {
"path": "tests",
"name": "tests"
"name": "tests",
"has_branch_placeholder": false
},
"d020": {
"path": "tools",
"name": "tools"
"name": "tools",
"has_branch_placeholder": false
},
"d021": {
"path": "{{BRANCH}}_json",
"name": "{{BRANCH}}_json"
"name": "{{BRANCH}}_json",
"has_branch_placeholder": true
},
"d022": {
"path": "{{BRANCH}}_json/custom_config",
"name": "custom_config"
"name": "custom_config",
"has_branch_placeholder": false
}
}
}
@@ -19,7 +19,7 @@
"identity": {
"citizen_class": "builder",
"role": "{{ROLE}}",
"purpose": "",
"purpose": "{{PURPOSE_BRIEF}}",
"what_i_do": [],
"what_i_dont_do": []
},
@@ -32,7 +32,7 @@ def discover_modules() -> List[Any]:
if file_path.name.startswith("_"):
continue
module_name = f"apps.modules.{file_path.stem}"
module_name = f"aipass.{{BRANCH}}.apps.modules.{file_path.stem}"
try:
module = importlib.import_module(module_name)
@@ -5,10 +5,10 @@
"creator": "SYSTEM",
"owner": "{{BRANCHNAME}}",
"rarity": "unique",
"description": "Official birth certificate for {{BRANCHNAME}}. Citizen #{{CITIZEN_NUMBER}}, registered using '{{PROFILE}}' template. Purpose: {{PURPOSE_BRIEF}}",
"description": "Official birth certificate for {{BRANCHNAME}}. Citizen #{{CITIZEN_NUMBER}}, builder class. Purpose: {{PURPOSE_BRIEF}}",
"metadata": {
"citizen_number": "{{CITIZEN_NUMBER}}",
"template": "{{PROFILE}}",
"citizen_class": "builder",
"purpose": "{{PURPOSE_BRIEF}}"
},
"created_at": "{{DATE}}"
+7
View File
@@ -13,6 +13,8 @@ import json
from pathlib import Path
from unittest.mock import patch
import pytest
from aipass.spawn.apps.handlers.file_ops import (
SKIP_NAMES,
_replace_path_placeholders,
@@ -68,6 +70,11 @@ class TestEnsureDirectory:
assert target.exists()
assert target.is_dir()
def test_ensure_directory_none_raises_valueerror(self) -> None:
"""Passing None raises ValueError with clear message."""
with pytest.raises(ValueError, match="ensure_directory received None path"):
ensure_directory(None)
# ---------------------------------------------------------------------------
# copy_template
+8 -16
View File
@@ -119,8 +119,7 @@ class TestDeleteBranch:
"""Successful delete should archive the branch and remove from registry."""
from aipass.spawn.apps.handlers.delete_ops import delete_branch
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
result = delete_branch("test_api", confirm=False)
@@ -146,8 +145,7 @@ class TestDeleteBranch:
"""Cannot delete spawn (self-protection)."""
from aipass.spawn.apps.handlers.delete_ops import delete_branch
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
result = delete_branch("spawn", confirm=False)
@@ -158,8 +156,7 @@ class TestDeleteBranch:
"""Cannot delete devpulse (orchestration hub protection)."""
from aipass.spawn.apps.handlers.delete_ops import delete_branch
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
result = delete_branch("devpulse", confirm=False)
@@ -170,8 +167,7 @@ class TestDeleteBranch:
"""Cannot delete drone (routing infrastructure protection)."""
from aipass.spawn.apps.handlers.delete_ops import delete_branch
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
result = delete_branch("drone", confirm=False)
@@ -182,8 +178,7 @@ class TestDeleteBranch:
"""Dry run should NOT delete or archive anything."""
from aipass.spawn.apps.handlers.delete_ops import delete_branch
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
result = delete_branch("test_api", confirm=False, dry_run=True)
@@ -202,8 +197,7 @@ class TestDeleteBranch:
"""Deleting a branch not in registry should fail gracefully."""
from aipass.spawn.apps.handlers.delete_ops import delete_branch
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
result = delete_branch("nonexistent", confirm=False)
@@ -214,8 +208,7 @@ class TestDeleteBranch:
"""Cancelling confirmation should not delete."""
from aipass.spawn.apps.handlers.delete_ops import delete_branch
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry), \
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry), \
patch("builtins.input", return_value="n"):
result = delete_branch("test_api", confirm=True)
@@ -736,8 +729,7 @@ class TestHandleDelete:
"""handle_delete should reject protected branches."""
from aipass.spawn.apps.modules.delete import handle_delete
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
result = handle_delete(["--yes", "@spawn"])
+12 -24
View File
@@ -201,8 +201,7 @@ class TestUpdateBranch:
assert not meta_path.exists()
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
result = update_branch("test_branch")
@@ -220,8 +219,7 @@ class TestUpdateBranch:
dashboard_before = (branch_dir / "DASHBOARD.local.json").read_text()
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
result = update_branch("test_branch", dry_run=True)
@@ -248,8 +246,7 @@ class TestUpdateBranch:
original_content = branch_py_content
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
result = update_branch("test_branch")
@@ -282,8 +279,7 @@ class TestUpdateBranch:
reg_path.write_text(json.dumps(reg, indent=2) + "\n")
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
result = update_branch("test_branch")
@@ -302,8 +298,7 @@ class TestUpdateBranch:
from aipass.spawn.apps.handlers.update_ops import update_branch
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
result = update_branch("nonexistent_branch")
@@ -329,8 +324,7 @@ class TestUpdateBranch:
reg_path.write_text(json.dumps(reg, indent=2) + "\n")
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
result = update_branch("test_branch")
@@ -373,8 +367,7 @@ class TestUpdateBranch:
save_branch_meta(branch_dir, meta)
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
result = update_branch("test_branch")
@@ -398,8 +391,7 @@ class TestUpdateAll:
from aipass.spawn.apps.handlers.update_ops import update_all
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
results = update_all()
@@ -434,8 +426,7 @@ class TestUpdateAll:
mock_registry.write_text(json.dumps(reg, indent=2) + "\n")
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
results = update_all()
@@ -459,8 +450,7 @@ class TestHandleUpdate:
from aipass.spawn.apps.modules.update import handle_update
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
result = handle_update(["@test_branch"])
@@ -471,8 +461,7 @@ class TestHandleUpdate:
from aipass.spawn.apps.modules.update import handle_update
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
result = handle_update(["--dry-run", "@test_branch"])
@@ -493,8 +482,7 @@ class TestHandleUpdate:
from aipass.spawn.apps.modules.update import handle_update
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
result = handle_update(["builder", "--all"])
@@ -6,7 +6,7 @@ Event bus and error dispatch for AIPass. I detect errors, fingerprint them, gate
## Architecture
```
trigger.py (entry point — auto-discovers modules/)
├── core.py → Event bus: fire/on/off/status (12 events, 12 handlers)
├── core.py → Event bus: fire/on/off/status (14 events, 14 handlers)
├── errors.py → Error registry CLI: list/detail/suppress/resolve/stats/circuit-breaker
├── medic.py → Medic toggle: on/off/status/mute/unmute
├── log_events.py → Centralized log watcher (system_logs/)
+26 -8
View File
@@ -119,7 +119,7 @@
"file": "apps/config.py",
"standard": "error_handling",
"lines": [
31
36
],
"pattern": "except Exception: pass",
"reason": "Meta-logging helper _log_warning() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass."
@@ -128,16 +128,16 @@
"file": "apps/modules/core.py",
"standard": "error_handling",
"lines": [
160
61
],
"pattern": "except Exception: pass",
"reason": "Deferred event queue handler execution \u2014 logging would cause infinite recursion (logger fires events via trigger). Same as silent_catch bypass."
"pattern": "except ImportError",
"reason": "Handler import failure in _ensure_initialized — has logger.warning, not actually silent. Checker false positive on try block line."
},
{
"file": "apps/handlers/json/json_handler.py",
"standard": "error_handling",
"lines": [
24
31
],
"pattern": "except Exception: pass",
"reason": "Meta-logging helper _log_warning() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass."
@@ -146,7 +146,7 @@
"file": "apps/handlers/events/plan_file.py",
"standard": "error_handling",
"lines": [
58
59
],
"pattern": "except Exception: pass",
"reason": "Meta-logging helper _log_error() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass."
@@ -155,7 +155,7 @@
"file": "apps/handlers/events/memory_threshold_exceeded.py",
"standard": "error_handling",
"lines": [
46
47
],
"pattern": "except Exception: pass",
"reason": "Meta-logging helper _log_warning() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass."
@@ -182,7 +182,7 @@
"file": "apps/handlers/events/error_detected.py",
"standard": "error_handling",
"lines": [
54
53
],
"pattern": "except Exception: pass",
"reason": "Meta-logging helper _log_warning() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass."
@@ -373,6 +373,24 @@
"standard": "silent_catch",
"lines": [247],
"reason": "Exception is handled: calls error() to display user-facing message with suggestion. Not a silent catch \u2014 the user sees the error output."
},
{
"file": "apps/modules/core.py",
"standard": "silent_catch",
"lines": [26, 171, 175],
"reason": "Line 26: ImportError fallback to rich Console. Lines 171/175: _coerce_value() type-testing try/except — intentional control flow for int/float/str coercion."
},
{
"file": "apps/config.py",
"standard": "silent_catch",
"lines": [37, 66, 96],
"reason": "Line 37: meta-logging _log_warning() — cannot log a failure to log. Line 66: atomic_write_json cleanup of tmp file during error — can't fail on cleanup. Line 96: ImportError fallback with _log_warning + rich Console."
},
{
"file": "apps/handlers/log_watcher.py",
"standard": "silent_catch",
"lines": [233],
"reason": "Timestamp format parsing loop — tries multiple datetime formats, ValueError is expected control flow on non-matching formats."
}
],
"notes": {
+23
View File
@@ -13,9 +13,11 @@ Provides package-relative paths for trigger data directories.
Works in both pip-installed and development environments.
"""
import fcntl
import json
import os
import tempfile
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime, timezone
@@ -66,6 +68,27 @@ def atomic_write_json(path: Path, data, indent: int = 2, ensure_ascii: bool = Tr
raise
@contextmanager
def json_file_lock(path: Path):
"""Acquire exclusive lock for a JSON file's read-modify-write cycle.
Uses a .lock sidecar file with fcntl.flock to prevent concurrent
processes from corrupting state during read-modify-write. Combine
with atomic_write_json for both concurrency and crash safety.
Args:
path: The JSON file to lock (lock acquired on path.with_suffix('.lock'))
"""
lock_path = path.with_suffix('.lock')
lock_path.parent.mkdir(parents=True, exist_ok=True)
with open(lock_path, 'w', encoding='utf-8') as lock_f:
fcntl.flock(lock_f, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_f, fcntl.LOCK_UN)
def print_introspection():
"""Display module introspection info."""
try:
@@ -45,12 +45,13 @@ from typing import Any, Dict, List, Optional
from aipass.prax.apps.modules.logger import get_direct_logger
from aipass.trigger.apps.config import TRIGGER_ROOT, atomic_write_json
from aipass.trigger.apps.config import TRIGGER_ROOT, atomic_write_json, json_file_lock
from aipass.trigger.apps.handlers.json import json_handler
logger = get_direct_logger()
REGISTRY_FILE = TRIGGER_ROOT / "trigger_json" / "error_registry.json"
TRIGGER_CONFIG_FILE = TRIGGER_ROOT / "trigger_json" / "trigger_config.json"
CB_STATE_FILE = TRIGGER_ROOT / "trigger_json" / "trigger_cb_state.json"
VALID_STATUSES = ('new', 'investigating', 'suppressed', 'resolved')
VALID_SEVERITIES = ('low', 'medium', 'high', 'critical')
@@ -129,79 +130,95 @@ class CircuitBreakerState:
# ---------------------------------------------------------------------------
def _save_circuit_breaker_state() -> None:
"""Persist circuit breaker state to trigger_config.json.
"""Persist full circuit breaker + per-fingerprint state to trigger_cb_state.json.
Saves the current breaker state, opened_at timestamp, and cooldown
under the 'circuit_breaker' key so the state survives process restarts.
Saves CB state (including recent_errors, half_open_allow) and per-fingerprint
dispatch tracking so the full state survives process restarts.
"""
try:
data: Dict[str, Any] = {}
if TRIGGER_CONFIG_FILE.exists():
raw = TRIGGER_CONFIG_FILE.read_text(encoding='utf-8').strip()
if raw:
data = json.loads(raw)
data['circuit_breaker'] = {
'state': _circuit_breaker.state,
'opened_at': _circuit_breaker.opened_at,
'cooldown_seconds': _circuit_breaker.cooldown_seconds,
}
atomic_write_json(TRIGGER_CONFIG_FILE, data)
with json_file_lock(CB_STATE_FILE):
state_data = {
'circuit_breaker': {
'state': _circuit_breaker.state,
'opened_at': _circuit_breaker.opened_at,
'cooldown_seconds': _circuit_breaker.cooldown_seconds,
'recent_errors': _circuit_breaker.recent_errors,
'summary_sent': _circuit_breaker.summary_sent,
'half_open_allow': _circuit_breaker.half_open_allow,
},
'per_fingerprint': {
fp: {
'last_dispatch': max(times) if times else 0.0,
'count': _fingerprint_dispatch_count.get(fp, 0),
}
for fp, times in _fingerprint_dispatch_times.items()
},
}
atomic_write_json(CB_STATE_FILE, state_data)
except Exception as exc:
logger.warning("Failed to save circuit breaker state: %s", exc)
def _load_circuit_breaker_state() -> CircuitBreakerState:
"""Load persisted circuit breaker state from trigger_config.json.
def _restore_fingerprint_tracking(pf_data: dict) -> None:
"""Restore per-fingerprint dispatch tracking from persisted data."""
global _fingerprint_dispatch_times, _fingerprint_dispatch_count
for fp, info in pf_data.items():
last = float(info.get('last_dispatch', 0.0))
count = int(info.get('count', 0))
if last > 0:
_fingerprint_dispatch_times[fp] = [last]
_fingerprint_dispatch_count[fp] = count
If a valid persisted state exists, restores it into a new
CircuitBreakerState. Otherwise returns a fresh default instance.
def _load_circuit_breaker_state() -> CircuitBreakerState:
"""Load full circuit breaker state from trigger_cb_state.json.
Restores CB state including recent_errors and half_open_allow,
plus per-fingerprint dispatch tracking into module-level dicts.
Returns:
CircuitBreakerState populated from disk or defaults.
"""
try:
if TRIGGER_CONFIG_FILE.exists():
raw = TRIGGER_CONFIG_FILE.read_text(encoding='utf-8').strip()
if not raw:
return CircuitBreakerState()
data = json.loads(raw)
cb_data = data.get('circuit_breaker')
if isinstance(cb_data, dict) and cb_data.get('state') in ('closed', 'open', 'half_open'):
breaker = CircuitBreakerState()
breaker.state = cb_data['state']
breaker.opened_at = float(cb_data.get('opened_at', 0.0))
breaker.cooldown_seconds = int(cb_data.get('cooldown_seconds', breaker.base_cooldown))
return breaker
if not CB_STATE_FILE.exists():
return CircuitBreakerState()
raw = CB_STATE_FILE.read_text(encoding='utf-8').strip()
if not raw:
return CircuitBreakerState()
data = json.loads(raw)
cb_data = data.get('circuit_breaker')
if not isinstance(cb_data, dict) or cb_data.get('state') not in ('closed', 'open', 'half_open'):
return CircuitBreakerState()
breaker = CircuitBreakerState()
breaker.state = cb_data['state']
breaker.opened_at = float(cb_data.get('opened_at', 0.0))
breaker.cooldown_seconds = int(cb_data.get('cooldown_seconds', breaker.base_cooldown))
breaker.recent_errors = [float(t) for t in cb_data.get('recent_errors', [])]
breaker.summary_sent = bool(cb_data.get('summary_sent', False))
breaker.half_open_allow = bool(cb_data.get('half_open_allow', True))
_restore_fingerprint_tracking(data.get('per_fingerprint', {}))
return breaker
except Exception as exc:
logger.warning("Failed to load circuit breaker state: %s", exc)
return CircuitBreakerState()
def _clear_circuit_breaker_state() -> None:
"""Remove persisted circuit breaker state from trigger_config.json.
Deletes the 'circuit_breaker' key so restarts start with a clean slate.
"""
"""Remove persisted circuit breaker state file."""
try:
if TRIGGER_CONFIG_FILE.exists():
raw = TRIGGER_CONFIG_FILE.read_text(encoding='utf-8').strip()
if not raw:
return
data = json.loads(raw)
if 'circuit_breaker' in data:
del data['circuit_breaker']
atomic_write_json(TRIGGER_CONFIG_FILE, data)
if CB_STATE_FILE.exists():
CB_STATE_FILE.unlink()
except Exception as exc:
logger.warning("Failed to clear circuit breaker state: %s", exc)
# Module-level circuit breaker state (restored from disk if available)
_circuit_breaker = _load_circuit_breaker_state()
# Module-level dicts for per-fingerprint dispatch tracking
_fingerprint_dispatch_times: Dict[str, List[float]] = {} # fingerprint -> [dispatch_timestamps]
_fingerprint_dispatch_count: Dict[str, int] = {} # fingerprint -> total dispatch count
# Module-level circuit breaker state (restored from disk if available)
_circuit_breaker = _load_circuit_breaker_state()
# ---------------------------------------------------------------------------
# Circuit Breaker
@@ -411,6 +428,7 @@ def record_dispatch(fingerprint: str) -> None:
_fingerprint_dispatch_times[fingerprint].append(now)
_fingerprint_dispatch_count[fingerprint] = \
_fingerprint_dispatch_count.get(fingerprint, 0) + 1
_save_circuit_breaker_state()
# ---------------------------------------------------------------------------
@@ -576,53 +594,54 @@ def report(
# invalid arguments are auto-suppressed to avoid noisy dispatch.
is_user_error = bool(_USER_ERROR_PATTERNS.search(message))
registry = _load_registry()
now = datetime.now().isoformat()
with json_file_lock(REGISTRY_FILE):
registry = _load_registry()
now = datetime.now().isoformat()
if fingerprint in registry["errors"]:
# Existing error - increment count and update last_seen
entry = registry["errors"][fingerprint]
entry["count"] = entry.get("count", 1) + 1
entry["last_seen"] = now
# Update log_path if provided (might be from a different log file)
if log_path:
entry["log_path"] = log_path
# Auto-suppress user errors that were previously unsuppressed
if is_user_error and entry.get("status") != "suppressed":
entry["status"] = "suppressed"
entry["suppress_reason"] = "user_error"
_save_registry(registry)
result = dict(entry)
result["is_new"] = False
json_handler.log_operation("error_registered", {"fingerprint": fingerprint[:12], "count": entry["count"]})
return result
else:
# New error - create entry
initial_status = "suppressed" if is_user_error else "new"
initial_suppress_reason = "user_error" if is_user_error else ""
if fingerprint in registry["errors"]:
# Existing error - increment count and update last_seen
entry = registry["errors"][fingerprint]
entry["count"] = entry.get("count", 1) + 1
entry["last_seen"] = now
# Update log_path if provided (might be from a different log file)
if log_path:
entry["log_path"] = log_path
# Auto-suppress user errors that were previously unsuppressed
if is_user_error and entry.get("status") != "suppressed":
entry["status"] = "suppressed"
entry["suppress_reason"] = "user_error"
_save_registry(registry)
result = dict(entry)
result["is_new"] = False
json_handler.log_operation("error_registered", {"fingerprint": fingerprint[:12], "count": entry["count"]})
return result
else:
# New error - create entry
initial_status = "suppressed" if is_user_error else "new"
initial_suppress_reason = "user_error" if is_user_error else ""
event = ErrorEvent(
fingerprint=fingerprint,
error_type=error_type,
message=message,
normalized_message=normalized,
component=component,
severity=severity,
count=1,
status=initial_status,
first_seen=now,
last_seen=now,
log_path=log_path,
suppress_reason=initial_suppress_reason,
source_fix_status="none"
)
entry_dict = asdict(event)
registry["errors"][fingerprint] = entry_dict
_save_registry(registry)
result = dict(entry_dict)
result["is_new"] = True
json_handler.log_operation("error_registered", {"fingerprint": fingerprint[:12], "count": 1})
return result
event = ErrorEvent(
fingerprint=fingerprint,
error_type=error_type,
message=message,
normalized_message=normalized,
component=component,
severity=severity,
count=1,
status=initial_status,
first_seen=now,
last_seen=now,
log_path=log_path,
suppress_reason=initial_suppress_reason,
source_fix_status="none"
)
entry_dict = asdict(event)
registry["errors"][fingerprint] = entry_dict
_save_registry(registry)
result = dict(entry_dict)
result["is_new"] = True
json_handler.log_operation("error_registered", {"fingerprint": fingerprint[:12], "count": 1})
return result
except Exception as exc:
logger.warning("Failed to report error for component '%s': %s", component, exc)
@@ -142,11 +142,10 @@ def report_error(
)
result["dispatched"] = False
# Fire event on first occurrence (count==1) for registration and on
# second occurrence (count==2) so the handler can apply the dispatch
# threshold. Skip all other counts (backoff handles later dispatches).
# Fire event on every call — let the error_detected handler decide
# when to dispatch via circuit breaker, backoff, and rate limiting.
error_count = result.get("count", 1)
if not fire_event or (not result.get("is_new", False) and error_count != 2):
if not fire_event:
return result
try:
@@ -350,7 +350,7 @@ def _write_suppression_log(reason: str, branch: str, module: str, message: str)
try:
suppressed_log = TRIGGER_ROOT / "logs" / "medic_suppressed.log"
suppressed_log.parent.mkdir(parents=True, exist_ok=True)
with open(suppressed_log, 'a') as f:
with open(suppressed_log, 'a', encoding='utf-8') as f:
f.write(
f"{datetime.now().isoformat()} | "
f"{reason} - {branch}: {module} - {message[:100]}\n"
@@ -364,7 +364,7 @@ def _write_rate_log(reason: str, detail: str) -> None:
try:
rate_log = TRIGGER_ROOT / "logs" / "rate_limited.log"
rate_log.parent.mkdir(parents=True, exist_ok=True)
with open(rate_log, 'a') as f:
with open(rate_log, 'a', encoding='utf-8') as f:
f.write(f"{datetime.now().isoformat()} | {reason}: {detail}\n")
except Exception as exc:
_log_warning(f"rate log write failed ({reason}): {exc}")
@@ -445,6 +445,10 @@ def handle_error_detected(
_write_suppression_log(f"First occurrence (count={count}) - waiting for pattern", branch, module, message)
return
# Record error for circuit breaker at detection time (not dispatch time)
if _REGISTRY_DISPATCH_AVAILABLE:
circuit_breaker_record_error()
# Callback must be set by module layer before events fire
if _send_email is None:
return
@@ -516,17 +520,23 @@ def handle_error_detected(
subject=email_subject,
message=notification_message,
auto_execute=True,
reply_to='@trigger',
reply_to='@devpulse',
from_branch='@trigger'
)
# Wake the target branch so the email is processed immediately
try:
from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch
wake_branch(recipient, fresh=False, sender='@trigger')
except Exception:
pass # Silent — email in inbox as fallback
json_handler.log_operation("dispatch_sent", {"recipient": recipient})
# Record dispatch for tracking
if _REGISTRY_DISPATCH_AVAILABLE and fingerprint:
# Medic v2: per-fingerprint dispatch tracking + circuit breaker
# Medic v2: per-fingerprint dispatch tracking
registry_record_dispatch(fingerprint)
circuit_breaker_record_error()
else:
# Legacy: per-branch rate limiting
_record_dispatch(recipient)
@@ -1,37 +1,23 @@
# =================== AIPass ====================
# Name: error_logged.py
# Description: Legacy error logged event handler with medic gating (deprecated)
# Version: 2.0.0
# Description: Legacy error logged event handler — monitor-only (no dispatch)
# Version: 3.0.0
# Created: 2026-01-31
# Modified: 2026-02-25
# Modified: 2026-04-10
# =============================================
"""
Error Logged Event Handler (DEPRECATED)
Error Logged Event Handler (Monitor-Only)
Legacy handler for error_logged events. The primary error dispatch pipeline
is now error_detected.py (Medic v2) which provides circuit breaker, per-fingerprint
Legacy handler for error_logged events. All dispatch now goes through
error_detected.py (Medic v2) which provides circuit breaker, per-fingerprint
backoff, and registry-based deduplication.
This handler remains for backward compatibility with code that fires error_logged
events directly. It now includes full medic gating (medic_enabled, branch_muted,
rate limiting, devpulse protection) to prevent bypass.
Event data expected:
- branch: Branch where error occurred (e.g., FLOW)
- message: Error message text
- error_hash: Unique hash for deduplication
- timestamp: When the error occurred
- log_file: Path to log file
- source_module: Module that logged the error
- level: Log level (always 'error' for this handler)
This handler logs event metadata for monitoring. No email, no wake_branch.
"""
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List
from typing import Any
from aipass.trigger.apps.config import TRIGGER_ROOT
from aipass.trigger.apps.handlers.json import json_handler
@@ -49,291 +35,38 @@ def _log_warning(message: str) -> None:
pass
def _find_repo_root() -> Path:
"""Walk up from this file 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()
_REPO_ROOT = _find_repo_root()
TRIGGER_CONFIG_FILE = TRIGGER_ROOT / "trigger_json" / "trigger_config.json"
BRANCH_REGISTRY_FILE = _REPO_ROOT / "AIPASS_REGISTRY.json"
SUPPRESSED_LOG = TRIGGER_ROOT / "logs" / "medic_suppressed.log"
# Legacy rate limiting
_dispatch_timestamps: Dict[str, List[float]] = {}
MAX_DISPATCHES_PER_WINDOW = 3
RATE_LIMIT_WINDOW_SECONDS = 600 # 10 minutes
def _is_medic_enabled() -> bool:
"""Check if medic dispatch is enabled globally.
Reads medic_enabled from trigger_config.json.
Defaults to True if config is missing or unreadable.
Returns:
True if medic dispatch is enabled
"""
try:
if TRIGGER_CONFIG_FILE.exists():
data = json.loads(TRIGGER_CONFIG_FILE.read_text(encoding='utf-8'))
return bool(data.get('config', {}).get('medic_enabled', True))
except Exception as exc:
_log_warning(f"_is_medic_enabled config read failed: {exc}")
return True
return True
def _is_branch_muted(branch_name: str) -> bool:
"""Check if a specific branch is muted for medic dispatch.
Reads muted_branches list from trigger_config.json.
Args:
branch_name: Branch name (case-insensitive)
Returns:
True if branch is in the muted list
"""
try:
if TRIGGER_CONFIG_FILE.exists():
data = json.loads(TRIGGER_CONFIG_FILE.read_text(encoding='utf-8'))
muted = data.get('config', {}).get('muted_branches', [])
return branch_name.lower() in [b.lower() for b in muted]
except Exception as exc:
_log_warning(f"_is_branch_muted config read failed: {exc}")
return False
return False
def _get_registered_emails() -> set:
"""Read registered branch emails from AIPASS_REGISTRY.json.
Returns:
Set of registered email addresses (e.g., {'@flow', '@drone'})
"""
try:
if BRANCH_REGISTRY_FILE.exists():
data = json.loads(BRANCH_REGISTRY_FILE.read_text(encoding='utf-8'))
return {b["email"] for b in data.get("branches", [])}
except Exception as exc:
_log_warning(f"_get_registered_emails registry read failed: {exc}")
return set()
return set()
def _is_rate_limited(branch_email: str) -> bool:
"""Check if a branch has exceeded the dispatch rate limit.
Args:
branch_email: Target branch email (e.g., '@flow')
Returns:
True if branch has hit the limit (3 dispatches in 10 minutes)
"""
now = time.time()
cutoff = now - RATE_LIMIT_WINDOW_SECONDS
if branch_email not in _dispatch_timestamps:
_dispatch_timestamps[branch_email] = []
_dispatch_timestamps[branch_email] = [
ts for ts in _dispatch_timestamps[branch_email] if ts > cutoff
]
return len(_dispatch_timestamps[branch_email]) >= MAX_DISPATCHES_PER_WINDOW
def _record_dispatch(branch_email: str) -> None:
"""Record a dispatch timestamp for rate limiting.
Args:
branch_email: Target branch email (e.g., '@flow')
"""
if branch_email not in _dispatch_timestamps:
_dispatch_timestamps[branch_email] = []
_dispatch_timestamps[branch_email].append(time.time())
def _log_suppression(reason: str, branch: str, source_module: str, message: str) -> None:
"""Log a suppressed dispatch to medic_suppressed.log.
Args:
reason: Why dispatch was suppressed
branch: Target branch name
source_module: Module that logged the error
message: Error message (truncated to 100 chars)
"""
try:
SUPPRESSED_LOG.parent.mkdir(parents=True, exist_ok=True)
with open(SUPPRESSED_LOG, 'a') as f:
f.write(
f"{datetime.now().isoformat()} | "
f"{reason} - suppressed dispatch for {branch}: "
f"{source_module} - {message[:100]}\n"
)
except Exception as exc:
_log_warning(f"_log_suppression write failed: {exc}")
return
def _build_notification_message(
error_hash: str,
source_module: str,
message: str,
timestamp: str,
log_file: str
) -> str:
"""Build error notification message with investigation instructions.
Args:
error_hash: Unique error identifier
source_module: Module that logged the error
message: Error message text
timestamp: When error occurred
log_file: Path to source log file
Returns:
Formatted message string
"""
return f"""Error detected - investigate and respond.
Error ID: {error_hash}
Module: {source_module}
Timestamp: {timestamp}
Log file: {log_file}
Error message:
{message}
---
INVESTIGATION STEPS:
1. Check the log file for context around this error
2. Identify root cause
DECISION TREE:
- SIMPLE FIX (typo, missing import, config issue):
-> Fix it yourself, then report what you did to @devpulse
- COMPLEX/UNCLEAR (needs research, affects multiple files):
-> Report findings only to @devpulse, recommend action, don't fix
- CRITICAL (data loss risk, security, system stability):
-> STOP immediately, escalate to @devpulse with full context
REPORT TO @devpulse:
ai_mail email @devpulse "ERROR {error_hash[:8]} - [STATUS]" "Findings..."
"""
def handle_error_logged(
branch: str | None = None,
message: str | None = None,
error_hash: str | None = None,
timestamp: str | None = None,
log_file: str | None = None,
source_module: str | None = None,
module_name: str | None = None,
level: str | None = None, # noqa: ARG001
**kwargs: Any # noqa: ARG001
**_kwargs: Any
) -> None:
"""Handle error_logged event with full medic gating.
"""Handle error_logged event — monitor-only, no dispatch.
DEPRECATED: This is the legacy error notification handler. The primary
pipeline is error_detected.py (Medic v2). This handler remains for
backward compatibility with code that fires error_logged events.
Gating (matches error_detected.py):
1. medic_enabled check (global toggle)
2. branch_muted check (per-branch suppression)
3. devpulse protection (never auto-trigger)
4. Branch validation (unknown branches logged + skipped)
5. Rate limiting (3 per 10 minutes per branch)
All dispatch now goes through error_detected.py (Medic v2).
This handler logs event metadata for monitoring only.
Args:
branch: Branch where error occurred - REQUIRED
message: Error message text - REQUIRED
error_hash: Unique error identifier - REQUIRED
timestamp: When error occurred (defaults to now)
log_file: Path to source log file
branch: Branch where error occurred
message: Error message text
error_hash: Unique error identifier
source_module: Module that logged the error
module_name: Deprecated alias for source_module
level: Log level (for reference, unused)
**kwargs: Additional event data (ignored)
**_kwargs: Additional event data (ignored)
"""
try:
if not branch or not message or not error_hash:
return
# Resolve source_module from either parameter name
effective_module = source_module or module_name or "unknown"
# --- Medic gating (FPLAN-0371 Phase 1) ---
# Gate 1: Global medic toggle
if not _is_medic_enabled():
_log_suppression("Medic OFF", branch, effective_module, message)
return
# Gate 2: Per-branch mute
if _is_branch_muted(branch):
_log_suppression("Branch muted", branch, effective_module, message)
return
# Gate 3: Convert branch name to email format
recipient = f"@{branch.lower()}"
# devpulse is protected from auto-triggering
if recipient == '@devpulse':
return
# Gate 4: Validate target branch exists in registry
registered_emails = _get_registered_emails()
if recipient not in registered_emails:
_log_suppression("Unknown branch skipped", branch, effective_module, message)
return
# Gate 5: Rate limiting (3 dispatches per 10 minutes per branch)
if _is_rate_limited(recipient):
_log_suppression("Rate limited", branch, effective_module, message)
return
# --- Dispatch ---
try:
from aipass.ai_mail.apps.modules.email import deliver_email_to_branch
except ImportError:
return
effective_timestamp = timestamp or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
effective_log_file = log_file or "unknown"
email_subject = f"[ERROR] {effective_module} - investigation needed"
notification_message = _build_notification_message(
error_hash=error_hash,
source_module=effective_module,
message=message,
timestamp=effective_timestamp,
log_file=effective_log_file
)
email_data = {
"from": "@trigger",
"from_name": "TRIGGER",
"to": recipient,
"subject": email_subject,
"message": f"⚡ DISPATCH TASK - READ THIS FIRST ⚡\n\n{notification_message}",
"timestamp": effective_timestamp,
}
deliver_email_to_branch(recipient, email_data)
# Record dispatch for rate limiting
_record_dispatch(recipient)
json_handler.log_operation("error_logged_event", {"success": True})
json_handler.log_operation("error_logged_event", {
"branch": branch,
"module": effective_module,
"error_hash": error_hash,
})
except Exception as exc:
_log_warning(f"handle_error_logged failed: {exc}")
@@ -134,9 +134,9 @@ def handle_memory_threshold_exceeded(
if not branch or not file_name or line_count is None:
return
# Import AI_Mail delivery
# Import AI_Mail delivery (modules-level API, not handler-level)
try:
from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch
from aipass.ai_mail.apps.modules.email import deliver_email_to_branch
except ImportError:
return
@@ -56,7 +56,7 @@ def _load_trigger_data() -> Dict[str, Any]:
"""Load trigger_data.json with error_catchup section."""
try:
if TRIGGER_DATA_FILE.exists():
with open(TRIGGER_DATA_FILE, 'r') as f:
with open(TRIGGER_DATA_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
if 'error_catchup' not in data:
data['error_catchup'] = {
@@ -103,7 +103,7 @@ def _log_suppression(reason: str) -> None:
"""
try:
SUPPRESSED_LOG.parent.mkdir(parents=True, exist_ok=True)
with open(SUPPRESSED_LOG, 'a') as f:
with open(SUPPRESSED_LOG, 'a', encoding='utf-8') as f:
f.write(f"{datetime.now().isoformat()} | error_catchup: {reason}\n")
except Exception as exc:
_log_warning(f"log suppression write failed: {exc}")
@@ -349,7 +349,7 @@ def _run_error_catchup(fire_event: Optional[Callable[..., None]] = None) -> None
if errors and fire_event is not None:
for error in errors:
fire_event('error_logged', **error)
fire_event('error_detected', **error)
hash_list = list(processed_hashes)
max_h = catchup.get('max_hashes', MAX_HASHES)
+55 -23
View File
@@ -29,7 +29,7 @@ import hashlib
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, Set, Optional, Callable
from aipass.trigger.apps.config import TRIGGER_ROOT, AIPASS_PKG_ROOT, atomic_write_json
from aipass.trigger.apps.config import TRIGGER_ROOT, AIPASS_PKG_ROOT, atomic_write_json, json_file_lock
from aipass.trigger.apps.handlers.json import json_handler
from aipass.prax.apps.modules.logger import get_direct_logger
@@ -97,6 +97,7 @@ except ImportError:
_branch_log_observer: Any = None
_active_watcher: Any = None # Reference to BranchLogWatcher for position persistence
_seen_error_hashes: Set[str] = set()
_fallback_error_counts: Dict[str, int] = {} # Local count per hash when registry unavailable
MAX_SEEN_HASHES = 2000 # Limit memory usage
# Explicit mapping of system_logs filenames to their owning branch.
@@ -111,9 +112,9 @@ SYSTEM_LOGS_DIR = AIPASS_PKG_ROOT.parent.parent / "system_logs"
# Known branch prefixes that appear in system_logs filenames (<prefix>_<module>.log).
# Sorted longest-first so longer prefixes match before shorter ones.
_SYSTEM_LOGS_BRANCH_PREFIXES: list = sorted([
'ai_mail', 'api', 'backup', 'cli', 'drone', 'flow',
'prax', 'trigger', 'seedgo', 'memory', 'The_Commons',
'aipass_os', 'aipass_business',
'ai_mail', 'api', 'cli', 'drone', 'flow',
'prax', 'trigger', 'seedgo', 'memory',
'spawn', 'devpulse',
], key=len, reverse=True)
# Event fire callback (set by module, avoids handler importing from modules)
@@ -146,11 +147,12 @@ def _save_seen_hashes() -> None:
Merges with existing trigger_data.json content to preserve other keys.
"""
try:
data: Dict[str, Any] = {}
if TRIGGER_DATA_FILE.exists():
data = json.loads(TRIGGER_DATA_FILE.read_text(encoding='utf-8'))
data['seen_error_hashes'] = list(_seen_error_hashes)
atomic_write_json(TRIGGER_DATA_FILE, data)
with json_file_lock(TRIGGER_DATA_FILE):
data: Dict[str, Any] = {}
if TRIGGER_DATA_FILE.exists():
data = json.loads(TRIGGER_DATA_FILE.read_text(encoding='utf-8'))
data['seen_error_hashes'] = list(_seen_error_hashes)
atomic_write_json(TRIGGER_DATA_FILE, data)
except Exception as exc:
logger.warning("Failed to save seen hashes: %s", exc)
return # Write failure - hashes remain in memory only
@@ -188,11 +190,12 @@ def _save_log_positions(positions: Dict[str, int]) -> None:
positions: Dict mapping file paths to byte offsets
"""
try:
data: Dict[str, Any] = {}
if TRIGGER_DATA_FILE.exists():
data = json.loads(TRIGGER_DATA_FILE.read_text(encoding='utf-8'))
data['log_positions'] = positions
atomic_write_json(TRIGGER_DATA_FILE, data)
with json_file_lock(TRIGGER_DATA_FILE):
data: Dict[str, Any] = {}
if TRIGGER_DATA_FILE.exists():
data = json.loads(TRIGGER_DATA_FILE.read_text(encoding='utf-8'))
data['log_positions'] = positions
atomic_write_json(TRIGGER_DATA_FILE, data)
except Exception as exc:
logger.warning("Failed to save log positions: %s", exc)
return # Write failure - positions remain in memory only
@@ -512,13 +515,9 @@ class BranchLogWatcher(WatchdogFileSystemEventHandler if WATCHDOG_AVAILABLE else
severity='medium'
)
# Fire event for new errors (count == 1) and on second
# occurrence (count == 2) so the handler can apply the
# dispatch threshold. Subsequent occurrences are silent
# until the per-fingerprint backoff schedule allows.
# Fire event on every occurrence — let the error_detected
# handler decide via circuit breaker, backoff, and rate limiting.
error_count = result.get('count', 1)
if not result.get('is_new', False) and error_count != 2:
return
# Fire error_detected event with registry data
if _fire_event is not None:
@@ -551,11 +550,43 @@ class BranchLogWatcher(WatchdogFileSystemEventHandler if WATCHDOG_AVAILABLE else
branch, module, e
)
# Fallback path: Medic v1 hash-based dedup
# Fallback path: retry lazy import of registry, else track count locally
error_hash = _generate_error_hash(module, message)
if _is_duplicate_error(error_hash):
# Retry registry import — may have failed at module load but be available now
try:
from aipass.trigger.apps.handlers.error_registry import report as _lazy_report
result = _lazy_report(
error_type=parsed['level'],
message=message,
component=branch,
log_path=log_path,
severity='medium'
)
error_count = result.get('count', 1)
if not result.get('is_new', False) and error_count != 2:
return
if _fire_event is not None:
_fire_event(
'error_detected',
branch=branch, module=module, message=message,
log_path=log_path,
error_hash=result.get('id', error_hash),
timestamp=parsed['timestamp'],
fingerprint=result.get('fingerprint', ''),
registry_id=result.get('id', ''),
first_seen=result.get('first_seen', ''),
last_seen=result.get('last_seen', ''),
count=error_count,
)
json_handler.log_operation("error_detected_in_log", {"branch": branch, "log_path": log_path})
return
except Exception as exc:
logger.warning("Lazy registry import failed in fallback path: %s", exc)
# Registry truly unavailable — track count locally, fire with count
_fallback_error_counts[error_hash] = _fallback_error_counts.get(error_hash, 0) + 1
local_count = _fallback_error_counts[error_hash]
if _fire_event is not None:
_fire_event(
@@ -565,7 +596,8 @@ class BranchLogWatcher(WatchdogFileSystemEventHandler if WATCHDOG_AVAILABLE else
message=message,
log_path=log_path,
error_hash=error_hash,
timestamp=parsed['timestamp']
timestamp=parsed['timestamp'],
count=local_count,
)
json_handler.log_operation("error_detected_in_log", {"branch": branch, "log_path": log_path})
else:
+29 -26
View File
@@ -22,7 +22,7 @@ from pathlib import Path
from typing import Any, Dict, List
from aipass.prax.apps.modules.logger import get_direct_logger
from aipass.trigger.apps.config import TRIGGER_ROOT, atomic_write_json
from aipass.trigger.apps.config import TRIGGER_ROOT, atomic_write_json, json_file_lock
from aipass.trigger.apps.handlers.json import json_handler
logger = get_direct_logger()
@@ -87,15 +87,16 @@ def set_enabled(enabled: bool) -> bool:
Returns:
True on success
"""
data = read_config()
if 'config' not in data:
data['config'] = {}
data['config']['medic_enabled'] = enabled
data['timestamp'] = datetime.now().strftime("%Y-%m-%d")
with json_file_lock(TRIGGER_CONFIG_FILE):
data = read_config()
if 'config' not in data:
data['config'] = {}
data['config']['medic_enabled'] = enabled
data['timestamp'] = datetime.now().strftime("%Y-%m-%d")
if write_config(data):
json_handler.log_operation("state_persisted", {"key": "medic_enabled", "value": enabled})
return True
if write_config(data):
json_handler.log_operation("state_persisted", {"key": "medic_enabled", "value": enabled})
return True
return False
@@ -142,15 +143,16 @@ def mute_branch(branch_name: str) -> bool:
True on success
"""
clean = _normalize_branch_name(branch_name)
data = read_config()
if 'config' not in data:
data['config'] = {}
muted = [_normalize_branch_name(b) for b in data['config'].get('muted_branches', [])]
if clean not in muted:
muted.append(clean)
data['config']['muted_branches'] = muted
data['timestamp'] = datetime.now().strftime("%Y-%m-%d")
return write_config(data)
with json_file_lock(TRIGGER_CONFIG_FILE):
data = read_config()
if 'config' not in data:
data['config'] = {}
muted = [_normalize_branch_name(b) for b in data['config'].get('muted_branches', [])]
if clean not in muted:
muted.append(clean)
data['config']['muted_branches'] = muted
data['timestamp'] = datetime.now().strftime("%Y-%m-%d")
return write_config(data)
def unmute_branch(branch_name: str) -> bool:
@@ -164,14 +166,15 @@ def unmute_branch(branch_name: str) -> bool:
True on success
"""
clean = _normalize_branch_name(branch_name)
data = read_config()
if 'config' not in data:
data['config'] = {}
muted = [_normalize_branch_name(b) for b in data['config'].get('muted_branches', [])]
muted = [b for b in muted if b != clean]
data['config']['muted_branches'] = muted
data['timestamp'] = datetime.now().strftime("%Y-%m-%d")
return write_config(data)
with json_file_lock(TRIGGER_CONFIG_FILE):
data = read_config()
if 'config' not in data:
data['config'] = {}
muted = [_normalize_branch_name(b) for b in data['config'].get('muted_branches', [])]
muted = [b for b in muted if b != clean]
data['config']['muted_branches'] = muted
data['timestamp'] = datetime.now().strftime("%Y-%m-%d")
return write_config(data)
def get_suppression_stats() -> Dict[str, Any]:
@@ -13,9 +13,9 @@ Watches system_logs/ for log file changes.
Detects ERROR/WARNING/INFO entries and fires appropriate events.
Events fired:
- error_logged: When ERROR level log detected
- error_detected: When ERROR level log detected (Medic v2 pipeline via registry_report)
- error_logged: Monitoring-only event (no dispatch)
- warning_logged: When WARNING level log detected
- log_entry: All log entries (for monitoring systems)
Architecture:
- Trigger OWNS all file watching (filesystem events)
@@ -257,7 +257,30 @@ class LogFileWatcher(WatchdogFileSystemEventHandler if WATCHDOG_AVAILABLE else o
}
if level == 'error':
trigger.fire('error_logged', **event_data)
# Route through Medic v2: registry_report() for dedup/count, then error_detected
try:
from aipass.trigger.apps.handlers.error_registry import report as registry_report
result = registry_report(
error_type='ERROR',
message=message,
component=branch,
log_path=log_file,
severity='medium'
)
error_count = result.get('count', 1)
trigger.fire('error_detected',
branch=branch, module=module_name, message=message,
log_path=log_file, error_hash=result.get('id', error_hash),
timestamp=timestamp,
fingerprint=result.get('fingerprint', ''),
registry_id=result.get('id', ''),
first_seen=result.get('first_seen', ''),
last_seen=result.get('last_seen', ''),
count=error_count,
)
except Exception:
# Registry unavailable — fire error_logged as monitoring-only fallback
trigger.fire('error_logged', **event_data)
json_handler.log_operation("system_log_event", {"level": level, "module": module_name})
elif level == 'warning':
trigger.fire('warning_logged', **event_data)
+17 -1
View File
@@ -48,6 +48,9 @@ class Trigger:
_deferred_queue = [] # Queue for events fired during handling
_draining_deferred = False # Prevents nested deferred processing
_log_watcher_started = False # Lazy-start flag for log watcher
_handler_failures = {} # handler -> consecutive failure count
_disabled_handlers = set() # handlers auto-disabled after repeated failures
_HANDLER_FAILURE_THRESHOLD = 5 # consecutive failures before auto-disable
@classmethod
def _ensure_initialized(cls):
@@ -96,12 +99,25 @@ class Trigger:
def _fire_to_handlers(cls, event: str, data: dict) -> None:
"""Fire a single event to its registered handlers."""
handlers = cls._handlers.get(event, [])
data = dict(data) # Copy to avoid mutating caller's dict
data['fire_event'] = cls.fire
for handler in handlers:
if handler in cls._disabled_handlers:
continue
try:
handler(**data)
cls._handler_failures.pop(handler, None) # Reset on success
except Exception as e:
logger.error(f"[TRIGGER] Handler error for {event}: {e}")
count = cls._handler_failures.get(handler, 0) + 1
cls._handler_failures[handler] = count
if count >= cls._HANDLER_FAILURE_THRESHOLD:
cls._disabled_handlers.add(handler)
logger.error(
f"[TRIGGER] Handler {getattr(handler, '__name__', handler)} "
f"disabled after {count} consecutive failures"
)
else:
logger.error(f"[TRIGGER] Handler error for {event}: {e}")
@classmethod
def _drain_deferred(cls) -> None:
@@ -304,8 +304,8 @@ class TestReportError:
mock_trigger.fire.assert_called_once()
assert result["dispatched"] is True
def test_count_3_does_not_fire_event(self, monkeypatch):
"""report_error does NOT fire event when count>=3 and is_new=False."""
def test_count_3_fires_event(self, monkeypatch):
"""report_error fires event at count=3 — handler decides dispatch via backoff."""
reporter = _import_reporter()
registry_report = _get_registry_report()
registry_report.return_value = {
@@ -324,11 +324,11 @@ class TestReportError:
result = reporter.report_error("ImportError", "No module foo", "FLOW")
mock_trigger.fire.assert_not_called()
assert result["dispatched"] is False
mock_trigger.fire.assert_called_once()
assert result["dispatched"] is True
def test_count_5_does_not_fire_event(self, monkeypatch):
"""report_error does NOT fire event for count=5."""
def test_count_5_fires_event(self, monkeypatch):
"""report_error fires event at count=5 — handler decides dispatch via backoff."""
reporter = _import_reporter()
registry_report = _get_registry_report()
registry_report.return_value = {
@@ -347,8 +347,8 @@ class TestReportError:
result = reporter.report_error("ImportError", "No module foo", "FLOW")
mock_trigger.fire.assert_not_called()
assert result["dispatched"] is False
mock_trigger.fire.assert_called_once()
assert result["dispatched"] is True
def test_fire_event_false_never_fires(self, monkeypatch):
"""report_error with fire_event=False never fires an event."""