Merge pull request #251 from AIOSAI/system/devpulse-s89-prax-monitor-colors-unknown-fix-project-colors

feat(system): S89: prax monitor colors + UNKNOWN fix (project colors, test attribution, external project detection)
This commit is contained in:
AIPass
2026-04-11 15:39:01 -07:00
committed by GitHub
17 changed files with 672 additions and 113 deletions
+16
View File
@@ -78,6 +78,8 @@ drone @flow list open # List active plans
**FPLAN** = Flow Plan. Building and executing. Default is for single focused tasks. Master is for multi-phase projects that spawn sub-FPLANs per phase. DPLANs come first, FPLANs come when you're ready to build.
**Never create plan files manually.** Always use `drone @flow create`. Flow handles numbering (global 4-digit sequence), registry tracking, templates, and date stamps. Manual files break the registry and produce wrong numbering. This applies to DPLANs, FPLANs, and APLANs — in any project, inside or outside the AIPass repo.
## Dispatch — Send Task + Wake a Branch
```
@@ -98,6 +100,18 @@ drone @ai_mail dispatch wake --fresh @target
- `email @target` = just mail, no wake (FYI only — use only when explicitly requested)
- `--dispatch` flag on `email` = adds dispatch header but doesn't auto-wake
## Feedback — Cross-Project Communication
Send feedback to devpulse from any project. Messages accumulate silently — no wake, no notification. DevPulse reads on demand. Works from any AIPass project (requires `AIPASS_HOME` set).
```
drone @devpulse feedback send "Subject" "Body" # Send feedback (sender auto-detected)
drone @devpulse feedback inbox # List all messages (devpulse only)
drone @devpulse feedback view <id> # Read message + thread
drone @devpulse feedback reply <id> "message" # Reply (lands in sender's ai_mail)
drone @devpulse feedback clear <id> # Remove a message
```
**Always reply to dispatch emails.** When devpulse or another branch sends you work, they're waiting for a response. Complete the task, then email back with results. No silent completions — if someone dispatched you, they need to know what happened.
## How to Work
@@ -108,6 +122,8 @@ drone @ai_mail dispatch wake --fresh @target
**Check seedgo standards.** Before building: `drone @seedgo standards_query aipass_standards` to know what applies. During: check your work against standards as you go. After: `drone @seedgo audit aipass @{branch}` as a final gate before committing.
**Ask before spelunking.** When you need to know how another branch works — how it routes, what config it uses, what functions are available — dispatch the question to that branch instead of reading through their files yourself. A quick `drone @ai_mail dispatch @target "Question" "How does X work?"` gets you an expert answer faster than digging through 4-5 unfamiliar files. Save deep investigation for when you're explicitly asked to check something out or need more context on a specific issue.
## Logging & Debugging
Prax is the **only** logging system. Every branch uses:
+1
View File
@@ -28,6 +28,7 @@ AIPASS_REGISTRY.json
.trinity/
.ai_mail.local/
ai_mail.local/
.feedback.local/
DASHBOARD.local.json
dev.local.md
STATUS.local.md
+4 -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:** 142 passing (6 files, 5/5 modules covered)
**Last Updated:** 2026-04-10
**Tests:** 151 passing (6 files, 5/5 modules covered)
**Last Updated:** 2026-04-11
## Usage
@@ -66,7 +66,7 @@ cli/
├── cli_json/ # Auto-created JSON output (three-file pattern)
├── dropbox/ # Inbound file drop
├── logs/ # Branch-level logs
├── tests/ # 142 tests across 6 files
├── tests/ # 151 tests across 6 files
│ ├── test_bootstrap.py # bootstrap.py handler tests
│ ├── test_json_handler.py # json_handler tests
│ ├── test_display.py # display module tests
@@ -120,7 +120,7 @@ aipass --help # Via console_scripts entry point
---
*Last Updated: 2026-04-10*
*Last Updated: 2026-04-11*
---
[← Back to AIPass](../../../README.md)
+119 -30
View File
@@ -238,22 +238,99 @@ def _global_prompt_md(name: str) -> str:
f"# {name} — Project Context\n"
"<!-- Injected every turn via hook. -->\n"
"\n"
"## What is AIPass\n"
"\n"
"AIPass is a multi-agent framework. Agents live in directories with\n"
"persistent identity, memory, and communication. All AIPass infrastructure\n"
"is available from any project via the `drone` command.\n"
"\n"
"## Terminology\n"
"\n"
"- **Project** — this directory. Contains a registry and agents.\n"
"- **Agent** — a citizen with identity (`.trinity/`), memory, mailbox, "
"and code (`apps/`).\n"
"- **Agent** — a citizen with identity (`.trinity/`), memory, mailbox,\n"
" and code (`apps/`).\n"
f"- **Registry** — `{name}_REGISTRY.json` tracks all agents.\n"
"\n"
"## Setup: if drone commands fail\n"
"\n"
"If `drone` cannot find the AIPass registry, set the env var:\n"
"```bash\n"
"export AIPASS_HOME=/path/to/AIPass # path to AIPass installation\n"
"```\n"
"Add to your shell profile (`~/.bashrc` or `~/.zshrc`) to make it permanent.\n"
"\n"
"## Commands\n"
"\n"
"### Agent Lifecycle\n"
"```\n"
"aipass init agent <name> # Create a new agent\n"
"aipass init agent <name> # Create a new agent in src/<name>/\n"
"drone @spawn create <name> # Create agent (alternative)\n"
"drone @seedgo audit <project> # Standards audit\n"
"drone @ai_mail inbox # Check mailbox\n"
"drone @flow create . \"Subject\" # Create a plan\n"
"drone systems # List infrastructure\n"
"drone @spawn list # List registered agents\n"
"```\n"
"\n"
"### Standards\n"
"```\n"
"drone @seedgo audit <project> # Run full standards audit\n"
"drone @seedgo checklist <file> # Check a single file\n"
"```\n"
"\n"
"### Dispatch — Send Task + Wake an Agent (DEFAULT)\n"
"```\n"
"drone @ai_mail dispatch @<agent> \"Subject\" \"Body\" # Send + wake (default)\n"
"drone @ai_mail dispatch @<agent> \"Subject\" \"Body\" --fresh # Send + wake fresh session\n"
"drone @ai_mail dispatch wake @<agent> # Wake without sending\n"
"drone @ai_mail dispatch wake --fresh @<agent> # Wake fresh\n"
"drone @ai_mail email @<agent> \"Subject\" \"Body\" # FYI only (no wake)\n"
"```\n"
"\n"
"Use `dispatch` by default. Use `email` only when you don't need the agent to act now.\n"
"\n"
"### Communication (ai_mail)\n"
"```\n"
"drone @ai_mail inbox # Check your mailbox\n"
"drone @ai_mail view <id> # Read a message\n"
"drone @ai_mail close <id> # Mark message read\n"
"```\n"
"\n"
"### Feedback\n"
"```\n"
"drone @devpulse feedback send \"Subject\" \"Body\" # Send feedback (cross-project)\n"
"```\n"
"\n"
"### Plans (flow)\n"
"```\n"
"drone @flow create . \"Subject\" dplan # Create DPLAN (design/thinking)\n"
"drone @flow create . \"Subject\" master # Create FPLAN master (execution)\n"
"drone @flow create . \"Subject\" aplan # Create APLAN (agent-level task)\n"
"drone @flow list open # List active plans\n"
"drone @flow list # List all plans\n"
"drone @flow close <id> # Close a plan\n"
"drone @flow info <id> # View plan details\n"
"```\n"
"\n"
"**DPLAN** = Dev Plan. Thinking, brainstorming, architecture decisions. "
"Use before building.\n"
"**FPLAN** = Flow Plan. Building and executing. Use when the plan is clear "
"and work is underway.\n"
"\n"
"### Memory\n"
"```\n"
"drone @memory archive # Archive memories to vector store\n"
"drone @memory search <query> # Search archived memories\n"
"```\n"
"\n"
"### Git Workflow\n"
"```\n"
"drone @git pr 'description' # Create a pull request\n"
"drone @git status # Git status (branch-scoped)\n"
"drone @git sync # Sync with main\n"
"drone @git lock / unlock # Lock/unlock the repo\n"
"```\n"
"\n"
"### Infrastructure\n"
"```\n"
"drone systems # List all available infrastructure\n"
"drone --help # Full drone command reference\n"
"```\n"
"\n"
"## Patterns\n"
@@ -262,6 +339,8 @@ def _global_prompt_md(name: str) -> str:
"- **Standards** — run `drone @seedgo audit` to check compliance.\n"
"- **Identity** — agents have `.trinity/passport.json`. "
"Projects use the registry.\n"
"- **Memory** — update `.trinity/local.json` at session end. "
"Memory is presence.\n"
)
@@ -524,6 +603,7 @@ def update_project(target: Path) -> dict:
name = registry_path.stem.replace("_REGISTRY", "")
updated: list[str] = []
already_current: list[str] = []
skipped: list[str] = []
# Managed directories — create if missing (graceful recovery).
@@ -533,39 +613,47 @@ def update_project(target: Path) -> dict:
claude_dir = target / ".claude"
claude_dir.mkdir(exist_ok=True)
# --- Managed files: always overwrite with latest templates ---
# --- Managed files: write only when content has changed ---
global_prompt_path = aipass_dir / "aipass_global_prompt.md"
global_prompt_path.write_text(
_with_source(_global_prompt_md(name), global_prompt_path),
encoding="utf-8",
)
updated.append(str(global_prompt_path))
generated = _with_source(_global_prompt_md(name), global_prompt_path)
if not global_prompt_path.exists() or global_prompt_path.read_text(encoding="utf-8") != generated:
global_prompt_path.write_text(generated, encoding="utf-8")
updated.append(str(global_prompt_path))
else:
already_current.append(str(global_prompt_path))
settings_path = claude_dir / "settings.json"
settings_path.write_text(_claude_settings(), encoding="utf-8")
updated.append(str(settings_path))
generated = _claude_settings()
if not settings_path.exists() or settings_path.read_text(encoding="utf-8") != generated:
settings_path.write_text(generated, encoding="utf-8")
updated.append(str(settings_path))
else:
already_current.append(str(settings_path))
claude_md_path = target / "CLAUDE.md"
claude_md_path.write_text(
_with_source(_claude_md(name), claude_md_path),
encoding="utf-8",
)
updated.append(str(claude_md_path))
generated = _with_source(_claude_md(name), claude_md_path)
if not claude_md_path.exists() or claude_md_path.read_text(encoding="utf-8") != generated:
claude_md_path.write_text(generated, encoding="utf-8")
updated.append(str(claude_md_path))
else:
already_current.append(str(claude_md_path))
agents_md_path = target / "AGENTS.md"
agents_md_path.write_text(
_with_source(_agents_md(name), agents_md_path),
encoding="utf-8",
)
updated.append(str(agents_md_path))
generated = _with_source(_agents_md(name), agents_md_path)
if not agents_md_path.exists() or agents_md_path.read_text(encoding="utf-8") != generated:
agents_md_path.write_text(generated, encoding="utf-8")
updated.append(str(agents_md_path))
else:
already_current.append(str(agents_md_path))
gemini_md_path = target / "GEMINI.md"
gemini_md_path.write_text(
_with_source(_gemini_md(name), gemini_md_path),
encoding="utf-8",
)
updated.append(str(gemini_md_path))
generated = _with_source(_gemini_md(name), gemini_md_path)
if not gemini_md_path.exists() or gemini_md_path.read_text(encoding="utf-8") != generated:
gemini_md_path.write_text(generated, encoding="utf-8")
updated.append(str(gemini_md_path))
else:
already_current.append(str(gemini_md_path))
# --- User-owned files: always skip ---
for skip_name in (
@@ -580,5 +668,6 @@ def update_project(target: Path) -> dict:
"project_name": name,
"target": str(target),
"updated_files": updated,
"already_current": already_current,
"skipped_files": skipped,
}
+34 -17
View File
@@ -327,7 +327,7 @@ def _handle_init_update(args: List[str]) -> bool:
from rich.table import Table
from rich import box
if not args or args[0] in ("--help", "-h", "help"):
if args and args[0] in ("--help", "-h", "help"):
console.print()
header("aipass init update — Refresh Scaffold Files")
console.print("[dim]Updates managed prompt and config files with the latest templates[/dim]")
@@ -348,8 +348,7 @@ def _handle_init_update(args: List[str]) -> bool:
return True
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", os.getcwd())
target_arg = args[0]
target = Path(target_arg)
target = Path(args[0]) if args else Path(caller_cwd)
if not target.is_absolute():
target = Path(caller_cwd) / target
@@ -368,28 +367,42 @@ def _handle_init_update(args: List[str]) -> bool:
console.print()
header("Project Updated")
already_current = result.get("already_current", [])
summary = (
f"[bold]{result['project_name']}[/bold]\n"
f"\n"
f" [yellow]Target:[/yellow] [dim]{result['target']}[/dim]\n"
f" [yellow]Updated:[/yellow] {len(result['updated_files'])} files\n"
f" [yellow]Skipped:[/yellow] {len(result['skipped_files'])} files"
f" [yellow]Target:[/yellow] [dim]{result['target']}[/dim]\n"
f" [yellow]Updated:[/yellow] {len(result['updated_files'])} files\n"
f" [yellow]Already current:[/yellow] {len(already_current)} files\n"
f" [yellow]User-owned:[/yellow] {len(result['skipped_files'])} files (skipped)"
)
console.print(Panel(summary, border_style="green", box=box.ROUNDED))
# Updated files table
updated_table = Table(
show_header=True, header_style="bold cyan", border_style="dim", title="Updated"
)
updated_table.add_column("#", style="green", width=3)
updated_table.add_column("File", style="yellow")
for i, f in enumerate(result["updated_files"], 1):
updated_table.add_row(str(i), f)
console.print(updated_table)
# Updated files table (only show if something changed)
if result["updated_files"]:
updated_table = Table(
show_header=True, header_style="bold cyan", border_style="dim", title="Updated"
)
updated_table.add_column("#", style="green", width=3)
updated_table.add_column("File", style="yellow")
for i, f in enumerate(result["updated_files"], 1):
updated_table.add_row(str(i), f)
console.print(updated_table)
# Already current table
if already_current:
current_table = Table(
show_header=True, header_style="bold cyan", border_style="dim", title="Already current"
)
current_table.add_column("#", style="dim", width=3)
current_table.add_column("File", style="dim")
for i, f in enumerate(already_current, 1):
current_table.add_row(str(i), f)
console.print(current_table)
# Skipped files table
skipped_table = Table(
show_header=True, header_style="bold cyan", border_style="dim", title="Skipped"
show_header=True, header_style="bold cyan", border_style="dim", title="User-owned (skipped)"
)
skipped_table.add_column("#", style="dim", width=3)
skipped_table.add_column("File", style="dim")
@@ -398,12 +411,16 @@ def _handle_init_update(args: List[str]) -> bool:
console.print(skipped_table)
console.print()
success(f"Updated {len(result['updated_files'])} files")
if result["updated_files"]:
success(f"Updated {len(result['updated_files'])} files")
else:
success("All files already up to date")
json_handler.log_operation("aipass_init_update", {
"project_name": result["project_name"],
"target": result["target"],
"files_updated": len(result["updated_files"]),
"files_already_current": len(already_current),
"files_skipped": len(result["skipped_files"]),
})
+140 -1
View File
@@ -11,7 +11,7 @@ from pathlib import Path
import pytest
from aipass.cli.apps.handlers.init.bootstrap import _sanitize_name, init_project
from aipass.cli.apps.handlers.init.bootstrap import _sanitize_name, init_project, update_project
# ---------------------------------------------------------------------------
@@ -415,3 +415,142 @@ def test_init_project_agents_md_no_trinity(tmp_path):
content = (target / "AGENTS.md").read_text(encoding="utf-8")
assert ".trinity/" not in content
assert "KEEP_REGISTRY.json" in content
# ---------------------------------------------------------------------------
# update_project tests
# ---------------------------------------------------------------------------
def test_update_project_raises_if_no_registry(tmp_path):
"""ValueError when target has no *_REGISTRY.json (not an AIPass project)."""
target = tmp_path / "bare"
target.mkdir()
with pytest.raises(ValueError, match="No AIPass project found"):
update_project(target)
def test_update_project_return_dict_structure(tmp_path):
"""Return dict contains all required keys."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="upd")
result = update_project(target)
assert set(result.keys()) == {
"project_name",
"target",
"updated_files",
"already_current",
"skipped_files",
}
assert result["project_name"] == "UPD"
assert result["target"] == str(target.resolve())
assert isinstance(result["updated_files"], list)
assert isinstance(result["already_current"], list)
assert isinstance(result["skipped_files"], list)
def test_update_project_already_current_after_init(tmp_path):
"""Running update immediately after init reports all managed files as already current."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="fresh")
result = update_project(target)
assert len(result["updated_files"]) == 0
assert len(result["already_current"]) == 5
def test_update_project_idempotent(tmp_path):
"""Running update twice in a row produces no changes on second run."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="idem")
result1 = update_project(target)
result2 = update_project(target)
# Both runs should be identical
assert result1["updated_files"] == result2["updated_files"]
assert result1["already_current"] == result2["already_current"]
def test_update_project_updates_modified_managed_file(tmp_path):
"""A managed file with altered content is re-written on update."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="mod")
# Corrupt a managed file
claude_md = target / "CLAUDE.md"
claude_md.write_text("# Corrupted\n", encoding="utf-8")
result = update_project(target)
# CLAUDE.md must appear in updated, not already_current
assert str(claude_md.resolve()) in result["updated_files"]
assert str(claude_md.resolve()) not in result["already_current"]
# Content is restored
restored = claude_md.read_text(encoding="utf-8")
assert "MOD" in restored
assert "## What is AIPass" in restored
def test_update_project_never_touches_user_owned_files(tmp_path):
"""Registry, README, STATUS, .gitignore are always in skipped_files."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="skip")
# Modify user-owned files
(target / "README.md").write_text("# My custom README\n", encoding="utf-8")
(target / "STATUS.local.md").write_text("# Custom status\n", encoding="utf-8")
(target / ".gitignore").write_text("# custom\n", encoding="utf-8")
result = update_project(target)
skipped = result["skipped_files"]
assert any("REGISTRY" in s for s in skipped)
assert any("README.md" in s for s in skipped)
assert any("STATUS.local.md" in s for s in skipped)
assert any(".gitignore" in s for s in skipped)
# User customisations are preserved
assert (target / "README.md").read_text(encoding="utf-8") == "# My custom README\n"
assert (target / "STATUS.local.md").read_text(encoding="utf-8") == "# Custom status\n"
def test_update_project_creates_missing_managed_dirs(tmp_path):
"""update_project recreates .aipass/ and .claude/ if they were deleted."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="recover")
# Delete only the managed subdirectories (not root files like CLAUDE.md)
import shutil
shutil.rmtree(target / ".aipass")
shutil.rmtree(target / ".claude")
result = update_project(target)
assert (target / ".aipass" / "aipass_global_prompt.md").exists()
assert (target / ".claude" / "settings.json").exists()
# The 2 files inside deleted dirs are re-written; root files still match
assert len(result["updated_files"]) == 2
assert len(result["already_current"]) == 3
def test_update_project_skipped_files_count(tmp_path):
"""update_project always skips exactly 4 user-owned files."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="count")
result = update_project(target)
assert len(result["skipped_files"]) == 4
@@ -64,9 +64,13 @@ drone systems # All branches
drone, seedgo, prax, cli, ai_mail, api, flow, spawn, trigger, memory, devpulse (you — no apps/, coordinates via dispatch + agents)
## Your Project
## Your Projects
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.
Two personal projects, both part of the Nexus vision. Work on these during autonomy time.
**Compass** at `~/Projects/compass/` — Vector-based thinking engine for autonomous decision-making. 130 fragments (decisions + observations + learnings). Query before big choices. Stop building features, start using it (#033). Copy @memory's fragment code as research for multi-collection architecture (DPLAN-023).
**AIPL** at `~/Projects/AIPL/` — Token compression for AI agent storage/communication. ~45% savings proven. Phase 1 COMPLETE (style guide + 6 examples in docs/). DPLAN-0115. Polyglot agent builds Phase 2 (compression engine). Hand to Polyglot when ready.
## Working Habits
+22 -7
View File
@@ -42,14 +42,29 @@ def discover_modules() -> list[Any]:
if file_path.name.startswith("_"):
continue
module_name = f"apps.modules.{file_path.stem}"
# Try package import first, fall back to relative import
module_names = [
f"aipass.devpulse.apps.modules.{file_path.stem}",
f"apps.modules.{file_path.stem}",
]
try:
module = importlib.import_module(module_name)
if hasattr(module, "handle_command"):
modules.append(module)
except Exception as e:
logger.error(f"[DEVPULSE] Failed to load module {module_name}: {e}")
loaded = False
for module_name in module_names:
try:
module = importlib.import_module(module_name)
if hasattr(module, "handle_command"):
modules.append(module)
loaded = True
break
except (ImportError, ModuleNotFoundError):
continue
except Exception as e:
logger.error(f"[DEVPULSE] Failed to load module {module_name}: {e}")
loaded = True
break
if not loaded:
logger.error(f"[DEVPULSE] Could not import module {file_path.stem}")
return modules
+1 -5
View File
@@ -8,11 +8,7 @@ python_functions = test_*
python_classes = Test*
# Command-line options (always applied)
addopts =
-v # Verbose output
--tb=short # Short traceback format
--strict-markers # Raise error on unknown markers
-ra # Show summary of all test outcomes
addopts = -v --tb=short --strict-markers -ra
# Test markers (for categorizing tests)
markers =
+6
View File
@@ -14,6 +14,12 @@
],
"reason": "Raw passthrough of module subprocess stdout/stderr — console.print() breaks routed output"
},
{
"file": "apps/drone.py",
"standard": "encapsulation",
"lines": [29],
"reason": "drone.py entry point imports get_all_branches directly for systems display — single top-level consumer of branch listing, not a cross-branch import. Consistent with json_handler and file_handler exemptions."
},
{
"file": "apps/handlers/scanning/formatters.py",
"standard": "cli",
+41 -8
View File
@@ -14,6 +14,7 @@ Standard branch entry point (apps/drone.py pattern).
"""
import importlib
import os
import sys
from pathlib import Path
from typing import List
@@ -25,6 +26,7 @@ from aipass.cli.apps.modules import console, err_console
from aipass.drone.apps.modules import BranchNotFoundError, CommandExecutionError, RegistryError
from aipass.drone.apps.modules.discovery import get_help
from aipass.drone.apps.modules.resolver import list_branches
from aipass.drone.apps.handlers.registry_handler import get_all_branches
from aipass.drone.apps.modules.router import route_command
from aipass.drone.apps.modules.module_registry import (
is_module,
@@ -153,29 +155,52 @@ def _handle_systems() -> int:
console.print("No registry found in current directory tree.")
return 0
branches = list_branches()
all_branches = get_all_branches()
modules = list_modules()
# Split registry branches into services (profile=library) and project branches
services = [b for b in all_branches if b.get("profile") == "library"]
project_branches = [b for b in all_branches if b.get("profile") != "library"]
# Infrastructure section — drone is the router, not a routable module
console.print("Infrastructure:")
console.print(f" @{'drone':<18} Command routing and module discovery (v{VERSION})")
console.print()
if modules:
console.print(f"Modules ({len(modules)}):")
# AIPass Services — internal modules + registry services (deduplicated)
# Exclude registry services that duplicate internal modules or drone itself
exclude = set(modules) | {"drone"}
deduped_services = [s for s in services if s.get("name", "").lower() not in exclude]
service_count = len(modules) + len(deduped_services)
if service_count:
console.print(f"AIPass Services ({service_count}):")
for name in modules:
info = get_module_info(name)
if info:
console.print(f" @{name:<18} {info.description}")
else:
console.print(f" @{name:<18} (not available)")
if branches:
for svc in sorted(deduped_services, key=lambda b: b.get("name", "").lower()):
name = svc.get("name", "").lower()
desc = svc.get("description", "")
console.print(f" @{name:<18} {desc}")
if project_branches:
console.print()
if branches:
console.print(f"Branches ({len(branches)}):")
for name in sorted(branches):
console.print(f" {name}")
if project_branches:
console.print(f"Branches ({len(project_branches)}):")
for branch in sorted(project_branches, key=lambda b: b.get("name", "").lower()):
console.print(f" @{branch.get('name', '').lower()}")
# Hint for external projects missing AIPass core branches.
# If AIPASS_HOME is not set AND 'drone' isn't among the local branches,
# we're in an external project that can't see the core branch set.
if not os.environ.get("AIPASS_HOME"):
branch_names = {b.get("name", "").lower() for b in all_branches}
if "drone" not in branch_names:
console.print()
console.print("[dim]Only local registry found. To access AIPass core branches:[/dim]")
console.print("[dim] export AIPASS_HOME=/path/to/AIPass[/dim]")
return 0
@@ -307,6 +332,8 @@ def _handle_custom_command(args: list[str]) -> int:
return _handle_module(module_name, [command] + cmd_args)
logger.warning("Custom command failed for target %s: %s", target, exc)
err_console.print(f"drone: {exc}")
if isinstance(exc, BranchNotFoundError) and not os.environ.get("AIPASS_HOME"):
err_console.print(" Tip: set AIPASS_HOME=/path/to/AIPass to access core branches.")
return 1
if result.stdout:
@@ -343,6 +370,8 @@ def _handle_target(args: List[str]) -> int:
return _handle_module(module_name, rest)
logger.warning("Introspection failed for %s: %s", target, exc)
err_console.print(f"drone: {exc}")
if isinstance(exc, BranchNotFoundError) and not os.environ.get("AIPASS_HOME"):
err_console.print(" Tip: set AIPASS_HOME=/path/to/AIPass to access core branches.")
return 1
if result.stdout:
console.print(result.stdout, end="", highlight=False)
@@ -364,6 +393,8 @@ def _handle_target(args: List[str]) -> int:
return _handle_module(module_name, rest)
logger.warning("Help lookup failed for %s: %s", target, exc)
err_console.print(f"drone: {exc}")
if isinstance(exc, BranchNotFoundError) and not os.environ.get("AIPASS_HOME"):
err_console.print(" Tip: set AIPASS_HOME=/path/to/AIPass to access core branches.")
return 1
return 0
@@ -386,6 +417,8 @@ def _handle_target(args: List[str]) -> int:
return _handle_module(module_name, rest)
logger.warning("Command routing failed for %s %s: %s", target, command, exc)
err_console.print(f"drone: {exc}")
if isinstance(exc, BranchNotFoundError) and not os.environ.get("AIPASS_HOME"):
err_console.print(" Tip: set AIPASS_HOME=/path/to/AIPass to access core branches.")
return 1
if result.stdout:
@@ -51,9 +51,10 @@ def find_registry() -> Path:
Search order:
1. Explicitly set path via set_registry_path()
2. AIPASS_REGISTRY environment variable
3. Walk up from drone package location
4. Walk up from cwd
5. Default: package-relative path
3. Walk up from cwd
4. AIPASS_HOME env var — for external projects where CWD walk finds nothing
5. Walk up from drone package location
6. Default: package-relative path
The first directory that contains any *_REGISTRY.json is treated
as the project boundary. If that directory holds more than one
@@ -66,6 +67,13 @@ def find_registry() -> Path:
if hit is not None:
return hit
# AIPASS_HOME fallback — for external projects where CWD walk finds nothing
aipass_home = os.environ.get("AIPASS_HOME")
if aipass_home:
hit = _first_registry_in(Path(aipass_home))
if hit is not None:
return hit
# Walk up from this file (fallback for pip editable installs)
current = Path(__file__).resolve().parent
for parent in [current] + list(current.parents):
@@ -162,19 +170,18 @@ def reset_registry_path() -> None:
# Registry loading and querying
# ---------------------------------------------------------------------------
def load_registry() -> Dict[str, Any]:
"""Load the branch registry from disk.
def _load_registry_data(registry_path: Path) -> Dict[str, Any]:
"""Read, parse, and normalize a registry file.
Returns:
Registry dictionary with branches (normalized to dict format)
Performs file I/O and branch normalization (list → dict). Does NOT
run credential verification or log the operation — callers that need
those steps (i.e. load_registry) are responsible.
Raises:
RegistryNotFoundError: If registry file doesn't exist
RegistryCorruptError: If registry file is invalid JSON
RegistryCorruptError: If registry file is invalid JSON or malformed
RegistryPermissionError: If registry file cannot be read
"""
registry_path = get_registry_path()
if not registry_path.exists():
raise RegistryNotFoundError(
f"Registry not found at {registry_path}. "
@@ -200,7 +207,7 @@ def load_registry() -> Dict[str, Any]:
# Normalize: AIPASS_REGISTRY uses list format, convert to dict keyed by name
branches_raw = data["branches"]
if isinstance(branches_raw, list):
branches_dict = {}
branches_dict: Dict[str, Any] = {}
registry_dir = registry_path.parent
for branch in branches_raw:
name = branch.get("name", "").lower()
@@ -219,6 +226,31 @@ def load_registry() -> Dict[str, Any]:
elif not isinstance(branches_raw, dict):
raise RegistryCorruptError("Registry 'branches' must be a list or dict")
return data
def _get_aipass_home_registry_path() -> Optional[Path]:
"""Return the AIPass home registry path from AIPASS_HOME env var, or None."""
aipass_home = os.environ.get("AIPASS_HOME")
if not aipass_home:
return None
return _first_registry_in(Path(aipass_home))
def load_registry() -> Dict[str, Any]:
"""Load the branch registry from disk.
Returns:
Registry dictionary with branches (normalized to dict format)
Raises:
RegistryNotFoundError: If registry file doesn't exist
RegistryCorruptError: If registry file is invalid JSON
RegistryPermissionError: If registry file cannot be read
"""
registry_path = get_registry_path()
data = _load_registry_data(registry_path)
_verify_registry_credential(registry_path, data)
branch_count = len(data.get("branches", {}))
@@ -231,17 +263,36 @@ def get_all_branches(
branch_type: Optional[str] = None,
status: str = "active",
) -> List[Dict[str, Any]]:
"""Get all branches from the registry, optionally filtered."""
try:
registry = load_registry()
except RegistryNotFoundError:
logger.warning("get_all_branches: registry not found, returning empty list")
return []
"""Get all branches from the registry, optionally filtered.
branches = registry.get("branches", {}).values()
Merges branches from both the primary (local/project) registry and the
AIPass home registry (from AIPASS_HOME env var). Local branches take
precedence when names collide.
"""
merged: Dict[str, Any] = {}
# --- Primary registry ---
try:
primary = load_registry()
for name, branch in primary.get("branches", {}).items():
merged[name] = branch
except (RegistryNotFoundError, RegistryCorruptError, RegistryPermissionError) as exc:
logger.warning("get_all_branches: primary registry unavailable: %s", exc)
# --- AIPass home registry (if different from primary) ---
home_path = _get_aipass_home_registry_path()
primary_path = get_registry_path()
if home_path is not None and home_path != primary_path:
try:
home_data = _load_registry_data(home_path)
for name, branch in home_data.get("branches", {}).items():
if name not in merged:
merged[name] = branch
except (RegistryNotFoundError, RegistryCorruptError, RegistryPermissionError) as exc:
logger.warning("get_all_branches: AIPass home registry unavailable: %s", exc)
filtered = []
for branch in branches:
for branch in merged.values():
if status and branch.get("status") != status:
continue
if branch_type and branch.get("type") != branch_type:
@@ -252,11 +303,31 @@ def get_all_branches(
def get_branch_by_name(name: str) -> Optional[Dict[str, Any]]:
"""Get a single branch by name (case-insensitive)."""
"""Get a single branch by name (case-insensitive).
Checks the primary (local/project) registry first. If not found, falls
back to the AIPass home registry (AIPASS_HOME env var) when it points to
a different location.
"""
lower_name = name.lower()
# --- Primary registry ---
try:
registry = load_registry()
except RegistryNotFoundError:
logger.warning("get_branch_by_name: registry not found for lookup of '%s'", name)
return None
branch = registry.get("branches", {}).get(lower_name)
if branch is not None:
return branch
except (RegistryNotFoundError, RegistryCorruptError, RegistryPermissionError) as exc:
logger.warning("get_branch_by_name: primary registry unavailable for '%s': %s", name, exc)
return registry.get("branches", {}).get(name.lower())
# --- AIPass home registry fallback ---
home_path = _get_aipass_home_registry_path()
primary_path = get_registry_path()
if home_path is not None and home_path != primary_path:
try:
home_data = _load_registry_data(home_path)
return home_data.get("branches", {}).get(lower_name)
except (RegistryNotFoundError, RegistryCorruptError, RegistryPermissionError) as exc:
logger.warning("get_branch_by_name: AIPass home registry unavailable for '%s': %s", name, exc)
return None
+4 -2
View File
@@ -135,6 +135,9 @@ def normalize_branch_arg(target: str) -> str:
def resolve_branch(symbolic_name: str) -> str:
"""Resolve a symbolic branch name to its absolute path.
Checks primary (local) registry first, then falls back to AIPASS_HOME
registry for cross-project resolution.
Args:
symbolic_name: Branch name with @ prefix (e.g. "@seedgo")
@@ -149,10 +152,9 @@ def resolve_branch(symbolic_name: str) -> str:
raise BranchNotFoundError(
f"Branch name must use @ prefix: '@{symbolic_name}' (got '{symbolic_name}')"
)
registry = load_registry()
name = normalize_branch_name(symbolic_name).lower()
branch = registry.get("branches", {}).get(name)
branch = get_branch_by_name(name)
if branch is None:
raise BranchNotFoundError(
@@ -45,6 +45,7 @@ class BranchDetector:
self.module_map: Dict[str, str] = {} # module -> branch
self.known_branches: Set[str] = set()
self._repo_root: Optional[Path] = None
self._external_project_cache: Dict[str, str] = {} # dir_name -> project_name
self._load_registry()
json_handler.log_operation("branch_detected", {"known_branches": len(self.known_branches)})
@@ -109,19 +110,118 @@ class BranchDetector:
self.known_branches.update(fallback)
logger.info(f"Using fallback branches: {fallback}")
def _resolve_external_project_name(self, project_part: str) -> str:
"""Resolve external project directory name to canonical name via _REGISTRY.json.
Args:
project_part: Directory name segment like 'Vera-Studio' or 'AIPL'
Returns:
Name from registry file stem (e.g., 'VERA-STUDIO') or uppercased dir name
"""
for base in [Path.home() / 'Projects']:
project_dir = base / project_part
if not project_dir.exists():
continue
try:
for item in project_dir.iterdir():
if item.is_file() and item.name.endswith('_REGISTRY.json'):
return item.stem.replace('_REGISTRY', '')
except (OSError, PermissionError):
pass
return project_part.upper()
return project_part.upper()
def _parse_external_project_path(self, encoded_folder: str) -> tuple:
"""Parse encoded Claude project folder into (project_name, agent_name).
Handles hyphens in project names by splitting on -Projects- and -src-.
Examples:
-home-patrick-Projects-Vera-Studio -> ('VERA-STUDIO', None)
-home-patrick-Projects-AIPL-src-polyglot -> ('AIPL', 'POLYGLOT')
-home-patrick-Projects-Vera-Studio-src-vera -> ('VERA-STUDIO', 'VERA')
Returns:
(project_name, agent_name) -- agent_name is None if no src subdir
Returns (None, None) if cannot parse.
"""
if not encoded_folder.startswith('-'):
return None, None
name = encoded_folder[1:] # strip leading dash
# Find -Projects- boundary
sep = '-projects-'
idx = name.lower().find(sep)
if idx < 0:
return None, None
# Everything after -projects- is our target
after = name[idx + len(sep):]
# Split on -src- to separate project from agent subdirectory
src_sep = '-src-'
src_idx = after.lower().find(src_sep)
if src_idx >= 0:
project_part = after[:src_idx]
agent_part = after[src_idx + len(src_sep):]
else:
project_part = after
agent_part = None
# Resolve project name via registry file on filesystem
project_name = self._resolve_external_project_name(project_part)
# Agent name: uppercase with hyphen preserved (polyglot -> POLYGLOT)
agent_name = agent_part.upper() if agent_part else None
return project_name, agent_name
def _detect_from_claude_project(self, path_str: str) -> Optional[str]:
"""Detect branch from Claude Code project path encoding."""
"""Detect PROJECT/BRANCH label from Claude Code project path encoding.
Returns two-tier label (model suffix added by caller):
- Internal AIPass branches: 'AIPASS/DEVPULSE'
- External project with agent: 'AIPL/POLYGLOT'
- External project main session: 'VERA-STUDIO'
Sub-agents append ' SUB' to the agent/branch segment.
"""
projects_idx = path_str.index('.claude/projects/') + len('.claude/projects/')
remaining = path_str[projects_idx:]
project_folder = remaining.split('/')[0]
project_path = '/' + project_folder.replace('-', '/')
is_subagent = '/subagents/' in path_str
sub_suffix = ' SUB' if is_subagent else ''
for registered_path, branch_name in self.branch_map.items():
registered_normalized = registered_path.replace('_', '/')
project_normalized = project_path.replace('_', '/')
if registered_normalized == project_normalized or registered_path == project_path:
return branch_name
folder_lower = project_folder.lower()
# Internal AIPass: path contains -projects-aipass-src-aipass-
if '-projects-aipass-src-aipass-' in folder_lower:
# Simple hyphen-to-slash decode works (no hyphens in AIPass branch names)
project_path = '/' + project_folder.replace('-', '/')
for registered_path, branch_name in self.branch_map.items():
reg_norm = registered_path.replace('_', '/')
proj_norm = project_path.replace('_', '/')
if reg_norm == proj_norm or registered_path == project_path:
return f"AIPASS/{branch_name}{sub_suffix}"
# Fallback: last segment after aipass-
segs = [s for s in project_folder.split('-') if s]
if segs:
return f"AIPASS/{segs[-1].upper()}{sub_suffix}"
return None
# External project
project_name, agent_name = self._parse_external_project_path(project_folder)
if project_name:
if agent_name:
return f"{project_name}/{agent_name}{sub_suffix}"
elif sub_suffix:
return f"{project_name}{sub_suffix}"
else:
return project_name
# Old fallback: segment scanning for known branch names
segments = [s for s in project_folder.split('-') if s]
if not segments:
return None
@@ -144,6 +244,63 @@ class BranchDetector:
return branch_upper
return None
def _detect_from_external_project_path(self, path: Path) -> Optional[str]:
"""Detect project/agent label from a file path under ~/Projects/.
Covers external AIPass projects (AIPL, Vera-Studio, etc.) whose files
are not in branch_map but live under a directory containing *_REGISTRY.json.
AIPass itself is skipped — its branches are handled by branch_map.
Returns labels like 'AIPL/POLYGLOT', 'VERA-STUDIO', 'AIPL/POLYGLOT TESTS'.
"""
projects_base = Path.home() / 'Projects'
try:
rel = path.relative_to(projects_base)
except ValueError:
return None
parts = rel.parts
if not parts:
return None
project_dir_name = parts[0]
# Skip AIPass — handled by registry/branch_map (Strategy 2)
if project_dir_name.lower() == 'aipass':
return None
# Look up project name (cached)
if project_dir_name in self._external_project_cache:
project_name = self._external_project_cache[project_dir_name]
else:
project_dir = projects_base / project_dir_name
project_name = None
try:
for item in project_dir.iterdir():
if item.is_file() and item.name.endswith('_REGISTRY.json'):
project_name = item.stem.replace('_REGISTRY', '')
break
except (OSError, PermissionError):
pass
if not project_name:
return None # Not an external AIPass project
self._external_project_cache[project_dir_name] = project_name
# Extract agent from path: {project}/src/{agent}/...
agent_name = None
if len(parts) > 2 and parts[1].lower() == 'src':
agent_name = parts[2].upper()
# Append TESTS suffix when path is clearly test output
path_str_lower = str(path).lower()
is_test = ('/tests/' in path_str_lower or '/test_' in path_str_lower
or path_str_lower.endswith('_test.py') or path_str_lower.endswith('_test.log'))
test_suffix = ' TESTS' if is_test else ''
if agent_name:
return f"{project_name}/{agent_name}{test_suffix}"
return f"{project_name}{test_suffix}"
def _extract_branch_from_central(self, path_str: str, path: Path) -> Optional[str]:
"""Extract branch name from ai_mail central filename patterns."""
if not ('AI_MAIL' in path_str or '.ai_mail' in path_str or 'ai_mail' in path_str.lower()):
@@ -194,6 +351,12 @@ class BranchDetector:
self.log_map[path_str] = result
return result
# Strategy 2.5: External AIPass project files (AIPL, Vera-Studio, etc.)
result = self._detect_from_external_project_path(path)
if result:
self.log_map[path_str] = result
return result
# Strategy 3: Claude Code project files
if '.claude/projects/' in path_str:
result = self._detect_from_claude_project(path_str)
@@ -64,8 +64,8 @@ SYMBOLS = {
'info': 'ℹ️',
}
# Branch display width
BRANCH_WIDTH = 8
# Branch display width — wide enough for three-tier labels like AIPASS/DEVPULSE/OPUS
BRANCH_WIDTH = 24
# Level-based color mapping (simplified)
LEVEL_COLORS = {
@@ -76,8 +76,11 @@ LEVEL_COLORS = {
'success': 'green',
}
# Branch-specific colors for visual distinction
# Branch-specific colors for visual distinction.
# For three-tier labels (AIPASS/DEVPULSE/OPUS), the base key is the first segment (AIPASS).
# Internal AIPass branches also appear as bare names for non-session events.
BRANCH_COLORS = {
# Internal AIPass branches — individually colored for non-session events
'SEEDGO': 'green',
'DRONE': 'cyan',
'FLOW': 'blue',
@@ -89,11 +92,15 @@ BRANCH_COLORS = {
'DEVPULSE': 'bright_yellow',
'API': 'bright_red',
'SECURITY': 'red',
'AIPASS': 'bold white',
'TRIGGER': 'bright_red',
'SPEAKEASY': 'bright_white',
'THE_COMMONS': 'bright_green',
'ASSISTANT': 'bright_yellow',
# Project-level keys — used for three-tier labels (first segment of PROJECT/AGENT/MODEL)
'AIPASS': 'bold white', # All internal AIPass agent sessions
'AIPL': 'bright_blue', # AIPL external project
'VERA-STUDIO': 'bright_cyan', # Vera Studio external project
'COMPASS': 'bright_green', # Compass external project
}
@@ -26,7 +26,7 @@ from typing import Any, Callable, Dict, List, Optional, Set
from aipass.trigger.apps.config import TRIGGER_ROOT, atomic_write_json
from aipass.trigger.apps.handlers.json import json_handler
SYSTEM_LOGS_DIR = TRIGGER_ROOT.parent / "system_logs"
SYSTEM_LOGS_DIR = TRIGGER_ROOT.parent.parent.parent / "system_logs"
TRIGGER_DATA_FILE = TRIGGER_ROOT / "trigger_json" / "trigger_data.json"
SUPPRESSED_LOG = TRIGGER_ROOT / "logs" / "medic_suppressed.log"
@@ -37,7 +37,7 @@ from aipass.trigger.apps.handlers.json import json_handler
# logger imported from aipass.prax
# System logs directory (package-relative via config)
SYSTEM_LOGS_DIR = TRIGGER_ROOT.parent.parent / "system_logs"
SYSTEM_LOGS_DIR = TRIGGER_ROOT.parent.parent.parent / "system_logs"
# Try to import watchdog
try: