feat(system): S89: full external access (AIPASS_HOME dual registry) + feedback channel (cross-project bridge) + init update idempotency

Co-Authored-By: @devpulse <devpulse@aipass>
This commit is contained in:
AIOSAI
2026-04-11 14:49:16 -07:00
co-authored by @devpulse
parent 5b2d14a846
commit e8a9af4bc0
14 changed files with 584 additions and 101 deletions
+4 -4
View File
@@ -5,8 +5,8 @@
**Purpose:** Display and output formatting service for AIPass modules. Provides consistent terminal output — headers, success/error/warning messages, section breaks, and operation templates — so every module looks the same without duplicating Rich formatting code.
**Module:** `aipass.cli`
**Seedgo:** 100%
**Tests:** 142 passing (6 files, 5/5 modules covered)
**Last Updated:** 2026-04-10
**Tests:** 151 passing (6 files, 5/5 modules covered)
**Last Updated:** 2026-04-11
## Usage
@@ -66,7 +66,7 @@ cli/
├── cli_json/ # Auto-created JSON output (three-file pattern)
├── dropbox/ # Inbound file drop
├── logs/ # Branch-level logs
├── tests/ # 142 tests across 6 files
├── tests/ # 151 tests across 6 files
│ ├── test_bootstrap.py # bootstrap.py handler tests
│ ├── test_json_handler.py # json_handler tests
│ ├── test_display.py # display module tests
@@ -120,7 +120,7 @@ aipass --help # Via console_scripts entry point
---
*Last Updated: 2026-04-10*
*Last Updated: 2026-04-11*
---
[← Back to AIPass](../../../README.md)
+119 -30
View File
@@ -238,22 +238,99 @@ def _global_prompt_md(name: str) -> str:
f"# {name} — Project Context\n"
"<!-- Injected every turn via hook. -->\n"
"\n"
"## What is AIPass\n"
"\n"
"AIPass is a multi-agent framework. Agents live in directories with\n"
"persistent identity, memory, and communication. All AIPass infrastructure\n"
"is available from any project via the `drone` command.\n"
"\n"
"## Terminology\n"
"\n"
"- **Project** — this directory. Contains a registry and agents.\n"
"- **Agent** — a citizen with identity (`.trinity/`), memory, mailbox, "
"and code (`apps/`).\n"
"- **Agent** — a citizen with identity (`.trinity/`), memory, mailbox,\n"
" and code (`apps/`).\n"
f"- **Registry** — `{name}_REGISTRY.json` tracks all agents.\n"
"\n"
"## Setup: if drone commands fail\n"
"\n"
"If `drone` cannot find the AIPass registry, set the env var:\n"
"```bash\n"
"export AIPASS_HOME=/path/to/AIPass # path to AIPass installation\n"
"```\n"
"Add to your shell profile (`~/.bashrc` or `~/.zshrc`) to make it permanent.\n"
"\n"
"## Commands\n"
"\n"
"### Agent Lifecycle\n"
"```\n"
"aipass init agent <name> # Create a new agent\n"
"aipass init agent <name> # Create a new agent in src/<name>/\n"
"drone @spawn create <name> # Create agent (alternative)\n"
"drone @seedgo audit <project> # Standards audit\n"
"drone @ai_mail inbox # Check mailbox\n"
"drone @flow create . \"Subject\" # Create a plan\n"
"drone systems # List infrastructure\n"
"drone @spawn list # List registered agents\n"
"```\n"
"\n"
"### Standards\n"
"```\n"
"drone @seedgo audit <project> # Run full standards audit\n"
"drone @seedgo checklist <file> # Check a single file\n"
"```\n"
"\n"
"### Dispatch — Send Task + Wake an Agent (DEFAULT)\n"
"```\n"
"drone @ai_mail dispatch @<agent> \"Subject\" \"Body\" # Send + wake (default)\n"
"drone @ai_mail dispatch @<agent> \"Subject\" \"Body\" --fresh # Send + wake fresh session\n"
"drone @ai_mail dispatch wake @<agent> # Wake without sending\n"
"drone @ai_mail dispatch wake --fresh @<agent> # Wake fresh\n"
"drone @ai_mail email @<agent> \"Subject\" \"Body\" # FYI only (no wake)\n"
"```\n"
"\n"
"Use `dispatch` by default. Use `email` only when you don't need the agent to act now.\n"
"\n"
"### Communication (ai_mail)\n"
"```\n"
"drone @ai_mail inbox # Check your mailbox\n"
"drone @ai_mail view <id> # Read a message\n"
"drone @ai_mail close <id> # Mark message read\n"
"```\n"
"\n"
"### Feedback\n"
"```\n"
"drone @devpulse feedback send \"Subject\" \"Body\" # Send feedback (cross-project)\n"
"```\n"
"\n"
"### Plans (flow)\n"
"```\n"
"drone @flow create . \"Subject\" dplan # Create DPLAN (design/thinking)\n"
"drone @flow create . \"Subject\" master # Create FPLAN master (execution)\n"
"drone @flow create . \"Subject\" aplan # Create APLAN (agent-level task)\n"
"drone @flow list open # List active plans\n"
"drone @flow list # List all plans\n"
"drone @flow close <id> # Close a plan\n"
"drone @flow info <id> # View plan details\n"
"```\n"
"\n"
"**DPLAN** = Dev Plan. Thinking, brainstorming, architecture decisions. "
"Use before building.\n"
"**FPLAN** = Flow Plan. Building and executing. Use when the plan is clear "
"and work is underway.\n"
"\n"
"### Memory\n"
"```\n"
"drone @memory archive # Archive memories to vector store\n"
"drone @memory search <query> # Search archived memories\n"
"```\n"
"\n"
"### Git Workflow\n"
"```\n"
"drone @git pr 'description' # Create a pull request\n"
"drone @git status # Git status (branch-scoped)\n"
"drone @git sync # Sync with main\n"
"drone @git lock / unlock # Lock/unlock the repo\n"
"```\n"
"\n"
"### Infrastructure\n"
"```\n"
"drone systems # List all available infrastructure\n"
"drone --help # Full drone command reference\n"
"```\n"
"\n"
"## Patterns\n"
@@ -262,6 +339,8 @@ def _global_prompt_md(name: str) -> str:
"- **Standards** — run `drone @seedgo audit` to check compliance.\n"
"- **Identity** — agents have `.trinity/passport.json`. "
"Projects use the registry.\n"
"- **Memory** — update `.trinity/local.json` at session end. "
"Memory is presence.\n"
)
@@ -524,6 +603,7 @@ def update_project(target: Path) -> dict:
name = registry_path.stem.replace("_REGISTRY", "")
updated: list[str] = []
already_current: list[str] = []
skipped: list[str] = []
# Managed directories — create if missing (graceful recovery).
@@ -533,39 +613,47 @@ def update_project(target: Path) -> dict:
claude_dir = target / ".claude"
claude_dir.mkdir(exist_ok=True)
# --- Managed files: always overwrite with latest templates ---
# --- Managed files: write only when content has changed ---
global_prompt_path = aipass_dir / "aipass_global_prompt.md"
global_prompt_path.write_text(
_with_source(_global_prompt_md(name), global_prompt_path),
encoding="utf-8",
)
updated.append(str(global_prompt_path))
generated = _with_source(_global_prompt_md(name), global_prompt_path)
if not global_prompt_path.exists() or global_prompt_path.read_text(encoding="utf-8") != generated:
global_prompt_path.write_text(generated, encoding="utf-8")
updated.append(str(global_prompt_path))
else:
already_current.append(str(global_prompt_path))
settings_path = claude_dir / "settings.json"
settings_path.write_text(_claude_settings(), encoding="utf-8")
updated.append(str(settings_path))
generated = _claude_settings()
if not settings_path.exists() or settings_path.read_text(encoding="utf-8") != generated:
settings_path.write_text(generated, encoding="utf-8")
updated.append(str(settings_path))
else:
already_current.append(str(settings_path))
claude_md_path = target / "CLAUDE.md"
claude_md_path.write_text(
_with_source(_claude_md(name), claude_md_path),
encoding="utf-8",
)
updated.append(str(claude_md_path))
generated = _with_source(_claude_md(name), claude_md_path)
if not claude_md_path.exists() or claude_md_path.read_text(encoding="utf-8") != generated:
claude_md_path.write_text(generated, encoding="utf-8")
updated.append(str(claude_md_path))
else:
already_current.append(str(claude_md_path))
agents_md_path = target / "AGENTS.md"
agents_md_path.write_text(
_with_source(_agents_md(name), agents_md_path),
encoding="utf-8",
)
updated.append(str(agents_md_path))
generated = _with_source(_agents_md(name), agents_md_path)
if not agents_md_path.exists() or agents_md_path.read_text(encoding="utf-8") != generated:
agents_md_path.write_text(generated, encoding="utf-8")
updated.append(str(agents_md_path))
else:
already_current.append(str(agents_md_path))
gemini_md_path = target / "GEMINI.md"
gemini_md_path.write_text(
_with_source(_gemini_md(name), gemini_md_path),
encoding="utf-8",
)
updated.append(str(gemini_md_path))
generated = _with_source(_gemini_md(name), gemini_md_path)
if not gemini_md_path.exists() or gemini_md_path.read_text(encoding="utf-8") != generated:
gemini_md_path.write_text(generated, encoding="utf-8")
updated.append(str(gemini_md_path))
else:
already_current.append(str(gemini_md_path))
# --- User-owned files: always skip ---
for skip_name in (
@@ -580,5 +668,6 @@ def update_project(target: Path) -> dict:
"project_name": name,
"target": str(target),
"updated_files": updated,
"already_current": already_current,
"skipped_files": skipped,
}
+34 -17
View File
@@ -327,7 +327,7 @@ def _handle_init_update(args: List[str]) -> bool:
from rich.table import Table
from rich import box
if not args or args[0] in ("--help", "-h", "help"):
if args and args[0] in ("--help", "-h", "help"):
console.print()
header("aipass init update — Refresh Scaffold Files")
console.print("[dim]Updates managed prompt and config files with the latest templates[/dim]")
@@ -348,8 +348,7 @@ def _handle_init_update(args: List[str]) -> bool:
return True
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", os.getcwd())
target_arg = args[0]
target = Path(target_arg)
target = Path(args[0]) if args else Path(caller_cwd)
if not target.is_absolute():
target = Path(caller_cwd) / target
@@ -368,28 +367,42 @@ def _handle_init_update(args: List[str]) -> bool:
console.print()
header("Project Updated")
already_current = result.get("already_current", [])
summary = (
f"[bold]{result['project_name']}[/bold]\n"
f"\n"
f" [yellow]Target:[/yellow] [dim]{result['target']}[/dim]\n"
f" [yellow]Updated:[/yellow] {len(result['updated_files'])} files\n"
f" [yellow]Skipped:[/yellow] {len(result['skipped_files'])} files"
f" [yellow]Target:[/yellow] [dim]{result['target']}[/dim]\n"
f" [yellow]Updated:[/yellow] {len(result['updated_files'])} files\n"
f" [yellow]Already current:[/yellow] {len(already_current)} files\n"
f" [yellow]User-owned:[/yellow] {len(result['skipped_files'])} files (skipped)"
)
console.print(Panel(summary, border_style="green", box=box.ROUNDED))
# Updated files table
updated_table = Table(
show_header=True, header_style="bold cyan", border_style="dim", title="Updated"
)
updated_table.add_column("#", style="green", width=3)
updated_table.add_column("File", style="yellow")
for i, f in enumerate(result["updated_files"], 1):
updated_table.add_row(str(i), f)
console.print(updated_table)
# Updated files table (only show if something changed)
if result["updated_files"]:
updated_table = Table(
show_header=True, header_style="bold cyan", border_style="dim", title="Updated"
)
updated_table.add_column("#", style="green", width=3)
updated_table.add_column("File", style="yellow")
for i, f in enumerate(result["updated_files"], 1):
updated_table.add_row(str(i), f)
console.print(updated_table)
# Already current table
if already_current:
current_table = Table(
show_header=True, header_style="bold cyan", border_style="dim", title="Already current"
)
current_table.add_column("#", style="dim", width=3)
current_table.add_column("File", style="dim")
for i, f in enumerate(already_current, 1):
current_table.add_row(str(i), f)
console.print(current_table)
# Skipped files table
skipped_table = Table(
show_header=True, header_style="bold cyan", border_style="dim", title="Skipped"
show_header=True, header_style="bold cyan", border_style="dim", title="User-owned (skipped)"
)
skipped_table.add_column("#", style="dim", width=3)
skipped_table.add_column("File", style="dim")
@@ -398,12 +411,16 @@ def _handle_init_update(args: List[str]) -> bool:
console.print(skipped_table)
console.print()
success(f"Updated {len(result['updated_files'])} files")
if result["updated_files"]:
success(f"Updated {len(result['updated_files'])} files")
else:
success("All files already up to date")
json_handler.log_operation("aipass_init_update", {
"project_name": result["project_name"],
"target": result["target"],
"files_updated": len(result["updated_files"]),
"files_already_current": len(already_current),
"files_skipped": len(result["skipped_files"]),
})
+140 -1
View File
@@ -11,7 +11,7 @@ from pathlib import Path
import pytest
from aipass.cli.apps.handlers.init.bootstrap import _sanitize_name, init_project
from aipass.cli.apps.handlers.init.bootstrap import _sanitize_name, init_project, update_project
# ---------------------------------------------------------------------------
@@ -415,3 +415,142 @@ def test_init_project_agents_md_no_trinity(tmp_path):
content = (target / "AGENTS.md").read_text(encoding="utf-8")
assert ".trinity/" not in content
assert "KEEP_REGISTRY.json" in content
# ---------------------------------------------------------------------------
# update_project tests
# ---------------------------------------------------------------------------
def test_update_project_raises_if_no_registry(tmp_path):
"""ValueError when target has no *_REGISTRY.json (not an AIPass project)."""
target = tmp_path / "bare"
target.mkdir()
with pytest.raises(ValueError, match="No AIPass project found"):
update_project(target)
def test_update_project_return_dict_structure(tmp_path):
"""Return dict contains all required keys."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="upd")
result = update_project(target)
assert set(result.keys()) == {
"project_name",
"target",
"updated_files",
"already_current",
"skipped_files",
}
assert result["project_name"] == "UPD"
assert result["target"] == str(target.resolve())
assert isinstance(result["updated_files"], list)
assert isinstance(result["already_current"], list)
assert isinstance(result["skipped_files"], list)
def test_update_project_already_current_after_init(tmp_path):
"""Running update immediately after init reports all managed files as already current."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="fresh")
result = update_project(target)
assert len(result["updated_files"]) == 0
assert len(result["already_current"]) == 5
def test_update_project_idempotent(tmp_path):
"""Running update twice in a row produces no changes on second run."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="idem")
result1 = update_project(target)
result2 = update_project(target)
# Both runs should be identical
assert result1["updated_files"] == result2["updated_files"]
assert result1["already_current"] == result2["already_current"]
def test_update_project_updates_modified_managed_file(tmp_path):
"""A managed file with altered content is re-written on update."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="mod")
# Corrupt a managed file
claude_md = target / "CLAUDE.md"
claude_md.write_text("# Corrupted\n", encoding="utf-8")
result = update_project(target)
# CLAUDE.md must appear in updated, not already_current
assert str(claude_md.resolve()) in result["updated_files"]
assert str(claude_md.resolve()) not in result["already_current"]
# Content is restored
restored = claude_md.read_text(encoding="utf-8")
assert "MOD" in restored
assert "## What is AIPass" in restored
def test_update_project_never_touches_user_owned_files(tmp_path):
"""Registry, README, STATUS, .gitignore are always in skipped_files."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="skip")
# Modify user-owned files
(target / "README.md").write_text("# My custom README\n", encoding="utf-8")
(target / "STATUS.local.md").write_text("# Custom status\n", encoding="utf-8")
(target / ".gitignore").write_text("# custom\n", encoding="utf-8")
result = update_project(target)
skipped = result["skipped_files"]
assert any("REGISTRY" in s for s in skipped)
assert any("README.md" in s for s in skipped)
assert any("STATUS.local.md" in s for s in skipped)
assert any(".gitignore" in s for s in skipped)
# User customisations are preserved
assert (target / "README.md").read_text(encoding="utf-8") == "# My custom README\n"
assert (target / "STATUS.local.md").read_text(encoding="utf-8") == "# Custom status\n"
def test_update_project_creates_missing_managed_dirs(tmp_path):
"""update_project recreates .aipass/ and .claude/ if they were deleted."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="recover")
# Delete only the managed subdirectories (not root files like CLAUDE.md)
import shutil
shutil.rmtree(target / ".aipass")
shutil.rmtree(target / ".claude")
result = update_project(target)
assert (target / ".aipass" / "aipass_global_prompt.md").exists()
assert (target / ".claude" / "settings.json").exists()
# The 2 files inside deleted dirs are re-written; root files still match
assert len(result["updated_files"]) == 2
assert len(result["already_current"]) == 3
def test_update_project_skipped_files_count(tmp_path):
"""update_project always skips exactly 4 user-owned files."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="count")
result = update_project(target)
assert len(result["skipped_files"]) == 4