update
This commit is contained in:
+17
@@ -67,3 +67,20 @@ aipass_json/
|
||||
.claude/
|
||||
|
||||
# setup-workspace.sh — tracked, needed for Docker build
|
||||
src/aipass/trigger/trigger_json/trigger_data.json
|
||||
|
||||
|
||||
# Sorting/working
|
||||
|
||||
*config.json
|
||||
*data.json
|
||||
*inbox.json
|
||||
*log.json
|
||||
*sent.json
|
||||
.ai_mail.local
|
||||
ai_mail.local
|
||||
*_json
|
||||
dropbox
|
||||
system_logs
|
||||
AIPASS_REGISTRY.json
|
||||
docker.md
|
||||
|
||||
@@ -66,6 +66,14 @@ apps/
|
||||
## DevPulse
|
||||
DevPulse (`src/aipass/devpulse/`) is the orchestration hub for this repo. It coordinates work across modules, tracks status, and manages dev notes. It is to AIPass what DEV_CENTRAL is to Dev-Pass.
|
||||
|
||||
## Container Mounts
|
||||
When running inside the Docker container (code-server at localhost:8080):
|
||||
- **Shared folder**: `/home/coder/share` (read-write) — dropbox for files between host and container
|
||||
- **Screenshots**: `/home/coder/screenshots` (read-only) — host screenshots are accessible here
|
||||
- Use the `Read` tool to view screenshot images (PNG, JPG) — Claude is multimodal and can interpret them
|
||||
- When user pastes host paths like `file:///home/aipass/Pictures/Screenshots/...`, translate to `/home/coder/screenshots/...`
|
||||
- Never use host paths (`/home/aipass/...`) directly — they don't exist inside the container
|
||||
|
||||
## Core Principles
|
||||
- Code is truth - if it doesn't run, it's not real
|
||||
- Test in Docker for isolation verification
|
||||
|
||||
+3
-10
@@ -20,18 +20,11 @@ RUN python3 -m venv /opt/venv \
|
||||
&& chown -R 1000:1000 /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Startup script in /etc so it's NOT shadowed by /home/coder bind mount
|
||||
RUN mkdir -p /etc/entrypoint.d
|
||||
COPY setup-workspace.sh /etc/entrypoint.d/setup-workspace.sh
|
||||
RUN chmod +x /etc/entrypoint.d/setup-workspace.sh
|
||||
|
||||
# Override ENTRYPOINTD to our non-shadowed location
|
||||
ENV ENTRYPOINTD=/etc/entrypoint.d
|
||||
# Empty workspace — clone your own repo after boot
|
||||
RUN mkdir -p /home/coder/workspace && chown 1000:1000 /home/coder/workspace
|
||||
|
||||
USER 1000
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
EXPOSE 8080
|
||||
# Use the official entrypoint (already in the base image at /usr/bin/entrypoint.sh)
|
||||
# It runs fixuid -> entrypoint.d scripts -> dumb-init code-server
|
||||
ENTRYPOINT ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "--auth", "none", "/home/coder/workspace/AIPass"]
|
||||
ENTRYPOINT ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "--auth", "none", "/home/coder/workspace"]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"service": "ai_mail",
|
||||
"last_updated": "2026-03-07",
|
||||
"branch_stats": {
|
||||
"SPAWN": {
|
||||
"unread": 1,
|
||||
"total": 1
|
||||
},
|
||||
"DEVPULSE": {
|
||||
"unread": 1,
|
||||
"total": 1
|
||||
},
|
||||
"AI_MAIL": {
|
||||
"unread": 0,
|
||||
"total": 3
|
||||
}
|
||||
},
|
||||
"system_totals": {
|
||||
"total_unread": 2,
|
||||
"total_messages": 5
|
||||
}
|
||||
}
|
||||
@@ -303,7 +303,7 @@ def _check_pid_alive(pid: int) -> bool:
|
||||
# ─── Branch Resolution ──────────────────────────────────
|
||||
|
||||
def resolve_branch(branch_email: str) -> Optional[Tuple[Path, str]]:
|
||||
"""Resolve a branch email to its filesystem path."""
|
||||
"""Resolve a branch email to its absolute filesystem path."""
|
||||
email = f"@{branch_email.lstrip('@').lower()}"
|
||||
registry = _read_json(BRANCH_REGISTRY)
|
||||
if registry is None:
|
||||
@@ -311,6 +311,8 @@ def resolve_branch(branch_email: str) -> Optional[Tuple[Path, str]]:
|
||||
for branch in registry.get("branches", []):
|
||||
if branch.get("email", "").lower() == email:
|
||||
path = Path(branch.get("path", ""))
|
||||
if not path.is_absolute():
|
||||
path = _REPO_ROOT / path
|
||||
if path.exists():
|
||||
return path, email
|
||||
return None
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Extensions package - Drop-in extensions for branch functionality
|
||||
@@ -0,0 +1 @@
|
||||
"""JSON handler package for devpulse."""
|
||||
@@ -0,0 +1,54 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: json_handler.py - JSON Handler
|
||||
# Date: 2026-03-07
|
||||
# Version: 1.0.0
|
||||
# Category: devpulse/handlers/json
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-03-07): Initial implementation
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Satisfies Seed architecture standard for apps/handlers/json/ path
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
JSON Handler for DevPulse
|
||||
|
||||
Handles JSON read/write operations for devpulse_json/ storage.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# Infrastructure paths (package-relative)
|
||||
_DEVPULSE_ROOT = Path(__file__).resolve().parents[3] # devpulse/
|
||||
DEVPULSE_JSON_DIR = _DEVPULSE_ROOT / "devpulse_json"
|
||||
|
||||
|
||||
def load_json(file_path: Path) -> Any:
|
||||
"""Load and return JSON data from file."""
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_json(file_path: Path, data: Any, indent: int = 2) -> None:
|
||||
"""Save data as JSON to file."""
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=indent, ensure_ascii=False)
|
||||
f.write('\n')
|
||||
|
||||
|
||||
def ensure_json_exists(file_path: Path, default: Optional[Any] = None) -> Path:
|
||||
"""Ensure a JSON file exists, creating with default content if missing."""
|
||||
if not file_path.exists():
|
||||
save_json(file_path, default if default is not None else {})
|
||||
return file_path
|
||||
|
||||
|
||||
def get_json_path(filename: str) -> Path:
|
||||
"""Get the path to a JSON file in devpulse_json/."""
|
||||
return DEVPULSE_JSON_DIR / filename
|
||||
@@ -0,0 +1 @@
|
||||
# JSON Templates package - Default JSON file templates
|
||||
@@ -190,8 +190,15 @@ def _handle_target(args: List[str]) -> int:
|
||||
command = rest[0]
|
||||
cmd_args = rest[1:]
|
||||
|
||||
# Long-running interactive commands bypass capture + timeout
|
||||
interactive = command in ("monitor",)
|
||||
|
||||
try:
|
||||
result = route_command(target, command, args=cmd_args if cmd_args else None)
|
||||
result = route_command(
|
||||
target, command,
|
||||
args=cmd_args if cmd_args else None,
|
||||
interactive=interactive,
|
||||
)
|
||||
except BranchNotFoundError as exc:
|
||||
print(f"drone: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@@ -29,10 +29,15 @@ def execute_command(
|
||||
cwd: str,
|
||||
timeout: int = 30,
|
||||
env: dict | None = None,
|
||||
interactive: bool = False,
|
||||
) -> CommandResult:
|
||||
"""Execute a command via subprocess with safety guards.
|
||||
|
||||
Never uses shell=True to prevent shell injection attacks.
|
||||
|
||||
Args:
|
||||
interactive: If True, inherit stdio (no capture, no timeout).
|
||||
Used for long-running commands like prax monitor.
|
||||
"""
|
||||
import os
|
||||
|
||||
@@ -45,14 +50,23 @@ def execute_command(
|
||||
run_env.update(env)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
full_cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
shell=False,
|
||||
env=run_env,
|
||||
)
|
||||
if interactive:
|
||||
# Inherit stdin/stdout/stderr for live interaction
|
||||
result = subprocess.run(
|
||||
full_cmd,
|
||||
cwd=cwd,
|
||||
shell=False,
|
||||
env=run_env,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
full_cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
shell=False,
|
||||
env=run_env,
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise CommandExecutionError(
|
||||
f"Command timed out after {timeout}s: {' '.join(full_cmd)}"
|
||||
@@ -66,6 +80,15 @@ def execute_command(
|
||||
f"OS error executing command: {e}"
|
||||
) from e
|
||||
|
||||
if interactive:
|
||||
return CommandResult(
|
||||
stdout="",
|
||||
stderr="",
|
||||
exit_code=result.returncode,
|
||||
branch="",
|
||||
command="",
|
||||
)
|
||||
|
||||
stdout = result.stdout.decode("utf-8", errors="replace")
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
@@ -32,12 +32,16 @@ def route_command(
|
||||
command: str,
|
||||
args: Optional[List[str]] = None,
|
||||
timeout: int = 30,
|
||||
interactive: bool = False,
|
||||
) -> CommandResult:
|
||||
"""Route a command to a branch's entry point.
|
||||
|
||||
Resolves @target to an absolute path, locates the branch entry point at
|
||||
{path}/apps/{branch_name}.py, then executes:
|
||||
python3 apps/{name}.py {command} [args...]
|
||||
|
||||
Args:
|
||||
interactive: If True, inherit stdio for long-running/interactive commands.
|
||||
"""
|
||||
if args is None:
|
||||
args = []
|
||||
@@ -59,6 +63,7 @@ def route_command(
|
||||
cwd=branch_path,
|
||||
timeout=timeout,
|
||||
env=caller_env,
|
||||
interactive=interactive,
|
||||
)
|
||||
|
||||
return CommandResult(
|
||||
|
||||
@@ -240,6 +240,7 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
_monitoring_active = True
|
||||
|
||||
# Parse args for initial branch filters
|
||||
_is_tty = sys.stdin.isatty()
|
||||
if args:
|
||||
if args[0] == 'all':
|
||||
# Watch all branches
|
||||
@@ -250,13 +251,20 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
initial_branches = args[0].split(',')
|
||||
_filter_state.watched_branches = set(normalize_branch_arg(b.strip()) for b in initial_branches)
|
||||
_filter_state.show_info = True
|
||||
elif not _is_tty:
|
||||
# No TTY and no args — default to watching all (can't type 'watch all')
|
||||
_filter_state.show_all = True
|
||||
_filter_state.show_info = True
|
||||
|
||||
# Display header
|
||||
console.print()
|
||||
header("PRAX Mission Control - Unified Monitoring")
|
||||
console.print()
|
||||
console.print("[green]Monitoring system starting...[/green]")
|
||||
console.print("[yellow]Quiet mode active - type 'help' for commands[/yellow]")
|
||||
if _is_tty:
|
||||
console.print("[yellow]Quiet mode active - type 'help' for commands[/yellow]")
|
||||
else:
|
||||
console.print("[yellow]Passive mode - no TTY detected, watching all (Ctrl+C to stop)[/yellow]")
|
||||
console.print()
|
||||
|
||||
# Start monitoring threads + Telegram relay
|
||||
@@ -629,9 +637,19 @@ def _log_watcher_worker():
|
||||
|
||||
|
||||
def _interactive_loop():
|
||||
"""Interactive command loop - handles user input"""
|
||||
"""Interactive command loop - handles user input, or passive loop if no TTY"""
|
||||
global _monitoring_active, _filter_state, _event_queue
|
||||
|
||||
# Non-TTY mode: no interactive input available, just keep alive
|
||||
if not sys.stdin.isatty():
|
||||
logger.info("[monitor] No TTY detected - running in passive mode (Ctrl+C to stop)")
|
||||
try:
|
||||
while _monitoring_active:
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Stopping monitoring...[/yellow]")
|
||||
return
|
||||
|
||||
from aipass.prax.apps.handlers.monitoring.interactive_filter import (
|
||||
parse_command,
|
||||
apply_filter,
|
||||
|
||||
@@ -69,7 +69,14 @@ spawn/
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
- Python stdlib only (`pathlib`, `json`, `shutil`, `hashlib`, `re`)
|
||||
- `aipass.prax` — Logging via `system_logger`
|
||||
- `aipass.cli` — Console output and headers
|
||||
- Python stdlib (`pathlib`, `json`, `shutil`, `hashlib`, `re`)
|
||||
|
||||
### Provides To
|
||||
- All modules — agent scaffolding and lifecycle setup
|
||||
- All modules — agent creation service and lifecycle setup
|
||||
- Registry: Reads/writes `AIPASS_REGISTRY.json`
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2026-03-07*
|
||||
|
||||
@@ -6,6 +6,6 @@ Usage:
|
||||
result = spawn_agent("/path/to/new/agent", role="My Role", purpose="My Purpose")
|
||||
"""
|
||||
|
||||
from aipass.spawn.apps.modules.core import spawn_agent
|
||||
from aipass.spawn.apps.modules.core import _spawn_agent as spawn_agent
|
||||
|
||||
__all__ = ["spawn_agent"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Extensions package
|
||||
@@ -1,5 +1,15 @@
|
||||
# =================== META ====================
|
||||
# Name: file_ops.py
|
||||
# Description: Template copy and file operations
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-05
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""Template copy and file rename operations."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
@@ -9,6 +19,11 @@ from aipass.spawn.apps.handlers.placeholders import replace_placeholders
|
||||
SKIP_NAMES = {"__pycache__", ".git", ".template_registry.json"}
|
||||
|
||||
|
||||
def ensure_directory(path):
|
||||
"""Create directory and parents if they don't exist."""
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def copy_template(template_dir, target_dir, replacements):
|
||||
"""
|
||||
Recursively copy template to target, replacing placeholders in text files.
|
||||
@@ -123,3 +138,77 @@ def _replace_path_placeholders(rel_path, replacements):
|
||||
new_part = replace_placeholders(part, replacements)
|
||||
parts.append(new_part)
|
||||
return Path(*parts) if parts else rel_path
|
||||
|
||||
|
||||
def regenerate_template_registry(target_dir):
|
||||
"""
|
||||
Regenerate .template_registry.json with fresh SHA-256 hashes.
|
||||
|
||||
Scans all files in the agent directory and builds a new registry
|
||||
with accurate content hashes.
|
||||
|
||||
Args:
|
||||
target_dir: Path to the spawned agent directory
|
||||
"""
|
||||
target_dir = Path(target_dir)
|
||||
agent_dir = target_dir / ".agent"
|
||||
if not agent_dir.exists():
|
||||
return
|
||||
|
||||
registry_file = agent_dir / ".template_registry.json"
|
||||
|
||||
files = {}
|
||||
directories = {}
|
||||
file_idx = 1
|
||||
dir_idx = 1
|
||||
|
||||
for item in sorted(target_dir.rglob("*")):
|
||||
rel = item.relative_to(target_dir)
|
||||
|
||||
# Skip .agent internal files and __pycache__
|
||||
if ".agent" in rel.parts or "__pycache__" in rel.parts:
|
||||
continue
|
||||
|
||||
if item.is_dir():
|
||||
dir_id = f"d{dir_idx:03d}"
|
||||
directories[dir_id] = {
|
||||
"path": str(rel),
|
||||
"name": item.name,
|
||||
}
|
||||
dir_idx += 1
|
||||
elif item.is_file():
|
||||
file_id = f"f{file_idx:03d}"
|
||||
try:
|
||||
content = item.read_bytes()
|
||||
content_hash = hashlib.sha256(content).hexdigest()[:16]
|
||||
except (IOError, PermissionError):
|
||||
content_hash = "unreadable"
|
||||
|
||||
has_placeholder = False
|
||||
try:
|
||||
text = item.read_text(encoding="utf-8")
|
||||
has_placeholder = "{{" in text and "}}" in text
|
||||
except (UnicodeDecodeError, IOError):
|
||||
pass
|
||||
|
||||
files[file_id] = {
|
||||
"path": str(rel),
|
||||
"name": item.name,
|
||||
"content_hash": content_hash,
|
||||
"has_branch_placeholder": has_placeholder,
|
||||
}
|
||||
file_idx += 1
|
||||
|
||||
registry = {
|
||||
"metadata": {
|
||||
"description": "Template registry for tracking files",
|
||||
"generated": True,
|
||||
},
|
||||
"files": files,
|
||||
"directories": directories,
|
||||
}
|
||||
|
||||
registry_file.write_text(
|
||||
json.dumps(registry, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# JSON handlers package
|
||||
@@ -0,0 +1,31 @@
|
||||
# =================== META ====================
|
||||
# Name: json_handler.py
|
||||
# Description: JSON file read/write operations for spawn
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""JSON handler for spawn module."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def read_json(file_path: Path) -> Optional[dict]:
|
||||
"""Read and parse a JSON file."""
|
||||
try:
|
||||
return json.loads(file_path.read_text())
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
return None
|
||||
|
||||
|
||||
def write_json(file_path: Path, data: Any, indent: int = 2) -> bool:
|
||||
"""Write data to a JSON file."""
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(json.dumps(data, indent=indent) + "\n")
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
@@ -1,3 +1,11 @@
|
||||
# =================== META ====================
|
||||
# Name: metadata.py
|
||||
# Description: Branch name extraction and profile detection
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-05
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""Branch name extraction and profile detection."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# =================== META ====================
|
||||
# Name: placeholders.py
|
||||
# Description: Placeholder replacement engine
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-05
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""Placeholder replacement engine for agent templates."""
|
||||
|
||||
import re
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# =================== META ====================
|
||||
# Name: registry.py
|
||||
# Description: AIPASS_REGISTRY.json CRUD operations
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-05
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""AIPASS_REGISTRY.json CRUD operations."""
|
||||
|
||||
import json
|
||||
@@ -119,6 +127,19 @@ def save_registry(registry_path, data):
|
||||
return False
|
||||
|
||||
|
||||
def get_next_citizen_number(registry_path):
|
||||
"""Get next citizen number from registry (count of existing branches + 1).
|
||||
|
||||
Args:
|
||||
registry_path: Path to AIPASS_REGISTRY.json
|
||||
|
||||
Returns:
|
||||
int: Next citizen number
|
||||
"""
|
||||
data = load_registry(registry_path)
|
||||
return len(data.get("branches", [])) + 1
|
||||
|
||||
|
||||
def add_to_registry(registry_path, branch_name, branch_path, profile, email, purpose=""):
|
||||
"""
|
||||
Add a new branch entry to the registry.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# JSON templates package
|
||||
@@ -1,3 +1,11 @@
|
||||
# =================== META ====================
|
||||
# Name: core.py
|
||||
# Description: Main orchestrator for agent spawning
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-05
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Spawn Module — Create new AIPass agents from templates.
|
||||
|
||||
@@ -11,21 +19,64 @@ Orchestrates the full agent creation workflow:
|
||||
7. Validate no unreplaced placeholders remain
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.spawn.apps.handlers.metadata import get_branch_name, normalize_branch_name, detect_profile
|
||||
from aipass.spawn.apps.handlers.placeholders import build_replacements_dict, validate_no_placeholders
|
||||
from aipass.spawn.apps.handlers.file_ops import copy_template, rename_placeholder_paths
|
||||
from aipass.spawn.apps.handlers.registry import find_registry, add_to_registry
|
||||
from aipass.spawn.apps.handlers.file_ops import copy_template, rename_placeholder_paths, regenerate_template_registry, ensure_directory
|
||||
from aipass.spawn.apps.handlers.registry import find_registry, add_to_registry, get_next_citizen_number
|
||||
|
||||
# Default template location (relative to spawn package root)
|
||||
DEFAULT_TEMPLATE = Path(__file__).parents[2] / "templates" / "agent.template"
|
||||
|
||||
|
||||
def spawn_agent(target_path, role="", traits="", purpose="", profile=None,
|
||||
template_dir=None, registry_path=None):
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Route spawn commands to implementation.
|
||||
|
||||
Args:
|
||||
command: The command string (e.g. "create")
|
||||
args: List of arguments for the command
|
||||
|
||||
Returns:
|
||||
True if command succeeded, False otherwise
|
||||
"""
|
||||
if command == "create":
|
||||
if not args:
|
||||
logger.error("spawn create requires a target path")
|
||||
return False
|
||||
target_path = args[0]
|
||||
kwargs = {}
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] == "--role" and i + 1 < len(args):
|
||||
kwargs["role"] = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--traits" and i + 1 < len(args):
|
||||
kwargs["traits"] = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--purpose" and i + 1 < len(args):
|
||||
kwargs["purpose"] = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--template" and i + 1 < len(args):
|
||||
kwargs["template_dir"] = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--registry" and i + 1 < len(args):
|
||||
kwargs["registry_path"] = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
result = _spawn_agent(target_path, **kwargs)
|
||||
return result["success"]
|
||||
else:
|
||||
logger.error(f"Unknown spawn command: {command}")
|
||||
return False
|
||||
|
||||
|
||||
def _spawn_agent(target_path, role="", traits="", purpose="", profile=None,
|
||||
template_dir=None, registry_path=None):
|
||||
"""
|
||||
Create a new AIPass agent from template.
|
||||
|
||||
@@ -65,7 +116,7 @@ def spawn_agent(target_path, role="", traits="", purpose="", profile=None,
|
||||
|
||||
# Determine citizen number from registry
|
||||
reg_path = Path(registry_path) if registry_path else find_registry(target.parent)
|
||||
citizen_number = _get_next_citizen_number(reg_path)
|
||||
citizen_number = get_next_citizen_number(reg_path)
|
||||
|
||||
# Build placeholder replacements
|
||||
replacements = build_replacements_dict(
|
||||
@@ -75,14 +126,14 @@ def spawn_agent(target_path, role="", traits="", purpose="", profile=None,
|
||||
)
|
||||
|
||||
# Step 1: Copy template with placeholder replacement in content
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
ensure_directory(target)
|
||||
copied, skipped = copy_template(template, target, replacements)
|
||||
|
||||
# Step 2: Rename any {{BRANCH}} dirs/files that weren't caught by path replacement
|
||||
renamed = rename_placeholder_paths(target, folder_name)
|
||||
|
||||
# Step 3: Regenerate .template_registry.json with fresh hashes
|
||||
_regenerate_template_registry(target)
|
||||
regenerate_template_registry(target)
|
||||
|
||||
# Step 4: Register in AIPASS_REGISTRY.json
|
||||
registry_updated = add_to_registry(
|
||||
@@ -108,89 +159,6 @@ def spawn_agent(target_path, role="", traits="", purpose="", profile=None,
|
||||
}
|
||||
|
||||
|
||||
def _get_next_citizen_number(registry_path):
|
||||
"""Get next citizen number from registry (count of existing branches + 1)."""
|
||||
registry_path = Path(registry_path)
|
||||
if not registry_path.exists():
|
||||
return 1
|
||||
try:
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
return len(data.get("branches", [])) + 1
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return 1
|
||||
|
||||
|
||||
def _regenerate_template_registry(target_dir):
|
||||
"""
|
||||
Regenerate .template_registry.json with fresh SHA-256 hashes.
|
||||
|
||||
Scans all files in the agent directory and builds a new registry
|
||||
with accurate content hashes.
|
||||
"""
|
||||
target_dir = Path(target_dir)
|
||||
agent_dir = target_dir / ".agent"
|
||||
if not agent_dir.exists():
|
||||
return
|
||||
|
||||
registry_file = agent_dir / ".template_registry.json"
|
||||
|
||||
files = {}
|
||||
directories = {}
|
||||
file_idx = 1
|
||||
dir_idx = 1
|
||||
|
||||
for item in sorted(target_dir.rglob("*")):
|
||||
rel = item.relative_to(target_dir)
|
||||
|
||||
# Skip .agent internal files and __pycache__
|
||||
if ".agent" in rel.parts or "__pycache__" in rel.parts:
|
||||
continue
|
||||
|
||||
if item.is_dir():
|
||||
dir_id = f"d{dir_idx:03d}"
|
||||
directories[dir_id] = {
|
||||
"path": str(rel),
|
||||
"name": item.name,
|
||||
}
|
||||
dir_idx += 1
|
||||
elif item.is_file():
|
||||
file_id = f"f{file_idx:03d}"
|
||||
try:
|
||||
content = item.read_bytes()
|
||||
content_hash = hashlib.sha256(content).hexdigest()[:16]
|
||||
except (IOError, PermissionError):
|
||||
content_hash = "unreadable"
|
||||
|
||||
has_placeholder = False
|
||||
try:
|
||||
text = item.read_text(encoding="utf-8")
|
||||
has_placeholder = "{{" in text and "}}" in text
|
||||
except (UnicodeDecodeError, IOError):
|
||||
pass
|
||||
|
||||
files[file_id] = {
|
||||
"path": str(rel),
|
||||
"name": item.name,
|
||||
"content_hash": content_hash,
|
||||
"has_branch_placeholder": has_placeholder,
|
||||
}
|
||||
file_idx += 1
|
||||
|
||||
registry = {
|
||||
"metadata": {
|
||||
"description": "Template registry for tracking files",
|
||||
"generated": True,
|
||||
},
|
||||
"files": files,
|
||||
"directories": directories,
|
||||
}
|
||||
|
||||
registry_file.write_text(
|
||||
json.dumps(registry, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _error(message):
|
||||
"""Return error result dict."""
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# =================== META ====================
|
||||
# Name: spawn.py
|
||||
# Description: Entry point CLI for drone @spawn
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-05
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
SPAWN Branch - Agent Creation System
|
||||
|
||||
@@ -40,7 +48,7 @@ def print_help():
|
||||
|
||||
def handle_create(args):
|
||||
"""Handle the create command."""
|
||||
from aipass.spawn.apps.modules.core import spawn_agent
|
||||
from aipass.spawn.apps.modules.core import _spawn_agent as spawn_agent
|
||||
|
||||
if not args:
|
||||
console.print("[red]Error: target path required[/red]")
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# =================== META ====================
|
||||
# Name: test_spawn.py
|
||||
# Description: Test suite for spawn module
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-05
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""Tests for the aipass.spawn module."""
|
||||
|
||||
import json
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"error_catchup": {
|
||||
"last_scan_timestamp": "2026-03-06T14:27:27.869613",
|
||||
"last_scan_timestamp": "2026-03-07T04:46:08.802277",
|
||||
"processed_hashes": [
|
||||
"ace01637",
|
||||
"69de737b",
|
||||
"c7edc03b",
|
||||
"4095ef97",
|
||||
"68dd0ab6",
|
||||
"4095ef97",
|
||||
"69de737b",
|
||||
"ace01637",
|
||||
"02d93909",
|
||||
"541e0b9b",
|
||||
"02d93909"
|
||||
"c7edc03b"
|
||||
],
|
||||
"max_hashes": 500,
|
||||
"max_lookback_hours": 24
|
||||
|
||||
Reference in New Issue
Block a user