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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"]),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user