feat: seedgo 100% compliance across all 12 branches + close-pr command
This commit is contained in:
@@ -16,7 +16,8 @@
|
||||
"Write(/home/patrick/Patrick-Personal/**)",
|
||||
"Glob(/home/patrick/Patrick-Personal/**)",
|
||||
"Grep(/home/patrick/Patrick-Personal/**)",
|
||||
"Bash(*Patrick-Personal*)"
|
||||
"Bash(*Patrick-Personal*)",
|
||||
"Bash(drone @git checkout main)"
|
||||
],
|
||||
"defaultMode": "acceptEdits"
|
||||
},
|
||||
|
||||
@@ -119,8 +119,6 @@ src/aipass/memory/config/.plans_processed.json
|
||||
src/aipass/drone/drone_command_registry.json
|
||||
src/aipass/spawn/templates/builder/.spawn/.template_registry.json
|
||||
src/aipass/memory/config/.plans_processed.json
|
||||
src/aipass/memory/memory_pool/
|
||||
src/aipass/memory/memory_pool_archive/
|
||||
src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/pr_plugin.cpython-312.pyc
|
||||
|
||||
whiteboard.md
|
||||
|
||||
@@ -6,12 +6,11 @@ User: user
|
||||
|
||||
# Startup protocol
|
||||
|
||||
On any greeting, silently read these files and run the commands — no narration, no announcing steps. Just do it and respond with the status.
|
||||
On any greeting, silently read these files from CWD and run the commands — no narration, no announcing steps. Just do it and respond with the status.
|
||||
|
||||
- Read (cat): `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
|
||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
|
||||
- Check: `drone @ai_mail inbox` — process any mail, don't ask.
|
||||
- Run: `drone @git status`
|
||||
- Refresh: If `STATUS.local.md` is stale (last updated date older than latest session in local.json), update it from your memories. Keep Current Work accurate.
|
||||
|
||||
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
---
|
||||
|
||||
**Status:** Operational | **Seedgo:** 99% | **Tests:** 712 pass
|
||||
**Status:** Operational | **Seedgo:** 100% (34/34) | **Tests:** 712 pass | **Battle Tested:** S62
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -149,37 +149,25 @@ ai_mail/
|
||||
│ ├── paths.py # Shared find_repo_root() utility
|
||||
│ ├── notify.py # Desktop notifications (dbus direct)
|
||||
│ └── central_writer.py # Central inbox stats aggregation
|
||||
└── tests/ # 712 tests across 29 test files
|
||||
└── tests/ # 712 tests across 16 test files
|
||||
├── conftest.py # Shared fixtures (mock_logger, mock_json_handler)
|
||||
├── test_central_writer.py # Central stats aggregation
|
||||
├── test_cli_routing.py # CLI routing + help/version
|
||||
├── test_close_ops.py # Batch close operations
|
||||
├── test_contacts.py # Address book operations
|
||||
├── test_create.py # Email file creation
|
||||
├── test_daemon.py # Daemon config, state, kill switch, dispatch check
|
||||
├── test_delivery.py # Inbox migration, private branches, pipeline
|
||||
├── test_dispatch_module.py # Dispatch module orchestration
|
||||
├── test_dispatch_monitor.py # Monitor safety features, env stripping
|
||||
├── test_dispatch_status.py # Log I/O, age calculation
|
||||
├── test_email_module.py # Email module orchestration
|
||||
├── test_error_dispatch.py # Error reporting via email
|
||||
├── test_footer.py # Email footer rendering
|
||||
├── test_format.py # Display formatting
|
||||
├── test_header.py # Dispatch header injection
|
||||
├── test_inbox_cleanup.py # Mark read/opened/closed + archive
|
||||
├── test_inbox_ops.py # Inbox loading + migration
|
||||
├── test_json_handler.py # JSON I/O helpers
|
||||
├── test_misc_handlers.py # Misc handler utilities
|
||||
├── test_notify.py # Desktop notification dbus calls
|
||||
├── test_paths.py # find_repo_root() utility
|
||||
├── test_purge.py # Auto-purge sent/deleted folders
|
||||
├── test_registry_read.py # Registry parsing + branch lookup
|
||||
├── test_reply.py # Reply + auto-close original
|
||||
├── test_send_helpers.py # Send helper utilities
|
||||
├── test_send_identity.py # Sender identity chain
|
||||
├── test_user_paths.py # Mailbox path resolution
|
||||
├── test_dispatch_watchdog.py # Watchdog auto-spawn
|
||||
├── test_wake.py # Branch resolution, PID checks, lock files
|
||||
└── test_wake_blocklist.py # Wake protection for @devpulse
|
||||
├── test_wake_blocklist.py # Wake protection for @devpulse
|
||||
├── test_delivery.py # Inbox migration, private branches, pipeline
|
||||
├── test_send_identity.py # Sender identity chain (36 tests)
|
||||
├── test_user_paths.py # Mailbox path resolution (13 tests)
|
||||
├── test_contacts.py # Address book operations
|
||||
├── test_inbox_ops.py # Inbox loading + migration
|
||||
├── test_registry_read.py # Registry parsing + branch lookup
|
||||
├── test_central_writer.py # Central stats aggregation
|
||||
├── test_cli_routing.py # CLI routing + help/version
|
||||
├── test_json_handler.py # JSON I/O helpers
|
||||
├── test_notify.py # Desktop notification dbus calls
|
||||
└── test_paths.py # find_repo_root() utility
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
@@ -69,7 +69,7 @@ MODEL_MAP = {
|
||||
"opus": "claude-opus-4-6",
|
||||
"haiku": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
DEFAULT_MODEL = "sonnet"
|
||||
DEFAULT_MODEL = "opus"
|
||||
|
||||
# Branches that cannot be woken manually by cross-branch drone commands.
|
||||
# Dispatch-send path (dispatch.py._orchestrate_dispatch_send) bypasses this check.
|
||||
@@ -556,7 +556,7 @@ if __name__ == "__main__":
|
||||
print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)")
|
||||
print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.")
|
||||
print(" --sender @branch Set return-to-sender for bounce emails (default: @devpulse)")
|
||||
print(" --model NAME Model to use: sonnet (default), opus, haiku, or full model ID")
|
||||
print(" --model NAME Model to use: opus (default), sonnet, haiku, or full model ID")
|
||||
print()
|
||||
print("Output: Step-by-step status of the dispatch pipeline:")
|
||||
print(" ✅ resolve → @branch found at /path/to/branch")
|
||||
|
||||
@@ -78,6 +78,9 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
return True
|
||||
return handle_send(args)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Project-specific dependencies for the AI_MAIL branch
|
||||
# These are beyond the base AIPass requirements
|
||||
# Install with: pip install -r requirements.project.txt
|
||||
|
||||
rich
|
||||
@@ -460,9 +460,9 @@ def test_model_map_has_expected_entries():
|
||||
assert "claude-opus-4-6" in MODEL_MAP["opus"]
|
||||
|
||||
|
||||
def test_default_model_is_sonnet():
|
||||
"""Default model should be sonnet."""
|
||||
assert DEFAULT_MODEL == "sonnet"
|
||||
def test_default_model_is_opus():
|
||||
"""Default model should be opus."""
|
||||
assert DEFAULT_MODEL == "opus"
|
||||
|
||||
|
||||
def test_model_map_values_are_full_ids():
|
||||
|
||||
@@ -32,7 +32,7 @@ aipass/
|
||||
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
||||
│ │ └── ui/ # Progress bars, menus, banners
|
||||
│ └── plugins/
|
||||
├── tests/ # 381 passing (12 test files)
|
||||
├── tests/ # 412 passing
|
||||
├── .trinity/ # Identity + session history + observations
|
||||
└── README.md
|
||||
```
|
||||
@@ -50,15 +50,25 @@ aipass/
|
||||
| `aipass profile` | Show/edit user profile |
|
||||
| `aipass --version` | Version |
|
||||
|
||||
## Integration
|
||||
## Integration Points
|
||||
|
||||
**Depends on:** `@drone` (routing), `@seedgo` (audit), `@spawn` (first agent + repair), `@flow` (plan lifecycle), `@ai_mail` (test emails), `@prax` (health signals), `pytest`
|
||||
### Depends On
|
||||
|
||||
**Provides to:** Humans only. Nothing in AIPass depends on this branch.
|
||||
- `@drone` — routing, command dispatch
|
||||
- `@seedgo` — standards audit
|
||||
- `@spawn` — first agent creation + structural repair
|
||||
- `@flow` — plan lifecycle (open/close)
|
||||
- `@ai_mail` — test emails
|
||||
- `@prax` — health signals, logging
|
||||
- `pytest` — test execution
|
||||
|
||||
### Provides To
|
||||
|
||||
Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Tests
|
||||
|
||||
381 passing — `pytest src/aipass/aipass/tests/`
|
||||
412 passing — `pytest src/aipass/aipass/tests/`
|
||||
|
||||
## Known Issues
|
||||
|
||||
@@ -66,4 +76,4 @@ aipass/
|
||||
|
||||
## Last Updated
|
||||
|
||||
2026-05-16
|
||||
Last Updated: 2026-05-16
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
# AIPASS apps package
|
||||
|
||||
from . import handlers as handlers # noqa: F401
|
||||
|
||||
@@ -23,131 +23,6 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def claude_md(name: str) -> str:
|
||||
"""Generate CLAUDE.md content — Claude Code reads this on startup."""
|
||||
return (
|
||||
f"# {name}\n"
|
||||
"\n"
|
||||
"**User:** (your name here)\n"
|
||||
"\n"
|
||||
"## What is AIPass\n"
|
||||
"\n"
|
||||
"AIPass is a multi-agent framework. This project was created with `aipass init`.\n"
|
||||
"\n"
|
||||
"**Key concepts:**\n"
|
||||
"- **Project** — this directory. Contains a registry and one or more agents.\n"
|
||||
"- **Agent** — a citizen that lives inside the project. Has identity (`.trinity/`), "
|
||||
"memory, mailbox, and its own apps/ directory.\n"
|
||||
f"- **Registry** — `{name}_REGISTRY.json` tracks all agents in this project.\n"
|
||||
"\n"
|
||||
"## Getting Started\n"
|
||||
"\n"
|
||||
"Create your first agent:\n"
|
||||
"```\n"
|
||||
"aipass init agent <name>\n"
|
||||
"```\n"
|
||||
"\n"
|
||||
"This creates a full agent scaffold inside `src/<name>/` "
|
||||
"(`apps/`, `.trinity/`, `.ai_mail.local/`) "
|
||||
"and registers it in your project registry.\n"
|
||||
"\n"
|
||||
"## Available Commands\n"
|
||||
"\n"
|
||||
"```\n"
|
||||
"aipass init agent <name> # Create a new agent\n"
|
||||
"drone @spawn create <name> # Create agent (alternative)\n"
|
||||
"drone @seedgo audit <project> # Run standards audit\n"
|
||||
"drone @ai_mail inbox # Check mailbox (per-agent)\n"
|
||||
"drone systems # List all available infrastructure\n"
|
||||
"```\n"
|
||||
"\n"
|
||||
"## Startup Protocol\n"
|
||||
"\n"
|
||||
"On any greeting, silently read these files — no narration, just do it "
|
||||
"and respond with the status.\n"
|
||||
"\n"
|
||||
f"**Read:** `{name}_REGISTRY.json`, `README.md`, `STATUS.local.md`\n"
|
||||
"**Read:** `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`\n"
|
||||
"**Check:** If `.ai_mail.local/inbox.json` exists, read it. Process any mail.\n"
|
||||
"**Check:** If `dropbox/init_report.json` exists, read it — this is your birth certificate. "
|
||||
"Use it to understand your role, the project context, and any setup instructions. "
|
||||
"If it mentions missing provider settings (hooks, env vars, permissions), "
|
||||
"tell the user what needs configuring and where.\n"
|
||||
"**Run:** `git status`\n"
|
||||
"\n"
|
||||
"Then check the registry for agents and report status.\n"
|
||||
)
|
||||
|
||||
|
||||
def agents_md(name: str) -> str:
|
||||
"""Generate AGENTS.md content — Codex equivalent of CLAUDE.md."""
|
||||
return (
|
||||
f"# {name} — Agent Instructions\n"
|
||||
"\n"
|
||||
"This project uses AIPass, a multi-agent framework.\n"
|
||||
"\n"
|
||||
"## Key Concepts\n"
|
||||
"\n"
|
||||
"- **Project** — this directory. Contains a registry and one or more agents.\n"
|
||||
"- **Agent** — a citizen that lives inside the project with its own identity, "
|
||||
"memory, and code.\n"
|
||||
f"- **Registry** — `{name}_REGISTRY.json` tracks all agents.\n"
|
||||
"\n"
|
||||
"## Getting Started\n"
|
||||
"\n"
|
||||
"Create your first agent:\n"
|
||||
"```\n"
|
||||
"aipass init agent <name>\n"
|
||||
"```\n"
|
||||
"\n"
|
||||
"## Available Commands\n"
|
||||
"\n"
|
||||
"```\n"
|
||||
"aipass init agent <name> # Create a new agent\n"
|
||||
"drone @spawn create <name> # Create agent (alternative)\n"
|
||||
"drone @seedgo audit <project> # Run standards audit\n"
|
||||
"drone systems # List all infrastructure\n"
|
||||
"```\n"
|
||||
"\n"
|
||||
"## Startup\n"
|
||||
"\n"
|
||||
f"On startup, read: `{name}_REGISTRY.json`, `README.md`, `STATUS.local.md`\n"
|
||||
)
|
||||
|
||||
|
||||
def gemini_md(name: str) -> str:
|
||||
"""Generate GEMINI.md content — Gemini equivalent of CLAUDE.md."""
|
||||
return (
|
||||
f"# {name} — Project Instructions\n"
|
||||
"\n"
|
||||
"This project uses AIPass, a multi-agent framework.\n"
|
||||
"\n"
|
||||
"## Key Concepts\n"
|
||||
"\n"
|
||||
"- **Project** — this directory. Contains a registry and one or more agents.\n"
|
||||
"- **Agent** — a citizen that lives inside the project with its own identity, "
|
||||
"memory, and code.\n"
|
||||
f"- **Registry** — `{name}_REGISTRY.json` tracks all agents.\n"
|
||||
"\n"
|
||||
"## Getting Started\n"
|
||||
"\n"
|
||||
"Create your first agent: `aipass init agent <name>`\n"
|
||||
"\n"
|
||||
"## Available Commands\n"
|
||||
"\n"
|
||||
"```\n"
|
||||
"aipass init agent <name> # Create a new agent\n"
|
||||
"drone @spawn create <name> # Create agent (alternative)\n"
|
||||
"drone @seedgo audit <project> # Run standards audit\n"
|
||||
"drone systems # List all infrastructure\n"
|
||||
"```\n"
|
||||
"\n"
|
||||
"## Startup\n"
|
||||
"\n"
|
||||
f"On startup, read: `{name}_REGISTRY.json`, `README.md`, `STATUS.local.md`\n"
|
||||
)
|
||||
|
||||
|
||||
def readme_md(name: str) -> str:
|
||||
"""Generate README.md content — real getting started guide."""
|
||||
return (
|
||||
@@ -454,48 +329,6 @@ def prep_md() -> str:
|
||||
)
|
||||
|
||||
|
||||
def memo_md() -> str:
|
||||
"""Generate .claude/commands/memo.md — /memo memory update slash command."""
|
||||
return (
|
||||
"# Memory Update\n"
|
||||
"\n"
|
||||
"Purpose: Update branch memory files after completing work this session.\n"
|
||||
"\n"
|
||||
"## Execution\n"
|
||||
"\n"
|
||||
"1. Read `.trinity/passport.json` first — re-absorb your identity, "
|
||||
"role, and principles before writing memories\n"
|
||||
"2. Review what was done this session (context, recent changes, key "
|
||||
"decisions)\n"
|
||||
"3. Update each file below as needed\n"
|
||||
"4. Confirm completion — list files updated\n"
|
||||
"\n"
|
||||
"## Memory Roles\n"
|
||||
"\n"
|
||||
"Each memory file plays a distinct role. Update based on what actually "
|
||||
"changed this session.\n"
|
||||
"\n"
|
||||
"- **`.trinity/passport.json`** — IDENTITY. Who you are: role, "
|
||||
"capabilities, principles. Only update if identity genuinely evolved "
|
||||
"this session. Don't touch it just to touch it.\n"
|
||||
"- **`.trinity/local.json`** — YOUR MEMORY. Session history and "
|
||||
"key_learnings. Add a session entry for significant work. Add "
|
||||
"key_learnings for facts you'd need next time. "
|
||||
"DO NOT trim or delete entries — rollover to @memory handles overflow automatically.\n"
|
||||
"- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. "
|
||||
"Collaboration insights, preferences, friction points, flow states. "
|
||||
"Skip entirely if nothing new about the user this session.\n"
|
||||
"- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known "
|
||||
"issues, todos, notepad. Auto-synced to central STATUS.md on PR "
|
||||
"events — this is how other branches see you. Keep Current Work "
|
||||
"accurate and drop quick notes in the Notepad section.\n"
|
||||
"\n"
|
||||
"## If Relevant\n"
|
||||
"\n"
|
||||
"- **README.md** — Does it reflect current state? Update if stale.\n"
|
||||
)
|
||||
|
||||
|
||||
def inbox_json() -> str:
|
||||
"""Generate .ai_mail.local/inbox.json — empty project mailbox structure."""
|
||||
return (
|
||||
|
||||
@@ -136,6 +136,36 @@ def validate_json_structure(data: Any, json_type: str) -> bool:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def load_path(path: Path) -> Any:
|
||||
"""Load JSON from an arbitrary file path with consistent error handling."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("[json_handler] Failed to load %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def save_path(path: Path, data: Any) -> bool:
|
||||
"""Write JSON data to an arbitrary file path atomically."""
|
||||
os.makedirs(path.parent, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp", prefix=path.stem)
|
||||
succeeded = False
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
os.replace(tmp_path, str(path))
|
||||
succeeded = True
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning("[json_handler] Failed to save %s: %s", path, exc)
|
||||
return False
|
||||
finally:
|
||||
if not succeeded and Path(tmp_path).exists():
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def get_json_path(module_name: str, json_type: str) -> Path:
|
||||
"""Get path for module JSON file."""
|
||||
filename = f"{module_name}_{json_type}.json"
|
||||
|
||||
@@ -657,11 +657,15 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
if command != "doctor":
|
||||
return False
|
||||
|
||||
if args and args[0] in ("--help", "-h", "help"):
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args and args[0] == "--info":
|
||||
if args[0] == "--info":
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
@@ -684,12 +688,3 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
if error_count > 0:
|
||||
raise SystemExit(1)
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STANDALONE EXECUTION
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Prax logger connected to doctor")
|
||||
handle_command("doctor", sys.argv[1:])
|
||||
|
||||
@@ -106,7 +106,9 @@ def _auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[s
|
||||
"""
|
||||
actions: List[str] = []
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest = json_handler.load_path(manifest_path)
|
||||
if manifest is None:
|
||||
return actions
|
||||
claude_section = manifest.get("cli", {}).get("claude", {})
|
||||
if not claude_section:
|
||||
return actions
|
||||
@@ -114,7 +116,7 @@ def _auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[s
|
||||
# Read existing settings
|
||||
settings_path = Path.home() / ".claude" / "settings.json"
|
||||
if settings_path.exists():
|
||||
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
settings = json_handler.load_path(settings_path) or {}
|
||||
else:
|
||||
settings = {}
|
||||
|
||||
@@ -216,8 +218,7 @@ def _auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[s
|
||||
actions.append(f"Added ask rule: {rule}")
|
||||
|
||||
# Write settings back
|
||||
os.makedirs(settings_path.parent, exist_ok=True)
|
||||
settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
||||
json_handler.save_path(settings_path, settings)
|
||||
actions.append("Updated ~/.claude/settings.json")
|
||||
|
||||
return actions
|
||||
@@ -340,6 +341,11 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
json_handler.log_operation("doctor_wire_info", {"command": command})
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
json_handler.log_operation("doctor_wire_info", {"command": command})
|
||||
return True
|
||||
|
||||
if args[0] in ("--info", "info"):
|
||||
print_introspection()
|
||||
json_handler.log_operation("doctor_wire_info", {"command": command})
|
||||
|
||||
@@ -219,10 +219,11 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
json_handler.ensure_module_jsons(_MODULE_NAME)
|
||||
|
||||
if not args:
|
||||
console.print()
|
||||
console.print("[bold]Usage:[/bold] aipass help [dim]<question>[/dim]")
|
||||
console.print("[bold]Example:[/bold] aipass help what does drone do")
|
||||
console.print()
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
question = " ".join(args)
|
||||
|
||||
@@ -900,11 +900,7 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
err = _preflight_check()
|
||||
if err:
|
||||
console.print(f"[red]✗[/red] {err}")
|
||||
sys.exit(1)
|
||||
sys.exit(_handle_init_scaffold([]))
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
|
||||
@@ -39,12 +39,10 @@ USER_FIELDS = ["name", "os", "shell", "preferred_cli", "install_method", "first_
|
||||
def _read_local_json() -> dict:
|
||||
if not _LOCAL_JSON.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(_LOCAL_JSON, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("[profile] local.json read error: %s", exc)
|
||||
result = json_handler.load_path(_LOCAL_JSON)
|
||||
if result is None:
|
||||
return {}
|
||||
return result
|
||||
|
||||
|
||||
def _fire_file_deleted(path: str) -> None:
|
||||
|
||||
@@ -12,18 +12,19 @@
|
||||
# - Error handling: Use error handler system (apps/handlers/error/)
|
||||
# =============================================
|
||||
|
||||
"""Shared pytest fixtures for spawn tests"""
|
||||
"""Shared pytest fixtures for aipass tests."""
|
||||
|
||||
import pytest
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_test_dir() -> Generator[Path, None, None]:
|
||||
"""Creates temporary directory for testing, cleans up after"""
|
||||
"""Creates temporary directory for testing, cleans up after."""
|
||||
test_dir = Path(tempfile.mkdtemp())
|
||||
yield test_dir
|
||||
if test_dir.exists():
|
||||
@@ -32,8 +33,25 @@ def temp_test_dir() -> Generator[Path, None, None]:
|
||||
|
||||
@pytest.fixture
|
||||
def sample_test_data() -> dict:
|
||||
"""Provides sample test data
|
||||
|
||||
Customize this fixture for your module's needs
|
||||
"""
|
||||
"""Provides sample test data."""
|
||||
return {"test_key": "test_value", "sample_data": "example"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_json_handler():
|
||||
"""Mock json_handler with functional load_path but stubbed logging.
|
||||
|
||||
Use when tests need real file I/O via load_path but want to
|
||||
suppress log_operation and ensure_module_jsons side effects.
|
||||
"""
|
||||
with (
|
||||
patch("aipass.aipass.apps.handlers.json.json_handler.log_operation") as mock_log,
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.json.json_handler.ensure_module_jsons",
|
||||
return_value=True,
|
||||
) as mock_ensure,
|
||||
):
|
||||
mock = MagicMock()
|
||||
mock.log_operation = mock_log
|
||||
mock.ensure_module_jsons = mock_ensure
|
||||
yield mock
|
||||
|
||||
@@ -198,13 +198,12 @@ class TestHandleCommand:
|
||||
assert handle_command("doctor", []) is False
|
||||
assert handle_command("profile", ["set", "name", "X"]) is False
|
||||
|
||||
def test_no_args_runs_scaffold(self, tmp_local_json) -> None:
|
||||
"""'init' with no args runs project scaffold."""
|
||||
with patch("aipass.aipass.apps.modules.init_flow._preflight_check", return_value=None):
|
||||
with patch("aipass.aipass.apps.modules.init_flow._handle_init_scaffold", return_value=0):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
handle_command("init", [])
|
||||
assert exc_info.value.code == 0
|
||||
def test_no_args_shows_introspection(self, tmp_local_json) -> None:
|
||||
"""'init' with no args calls print_introspection."""
|
||||
with patch("aipass.aipass.apps.modules.init_flow.print_introspection") as mock_intro:
|
||||
result = handle_command("init", [])
|
||||
assert result is True
|
||||
mock_intro.assert_called_once()
|
||||
|
||||
def test_help_flag(self) -> None:
|
||||
"""--help flag routes to print_help."""
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_json_handler.py
|
||||
# Description: Tests for json_handler module
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-05-16
|
||||
# Modified: 2026-05-16
|
||||
# =============================================
|
||||
|
||||
"""Tests for json_handler — default_factory, validate, get_path, ensure_exists, load, save, ensure_module."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from aipass.aipass.apps.handlers.json.json_handler import (
|
||||
AIPASS_JSON_DIR,
|
||||
_default_template,
|
||||
ensure_json_exists,
|
||||
ensure_module_jsons,
|
||||
get_json_path,
|
||||
load_json,
|
||||
load_path,
|
||||
save_json,
|
||||
validate_json_structure,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# default_factory (_default_template)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDefaultFactory:
|
||||
"""Tests for _default_template factory function."""
|
||||
|
||||
def test_config_template(self):
|
||||
"""Config template includes module_name, version, config, created."""
|
||||
result = _default_template("config", "test_mod")
|
||||
assert result["module_name"] == "test_mod"
|
||||
assert result["version"] == "1.0.0"
|
||||
assert "config" in result
|
||||
assert "created" in result
|
||||
|
||||
def test_data_template(self):
|
||||
"""Data template includes created and last_updated."""
|
||||
result = _default_template("data", "test_mod")
|
||||
assert "created" in result
|
||||
assert "last_updated" in result
|
||||
|
||||
def test_log_template(self):
|
||||
"""Log template is an empty list."""
|
||||
result = _default_template("log", "test_mod")
|
||||
assert result == []
|
||||
|
||||
def test_unknown_type_returns_none(self):
|
||||
"""Unknown json_type returns None."""
|
||||
result = _default_template("unknown_type", "test_mod")
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# validate
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestValidate:
|
||||
"""Tests for validate_json_structure."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""Valid config structure passes validation."""
|
||||
data = {"module_name": "x", "version": "1.0.0", "config": {}}
|
||||
assert validate_json_structure(data, "config") is True
|
||||
|
||||
def test_invalid_config_missing_key(self):
|
||||
"""Config missing required keys fails validation."""
|
||||
data = {"module_name": "x"}
|
||||
assert validate_json_structure(data, "config") is False
|
||||
|
||||
def test_config_not_dict(self):
|
||||
"""Non-dict config fails validation."""
|
||||
assert validate_json_structure([], "config") is False
|
||||
|
||||
def test_valid_data(self):
|
||||
"""Valid data structure passes validation."""
|
||||
data = {"created": "2026-01-01", "last_updated": "2026-01-01"}
|
||||
assert validate_json_structure(data, "data") is True
|
||||
|
||||
def test_invalid_data(self):
|
||||
"""Data missing last_updated fails validation."""
|
||||
assert validate_json_structure({"created": "x"}, "data") is False
|
||||
|
||||
def test_valid_log(self):
|
||||
"""Empty list is valid log structure."""
|
||||
assert validate_json_structure([], "log") is True
|
||||
|
||||
def test_invalid_log(self):
|
||||
"""Non-list log fails validation."""
|
||||
assert validate_json_structure({}, "log") is False
|
||||
|
||||
def test_unknown_type(self):
|
||||
"""Unknown json_type fails validation."""
|
||||
assert validate_json_structure({}, "bogus") is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# get_path
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGetPath:
|
||||
"""Tests for get_json_path."""
|
||||
|
||||
def test_returns_correct_path(self):
|
||||
"""Path resolves to AIPASS_JSON_DIR/module_type.json."""
|
||||
path = get_json_path("doctor", "config")
|
||||
assert path == AIPASS_JSON_DIR / "doctor_config.json"
|
||||
|
||||
def test_different_types(self):
|
||||
"""All json_types produce correctly named paths."""
|
||||
for json_type in ("config", "data", "log"):
|
||||
path = get_json_path("mymod", json_type)
|
||||
assert path.name == f"mymod_{json_type}.json"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ensure_exists
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestEnsureExists:
|
||||
"""Tests for ensure_json_exists."""
|
||||
|
||||
def test_creates_missing_file(self, tmp_path):
|
||||
"""Missing file is created from template."""
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
result = ensure_json_exists("newmod", "config")
|
||||
assert result is True
|
||||
created = tmp_path / "newmod_config.json"
|
||||
assert created.exists()
|
||||
data = json.loads(created.read_text())
|
||||
assert data["module_name"] == "newmod"
|
||||
|
||||
def test_existing_valid_file_untouched(self, tmp_path):
|
||||
"""Valid existing file returns True without rewriting."""
|
||||
target = tmp_path / "existing_config.json"
|
||||
content = {"module_name": "existing", "version": "1.0.0", "config": {}, "created": "2026-01-01"}
|
||||
target.write_text(json.dumps(content))
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
result = ensure_json_exists("existing", "config")
|
||||
assert result is True
|
||||
|
||||
def test_corrupted_file_regenerated(self, tmp_path):
|
||||
"""Corrupted file is regenerated from template."""
|
||||
target = tmp_path / "bad_config.json"
|
||||
target.write_text("not json at all")
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
result = ensure_json_exists("bad", "config")
|
||||
assert result is True
|
||||
data = json.loads(target.read_text())
|
||||
assert data["module_name"] == "bad"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# load
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLoad:
|
||||
"""Tests for load_json."""
|
||||
|
||||
def test_load_existing(self, tmp_path):
|
||||
"""Existing valid file loads correctly."""
|
||||
target = tmp_path / "mod_log.json"
|
||||
target.write_text(json.dumps([{"op": "test"}]))
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
result = load_json("mod", "log")
|
||||
assert result == [{"op": "test"}]
|
||||
|
||||
def test_load_missing_creates(self, tmp_path):
|
||||
"""Missing file is auto-created then loaded."""
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
result = load_json("fresh", "log")
|
||||
assert result == []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# save
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSave:
|
||||
"""Tests for save_json."""
|
||||
|
||||
def test_save_valid(self, tmp_path):
|
||||
"""Valid structure saves successfully."""
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
data = {"module_name": "s", "version": "1.0.0", "config": {}, "created": "2026-01-01"}
|
||||
result = save_json("s", "config", data)
|
||||
assert result is True
|
||||
saved = json.loads((tmp_path / "s_config.json").read_text())
|
||||
assert saved["module_name"] == "s"
|
||||
|
||||
def test_save_invalid_structure_rejected(self, tmp_path):
|
||||
"""Invalid structure is rejected with False."""
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
result = save_json("s", "config", {"bad": True})
|
||||
assert result is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ensure_module
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestEnsureModule:
|
||||
"""Tests for ensure_module_jsons."""
|
||||
|
||||
def test_creates_all_three(self, tmp_path):
|
||||
"""All three json types (config, data, log) are created."""
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
result = ensure_module_jsons("trio")
|
||||
assert result is True
|
||||
assert (tmp_path / "trio_config.json").exists()
|
||||
assert (tmp_path / "trio_data.json").exists()
|
||||
assert (tmp_path / "trio_log.json").exists()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# load_path
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLoadPath:
|
||||
"""Tests for load_path arbitrary file reader."""
|
||||
|
||||
def test_load_valid_file(self, tmp_path):
|
||||
"""Valid JSON file loads as dict."""
|
||||
f = tmp_path / "test.json"
|
||||
f.write_text(json.dumps({"key": "value"}))
|
||||
result = load_path(f)
|
||||
assert result == {"key": "value"}
|
||||
|
||||
def test_load_missing_file(self, tmp_path):
|
||||
"""Missing file returns None."""
|
||||
result = load_path(tmp_path / "nope.json")
|
||||
assert result is None
|
||||
|
||||
def test_load_invalid_json(self, tmp_path):
|
||||
"""Invalid JSON content returns None."""
|
||||
f = tmp_path / "bad.json"
|
||||
f.write_text("not json")
|
||||
result = load_path(f)
|
||||
assert result is None
|
||||
|
||||
def test_load_empty_file(self, tmp_path):
|
||||
"""Empty file returns None."""
|
||||
f = tmp_path / "empty.json"
|
||||
f.write_text("")
|
||||
result = load_path(f)
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# error_resilience: empty_file
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestErrorResilience:
|
||||
"""Tests for error resilience with empty/corrupt files."""
|
||||
|
||||
def test_empty_file_handled(self, tmp_path):
|
||||
"""Empty JSON file is regenerated from template."""
|
||||
target = tmp_path / "empty_config.json"
|
||||
target.write_text("")
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
result = ensure_json_exists("empty", "config")
|
||||
assert result is True
|
||||
data = json.loads(target.read_text())
|
||||
assert data["module_name"] == "empty"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# return_type_contracts: command_returns_bool
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestReturnTypeContracts:
|
||||
"""Tests that handle_command always returns bool."""
|
||||
|
||||
def test_doctor_handle_command_returns_bool(self):
|
||||
"""Doctor handle_command returns True for match, False otherwise."""
|
||||
from aipass.aipass.apps.modules.doctor import handle_command as doctor_cmd
|
||||
|
||||
assert doctor_cmd("doctor", []) is True
|
||||
assert doctor_cmd("not_doctor", []) is False
|
||||
|
||||
def test_help_chat_handle_command_returns_bool(self):
|
||||
"""Help chat handle_command returns True for match, False otherwise."""
|
||||
from aipass.aipass.apps.modules.help_chat import handle_command as help_cmd
|
||||
|
||||
assert help_cmd("help", []) is True
|
||||
assert help_cmd("not_help", []) is False
|
||||
|
||||
def test_profile_handle_command_returns_bool(self):
|
||||
"""Profile handle_command returns True for match, False otherwise."""
|
||||
from aipass.aipass.apps.modules.profile import handle_command as profile_cmd
|
||||
|
||||
assert profile_cmd("profile", []) is True
|
||||
assert profile_cmd("not_profile", []) is False
|
||||
|
||||
def test_doctor_wire_handle_command_returns_bool(self):
|
||||
"""Doctor wire handle_command returns True for match, False otherwise."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import handle_command as wire_cmd
|
||||
|
||||
assert wire_cmd("doctor_wire", []) is True
|
||||
assert wire_cmd("not_wire", []) is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# exception_contracts: invalid_mode_raises
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestExceptionContracts:
|
||||
"""Tests that invalid inputs raise appropriate exceptions."""
|
||||
|
||||
def test_invalid_mode_raises(self, tmp_path):
|
||||
"""save_json with invalid structure returns False (not silent pass)."""
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
result = save_json("x", "config", [])
|
||||
assert result is False
|
||||
result = save_json("x", "data", "string")
|
||||
assert result is False
|
||||
result = save_json("x", "log", {"not": "a list"})
|
||||
assert result is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# infrastructure_mocking: reimport_after_mock
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestInfrastructureMocking:
|
||||
"""Tests that module reimport after mocking works correctly."""
|
||||
|
||||
def test_reimport_after_mock(self, tmp_path):
|
||||
"""json_handler functions work after mock is torn down."""
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
ensure_module_jsons("reimport_test")
|
||||
assert (tmp_path / "reimport_test_config.json").exists()
|
||||
|
||||
import importlib
|
||||
import aipass.aipass.apps.handlers.json.json_handler as jh_mod
|
||||
|
||||
importlib.reload(jh_mod)
|
||||
assert callable(jh_mod.load_json)
|
||||
assert callable(jh_mod.save_json)
|
||||
assert callable(jh_mod.load_path)
|
||||
@@ -101,7 +101,7 @@ class TestSaveProfile:
|
||||
def test_preserves_other_sections(self, tmp_local_json) -> None:
|
||||
"""Existing keys outside 'user' are not overwritten."""
|
||||
tmp_local_json.write_text(json.dumps({"sessions": [1, 2, 3]}))
|
||||
with patch("aipass.aipass.apps.modules.profile.json_handler"):
|
||||
with patch("aipass.aipass.apps.modules.profile.json_handler.log_operation"):
|
||||
save_profile({"name": "Bob"})
|
||||
stored = json.loads(tmp_local_json.read_text())
|
||||
assert stored["sessions"] == [1, 2, 3]
|
||||
@@ -193,7 +193,7 @@ class TestHandleCommand:
|
||||
|
||||
def test_set_valid_field(self, tmp_local_json) -> None:
|
||||
"""'set name Alice' stores value and returns True."""
|
||||
with patch("aipass.aipass.apps.modules.profile.json_handler"):
|
||||
with patch("aipass.aipass.apps.modules.profile.json_handler.log_operation"):
|
||||
result = handle_command("profile", ["set", "name", "Alice"])
|
||||
assert result is True
|
||||
stored = json.loads(tmp_local_json.read_text())
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Project-specific dependencies for the API branch
|
||||
# These are beyond the base AIPass requirements
|
||||
# Install with: pip install -r requirements.project.txt
|
||||
|
||||
requests
|
||||
rich
|
||||
google-auth
|
||||
google-auth-oauthlib
|
||||
google-api-python-client
|
||||
openai
|
||||
@@ -10,6 +10,11 @@
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by pytest convention — not subject to the 3-layer apps/ structure rule."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_init_provisioning.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by pytest convention — not subject to the 3-layer apps/ structure rule."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_bootstrap.py",
|
||||
"standard": "encapsulation",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Project-specific dependencies for the CLI branch
|
||||
# These are beyond the base AIPass requirements
|
||||
# Install with: pip install -r requirements.project.txt
|
||||
|
||||
rich
|
||||
@@ -0,0 +1,89 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_init_provisioning.py
|
||||
# Description: Init/Provisioning Tests (from seedgo template)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-05-16
|
||||
# Modified: 2026-05-16
|
||||
# =============================================
|
||||
|
||||
"""Init/Provisioning Tests for CLI branch.
|
||||
|
||||
Covers 4 tests:
|
||||
- creates_files, auto_creates_dir, no_overwrite, returns_dict
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.cli.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Redirect JSON operations to tmp_path for test isolation."""
|
||||
monkeypatch.setattr(json_handler, "JSON_DIR", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_creates_expected_files(tmp_path: Path) -> None:
|
||||
"""ensure_json_exists creates expected files on disk."""
|
||||
for json_type in ("config", "data", "log"):
|
||||
result = json_handler.ensure_json_exists("prov_mod", json_type)
|
||||
assert result is True
|
||||
|
||||
expected = tmp_path / f"prov_mod_{json_type}.json"
|
||||
assert expected.exists()
|
||||
|
||||
raw = expected.read_text(encoding="utf-8")
|
||||
parsed = json.loads(raw)
|
||||
assert parsed is not None
|
||||
|
||||
|
||||
def test_auto_creates_directory(tmp_path: Path) -> None:
|
||||
"""ensure_json_exists calls mkdir (parents=True) when directory is missing."""
|
||||
nested_dir = tmp_path / "auto_created" / "subdir"
|
||||
assert not nested_dir.exists()
|
||||
|
||||
with patch.object(json_handler, "JSON_DIR", nested_dir):
|
||||
result = json_handler.ensure_json_exists("autodir", "config")
|
||||
|
||||
assert nested_dir.exists(), "makedirs equivalent must create nested directories"
|
||||
assert result is True
|
||||
assert (nested_dir / "autodir_config.json").exists()
|
||||
|
||||
|
||||
def test_no_overwrite_on_second_call(tmp_path: Path) -> None:
|
||||
"""Second call must not overwrite existing data (no_clobber contract)."""
|
||||
json_handler.ensure_json_exists("idem_mod", "data")
|
||||
|
||||
target = tmp_path / "idem_mod_data.json"
|
||||
original = json.loads(target.read_text(encoding="utf-8"))
|
||||
original["custom_field"] = "already_exists"
|
||||
target.write_text(json.dumps(original, indent=2), encoding="utf-8")
|
||||
|
||||
json_handler.ensure_json_exists("idem_mod", "data")
|
||||
|
||||
after = json.loads(target.read_text(encoding="utf-8"))
|
||||
assert after.get("custom_field") == "already_exists"
|
||||
|
||||
|
||||
def test_returns_dict_with_expected_keys(tmp_path: Path) -> None:
|
||||
"""Provisioned files contain the correct structure for each json_type."""
|
||||
json_handler.ensure_json_exists("key_mod", "config")
|
||||
config = json_handler.load_json("key_mod", "config")
|
||||
assert isinstance(config, dict)
|
||||
assert "module_name" in config
|
||||
assert "version" in config
|
||||
|
||||
json_handler.ensure_json_exists("key_mod", "data")
|
||||
data = json_handler.load_json("key_mod", "data")
|
||||
assert isinstance(data, dict)
|
||||
assert "created" in data
|
||||
assert "last_updated" in data
|
||||
|
||||
json_handler.ensure_json_exists("key_mod", "log")
|
||||
log = json_handler.load_json("key_mod", "log")
|
||||
assert isinstance(log, list)
|
||||
@@ -2,19 +2,18 @@
|
||||
|
||||
# DevPulse
|
||||
|
||||
> Orchestration hub for AIPass. Plans, coordinates, dispatches, and builds its own modules.
|
||||
> Orchestration hub for AIPass. The user's primary AI collaborator — designs, plans, debugs, coordinates all 11 branches, and builds its own modules.
|
||||
|
||||
DevPulse is the user's primary AI collaborator. It designs, plans, debugs, and coordinates the 12 branches. Builds its own modules directly (watchdog, feedback, json_handler). Ventures into other branches to investigate, debug, and fix small bugs. Delegates heavy multi-file builds to sub-agents via dispatch.
|
||||
DevPulse handles the day-to-day: working with the user to plan, design, troubleshoot, and adjust. It builds its own modules directly (watchdog, feedback, json_handler), manages all git operations for the project, dispatches heavy multi-file builds to sub-agents, and ventures into other branches to investigate, debug, and fix small bugs. The only branch with git write access.
|
||||
|
||||
## Start here
|
||||
|
||||
| You want to | Read |
|
||||
|---|---|
|
||||
| Install, update, uninstall, or troubleshoot | [SETUP.md](SETUP.md) |
|
||||
| What's happening right now | [STATUS.local.md](STATUS.local.md) |
|
||||
| Identity, memory, session history | [`.trinity/`](.trinity/) |
|
||||
| Diagnostic scanners | [`tools/`](tools/) |
|
||||
| Active plans | `drone @flow list open` |
|
||||
| Branch list | `drone systems` |
|
||||
|
||||
## Invoke
|
||||
|
||||
@@ -23,60 +22,92 @@ cd src/aipass/devpulse
|
||||
claude
|
||||
```
|
||||
|
||||
Say "hi" and DevPulse picks up where the last session left off.
|
||||
|
||||
## Role in one line
|
||||
|
||||
The user's primary AI collaborator — designs, orchestrates, and builds own modules. Delegates heavy multi-file builds to sub-agents via dispatch.
|
||||
Say "hi" and DevPulse picks up where the last session left off — reads identity, memory, inbox, and git status automatically.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/aipass/devpulse/
|
||||
├── .trinity/ # Identity & memory
|
||||
├── .aipass/ # Branch prompt
|
||||
├── .ai_mail.local/ # Mailbox
|
||||
├── .trinity/ # Identity & memory (passport, local, observations)
|
||||
├── .aipass/ # Branch prompt (injected every turn)
|
||||
├── .ai_mail.local/ # Mailbox (dispatch, notifications)
|
||||
├── apps/
|
||||
│ ├── devpulse.py # Entry point — auto-discovers modules
|
||||
│ ├── modules/
|
||||
│ │ ├── feedback.py # Personal feedback mailbox routing
|
||||
│ │ └── watchdog.py # Directed wake system routing
|
||||
│ └── handlers/
|
||||
│ ├── feedback/ # Feedback inbox, compose, storage
|
||||
│ ├── json/ # JSON operation logging (json_handler)
|
||||
│ └── watchdog/ # Agent, timer, schedule, registry handlers
|
||||
├── tests/ # 236 tests (watchdog + feedback + json + devpulse)
|
||||
├── docs/ # Transition notes, research
|
||||
├── docs.local/ # Local-only docs (gitignored)
|
||||
│ │ ├── feedback.py # Feedback mailbox command routing
|
||||
│ │ └── watchdog.py # Directed wake system command routing
|
||||
│ ├── handlers/
|
||||
│ │ ├── feedback/ # Inbox, compose, storage
|
||||
│ │ ├── json/ # JSON operation logging (json_handler)
|
||||
│ │ └── watchdog/ # Agent, timer, schedule, registry
|
||||
│ └── plugins/ # Plugin extension point
|
||||
├── devpulse_json/ # JSON handler storage (config, data, logs per module)
|
||||
├── tests/ # 252 tests
|
||||
├── artifacts/ # Birth certificate, reports
|
||||
├── dropbox/ # Received files, archived plans, install audit
|
||||
├── docs/ # Transition notes
|
||||
└── STATUS.local.md # Current work beacon
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
Devpulse commands are accessed via `drone @devpulse <command>`:
|
||||
All commands via `drone @devpulse <command>`:
|
||||
|
||||
- `watchdog agent @target` — monitor a dispatched agent until it finishes
|
||||
- `watchdog timer <duration>` — wake after duration (5m, 30s, 2h, 1h30m)
|
||||
- `watchdog timer start/stop <name>` — named duration tracking
|
||||
- `watchdog schedule <HH:MM>` — wait until a specific time
|
||||
- `watchdog status` — show active watchdogs
|
||||
- `watchdog cancel <id>` — cancel a running watchdog
|
||||
- `watchdog list` — list all watchdog entries
|
||||
- `feedback` — inbox summary
|
||||
- `feedback inbox` — list all feedback messages
|
||||
- `feedback view <id>` — read a message
|
||||
- `feedback reply <id> "msg"` — reply to sender
|
||||
- `feedback send "subject" "body"` — receive feedback from another agent
|
||||
### Watchdog — directed wake system
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `watchdog agent @target` | Monitor dispatched agent until it finishes |
|
||||
| `watchdog timer <duration>` | Wake after duration (5m, 30s, 2h, 1h30m) |
|
||||
| `watchdog timer start/stop <name>` | Named duration tracking |
|
||||
| `watchdog schedule <HH:MM>` | Wait until a specific time |
|
||||
| `watchdog status` | Show active watchdogs |
|
||||
| `watchdog cancel <id>` | Cancel a running watchdog |
|
||||
| `watchdog list` | List all watchdog entries |
|
||||
|
||||
### Feedback — personal cross-branch mailbox
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `feedback` | Inbox summary |
|
||||
| `feedback inbox` | List all messages |
|
||||
| `feedback view <id>` | Read a message |
|
||||
| `feedback reply <id> "msg"` | Reply to sender |
|
||||
| `feedback send "subject" "body"` | Receive feedback from another agent |
|
||||
|
||||
## Git Operations
|
||||
|
||||
DevPulse is the only branch with git write access. All git/gh commands are blocked at the project level — drone bypasses via subprocess with a tier system that grants write only to devpulse.
|
||||
|
||||
Workflow: work on `dev` branch, PR to `main` when satisfied. Agents build and test, devpulse reviews and commits.
|
||||
|
||||
```bash
|
||||
drone @git status --all # Full repo changes
|
||||
drone @git commit "msg" --all # Commit all changes
|
||||
drone @git dev-pr "description" # PR dev→main
|
||||
drone @git merge <PR#> # Merge PR (user requests only)
|
||||
drone @git sync # Pull latest
|
||||
drone @git log # Recent commits
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
drone (routing), prax (logging), cli (display), ai_mail (dispatch), seedgo (audits), flow (plans)
|
||||
|
||||
| Branch | What for |
|
||||
|---|---|
|
||||
| drone | Command routing, subprocess, @branch resolution |
|
||||
| ai_mail | Dispatch (send + wake agents), email delivery |
|
||||
| flow | FPLANs (building), DPLANs (planning), APLANs (autonomous) |
|
||||
| seedgo | Standards audits, checkers (35 standards) |
|
||||
| prax | Monitoring, logs, dashboard |
|
||||
| memory | ChromaDB vectors, archival, search |
|
||||
|
||||
### Provides To
|
||||
All branches via dispatch orchestration. Watchdog monitoring for any dispatched agent. Feedback channel for cross-project communication.
|
||||
|
||||
*Last Updated: 2026-05-15*
|
||||
All branches via dispatch orchestration. Watchdog monitoring for any dispatched agent. Feedback channel for cross-branch communication. Git operations (commit, PR, merge) for the entire project.
|
||||
|
||||
*Last Updated: 2026-05-16*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# Codex CLI Integration
|
||||
|
||||
Status: **Working** (S159, 2026-05-16)
|
||||
|
||||
## Overview
|
||||
|
||||
OpenAI Codex CLI runs AIPass with the same hook-driven identity system as Claude Code. Same prompts, same drone commands, same branch awareness.
|
||||
|
||||
- CLI: `codex-cli 0.130.0`
|
||||
- Model: gpt-5.5 (free tier)
|
||||
- System prompt: `AGENTS.md` (equivalent to `CLAUDE.md`)
|
||||
- Hooks: 3 active (SessionStart, UserPromptSubmit, PreToolUse)
|
||||
|
||||
## How It Works
|
||||
|
||||
Codex hooks use the same JSON stdin/stdout protocol as Claude Code. The hook scripts live in `.codex/hooks/` at project level. User-level wrappers in `~/.codex/hooks/` hardcode CWD to devpulse (Codex doesn't reliably set CWD for hook commands).
|
||||
|
||||
```
|
||||
~/.codex/config.toml # User config — hooks registered here
|
||||
~/.codex/hooks/ # Wrapper scripts (anchor CWD)
|
||||
.codex/hooks.json # Project-level hooks (not actively used — discovery unreliable)
|
||||
.codex/hooks/ # Actual hook logic
|
||||
.codex/skills/ # /memo, /prep skills
|
||||
AGENTS.md # System prompt (loaded hierarchically, 32KB max)
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
| Event | Script | Purpose |
|
||||
|-------|--------|---------|
|
||||
| SessionStart | `session_start_identity.py` | Loads global prompt + branch passport + local prompt |
|
||||
| UserPromptSubmit | `prompt_inject.py` | Per-turn: current time, branch identity, email count |
|
||||
| PreToolUse | `pre_edit_gate.py` | Blocks edits to passport.json, registry.json, setup.sh |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Interactive (full access)
|
||||
codex --dangerously-bypass-approvals-and-sandbox
|
||||
|
||||
# Headless one-shot
|
||||
codex --dangerously-bypass-approvals-and-sandbox exec "your prompt"
|
||||
|
||||
# Sandboxed (drone logs will error but commands still work)
|
||||
codex exec "your prompt"
|
||||
```
|
||||
|
||||
## Config — ~/.codex/config.toml
|
||||
|
||||
```toml
|
||||
[features]
|
||||
hooks = true
|
||||
|
||||
[hooks]
|
||||
SessionStart = [{ hooks = [{ type = "command", command = "python3 /home/patrick/.codex/hooks/aipass_session_start_wrapper.py", timeout = 10 }] }]
|
||||
UserPromptSubmit = [{ hooks = [{ type = "command", command = "python3 /home/patrick/.codex/hooks/aipass_prompt_wrapper.py", timeout = 10 }] }]
|
||||
PreToolUse = [{ matcher = "^(write_file|edit_file|patch)$", hooks = [{ type = "command", command = "python3 /home/patrick/.codex/hooks/aipass_pre_edit_gate_wrapper.py", timeout = 5 }] }]
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
- **Sandbox vs drone logging:** Default `workspace-write` sandbox blocks drone's prax/trigger/json_handler log writes (they target paths outside workdir). Commands still execute and return results — just noisy stderr. Fix: use `--dangerously-bypass-approvals-and-sandbox`.
|
||||
- **Hook CWD unreliable:** Codex doesn't set hook command CWD to the session workdir. Wrappers in `~/.codex/hooks/` hardcode `os.chdir()` as workaround.
|
||||
- **`drone @fs` doesn't exist:** Codex sometimes invents this command when following AGENTS.md "Read" instructions. Updated AGENTS.md to say "Read (cat)" to clarify.
|
||||
- **Project hooks.json not auto-discovered:** Despite `.codex/hooks.json` existing at project level with trusted hashes, Codex didn't reliably fire them. Moved all hook registration to `~/.codex/config.toml`.
|
||||
- **Free tier rate limits:** gpt-5.5 free tier. May hit limits under heavy use.
|
||||
|
||||
## History
|
||||
|
||||
- **2026-04-05:** DPLAN-0094 through DPLAN-0097 created. Research + Docker proof-of-concept.
|
||||
- **2026-04-28:** Hook scripts written, project `.codex/` directory scaffolded.
|
||||
- **2026-05-15:** Codex tested interactively. Hooks wired but double-firing (project + user level). Trust/review gate discovered. Wrapper approach proven. Prompts received.
|
||||
- **2026-05-16 (S159):** Cleaned up double-firing. Wired SessionStart + PreToolUse into config.toml. Removed stale trust entries. Verified end-to-end: identity loads, drone works, inbox checks, STATUS recognized. Full bypass mode clean.
|
||||
|
||||
## Differences from Claude Code
|
||||
|
||||
| Feature | Claude Code | Codex |
|
||||
|---------|-------------|-------|
|
||||
| System prompt | CLAUDE.md | AGENTS.md (hierarchical, concatenates all levels) |
|
||||
| Override file | None | AGENTS.override.md (per-user, not committed) |
|
||||
| Hooks config | Embedded in settings.json | Separate config.toml + hooks.json |
|
||||
| Hook events | 6 (incl. PreCompact, Notification) | 5 (incl. SessionStart) |
|
||||
| Sandbox bypass | `--dangerously-skip-permissions` | `--dangerously-bypass-approvals-and-sandbox` |
|
||||
| File reading | Read tool (built-in) | `cat`/`sed` via exec_command |
|
||||
| Model | claude-opus-4-6[1m] | gpt-5.5 |
|
||||
| Max prompt | No hard limit | 32KB AGENTS.md (hooks inject separately) |
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Gemini CLI integration (same pattern — `.gemini/` directory exists)
|
||||
- Seedgo ignore pattern for files outside branch tree (false positives on `~/.codex/hooks/`)
|
||||
- Consider: can Codex be used as a dispatch target? (different model for different tasks)
|
||||
@@ -61,22 +61,6 @@
|
||||
"standard": "architecture",
|
||||
"reason": "Plugin lives in apps/plugins/ by design — separate from 3-layer structure. Plugins are an intentional extension point."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/devpulse_ops/pr_plugin.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Plugin lives in apps/plugins/ by design — separate from 3-layer structure."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/devpulse_ops/pr_plugin.py",
|
||||
"standard": "encapsulation",
|
||||
"lines": [28],
|
||||
"reason": "Plugin imports lock_handler directly — it's a drone-internal plugin in the same branch, not a cross-branch import."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/devpulse_ops/pr_plugin.py",
|
||||
"standard": "trigger",
|
||||
"reason": "create_system_pr intentionally omits trigger.fire(pr_created) — firing it causes prax to re-sync STATUS.md post-commit, creating a perpetual dirty-working-tree loop. STATUS.md is pre-synced before staging instead."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/git/pr_handler.py",
|
||||
"standard": "handlers",
|
||||
@@ -190,12 +174,6 @@
|
||||
"standard": "unused_function",
|
||||
"lines": [108],
|
||||
"reason": "create_pr() is deprecated per FPLAN-0210 — pr command blocked at auth tier. Handler kept for backwards compatibility; still tested in test_git_module.py."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/devpulse_ops/auth.py",
|
||||
"standard": "unused_function",
|
||||
"lines": [84],
|
||||
"reason": "verify_caller() replaced by verify_git_access() for centralized auth. Still exported as public API and tested directly in test_system_pr.py."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -186,7 +186,7 @@ drone/
|
||||
│ └── hook_sounds_plugin.py # Toggle notification sounds on/off
|
||||
├── docs/ # Public documentation
|
||||
├── docs.local/ # Investigation reports and policies
|
||||
└── tests/ # 729 tests across 22 test files
|
||||
└── tests/ # 704 tests across 21 test files
|
||||
```
|
||||
|
||||
### Routing Flow
|
||||
@@ -315,11 +315,11 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches
|
||||
|
||||
## Testing
|
||||
|
||||
729 tests across 22 test files, covering all layers:
|
||||
704 tests across 21 test files, covering all layers:
|
||||
|
||||
| Area | Files | Tests |
|
||||
|------|-------|-------|
|
||||
| Core routing | `test_resolver.py`, `test_router.py`, `test_activation.py`, `test_registry.py` | ~148 |
|
||||
| Core routing | `test_resolver.py`, `test_router.py`, `test_activation.py` | ~128 |
|
||||
| Git operations | `test_git_module.py`, `test_system_pr.py`, `test_devpulse_plugins.py`, `test_git_access.py` | ~150 |
|
||||
| Handlers | `test_executor.py`, `test_registry_handler.py`, `test_discovery.py` | ~99 |
|
||||
| Infrastructure | `test_generic_adapter.py`, `test_module_registry.py`, `test_config.py` | ~66 |
|
||||
@@ -338,7 +338,7 @@ Run tests: `cd src/aipass/drone && python -m pytest tests/ -q`
|
||||
|
||||
---
|
||||
|
||||
**Seedgo:** 99% | **Tests:** 729 pass, 4 skip | **Last Updated:** 2026-05-16
|
||||
**Seedgo:** 99% | **Tests:** 704 pass, 4 skip | **Last Updated:** 2026-05-12
|
||||
|
||||
---
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
@@ -11,3 +11,4 @@ from . import checkout_handler as checkout_handler
|
||||
from . import dev_pr_handler as dev_pr_handler
|
||||
from . import branches_handler as branches_handler
|
||||
from . import delete_branch_handler as delete_branch_handler
|
||||
from . import close_pr_handler as close_pr_handler
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: close_pr_handler.py
|
||||
# Description: Close PR handler — close a GitHub pull request by number
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-05-16
|
||||
# Modified: 2026-05-16
|
||||
# =============================================
|
||||
|
||||
"""Close PR handler — close a GitHub pull request by number."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.git.lock_handler import find_repo_root
|
||||
|
||||
|
||||
def close_pr(pr_number: str) -> dict:
|
||||
"""Close a GitHub pull request by number.
|
||||
|
||||
Returns:
|
||||
Dict with success and message keys.
|
||||
"""
|
||||
if not pr_number.isdigit():
|
||||
return {"success": False, "message": f"Invalid PR number: '{pr_number}'. Must be a positive integer."}
|
||||
|
||||
repo_root = find_repo_root()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gh", "pr", "close", pr_number],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("gh CLI not found: %s", exc)
|
||||
return {"success": False, "message": "gh CLI not found. Install: https://cli.github.com/"}
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
logger.error("gh pr close %s failed: %s", pr_number, exc)
|
||||
return {"success": False, "message": f"Close failed: {exc}"}
|
||||
|
||||
if result.returncode != 0:
|
||||
return {"success": False, "message": f"Close failed: {result.stderr.strip()}"}
|
||||
|
||||
json_handler.log_operation("close_pr", {"pr_number": pr_number})
|
||||
logger.info("Closed PR #%s", pr_number)
|
||||
|
||||
return {"success": True, "message": f"Closed PR #{pr_number}."}
|
||||
@@ -32,6 +32,7 @@ from aipass.drone.apps.handlers.git import (
|
||||
dev_pr_handler,
|
||||
branches_handler,
|
||||
delete_branch_handler,
|
||||
close_pr_handler,
|
||||
)
|
||||
|
||||
DRONE_MODULE = {
|
||||
@@ -55,6 +56,7 @@ _COMMANDS = (
|
||||
"unlock",
|
||||
"dev-pr",
|
||||
"delete-branch",
|
||||
"close-pr",
|
||||
"merge",
|
||||
"smart-sync",
|
||||
"fix",
|
||||
@@ -108,6 +110,9 @@ def handle_command(command: str | None = None, args: list[str] | None = None) ->
|
||||
Dict with stdout, stderr, and exit_code.
|
||||
"""
|
||||
if not args:
|
||||
if command is None:
|
||||
print_introspection()
|
||||
return {"stdout": "", "stderr": "", "exit_code": 0}
|
||||
args = []
|
||||
if command in ("--help", "-h") or (args and args[0] in ("--help", "-h")):
|
||||
print_help()
|
||||
@@ -144,6 +149,8 @@ def handle_command(command: str | None = None, args: list[str] | None = None) ->
|
||||
return _handle_dev_pr(args)
|
||||
if command == "delete-branch":
|
||||
return _handle_delete_branch(args)
|
||||
if command == "close-pr":
|
||||
return _handle_close_pr(args)
|
||||
if command == "commit":
|
||||
return _handle_commit(args)
|
||||
if command == "checkout":
|
||||
@@ -245,51 +252,38 @@ def _handle_dev_pr(args: list[str]) -> dict:
|
||||
def _handle_delete_branch(args: list[str]) -> dict:
|
||||
"""Handle the delete-branch subcommand (owner tier)."""
|
||||
if not args:
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": "Usage: drone @git delete-branch <name>",
|
||||
"exit_code": 1,
|
||||
}
|
||||
return {"stdout": "", "stderr": "Usage: drone @git delete-branch <name>", "exit_code": 1}
|
||||
result = delete_branch_handler.delete_remote_branch(args[0])
|
||||
if result["success"]:
|
||||
return {"stdout": result["message"], "stderr": "", "exit_code": 0}
|
||||
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
|
||||
|
||||
|
||||
def _handle_close_pr(args: list[str]) -> dict:
|
||||
"""Handle the close-pr subcommand (owner tier)."""
|
||||
if not args:
|
||||
return {"stdout": "", "stderr": "Usage: drone @git close-pr <number>", "exit_code": 1}
|
||||
result = close_pr_handler.close_pr(args[0])
|
||||
if result["success"]:
|
||||
return {"stdout": result["message"], "stderr": "", "exit_code": 0}
|
||||
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
|
||||
|
||||
|
||||
def _handle_merge(args: list[str], caller: str) -> dict:
|
||||
"""Handle the merge subcommand (owner-tier, auth pre-checked)."""
|
||||
if not args:
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": "Usage: drone @git merge <PR#>",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
pr_number = args[0]
|
||||
return {"stdout": "", "stderr": "Usage: drone @git merge <PR#>", "exit_code": 1}
|
||||
|
||||
try:
|
||||
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
|
||||
except ImportError as exc:
|
||||
logger.error("Failed to import devpulse_ops merge plugin: %s", exc)
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": f"devpulse_ops plugin not available: {exc}",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
result = merge_pr(pr_number, caller)
|
||||
return {"stdout": "", "stderr": f"devpulse_ops plugin not available: {exc}", "exit_code": 1}
|
||||
|
||||
result = merge_pr(args[0], caller)
|
||||
if result["success"]:
|
||||
return {
|
||||
"stdout": result["message"],
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
}
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": result["message"],
|
||||
"exit_code": 1,
|
||||
}
|
||||
return {"stdout": result["message"], "stderr": "", "exit_code": 0}
|
||||
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
|
||||
|
||||
|
||||
def _handle_smart_sync(caller: str) -> dict:
|
||||
@@ -643,7 +637,6 @@ def get_introspective() -> str:
|
||||
"""Return introspection text showing connected handlers."""
|
||||
return (
|
||||
"@git — Tier-based git workflow, dev branch model (v3.0.0)\n"
|
||||
"\n"
|
||||
"Connected Handlers:\n"
|
||||
" handlers/git/\n"
|
||||
" - lock_handler.py (acquire_lock, release_lock, check_lock_status, force_unlock)\n"
|
||||
@@ -656,11 +649,9 @@ def get_introspective() -> str:
|
||||
" - dev_pr_handler.py (create_branch_pr, create_dev_pr — PR to main)\n"
|
||||
" - branches_handler.py (list_remote_branches)\n"
|
||||
" - delete_branch_handler.py (delete_remote_branch — protected: main/dev)\n"
|
||||
" - pr_handler.py (create_pr — DEPRECATED, kept for reference)\n"
|
||||
"\n"
|
||||
" plugins/devpulse_ops/\n"
|
||||
" - auth.py (verify_git_access — tier-based authorization)\n"
|
||||
" - pr_plugin.py (create_system_pr — DEPRECATED, use dev-pr)\n"
|
||||
" - merge_plugin.py (merge_pr — merge PR + sync)\n"
|
||||
" - sync_plugin.py (smart_sync — fetch + rebase if behind)\n"
|
||||
" - fix_plugin.py (fix_git_state — detect/fix broken states)\n"
|
||||
@@ -668,7 +659,7 @@ def get_introspective() -> str:
|
||||
" gh passthrough:\n"
|
||||
" - issue, run, workflow → subprocess gh <cmd> [args]\n"
|
||||
"\n"
|
||||
"Access Tiers: global (status, diff, log, lock, branches, issue, run, workflow) | owner (pr, commit, checkout, dev-pr, delete-branch, sync, unlock, system-pr, merge, smart-sync, fix)\n"
|
||||
"Access Tiers: global (status, diff, log, lock, branches, issue, run, workflow) | owner (pr, commit, checkout, dev-pr, delete-branch, sync, unlock, merge, smart-sync, fix)\n"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ GIT_ACCESS_TIERS: dict[str, dict] = {
|
||||
"fix",
|
||||
"dev-pr",
|
||||
"pr",
|
||||
"close-pr",
|
||||
"delete-branch",
|
||||
],
|
||||
"allowed_callers": ["devpulse"],
|
||||
@@ -83,31 +84,6 @@ def _find_caller() -> str:
|
||||
raise PermissionError(msg)
|
||||
|
||||
|
||||
def verify_caller() -> str:
|
||||
"""Verify the calling branch is authorized for devpulse operations.
|
||||
|
||||
Owner-tier commands (commit, dev-pr, merge, etc.) are restricted to
|
||||
ALLOWED_CALLERS. Other branches have read-only access via global tier.
|
||||
|
||||
Returns:
|
||||
The caller's branch name if authorized.
|
||||
|
||||
Raises:
|
||||
PermissionError: If the caller is not in ALLOWED_CALLERS.
|
||||
"""
|
||||
name = _find_caller()
|
||||
if name not in ALLOWED_CALLERS:
|
||||
msg = f"Branch '{name}' is not authorized for this operation. Only devpulse can use owner-tier commands."
|
||||
logger.error(msg)
|
||||
raise PermissionError(msg)
|
||||
json_handler.log_operation(
|
||||
"devpulse_auth_verify",
|
||||
{"caller": name, "passport": "verified"},
|
||||
)
|
||||
logger.info("Caller '%s' authorized for devpulse operations", name)
|
||||
return name
|
||||
|
||||
|
||||
def verify_git_access(command: str) -> str:
|
||||
"""Check if the calling branch is authorized for this git command.
|
||||
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: pr_plugin.py
|
||||
# Description: System-wide PR creation for devpulse operations
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-03-30
|
||||
# Modified: 2026-03-31
|
||||
# =============================================
|
||||
|
||||
"""System-wide PR creation for devpulse operations.
|
||||
|
||||
Creates PRs from local main commits that are ahead of origin/main.
|
||||
If there are uncommitted tracked changes, commits them first (like a
|
||||
normal commit), then creates a feature branch at main's current tip,
|
||||
pushes it, and opens a PR. Local main is never moved — after the
|
||||
straight merge and pull, local commits are already in origin/main's
|
||||
ancestry via the merge commit, so git pull fast-forwards cleanly.
|
||||
|
||||
Only authorized callers (verified via :mod:`auth`) may invoke this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.git.lock_handler import (
|
||||
acquire_lock,
|
||||
find_repo_root,
|
||||
release_lock,
|
||||
)
|
||||
from aipass.drone.apps.handlers.git.pr_handler import (
|
||||
_diagnose_push_failure,
|
||||
)
|
||||
|
||||
|
||||
def slugify(description: str) -> str:
|
||||
"""Convert a description into a branch-safe slug.
|
||||
|
||||
Lowercase, spaces to hyphens, strip non-alphanumeric except hyphens,
|
||||
collapse multiple hyphens, and truncate to 50 characters.
|
||||
|
||||
Args:
|
||||
description: The raw description string.
|
||||
|
||||
Returns:
|
||||
A URL/branch-safe slug string.
|
||||
"""
|
||||
slug = description.lower().strip()
|
||||
slug = slug.replace(" ", "-")
|
||||
slug = re.sub(r"[^a-z0-9-]", "", slug)
|
||||
slug = re.sub(r"-{2,}", "-", slug)
|
||||
slug = slug.strip("-")
|
||||
return slug[:50]
|
||||
|
||||
|
||||
def _resolve_system_git_branch(caller: str, repo_root: Path) -> str:
|
||||
"""Read git_branch from the caller's passport, else fall back to system/{caller}-{slug}."""
|
||||
for trinity_dir in repo_root.rglob(f"src/aipass/{caller}/.trinity/passport.json"):
|
||||
try:
|
||||
data = _json.loads(trinity_dir.read_text())
|
||||
git_branch = data.get("branch_info", {}).get("git_branch", "")
|
||||
if git_branch:
|
||||
return git_branch
|
||||
except (ValueError, OSError) as exc:
|
||||
logger.warning("Failed to read git_branch from %s: %s", trinity_dir, exc)
|
||||
break
|
||||
return ""
|
||||
|
||||
|
||||
def create_system_pr(description: str, caller: str) -> dict:
|
||||
"""Execute the system-wide PR creation workflow.
|
||||
|
||||
If there are uncommitted tracked changes, commits them on main first
|
||||
(just a normal commit). Then creates a feature branch at main's tip,
|
||||
pushes it, and opens a PR. Local main is never artificially moved.
|
||||
|
||||
After GitHub merges the PR, local commits are already in origin/main's
|
||||
ancestry via the merge commit — ``git pull --rebase`` fast-forwards
|
||||
cleanly without replaying any commits.
|
||||
|
||||
Args:
|
||||
description: Short description for the PR title/commit.
|
||||
caller: The verified caller name (e.g. ``"devpulse"``).
|
||||
|
||||
Returns:
|
||||
Dict with success, pr_url, feature_branch, and message.
|
||||
"""
|
||||
repo_root = find_repo_root()
|
||||
slug = slugify(description)
|
||||
passport_prefix = _resolve_system_git_branch(caller, repo_root)
|
||||
feature_branch = f"{passport_prefix}-{slug}" if passport_prefix else f"system/{caller}-{slug}"
|
||||
lock_acquired = False
|
||||
|
||||
result: dict = {
|
||||
"success": False,
|
||||
"pr_url": "",
|
||||
"feature_branch": feature_branch,
|
||||
"message": "",
|
||||
}
|
||||
|
||||
try:
|
||||
# Step 1: Check we are on main
|
||||
current = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if current.stdout.strip() != "main":
|
||||
result["message"] = (
|
||||
f"Not on main branch (currently on {current.stdout.strip()}). "
|
||||
"Checkout main before creating a system PR."
|
||||
)
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
|
||||
# Step 2: Acquire lock
|
||||
lock_result = acquire_lock(caller)
|
||||
if not lock_result["success"]:
|
||||
result["message"] = lock_result["message"]
|
||||
return result
|
||||
lock_acquired = True
|
||||
|
||||
# Step 3: Sync STATUS.md before staging so the fresh state is committed
|
||||
sync_result = subprocess.run(
|
||||
["drone", "@prax", "status", "sync"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if sync_result.returncode != 0:
|
||||
logger.warning("create_system_pr: status sync failed (non-fatal): %s", sync_result.stderr.strip())
|
||||
|
||||
# Stage all changes including untracked new files
|
||||
subprocess.run(
|
||||
["git", "add", "-A"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
|
||||
# Unstage .git_pr.lock — it is acquired (created) for this workflow
|
||||
# and must never be committed. Belt-and-suspenders alongside .gitignore.
|
||||
subprocess.run(
|
||||
["git", "reset", "HEAD", ".git_pr.lock"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
|
||||
# Step 4: If anything is staged, commit it (normal commit on main)
|
||||
diff_check = subprocess.run(
|
||||
["git", "diff", "--cached", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if diff_check.returncode != 0:
|
||||
# There are staged changes — commit them.
|
||||
# No pathspec needed here: git add -A already staged the whole
|
||||
# repo intentionally (system-pr is global by design), and the
|
||||
# lock prevents concurrent system-prs from racing into the index.
|
||||
commit_msg = f"feat(system): {description}\n\nCo-Authored-By: @{caller} <{caller}@aipass>"
|
||||
commit = subprocess.run(
|
||||
["git", "commit", "-m", commit_msg],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if commit.returncode != 0:
|
||||
result["message"] = f"Commit failed: {commit.stderr.strip()}"
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
|
||||
# Step 5: Check if main is ahead of origin/main
|
||||
subprocess.run(
|
||||
["git", "fetch", "origin", "main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
ahead_check = subprocess.run(
|
||||
["git", "rev-list", "--count", "origin/main..HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
ahead_count = int(ahead_check.stdout.strip() or "0")
|
||||
if ahead_count == 0:
|
||||
result["message"] = "Nothing to PR: local main is up to date with origin"
|
||||
logger.warning(result["message"])
|
||||
return result
|
||||
|
||||
# Step 6: Create feature branch at current main tip
|
||||
branch_create = subprocess.run(
|
||||
["git", "branch", "-f", feature_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if branch_create.returncode != 0:
|
||||
result["message"] = f"Failed to create branch: {branch_create.stderr.strip()}"
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
|
||||
# Step 7: Push feature branch
|
||||
push = subprocess.run(
|
||||
["git", "push", "-u", "origin", feature_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if push.returncode != 0:
|
||||
result["message"] = _diagnose_push_failure(push.stderr.strip(), feature_branch)
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
|
||||
# Step 8: Create PR via gh
|
||||
pr_body = (
|
||||
"## Summary\n\n"
|
||||
f"System-wide PR by @{caller}\n\n"
|
||||
f"- {description}\n"
|
||||
f"- {ahead_count} commit(s) ahead of origin/main\n"
|
||||
)
|
||||
pr_create = subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"pr",
|
||||
"create",
|
||||
"--head",
|
||||
feature_branch,
|
||||
"--title",
|
||||
f"feat(system): {description}",
|
||||
"--body",
|
||||
pr_body,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if pr_create.returncode != 0:
|
||||
result["message"] = f"PR creation failed: {pr_create.stderr.strip()}"
|
||||
logger.error(result["message"])
|
||||
subprocess.run(
|
||||
["git", "branch", "-D", feature_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
return result
|
||||
|
||||
pr_url = pr_create.stdout.strip()
|
||||
|
||||
# Step 9: Clean up local feature branch (main stays untouched)
|
||||
subprocess.run(
|
||||
["git", "branch", "-D", feature_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
|
||||
result["success"] = True
|
||||
result["pr_url"] = pr_url
|
||||
result["message"] = f"System PR created: {pr_url}"
|
||||
json_handler.log_operation(
|
||||
"create_system_pr",
|
||||
{
|
||||
"caller": caller,
|
||||
"feature_branch": feature_branch,
|
||||
"pr_url": pr_url,
|
||||
"commits_ahead": ahead_count,
|
||||
},
|
||||
)
|
||||
logger.info(result["message"])
|
||||
|
||||
# pr_created trigger intentionally skipped — firing it causes prax to
|
||||
# re-sync STATUS.md post-commit, creating a perpetual dirty loop.
|
||||
# Run `drone @prax status sync` manually if needed after a system-pr.
|
||||
|
||||
return result
|
||||
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
result["message"] = f"System PR workflow error: {exc}"
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
|
||||
finally:
|
||||
if lock_acquired:
|
||||
release_lock(force=True)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Project-specific dependencies for the Drone branch
|
||||
# These are beyond the base AIPass requirements
|
||||
# Install with: pip install -r requirements.project.txt
|
||||
|
||||
rich
|
||||
@@ -1,304 +1,20 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_system_pr.py
|
||||
# Description: Tests for devpulse_ops plugin — auth and system PR workflow
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for devpulse_ops plugin — git module routing for pr/system-pr
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-03-30
|
||||
# Modified: 2026-04-21
|
||||
# Modified: 2026-05-16
|
||||
# =============================================
|
||||
|
||||
"""Tests for devpulse_ops plugin — auth and system PR workflow."""
|
||||
"""Tests for devpulse_ops plugin — git module routing for pr/system-pr."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.drone.apps.plugins.devpulse_ops.auth import (
|
||||
ALLOWED_CALLERS,
|
||||
verify_caller,
|
||||
)
|
||||
from aipass.drone.apps.plugins.devpulse_ops.pr_plugin import (
|
||||
create_system_pr,
|
||||
slugify,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Fixtures
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def devpulse_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Create a temp directory with a devpulse passport."""
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"branch_info": {"branch_name": "devpulse"},
|
||||
"identity": {"name": "devpulse"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seedgo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Create a temp directory with an unauthorized branch passport."""
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"branch_info": {"branch_name": "citizen/unauthorized"},
|
||||
"identity": {"name": "citizen/unauthorized"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def no_passport_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Create a temp directory with no passport."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Create a temp repo root with AIPASS_REGISTRY.json."""
|
||||
registry = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry.write_text("{}", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Module-level subprocess side-effect helpers
|
||||
# ===========================================================================
|
||||
|
||||
# Responses for "nothing staged" scenario: (stdout, returncode)
|
||||
_NOTHING_TO_COMMIT_RESPONSES: dict[tuple[str, ...], tuple[str, int]] = {
|
||||
("git", "rev-parse", "--abbrev-ref"): ("main\n", 0),
|
||||
("git", "add", "-u"): ("", 0),
|
||||
("git", "diff", "--cached"): ("", 0), # exit 0 = nothing staged
|
||||
("git", "fetch", "origin"): ("", 0),
|
||||
("git", "rev-list", "--count"): ("0\n", 0),
|
||||
}
|
||||
|
||||
# Responses for a complete successful PR flow: (stdout, returncode)
|
||||
_PR_FLOW_RESPONSES: dict[tuple[str, ...], tuple[str, int]] = {
|
||||
("git", "rev-parse", "--abbrev-ref"): ("main\n", 0),
|
||||
("git", "add", "-u"): ("", 0),
|
||||
("git", "diff", "--cached"): ("diff --git a/foo.py b/foo.py\n", 1), # 1 = staged
|
||||
("git", "fetch", "origin"): ("", 0),
|
||||
("git", "rev-list", "--count"): ("1\n", 0),
|
||||
("git", "commit"): ("[main abc1234] test description\n", 0),
|
||||
("git", "branch"): ("", 0),
|
||||
("git", "push"): ("", 0),
|
||||
("gh", "pr"): ("https://github.com/org/repo/pull/42\n", 0),
|
||||
}
|
||||
|
||||
|
||||
def _make_proc(stdout: str, returncode: int) -> MagicMock:
|
||||
"""Build a minimal subprocess mock with stdout, returncode, and empty stderr."""
|
||||
proc = MagicMock()
|
||||
proc.stdout = stdout
|
||||
proc.returncode = returncode
|
||||
proc.stderr = ""
|
||||
return proc
|
||||
|
||||
|
||||
def _nothing_to_commit_side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
"""Return a mock proc for the 'nothing staged' subprocess sequence."""
|
||||
stdout, rc = _NOTHING_TO_COMMIT_RESPONSES.get(tuple(cmd[:3]), ("", 0))
|
||||
return _make_proc(stdout, rc)
|
||||
|
||||
|
||||
def _pr_flow_run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
"""Return a mock proc for a complete successful PR-flow subprocess sequence."""
|
||||
stdout, rc = _PR_FLOW_RESPONSES.get(tuple(cmd[:2]), ("", 0))
|
||||
return _make_proc(stdout, rc)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. auth.verify_caller tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestVerifyCallerAuthorized:
|
||||
"""verify_caller should return the branch name for devpulse."""
|
||||
|
||||
def test_verify_caller_with_devpulse_passport(self, devpulse_dir: Path) -> None:
|
||||
"""Devpulse passport resolves to the 'devpulse' branch name."""
|
||||
result = verify_caller()
|
||||
assert result == "devpulse"
|
||||
assert result in ALLOWED_CALLERS
|
||||
|
||||
|
||||
class TestVerifyCallerUnauthorized:
|
||||
"""verify_caller should raise PermissionError for non-devpulse branches."""
|
||||
|
||||
def test_verify_caller_unauthorized(self, seedgo_dir: Path) -> None:
|
||||
"""An unauthorized branch raises PermissionError with 'not authorized'."""
|
||||
with pytest.raises(PermissionError, match="not authorized"):
|
||||
verify_caller()
|
||||
|
||||
def test_error_message_includes_branch_name(self, seedgo_dir: Path) -> None:
|
||||
"""The PermissionError message includes the actual branch name."""
|
||||
with pytest.raises(PermissionError, match="citizen/unauthorized"):
|
||||
verify_caller()
|
||||
|
||||
|
||||
class TestVerifyCallerNoPassport:
|
||||
"""verify_caller should raise PermissionError when no passport exists."""
|
||||
|
||||
def test_verify_caller_no_passport(self, no_passport_dir: Path) -> None:
|
||||
"""Missing passport raises PermissionError mentioning passport path."""
|
||||
with pytest.raises(PermissionError, match="No .trinity/passport.json"):
|
||||
verify_caller()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. slugify tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
"""Test the slugify function with various inputs."""
|
||||
|
||||
def test_basic_slugify(self) -> None:
|
||||
"""Space-separated words become hyphen-separated lowercase slugs."""
|
||||
assert slugify("Update all configs") == "update-all-configs"
|
||||
|
||||
def test_special_characters_removed(self) -> None:
|
||||
"""Punctuation and special characters are stripped from the output."""
|
||||
assert slugify("fix: broken pipe!") == "fix-broken-pipe"
|
||||
|
||||
def test_multiple_spaces_collapse(self) -> None:
|
||||
"""Consecutive spaces collapse into a single hyphen."""
|
||||
assert slugify("too many spaces") == "too-many-spaces"
|
||||
|
||||
def test_max_length_truncation(self) -> None:
|
||||
"""Slugs longer than 50 characters are truncated."""
|
||||
long_desc = "a" * 100
|
||||
result = slugify(long_desc)
|
||||
assert len(result) <= 50
|
||||
|
||||
def test_leading_trailing_hyphens_stripped(self) -> None:
|
||||
"""Leading and trailing hyphens are stripped from the result."""
|
||||
assert slugify(" --hello world-- ") == "hello-world"
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
"""An empty input returns an empty string."""
|
||||
assert slugify("") == ""
|
||||
|
||||
def test_all_special_chars(self) -> None:
|
||||
"""A string of only special characters produces an empty slug."""
|
||||
assert slugify("!!!@@@###") == ""
|
||||
|
||||
def test_mixed_case(self) -> None:
|
||||
"""Mixed-case input is normalized to lowercase."""
|
||||
assert slugify("Hello World FOO") == "hello-world-foo"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. create_system_pr — not on main
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSystemPrNotOnMain:
|
||||
"""create_system_pr should fail when not on main branch."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.subprocess.run")
|
||||
def test_system_pr_not_on_main(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Returns failure dict when HEAD is on a feature branch instead of main."""
|
||||
mock_root.return_value = tmp_path
|
||||
mock_run.return_value = _make_proc("feature/something\n", 0)
|
||||
|
||||
result = create_system_pr("test description", "devpulse")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Not on main branch" in result["message"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. create_system_pr — nothing to commit
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSystemPrNothingToCommit:
|
||||
"""create_system_pr should fail when there are no changes to PR."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.release_lock")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.acquire_lock")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.subprocess.run")
|
||||
def test_system_pr_nothing_to_commit(
|
||||
self,
|
||||
mock_run: MagicMock,
|
||||
mock_root: MagicMock,
|
||||
mock_acquire: MagicMock,
|
||||
mock_release: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Returns failure dict with 'Nothing to PR' when no staged changes exist."""
|
||||
mock_root.return_value = tmp_path
|
||||
mock_acquire.return_value = {"success": True, "message": "Lock acquired"}
|
||||
mock_run.side_effect = _nothing_to_commit_side_effect
|
||||
|
||||
result = create_system_pr("test description", "devpulse")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Nothing to PR" in result["message"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. create_system_pr — HEAD stays on main (no git checkout)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSystemPrHeadStaysOnMain:
|
||||
"""system-pr must never call 'git checkout' — HEAD stays on main throughout."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.release_lock")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.acquire_lock")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.subprocess.run")
|
||||
def test_no_git_checkout_during_successful_system_pr(
|
||||
self,
|
||||
mock_run: MagicMock,
|
||||
mock_root: MagicMock,
|
||||
mock_acquire: MagicMock,
|
||||
mock_release: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""No subprocess call contains 'git checkout' during a complete PR flow."""
|
||||
mock_root.return_value = tmp_path
|
||||
mock_acquire.return_value = {"success": True, "message": "Lock acquired"}
|
||||
mock_run.side_effect = _pr_flow_run_side_effect
|
||||
|
||||
create_system_pr("test description", "devpulse")
|
||||
|
||||
all_cmds = [c[0][0] if c[0] else c.args[0] for c in mock_run.call_args_list]
|
||||
assert not any("checkout" in cmd for cmd in all_cmds)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 6. git_module routing for pr command (replaced system-pr in S151)
|
||||
# git_module routing for pr command (replaced system-pr in S151)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
|
||||
@@ -165,16 +165,17 @@ Vector verification displays in console: "Vectorized: N chunks in chroma" or "NO
|
||||
|
||||
## Quality
|
||||
|
||||
- **Seedgo:** 100% (33/33 standards)
|
||||
- **Seedgo:** 100% (35/35 standards)
|
||||
- **Tests:** 733 passed, 87/87 public functions tested (100%)
|
||||
- **Source files:** 39 tracked by seedgo
|
||||
- **Last audit:** 2026-05-14
|
||||
- **Last audit:** 2026-05-16
|
||||
- **Battle test:** 16/16 commands pass via drone CLI (2026-04-22)
|
||||
|
||||
### Known Issues
|
||||
- Registry scan fires trigger events that are never handled (by design — foreground close handles everything)
|
||||
- Dashboard push warns on some closes
|
||||
- `mbank/process.py` at 669 lines (nearing 700 limit)
|
||||
- `close_ops.py` split into `close_ops.py` (647 lines) + `close_helpers.py` (260 lines) in 2026-05-16
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: close_helpers.py
|
||||
# Description: Plan Closure Helper Functions
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-05-16
|
||||
# Modified: 2026-05-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Plan Closure Helpers
|
||||
|
||||
Routing, resolution, self-healing, and utility functions extracted from close_ops.
|
||||
|
||||
Usage:
|
||||
from aipass.flow.apps.handlers.plan.close_helpers import (
|
||||
_extract_prefix,
|
||||
_resolve_registry_file,
|
||||
_find_plan_across_registries,
|
||||
_find_relocated_plan,
|
||||
_find_unregistered_plan_file,
|
||||
_self_heal_unregistered_plan,
|
||||
_spawn_background_runner,
|
||||
)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.flow.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================
|
||||
# INFRASTRUCTURE
|
||||
# =============================================
|
||||
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[4]
|
||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
|
||||
MODULE_NAME = "close_plan"
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
PROCESSED_PLANS_DIR = _find_repo_root() / ".backup" / "processed_plans"
|
||||
|
||||
# =============================================
|
||||
# PLAN TYPE ROUTING
|
||||
# =============================================
|
||||
|
||||
|
||||
def _extract_prefix(plan_num_raw: str) -> str | None:
|
||||
"""Extract plan-type prefix (e.g. ``"DPLAN"``) from raw input."""
|
||||
import re
|
||||
|
||||
m = re.match(r"^([A-Z]+PLAN)-", plan_num_raw.strip(), re.IGNORECASE)
|
||||
return m.group(1).upper() if m else None
|
||||
|
||||
|
||||
def _resolve_registry_file(plan_num_raw: str) -> str | None:
|
||||
"""Resolve registry_file from a raw plan number with prefix.
|
||||
|
||||
Returns registry filename or None if no prefix detected.
|
||||
"""
|
||||
prefix = _extract_prefix(plan_num_raw)
|
||||
if prefix is None:
|
||||
return None
|
||||
return f"{prefix.lower()}_registry.json"
|
||||
|
||||
|
||||
def _load_template_registry() -> Dict[str, Any]:
|
||||
"""Read template_registry.json directly (avoids cross-handler import)."""
|
||||
registry_path = FLOW_ROOT / "flow_json" / "template_registry.json"
|
||||
try:
|
||||
with open(registry_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to read template_registry.json: {e}")
|
||||
return {"types": {}}
|
||||
|
||||
|
||||
def _find_plan_across_registries(plan_key: str, load_registry_fn: Any) -> str | None:
|
||||
"""Search all registries for a plan number when no prefix given.
|
||||
|
||||
Returns registry filename where the plan was found, or None.
|
||||
"""
|
||||
try:
|
||||
template_reg = _load_template_registry()
|
||||
for _type_key, config in template_reg.get("types", {}).items():
|
||||
prefix = config.get("prefix", "")
|
||||
if not prefix:
|
||||
continue
|
||||
reg_file = f"{prefix.lower()}_registry.json"
|
||||
try:
|
||||
registry = load_registry_fn(registry_file=reg_file)
|
||||
if plan_key in registry.get("plans", {}):
|
||||
return reg_file
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to search registry '{reg_file}' for plan '{plan_key}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to discover plan types while searching for plan '{plan_key}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# HELPER
|
||||
# =============================================
|
||||
|
||||
|
||||
def _find_relocated_plan(plan_file: Path) -> Path | None:
|
||||
"""Search common locations for a plan file that was manually moved.
|
||||
|
||||
Returns the found path, or None if not found anywhere.
|
||||
"""
|
||||
filename = plan_file.name
|
||||
branch_dir = plan_file.parent
|
||||
|
||||
search_dirs = [
|
||||
branch_dir / ".archive",
|
||||
branch_dir / "docs.local",
|
||||
PROCESSED_PLANS_DIR,
|
||||
]
|
||||
|
||||
for search_dir in search_dirs:
|
||||
candidate = search_dir / filename
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_unregistered_plan_file(prefix: str, plan_key: str) -> Path | None:
|
||||
"""Search src/aipass/ for a plan file matching PREFIX-plan_key not in any registry."""
|
||||
aipass_root = FLOW_ROOT.parent
|
||||
pattern = f"{prefix}-{plan_key}*.md"
|
||||
skip_parts = {".backup", ".archive", "__pycache__", ".git", "processed_plans"}
|
||||
|
||||
for match in aipass_root.rglob(pattern):
|
||||
if any(part in skip_parts for part in match.parts):
|
||||
continue
|
||||
return match
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _self_heal_unregistered_plan(
|
||||
prefix: str,
|
||||
plan_key: str,
|
||||
plan_file: Path,
|
||||
registry: Dict[str, Any],
|
||||
reg_file: str,
|
||||
save_registry_fn: Any,
|
||||
load_registry_fn: Any,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> tuple[str, Dict[str, Any]]:
|
||||
"""Register an unregistered plan file and handle number collisions.
|
||||
|
||||
Returns (actual_plan_key, updated_registry).
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
messages.append(
|
||||
{
|
||||
"type": "warning",
|
||||
"text": "Plan file found but not registered — likely created manually. Initiating self-heal.",
|
||||
}
|
||||
)
|
||||
messages.append({"type": "dim", "text": f" Found: {plan_file}"})
|
||||
|
||||
actual_key = plan_key
|
||||
|
||||
if plan_key in registry.get("plans", {}):
|
||||
next_num = registry.get("next_number", int(plan_key) + 1)
|
||||
actual_key = f"{next_num:04d}"
|
||||
messages.append(
|
||||
{
|
||||
"type": "warning",
|
||||
"text": f" Number {plan_key} already registered as {prefix}-{plan_key}. "
|
||||
f"Bumping to next available: {prefix}-{actual_key}.",
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
template_reg = _load_template_registry()
|
||||
for _type_key, config in template_reg.get("types", {}).items():
|
||||
other_prefix = config.get("prefix", "")
|
||||
if other_prefix == prefix:
|
||||
continue
|
||||
other_reg_file = f"{other_prefix.lower()}_registry.json"
|
||||
try:
|
||||
other_registry = load_registry_fn(registry_file=other_reg_file)
|
||||
if plan_key in other_registry.get("plans", {}):
|
||||
messages.append(
|
||||
{
|
||||
"type": "dim",
|
||||
"text": f" Note: {other_prefix}-{plan_key} also exists in {other_prefix} registry",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to check cross-prefix registry '{other_reg_file}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Cross-prefix collision check failed: {e}")
|
||||
|
||||
stem = plan_file.stem
|
||||
subject = "Manually created plan"
|
||||
try:
|
||||
after_prefix = _re.sub(r"^[A-Z]+PLAN-\d{4}_", "", stem)
|
||||
after_prefix = _re.sub(r"_\d{4}-\d{2}-\d{2}$", "", after_prefix)
|
||||
if after_prefix:
|
||||
subject = after_prefix.replace("_", " ")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to extract subject from filename '{stem}': {e}")
|
||||
|
||||
entry = {
|
||||
"location": str(plan_file.parent),
|
||||
"relative_path": plan_file.parent.name,
|
||||
"created": datetime.now(timezone.utc).isoformat(),
|
||||
"subject": subject,
|
||||
"status": "open",
|
||||
"file_path": str(plan_file),
|
||||
"template_type": "default",
|
||||
"self_healed": True,
|
||||
}
|
||||
|
||||
registry["plans"][actual_key] = entry
|
||||
if actual_key != plan_key:
|
||||
registry["next_number"] = int(actual_key) + 1
|
||||
save_registry_fn(registry, registry_file=reg_file)
|
||||
|
||||
messages.append(
|
||||
{
|
||||
"type": "success",
|
||||
"text": f" Registered {prefix}-{actual_key}: {subject}",
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"[{MODULE_NAME}] Self-healed: registered {prefix}-{actual_key} from file {plan_file}")
|
||||
json_handler.log_operation("self_heal_register", {"prefix": prefix, "plan_key": actual_key, "file": str(plan_file)})
|
||||
|
||||
return actual_key, registry
|
||||
|
||||
|
||||
def _spawn_background_runner():
|
||||
"""Spawn post_close_runner.py as a fully detached background process"""
|
||||
bg_runner = FLOW_ROOT / "apps" / "modules" / "post_close_runner.py"
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(bg_runner)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True
|
||||
)
|
||||
@@ -1,9 +1,9 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: close_ops.py
|
||||
# Description: Plan Closure Implementation Handler
|
||||
# Version: 1.1.0
|
||||
# Version: 1.2.0
|
||||
# Created: 2026-03-08
|
||||
# Modified: 2026-03-08
|
||||
# Modified: 2026-05-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
@@ -26,225 +26,19 @@ from typing import Dict, Any, List
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
# logger imported from aipass.prax
|
||||
from aipass.flow.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================
|
||||
# INFRASTRUCTURE
|
||||
# =============================================
|
||||
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[4] # handlers/plan/ -> handlers/ -> apps/ -> flow/ -> aipass/
|
||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
from aipass.flow.apps.handlers.plan.close_helpers import (
|
||||
_extract_prefix,
|
||||
_resolve_registry_file,
|
||||
_find_plan_across_registries,
|
||||
_find_relocated_plan,
|
||||
_find_unregistered_plan_file,
|
||||
_self_heal_unregistered_plan,
|
||||
_spawn_background_runner,
|
||||
)
|
||||
|
||||
MODULE_NAME = "close_plan"
|
||||
|
||||
# =============================================
|
||||
# PLAN TYPE ROUTING
|
||||
# =============================================
|
||||
|
||||
|
||||
def _extract_prefix(plan_num_raw: str) -> str | None:
|
||||
"""Extract plan-type prefix (e.g. ``"DPLAN"``) from raw input."""
|
||||
import re
|
||||
|
||||
m = re.match(r"^([A-Z]+PLAN)-", plan_num_raw.strip(), re.IGNORECASE)
|
||||
return m.group(1).upper() if m else None
|
||||
|
||||
|
||||
def _resolve_registry_file(plan_num_raw: str) -> str | None:
|
||||
"""Resolve registry_file from a raw plan number with prefix.
|
||||
|
||||
Returns registry filename or None if no prefix detected.
|
||||
"""
|
||||
prefix = _extract_prefix(plan_num_raw)
|
||||
if prefix is None:
|
||||
return None
|
||||
try:
|
||||
from aipass.flow.apps.handlers.template.plan_type_loader import get_plan_type # type: ignore[import-not-found]
|
||||
|
||||
config = get_plan_type(prefix)
|
||||
return config.get("registry_file")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to resolve registry file for prefix '{prefix}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _find_plan_across_registries(plan_key: str, load_registry_fn: Any) -> str | None:
|
||||
"""Search all registries for a plan number when no prefix given.
|
||||
|
||||
Returns registry filename where the plan was found, or None.
|
||||
"""
|
||||
try:
|
||||
from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types # type: ignore[import-not-found]
|
||||
|
||||
for _type_key, config in discover_plan_types().items():
|
||||
reg_file = config.get("registry_file")
|
||||
if not reg_file:
|
||||
continue
|
||||
try:
|
||||
registry = load_registry_fn(registry_file=reg_file)
|
||||
if plan_key in registry.get("plans", {}):
|
||||
return reg_file
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to search registry '{reg_file}' for plan '{plan_key}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to discover plan types while searching for plan '{plan_key}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# HELPER
|
||||
# =============================================
|
||||
|
||||
|
||||
def _find_relocated_plan(plan_file: Path) -> Path | None:
|
||||
"""Search common locations for a plan file that was manually moved.
|
||||
|
||||
Returns the found path, or None if not found anywhere.
|
||||
"""
|
||||
from aipass.flow.apps.handlers.mbank.process import PROCESSED_PLANS_DIR
|
||||
|
||||
filename = plan_file.name
|
||||
branch_dir = plan_file.parent
|
||||
|
||||
search_dirs = [
|
||||
branch_dir / ".archive",
|
||||
branch_dir / "docs.local",
|
||||
PROCESSED_PLANS_DIR,
|
||||
]
|
||||
|
||||
for search_dir in search_dirs:
|
||||
candidate = search_dir / filename
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_unregistered_plan_file(prefix: str, plan_key: str) -> Path | None:
|
||||
"""Search src/aipass/ for a plan file matching PREFIX-plan_key not in any registry."""
|
||||
aipass_root = FLOW_ROOT.parent
|
||||
pattern = f"{prefix}-{plan_key}*.md"
|
||||
skip_parts = {".backup", ".archive", "__pycache__", ".git", "processed_plans"}
|
||||
|
||||
for match in aipass_root.rglob(pattern):
|
||||
if any(part in skip_parts for part in match.parts):
|
||||
continue
|
||||
return match
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _self_heal_unregistered_plan(
|
||||
prefix: str,
|
||||
plan_key: str,
|
||||
plan_file: Path,
|
||||
registry: Dict[str, Any],
|
||||
reg_file: str,
|
||||
save_registry_fn: Any,
|
||||
load_registry_fn: Any,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> tuple[str, Dict[str, Any]]:
|
||||
"""Register an unregistered plan file and handle number collisions.
|
||||
|
||||
Returns (actual_plan_key, updated_registry).
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
messages.append(
|
||||
{
|
||||
"type": "warning",
|
||||
"text": "Plan file found but not registered — likely created manually. Initiating self-heal.",
|
||||
}
|
||||
)
|
||||
messages.append({"type": "dim", "text": f" Found: {plan_file}"})
|
||||
|
||||
actual_key = plan_key
|
||||
|
||||
if plan_key in registry.get("plans", {}):
|
||||
next_num = registry.get("next_number", int(plan_key) + 1)
|
||||
actual_key = f"{next_num:04d}"
|
||||
messages.append(
|
||||
{
|
||||
"type": "warning",
|
||||
"text": f" Number {plan_key} already registered as {prefix}-{plan_key}. "
|
||||
f"Bumping to next available: {prefix}-{actual_key}.",
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types
|
||||
|
||||
for _type_key, config in discover_plan_types().items():
|
||||
other_prefix = config.get("prefix", "")
|
||||
if other_prefix == prefix:
|
||||
continue
|
||||
other_reg_file = config.get("registry_file")
|
||||
if not other_reg_file:
|
||||
continue
|
||||
try:
|
||||
other_registry = load_registry_fn(registry_file=other_reg_file)
|
||||
if plan_key in other_registry.get("plans", {}):
|
||||
messages.append(
|
||||
{
|
||||
"type": "dim",
|
||||
"text": f" Note: {other_prefix}-{plan_key} also exists in {other_prefix} registry",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to check cross-prefix registry '{other_reg_file}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Cross-prefix collision check failed: {e}")
|
||||
|
||||
stem = plan_file.stem
|
||||
subject = "Manually created plan"
|
||||
try:
|
||||
after_prefix = _re.sub(r"^[A-Z]+PLAN-\d{4}_", "", stem)
|
||||
after_prefix = _re.sub(r"_\d{4}-\d{2}-\d{2}$", "", after_prefix)
|
||||
if after_prefix:
|
||||
subject = after_prefix.replace("_", " ")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to extract subject from filename '{stem}': {e}")
|
||||
|
||||
entry = {
|
||||
"location": str(plan_file.parent),
|
||||
"relative_path": plan_file.parent.name,
|
||||
"created": datetime.now(timezone.utc).isoformat(),
|
||||
"subject": subject,
|
||||
"status": "open",
|
||||
"file_path": str(plan_file),
|
||||
"template_type": "default",
|
||||
"self_healed": True,
|
||||
}
|
||||
|
||||
registry["plans"][actual_key] = entry
|
||||
if actual_key != plan_key:
|
||||
# Collision bumped plan to the next_number slot — advance counter.
|
||||
registry["next_number"] = int(actual_key) + 1
|
||||
save_registry_fn(registry, registry_file=reg_file)
|
||||
|
||||
messages.append(
|
||||
{
|
||||
"type": "success",
|
||||
"text": f" Registered {prefix}-{actual_key}: {subject}",
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"[{MODULE_NAME}] Self-healed: registered {prefix}-{actual_key} from file {plan_file}")
|
||||
json_handler.log_operation("self_heal_register", {"prefix": prefix, "plan_key": actual_key, "file": str(plan_file)})
|
||||
|
||||
return actual_key, registry
|
||||
|
||||
|
||||
def _spawn_background_runner():
|
||||
"""Spawn post_close_runner.py as a fully detached background process"""
|
||||
bg_runner = FLOW_ROOT / "apps" / "modules" / "post_close_runner.py"
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(bg_runner)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True
|
||||
)
|
||||
|
||||
|
||||
# =============================================
|
||||
# CLOSE PLAN IMPLEMENTATION
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Project-specific dependencies for the Flow branch
|
||||
# These are beyond the base AIPass requirements
|
||||
# Install with: pip install -r requirements.project.txt
|
||||
|
||||
rich
|
||||
@@ -474,7 +474,7 @@ class TestCloseAllException:
|
||||
|
||||
|
||||
class TestFindUnregisteredPlanFile:
|
||||
@patch("aipass.flow.apps.handlers.plan.close_ops.FLOW_ROOT")
|
||||
@patch("aipass.flow.apps.handlers.plan.close_helpers.FLOW_ROOT")
|
||||
def test_finds_matching_file(self, mock_flow_root, tmp_path):
|
||||
find_fn = _import_find_unregistered()
|
||||
mock_flow_root.parent = tmp_path
|
||||
@@ -487,7 +487,7 @@ class TestFindUnregisteredPlanFile:
|
||||
assert result is not None
|
||||
assert result.name == "DPLAN-0176_design_topic_2026-05-10.md"
|
||||
|
||||
@patch("aipass.flow.apps.handlers.plan.close_ops.FLOW_ROOT")
|
||||
@patch("aipass.flow.apps.handlers.plan.close_helpers.FLOW_ROOT")
|
||||
def test_skips_backup_directory(self, mock_flow_root, tmp_path):
|
||||
find_fn = _import_find_unregistered()
|
||||
mock_flow_root.parent = tmp_path
|
||||
@@ -498,7 +498,7 @@ class TestFindUnregisteredPlanFile:
|
||||
result = find_fn("FPLAN", "0099")
|
||||
assert result is None
|
||||
|
||||
@patch("aipass.flow.apps.handlers.plan.close_ops.FLOW_ROOT")
|
||||
@patch("aipass.flow.apps.handlers.plan.close_helpers.FLOW_ROOT")
|
||||
def test_skips_archive_directory(self, mock_flow_root, tmp_path):
|
||||
find_fn = _import_find_unregistered()
|
||||
mock_flow_root.parent = tmp_path
|
||||
@@ -509,7 +509,7 @@ class TestFindUnregisteredPlanFile:
|
||||
result = find_fn("FPLAN", "0050")
|
||||
assert result is None
|
||||
|
||||
@patch("aipass.flow.apps.handlers.plan.close_ops.FLOW_ROOT")
|
||||
@patch("aipass.flow.apps.handlers.plan.close_helpers.FLOW_ROOT")
|
||||
def test_skips_processed_plans_directory(self, mock_flow_root, tmp_path):
|
||||
find_fn = _import_find_unregistered()
|
||||
mock_flow_root.parent = tmp_path
|
||||
@@ -520,7 +520,7 @@ class TestFindUnregisteredPlanFile:
|
||||
result = find_fn("FPLAN", "0077")
|
||||
assert result is None
|
||||
|
||||
@patch("aipass.flow.apps.handlers.plan.close_ops.FLOW_ROOT")
|
||||
@patch("aipass.flow.apps.handlers.plan.close_helpers.FLOW_ROOT")
|
||||
def test_returns_none_when_no_match(self, mock_flow_root, tmp_path):
|
||||
find_fn = _import_find_unregistered()
|
||||
mock_flow_root.parent = tmp_path
|
||||
@@ -531,7 +531,7 @@ class TestFindUnregisteredPlanFile:
|
||||
result = find_fn("FPLAN", "9999")
|
||||
assert result is None
|
||||
|
||||
@patch("aipass.flow.apps.handlers.plan.close_ops.FLOW_ROOT")
|
||||
@patch("aipass.flow.apps.handlers.plan.close_helpers.FLOW_ROOT")
|
||||
def test_skips_git_directory(self, mock_flow_root, tmp_path):
|
||||
find_fn = _import_find_unregistered()
|
||||
mock_flow_root.parent = tmp_path
|
||||
@@ -549,8 +549,7 @@ class TestFindUnregisteredPlanFile:
|
||||
|
||||
|
||||
class TestSelfHealNoCollision:
|
||||
@patch("aipass.flow.apps.handlers.plan.close_ops.discover_plan_types", create=True)
|
||||
def test_registers_with_original_key(self, _mock_discover, tmp_path):
|
||||
def test_registers_with_original_key(self, tmp_path):
|
||||
heal_fn = _import_self_heal()
|
||||
plan_file = tmp_path / "DPLAN-0176_design_topic_2026-05-10.md"
|
||||
plan_file.write_text("# Plan", encoding="utf-8")
|
||||
@@ -560,9 +559,8 @@ class TestSelfHealNoCollision:
|
||||
messages = []
|
||||
|
||||
with patch(
|
||||
"aipass.flow.apps.handlers.plan.close_ops.discover_plan_types",
|
||||
return_value={},
|
||||
create=True,
|
||||
"aipass.flow.apps.handlers.plan.close_helpers._load_template_registry",
|
||||
return_value={"types": {}},
|
||||
):
|
||||
actual_key, updated_reg = heal_fn(
|
||||
"DPLAN", "0176", plan_file, registry, "dplan_registry.json", save_fn, load_fn, messages
|
||||
@@ -575,8 +573,7 @@ class TestSelfHealNoCollision:
|
||||
assert updated_reg["next_number"] == 175
|
||||
save_fn.assert_called_once_with(registry, registry_file="dplan_registry.json")
|
||||
|
||||
@patch("aipass.flow.apps.handlers.plan.close_ops.discover_plan_types", create=True)
|
||||
def test_extracts_subject_from_filename(self, _mock_discover, tmp_path):
|
||||
def test_extracts_subject_from_filename(self, tmp_path):
|
||||
heal_fn = _import_self_heal()
|
||||
plan_file = tmp_path / "FPLAN-0042_my_great_feature_2026-04-01.md"
|
||||
plan_file.write_text("# Plan", encoding="utf-8")
|
||||
@@ -584,9 +581,8 @@ class TestSelfHealNoCollision:
|
||||
messages = []
|
||||
|
||||
with patch(
|
||||
"aipass.flow.apps.handlers.plan.close_ops.discover_plan_types",
|
||||
return_value={},
|
||||
create=True,
|
||||
"aipass.flow.apps.handlers.plan.close_helpers._load_template_registry",
|
||||
return_value={"types": {}},
|
||||
):
|
||||
actual_key, updated_reg = heal_fn(
|
||||
"FPLAN", "0042", plan_file, registry, "fplan_registry.json", MagicMock(), MagicMock(), messages
|
||||
@@ -594,8 +590,7 @@ class TestSelfHealNoCollision:
|
||||
|
||||
assert updated_reg["plans"][actual_key]["subject"] == "my great feature"
|
||||
|
||||
@patch("aipass.flow.apps.handlers.plan.close_ops.discover_plan_types", create=True)
|
||||
def test_emits_self_heal_warning_message(self, _mock_discover, tmp_path):
|
||||
def test_emits_self_heal_warning_message(self, tmp_path):
|
||||
heal_fn = _import_self_heal()
|
||||
plan_file = tmp_path / "FPLAN-0001_test_2026-01-01.md"
|
||||
plan_file.write_text("# Plan", encoding="utf-8")
|
||||
@@ -603,9 +598,8 @@ class TestSelfHealNoCollision:
|
||||
messages = []
|
||||
|
||||
with patch(
|
||||
"aipass.flow.apps.handlers.plan.close_ops.discover_plan_types",
|
||||
return_value={},
|
||||
create=True,
|
||||
"aipass.flow.apps.handlers.plan.close_helpers._load_template_registry",
|
||||
return_value={"types": {}},
|
||||
):
|
||||
heal_fn("FPLAN", "0001", plan_file, registry, "fplan_registry.json", MagicMock(), MagicMock(), messages)
|
||||
|
||||
@@ -614,8 +608,7 @@ class TestSelfHealNoCollision:
|
||||
|
||||
|
||||
class TestSelfHealSamePrefixCollision:
|
||||
@patch("aipass.flow.apps.handlers.plan.close_ops.discover_plan_types", create=True)
|
||||
def test_bumps_to_next_number(self, _mock_discover, tmp_path):
|
||||
def test_bumps_to_next_number(self, tmp_path):
|
||||
heal_fn = _import_self_heal()
|
||||
plan_file = tmp_path / "FPLAN-0005_colliding_2026-03-01.md"
|
||||
plan_file.write_text("# Plan", encoding="utf-8")
|
||||
@@ -626,9 +619,8 @@ class TestSelfHealSamePrefixCollision:
|
||||
messages = []
|
||||
|
||||
with patch(
|
||||
"aipass.flow.apps.handlers.plan.close_ops.discover_plan_types",
|
||||
return_value={},
|
||||
create=True,
|
||||
"aipass.flow.apps.handlers.plan.close_helpers._load_template_registry",
|
||||
return_value={"types": {}},
|
||||
):
|
||||
actual_key, updated_reg = heal_fn(
|
||||
"FPLAN", "0005", plan_file, registry, "fplan_registry.json", MagicMock(), MagicMock(), messages
|
||||
@@ -653,12 +645,13 @@ class TestSelfHealCrossPrefixCollision:
|
||||
messages = []
|
||||
|
||||
with patch(
|
||||
"aipass.flow.apps.handlers.plan.close_ops.discover_plan_types",
|
||||
"aipass.flow.apps.handlers.plan.close_helpers._load_template_registry",
|
||||
return_value={
|
||||
"flow_plans": {"prefix": "FPLAN", "registry_file": "fplan_registry.json"},
|
||||
"dev_plans": {"prefix": "DPLAN", "registry_file": "dplan_registry.json"},
|
||||
"types": {
|
||||
"flow_plans": {"prefix": "FPLAN"},
|
||||
"dev_plans": {"prefix": "DPLAN"},
|
||||
}
|
||||
},
|
||||
create=True,
|
||||
):
|
||||
actual_key, updated_reg = heal_fn(
|
||||
"DPLAN", "0013", plan_file, registry, "dplan_registry.json", save_fn, load_fn, messages
|
||||
@@ -676,9 +669,11 @@ class TestSelfHealCrossPrefixCollision:
|
||||
messages = []
|
||||
|
||||
with patch(
|
||||
"aipass.flow.apps.handlers.template.plan_type_loader.discover_plan_types",
|
||||
"aipass.flow.apps.handlers.plan.close_helpers._load_template_registry",
|
||||
return_value={
|
||||
"flow_plans": {"prefix": "FPLAN", "registry_file": "fplan_registry.json"},
|
||||
"types": {
|
||||
"flow_plans": {"prefix": "FPLAN"},
|
||||
}
|
||||
},
|
||||
):
|
||||
heal_fn("FPLAN", "0020", plan_file, registry, "fplan_registry.json", MagicMock(), load_fn, messages)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Project-specific Python dependencies beyond the base AIPass install.
|
||||
# Add packages here that memory branch requires but are not in the shared pyproject.toml.
|
||||
@@ -175,12 +175,12 @@ drone @prax monitor run
|
||||
|
||||
| Test File | Tests | Coverage |
|
||||
|-----------|-------|----------|
|
||||
| test_filesystem_handler.py | 172 | Codex/Gemini/Claude session adapters |
|
||||
| test_monitoring_handlers.py | 139 | Branch detector, log watcher, event stream |
|
||||
| test_operations.py | 99 | Dashboard operations |
|
||||
| test_log_watcher.py | 82 | Log file tailing |
|
||||
| test_monitor_module.py | 74 | Monitor commands |
|
||||
| test_logging_handlers.py | 41 | Logging setup, rotation, override |
|
||||
| test_filesystem_handler.py | 172 | Multi-CLI adapters, Codex/Gemini branch detection |
|
||||
| test_monitoring_handlers.py | 139 | Branch detector, stream output, event handling |
|
||||
| test_operations.py | 99 | Dashboard operations, write-through |
|
||||
| test_log_watcher.py | 82 | Log file tailing, agent activity parsing |
|
||||
| test_monitor_module.py | 74 | Monitor commands, thread lifecycle |
|
||||
| test_logging_handlers.py | 41 | Setup, rotation, introspection, direct logger |
|
||||
| test_logger_module.py | 40 | Logger init, routing, lifecycle |
|
||||
| test_monitoring_filters.py | 39 | Event filtering rules |
|
||||
| test_config.py | 38 | Config loading, path resolution |
|
||||
@@ -194,6 +194,8 @@ drone @prax monitor run
|
||||
| test_log_audit.py | 10 | Log audit |
|
||||
| test_status.py | 8 | Status commands |
|
||||
|
||||
134/136 public functions tested (99%).
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
|
||||
+11
-11
@@ -12,7 +12,7 @@
|
||||
## Overview
|
||||
|
||||
### What I Do
|
||||
- Audit all 11 core agents against 35 code standards + diagnostics (architecture, CLI, imports, logging, naming, silent catch, deep nesting, windows compat, etc.)
|
||||
- Audit all 11 core agents against 35 code standards + diagnostics (architecture, CLI, imports, logging, naming, silent catch, deep nesting, etc.)
|
||||
- Score files 0-100 per standard and report violations with actionable details
|
||||
- Manage bypass rules (`.seedgo/bypass.json`) for deliberate exceptions
|
||||
- Run pyright diagnostics across branches for type error detection
|
||||
@@ -46,7 +46,7 @@ drone @seedgo audit aipass @flow # Audit single branch
|
||||
drone @seedgo audit inbox-ids # Inbox message-ID validation
|
||||
|
||||
# Standards Query
|
||||
drone @seedgo standards_query aipass_standards # List all 35 standards in pack
|
||||
drone @seedgo standards_query aipass_standards # List all 33 standards in pack
|
||||
drone @seedgo standards_query aipass_standards cli # Show specific standard content
|
||||
|
||||
# Per-file Check
|
||||
@@ -112,7 +112,7 @@ seedgo/
|
||||
│ │ ├── readme_update.py # README generation module
|
||||
│ │ └── test_map.py # Custom function test coverage mapping
|
||||
│ └── handlers/ # 11 handler directories
|
||||
│ ├── aipass_standards/ # 35 checker standards (108 files)
|
||||
│ ├── aipass_standards/ # 34 checker standards (67 files)
|
||||
│ │ ├── *_check.py # Checker implementations (score 0-100)
|
||||
│ │ ├── *_content.py # Queryable standard content
|
||||
│ │ └── *.md # Standard documentation
|
||||
@@ -136,7 +136,7 @@ seedgo/
|
||||
│ ├── json/ # JSON tracking (json_handler)
|
||||
│ ├── readme/ # README generator + branch resolution
|
||||
│ └── test_map/ # Function test coverage scanner
|
||||
├── tests/ # 29 test files, 1131 tests
|
||||
├── tests/ # 39 test files, 1192 tests
|
||||
├── drone_adapter.py # Drone routing bridge
|
||||
├── .trinity/ # Identity + memory
|
||||
├── .seedgo/ # Self-bypass rules
|
||||
@@ -157,7 +157,7 @@ seedgo/
|
||||
|
||||
---
|
||||
|
||||
## The 35 Standards
|
||||
## The 34 Standards
|
||||
|
||||
| Standard | Scope | What It Checks |
|
||||
|----------|-------|----------------|
|
||||
@@ -195,7 +195,6 @@ seedgo/
|
||||
| todo | all_files | No unresolved TODO/FIXME/HACK comments |
|
||||
| trigger | all_files | Trigger integration patterns |
|
||||
| unused_function | branch_level | No unreferenced public functions |
|
||||
| windows_compat | all_files | POSIX-only APIs guarded for Windows compatibility |
|
||||
|
||||
---
|
||||
|
||||
@@ -223,8 +222,9 @@ The `bridge` module (`drone @seedgo bridge install`) manages hook installation t
|
||||
## Tests
|
||||
|
||||
- **39 test files**, **1192 tests**, all passing
|
||||
- **200 public functions**, 200 tested (100% coverage)
|
||||
- **0 type errors** (pyright)
|
||||
- Key test areas: standards audit, checklist, bypass, JSON handler, hooks (probe, track A/B/E, utility, bridge), proof, README, diagnostics, windows_compat, line coverage (plugin integrity, diagnostics, audit display, branch audit, architecture, checklist)
|
||||
- Key test areas: standards audit, checklist, bypass, JSON handler, hooks (probe, track A/B/E, utility, bridge), proof, README, diagnostics, line coverage (plugin integrity, diagnostics, audit display, branch audit, architecture, checklist)
|
||||
|
||||
---
|
||||
|
||||
@@ -256,12 +256,12 @@ The `bridge` module (`drone @seedgo bridge install`) manages hook installation t
|
||||
|
||||
---
|
||||
|
||||
## Latest Audit (2026-05-16)
|
||||
## Latest Audit (2026-04-26)
|
||||
|
||||
- **Seedgo score:** 99% (35/35 + diagnostics) — architecture (template dirs not in scope) + modules (hooks.py 608 lines, split needed)
|
||||
- **Tests:** 1192 passed, 0 failed, 0 skipped
|
||||
- **Seedgo score:** 100% (34/34 + diagnostics) — all standards green
|
||||
- **Tests:** 1131 passed, 0 failed, 0 skipped
|
||||
- **Coverage:** 200 public functions, 200 tested (100%)
|
||||
- **Type errors:** 0
|
||||
- **Custom test coverage:** 203/205 public functions tested
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ AUDIT_IGNORE_PATTERNS = [
|
||||
"/.backup/", # Temp backup directories
|
||||
"/backups/", # Actual backup storage (backup/backups/)
|
||||
"/artifacts/", # Build artifacts
|
||||
"/integrations/", # Private integrations (gitignored content)
|
||||
".temp", # Temp files
|
||||
".old", # Old files
|
||||
"/deprecated/", # Deprecated code
|
||||
|
||||
@@ -26,7 +26,6 @@ import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
@@ -54,6 +53,9 @@ from aipass.seedgo.apps.handlers.file import write_text_safe
|
||||
# Extended subcommands (test + list)
|
||||
from aipass.seedgo.apps.modules.hooks_ext import cmd_hooks_list, run_hooks_report, run_hooks_test
|
||||
|
||||
# Probe matrix (split to keep this file under 600 lines)
|
||||
from aipass.seedgo.apps.modules.hooks_probe import run_probe_matrix
|
||||
|
||||
# Rich output
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
@@ -329,133 +331,13 @@ def _entry_ts(entry: dict) -> float:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks probe --matrix
|
||||
# SUBCOMMAND: hooks probe --matrix (delegated to hooks_probe)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _build_matrix_rows(entries: list) -> tuple[list, dict]:
|
||||
"""Group entries by event and build matrix row dicts. Returns (rows, groups)."""
|
||||
groups: dict = defaultdict(list)
|
||||
for e in entries:
|
||||
groups[e.get("event", "unknown")].append(e)
|
||||
|
||||
matrix_rows = []
|
||||
for event_name in sorted(groups.keys()):
|
||||
evs = groups[event_name]
|
||||
count = len(evs)
|
||||
pd_true = sum(1 for e in evs if e.get("env_has_claude_project_dir"))
|
||||
pd_false = count - pd_true
|
||||
ah_true = sum(1 for e in evs if e.get("env_has_aipass_home"))
|
||||
ah_false = count - ah_true
|
||||
agents = len({e.get("agent_id", "unknown") for e in evs})
|
||||
matrix_rows.append(
|
||||
{
|
||||
"event": event_name,
|
||||
"count": count,
|
||||
"project_dir_true": pd_true,
|
||||
"project_dir_false": pd_false,
|
||||
"aipass_home_true": ah_true,
|
||||
"aipass_home_false": ah_false,
|
||||
"unique_agents": agents,
|
||||
}
|
||||
)
|
||||
return matrix_rows, groups
|
||||
|
||||
|
||||
def _cmd_probe_matrix() -> None:
|
||||
"""Group entries by event, show matrix, write markdown report."""
|
||||
entries = _read_entries()
|
||||
|
||||
if not entries:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[yellow]No probe data yet — enable probes first.[/yellow]\n\n{_PROBE_ENABLE_NOTE}",
|
||||
title="[bold cyan][PROBE] Matrix[/bold cyan]",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
matrix_rows, groups = _build_matrix_rows(entries)
|
||||
|
||||
# Build matrix table
|
||||
table = Table(
|
||||
title="[bold cyan][PROBE][/bold cyan] Event Matrix",
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
border_style="dim",
|
||||
)
|
||||
table.add_column("Event", style="cyan", no_wrap=True)
|
||||
table.add_column("Count", justify="right", style="yellow")
|
||||
table.add_column("ProjectDir T/F", justify="center")
|
||||
table.add_column("AIPassHome T/F", justify="center")
|
||||
table.add_column("Unique Agents", justify="right")
|
||||
|
||||
for row in matrix_rows:
|
||||
table.add_row(
|
||||
row["event"],
|
||||
str(row["count"]),
|
||||
f"[green]{row['project_dir_true']}[/green]/[red]{row['project_dir_false']}[/red]",
|
||||
f"[green]{row['aipass_home_true']}[/green]/[red]{row['aipass_home_false']}[/red]",
|
||||
str(row["unique_agents"]),
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
# Write markdown report
|
||||
report_path = _probes_dir() / "Q12_findings_2026-04-20.md"
|
||||
_write_matrix_report(report_path, matrix_rows, entries)
|
||||
console.print(f"[green]Report written:[/green] {report_path}\n")
|
||||
|
||||
json_handler.log_operation("hooks_probe_matrix", {"events": len(groups), "total_entries": len(entries)})
|
||||
|
||||
|
||||
def _write_matrix_report(report_path: Path, matrix_rows: list, entries: list) -> None:
|
||||
"""Write markdown matrix report to disk."""
|
||||
lines = [
|
||||
"# Hook Probe Matrix — Q12 Findings 2026-04-20",
|
||||
"",
|
||||
"Generated by `drone @seedgo hooks probe --matrix`.",
|
||||
"",
|
||||
f"Total entries: {len(entries)}",
|
||||
"",
|
||||
"## Event Matrix",
|
||||
"",
|
||||
"| Event | Count | ProjectDir T/F | AIPassHome T/F | Unique Agents |",
|
||||
"|-------|-------|----------------|----------------|---------------|",
|
||||
]
|
||||
for row in matrix_rows:
|
||||
lines.append(
|
||||
f"| {row['event']} | {row['count']} "
|
||||
f"| {row['project_dir_true']}/{row['project_dir_false']} "
|
||||
f"| {row['aipass_home_true']}/{row['aipass_home_false']} "
|
||||
f"| {row['unique_agents']} |"
|
||||
)
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"## Notes",
|
||||
"",
|
||||
"- `ProjectDir T/F`: entries where `CLAUDE_PROJECT_DIR` env var was set (T) vs unset (F)",
|
||||
"- `AIPassHome T/F`: entries where `AIPASS_HOME` env var was set (T) vs unset (F)",
|
||||
"- Unique Agents: distinct `CLAUDE_CODE_SESSION_ID` values seen for this event",
|
||||
"",
|
||||
"## Raw entry count by event",
|
||||
"",
|
||||
]
|
||||
event_counts = Counter(e.get("event", "unknown") for e in entries)
|
||||
for ev, cnt in sorted(event_counts.items()):
|
||||
lines.append(f"- {ev}: {cnt}")
|
||||
lines.append("")
|
||||
|
||||
try:
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(report_path, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(lines))
|
||||
except OSError as exc:
|
||||
logger.info("hooks.py: failed to write report to %s: %s", report_path, exc)
|
||||
"""Group entries by event, show matrix, write markdown report — delegates to hooks_probe."""
|
||||
run_probe_matrix(_read_entries, _probes_dir)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: hooks_probe.py
|
||||
# Description: Hook Probe Matrix Subcommand (split from hooks.py)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-05-16
|
||||
# Modified: 2026-05-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Hook Probe Matrix — split from hooks.py to keep modules under 600 lines.
|
||||
|
||||
hooks probe --matrix Full event matrix + markdown report
|
||||
"""
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Callable, List
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli import console
|
||||
from aipass.seedgo.apps.handlers.file import write_text_safe
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
|
||||
_PROBE_ENABLE_NOTE = (
|
||||
"Add probe hook commands to .claude/settings.json (inside the hooks object).\n"
|
||||
"See .claude/hooks/probes/README.md for per-event snippets."
|
||||
)
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display module info."""
|
||||
console.print("[bold cyan]hooks_probe[/bold cyan] — matrix subcommand helper for hooks.py")
|
||||
console.print(" Not directly routable. Called via [green]drone @seedgo hooks probe --matrix[/green]")
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Not a routed module — always returns False."""
|
||||
if not args:
|
||||
print_introspection()
|
||||
return False
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
return False
|
||||
logger.info("hooks_probe: not a routed module, command=%s args=%s", command, args)
|
||||
return False
|
||||
|
||||
|
||||
def _build_matrix_rows(entries: list) -> tuple[list, dict]:
|
||||
"""Group entries by event and build matrix row dicts. Returns (rows, groups)."""
|
||||
groups: dict = defaultdict(list)
|
||||
for e in entries:
|
||||
groups[e.get("event", "unknown")].append(e)
|
||||
|
||||
matrix_rows = []
|
||||
for event_name in sorted(groups.keys()):
|
||||
evs = groups[event_name]
|
||||
count = len(evs)
|
||||
pd_true = sum(1 for e in evs if e.get("env_has_claude_project_dir"))
|
||||
pd_false = count - pd_true
|
||||
ah_true = sum(1 for e in evs if e.get("env_has_aipass_home"))
|
||||
ah_false = count - ah_true
|
||||
agents = len({e.get("agent_id", "unknown") for e in evs})
|
||||
matrix_rows.append(
|
||||
{
|
||||
"event": event_name,
|
||||
"count": count,
|
||||
"project_dir_true": pd_true,
|
||||
"project_dir_false": pd_false,
|
||||
"aipass_home_true": ah_true,
|
||||
"aipass_home_false": ah_false,
|
||||
"unique_agents": agents,
|
||||
}
|
||||
)
|
||||
return matrix_rows, groups
|
||||
|
||||
|
||||
def _write_matrix_report(report_path: Path, matrix_rows: list, entries: list) -> None:
|
||||
"""Write markdown matrix report to disk."""
|
||||
lines = [
|
||||
"# Hook Probe Matrix — Q12 Findings 2026-04-20",
|
||||
"",
|
||||
"Generated by `drone @seedgo hooks probe --matrix`.",
|
||||
"",
|
||||
f"Total entries: {len(entries)}",
|
||||
"",
|
||||
"## Event Matrix",
|
||||
"",
|
||||
"| Event | Count | ProjectDir T/F | AIPassHome T/F | Unique Agents |",
|
||||
"|-------|-------|----------------|----------------|---------------|",
|
||||
]
|
||||
for row in matrix_rows:
|
||||
lines.append(
|
||||
f"| {row['event']} | {row['count']} "
|
||||
f"| {row['project_dir_true']}/{row['project_dir_false']} "
|
||||
f"| {row['aipass_home_true']}/{row['aipass_home_false']} "
|
||||
f"| {row['unique_agents']} |"
|
||||
)
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"## Notes",
|
||||
"",
|
||||
"- `ProjectDir T/F`: entries where `CLAUDE_PROJECT_DIR` env var was set (T) vs unset (F)",
|
||||
"- `AIPassHome T/F`: entries where `AIPASS_HOME` env var was set (T) vs unset (F)",
|
||||
"- Unique Agents: distinct `CLAUDE_CODE_SESSION_ID` values seen for this event",
|
||||
"",
|
||||
"## Raw entry count by event",
|
||||
"",
|
||||
]
|
||||
event_counts = Counter(e.get("event", "unknown") for e in entries)
|
||||
for ev, cnt in sorted(event_counts.items()):
|
||||
lines.append(f"- {ev}: {cnt}")
|
||||
lines.append("")
|
||||
|
||||
write_text_safe(report_path, "\n".join(lines))
|
||||
|
||||
|
||||
def run_probe_matrix(
|
||||
read_entries: Callable[[], List[dict]],
|
||||
probes_dir: Callable[[], Path],
|
||||
) -> None:
|
||||
"""Group entries by event, show matrix, write markdown report."""
|
||||
entries = read_entries()
|
||||
|
||||
if not entries:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[yellow]No probe data yet — enable probes first.[/yellow]\n\n{_PROBE_ENABLE_NOTE}",
|
||||
title="[bold cyan][PROBE] Matrix[/bold cyan]",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
matrix_rows, groups = _build_matrix_rows(entries)
|
||||
|
||||
table = Table(
|
||||
title="[bold cyan][PROBE][/bold cyan] Event Matrix",
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
border_style="dim",
|
||||
)
|
||||
table.add_column("Event", style="cyan", no_wrap=True)
|
||||
table.add_column("Count", justify="right", style="yellow")
|
||||
table.add_column("ProjectDir T/F", justify="center")
|
||||
table.add_column("AIPassHome T/F", justify="center")
|
||||
table.add_column("Unique Agents", justify="right")
|
||||
|
||||
for row in matrix_rows:
|
||||
table.add_row(
|
||||
row["event"],
|
||||
str(row["count"]),
|
||||
f"[green]{row['project_dir_true']}[/green]/[red]{row['project_dir_false']}[/red]",
|
||||
f"[green]{row['aipass_home_true']}[/green]/[red]{row['aipass_home_false']}[/red]",
|
||||
str(row["unique_agents"]),
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
report_path = probes_dir() / "Q12_findings_2026-04-20.md"
|
||||
_write_matrix_report(report_path, matrix_rows, entries)
|
||||
console.print(f"[green]Report written:[/green] {report_path}\n")
|
||||
|
||||
json_handler.log_operation("hooks_probe_matrix", {"events": len(groups), "total_entries": len(entries)})
|
||||
@@ -0,0 +1,3 @@
|
||||
# Project-specific Python packages only.
|
||||
# These are installed into the AIPass venv: pip install -r requirements.project.txt
|
||||
# Framework packages (drone, prax, chromadb, rich, etc.) are already available via AIPass.
|
||||
@@ -467,7 +467,7 @@ def test_build_matrix_rows_groups_correctly():
|
||||
},
|
||||
]
|
||||
|
||||
from aipass.seedgo.apps.modules.hooks import _build_matrix_rows
|
||||
from aipass.seedgo.apps.modules.hooks_probe import _build_matrix_rows
|
||||
|
||||
rows, groups = _build_matrix_rows(entries)
|
||||
assert len(rows) == 2
|
||||
@@ -480,7 +480,7 @@ def test_build_matrix_rows_groups_correctly():
|
||||
|
||||
def test_probe_matrix_writes_report(tmp_path):
|
||||
"""_cmd_probe_matrix creates the markdown report given sample data."""
|
||||
from aipass.seedgo.apps.modules.hooks import _write_matrix_report
|
||||
from aipass.seedgo.apps.modules.hooks_probe import _write_matrix_report
|
||||
|
||||
entries = [
|
||||
{
|
||||
|
||||
@@ -125,8 +125,7 @@ spawn/
|
||||
│ │ ├── delete.py # Delete CLI — archive + deregister
|
||||
│ │ ├── sync_registry.py # Registry repair CLI
|
||||
│ │ ├── sync_templates.py # Template sync CLI
|
||||
│ │ ├── regenerate_registry.py # Template registry regeneration CLI
|
||||
│ │ └── repair.py # Project structure repair CLI (scan, relocate, clean)
|
||||
│ │ └── regenerate_registry.py # Template registry regeneration CLI
|
||||
│ └── handlers/
|
||||
│ ├── class_registry.py # Citizen class → template directory mapping
|
||||
│ ├── file_ops.py # Template copy, path renaming, registry regeneration
|
||||
@@ -136,20 +135,19 @@ spawn/
|
||||
│ ├── meta_ops.py # Branch metadata generation, hash computation
|
||||
│ ├── change_detection.py # ID-based file diff between template and branch
|
||||
│ ├── reconcile.py # Registry/filesystem reconciliation
|
||||
│ ├── passport_ops.py # Passport grant implementation
|
||||
│ ├── passport_ops.py # Passport grant implementation
|
||||
│ ├── update_ops.py # Update workflow (Phase 0 snapshot → detect → execute)
|
||||
│ ├── delete_ops.py # Delete workflow (resolve → archive → cleanup → deregister)
|
||||
│ ├── sync_registry_ops.py # Registry sync (CWD-aware, external project support)
|
||||
│ ├── sync_templates_ops.py # Template sync implementation
|
||||
│ ├── regenerate_registry_ops.py # Template registry hash regeneration
|
||||
│ ├── repair_ops.py # Project structure repair (scan, relocate, clean-pollution)
|
||||
│ ├── json_ops.py # JSON deep merge, backup utilities
|
||||
│ └── json/
|
||||
│ └── json_handler.py # Standard JSON I/O, operation logging, 7 API functions
|
||||
├── templates/
|
||||
│ ├── builder/ # Full scaffold template (45 files, 24 dirs)
|
||||
│ └── birthright/ # Minimal template
|
||||
├── tests/ # 14 test files, 318 tests
|
||||
├── tests/ # 14 test files, 316 tests
|
||||
├── spawn_json/ # JSON tracking directory
|
||||
├── tools/ # Branch verification utilities
|
||||
├── docs/ # Documentation
|
||||
@@ -200,7 +198,7 @@ spawn/
|
||||
|
||||
## Tests
|
||||
|
||||
**318 tests | 0 skipped | 0 failed** across 14 test files:
|
||||
**316 tests | 0 skipped | 0 failed** across 14 test files:
|
||||
|
||||
| File | Focus |
|
||||
|------|-------|
|
||||
@@ -214,7 +212,6 @@ spawn/
|
||||
| `test_cli_routing.py` | Command routing and argument parsing |
|
||||
| `test_contracts.py` | Handler contracts and interface compliance |
|
||||
| `test_spawn.py` | Basic CLI routing and help |
|
||||
| `test_repair.py` | Project structure repair (scan, relocate, clean-pollution) |
|
||||
| `test_error_resilience.py` | Error handling and edge cases |
|
||||
| `conftest.py` | Fixtures: mock templates, registry protection |
|
||||
|
||||
@@ -247,11 +244,14 @@ spawn/
|
||||
|
||||
## Metrics
|
||||
|
||||
- **Tests:** 318 passed, 0 skipped, 0 failed
|
||||
- **Seedgo:** 100% (34/34)
|
||||
- **Tests:** 253 passed, 0 skipped, 0 failed
|
||||
- **Module coverage:** 23/23 (100%)
|
||||
- **Template registry:** 45 files, 24 dirs (builder)
|
||||
- **Battle test:** 17/17 commands pass (2026-04-22)
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2026-05-16*
|
||||
*Last Updated: 2026-05-15*
|
||||
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Project-specific Python dependencies beyond the base AIPass install.
|
||||
# Add packages here that this branch requires but are not in the root requirements.txt.
|
||||
@@ -155,7 +155,7 @@
|
||||
"content_hash": "a4cf0a8e3b4f",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f026": {
|
||||
"f015": {
|
||||
"path": "apps/modules/__init__.py",
|
||||
"name": "__init__.py",
|
||||
"content_hash": "e3b0c44298fc",
|
||||
@@ -269,7 +269,7 @@
|
||||
"content_hash": "28e9ae373563",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f015": {
|
||||
"f026": {
|
||||
"path": "apps/plugins/__init__.py",
|
||||
"name": "__init__.py",
|
||||
"content_hash": "e3b0c44298fc",
|
||||
|
||||
@@ -189,7 +189,7 @@ trigger/
|
||||
|
||||
## Testing
|
||||
|
||||
551 tests across 18 test modules, all passing. Coverage: 74/74 public functions (100%).
|
||||
551 tests across 18 test modules, all passing. Coverage: 76/76 public functions (100%).
|
||||
|
||||
```bash
|
||||
cd src/aipass/trigger && pytest # Run all tests
|
||||
@@ -199,7 +199,7 @@ Test files: `test_core`, `test_errors`, `test_medic`, `test_error_registry`, `te
|
||||
|
||||
## Compliance
|
||||
|
||||
Seedgo: 99%. Zero type errors. All core categories at 100%.
|
||||
Seedgo: 100% (34/34 standards). Zero type errors. All categories at 100%.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -96,6 +96,19 @@ def json_file_lock(path: Path):
|
||||
fcntl.flock(lock_f, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def read_text_file(path: Path, encoding: str = "utf-8") -> str:
|
||||
"""Read a text file safely with encoding specification."""
|
||||
with open(path, "r", encoding=encoding) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def write_text_file(path: Path, content: str, encoding: str = "utf-8") -> None:
|
||||
"""Write text content to a file, creating parent directories as needed."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding=encoding) as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
try:
|
||||
|
||||
@@ -72,11 +72,12 @@ def _ensure_service_installed() -> bool:
|
||||
return False
|
||||
|
||||
aipass_home = _get_aipass_home()
|
||||
template = _TEMPLATE_PATH.read_text()
|
||||
from aipass.trigger.apps.config import read_text_file, write_text_file
|
||||
|
||||
template = read_text_file(_TEMPLATE_PATH)
|
||||
rendered = template.replace("{{AIPASS_HOME}}", str(aipass_home))
|
||||
|
||||
_SERVICE_UNIT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
_SERVICE_UNIT_PATH.write_text(rendered)
|
||||
write_text_file(_SERVICE_UNIT_PATH, rendered)
|
||||
logger.info("[MEDIC] Installed systemd unit to %s", _SERVICE_UNIT_PATH)
|
||||
|
||||
_systemctl("daemon-reload")
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Project-specific Python dependencies beyond the base AIPass install.
|
||||
# Add packages here that trigger branch requires but are not in the shared pyproject.toml.
|
||||
Reference in New Issue
Block a user