diff --git a/.aipass/.gitignore b/.aipass/.gitignore index 89af5913..fc59cf2b 100644 --- a/.aipass/.gitignore +++ b/.aipass/.gitignore @@ -6,5 +6,6 @@ !README.md !PROMPT_STYLE.md !project_CLAUDE.md +!project_AGENTS.md !project_hooks.json #Do not add other exceptions here without careful consideration. Developer permissions0ns needed. \ No newline at end of file diff --git a/.aipass/project_AGENTS.md b/.aipass/project_AGENTS.md new file mode 100644 index 00000000..f5e71c2d --- /dev/null +++ b/.aipass/project_AGENTS.md @@ -0,0 +1,15 @@ +# {name} + +Agent workspace powered by AIPass. + +# Startup protocol + +On any greeting, silently run this sequence — no narration, no announcing steps. Just do it and respond with the status. + + - Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md` + +Use drone commands for all operations. Never raw git, gh, or file access when drone provides it. + +# Memories + +Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`. diff --git a/CHANGELOG.md b/CHANGELOG.md index f172e747..6636d1aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,20 @@ PyPI version — not the changelog header. split under the 600-line limit (`pid_cache` extracted). +25 tests. (built by @prax, verified by devpulse) +- **`aipass init update` now refreshes `AGENTS.md` and prunes stale managed + cruft (issue #676).** Two gaps: (1) `update_project` synced `AGENTS.md` from a + `.aipass/project_AGENTS.md` template that never existed, so the branch silently + no-op'd and `AGENTS.md` was never refreshed on update (only `CLAUDE.md`, whose + template exists, synced) — added the template and reconciled create/update to + one source; (2) the update was additive-only — added a whitelist-scoped cleanup + pass (`_STALE_MANAGED_FILES`, currently the retired `aipass_global_prompt.md`) + that removes only positively-identified managed artifacts, logs every removal, + and never touches user-owned files. The template also had to be un-ignored in + `.aipass/.gitignore` (allowlist) or it would never have shipped — caught in + verify. +7 tests; live repro confirms update emits `AGENTS.md` and clears a + planted cruft file. (built by @aipass, verified by devpulse — incl. the + gitignore ship-gap) + --- ## [2026-07-09] diff --git a/src/aipass/aipass/apps/handlers/init/bootstrap.py b/src/aipass/aipass/apps/handlers/init/bootstrap.py index db3cab4f..ec93c25f 100644 --- a/src/aipass/aipass/apps/handlers/init/bootstrap.py +++ b/src/aipass/aipass/apps/handlers/init/bootstrap.py @@ -43,6 +43,10 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc logger = logging.getLogger(__name__) +_STALE_MANAGED_FILES: list[Path] = [ + Path(".aipass") / "aipass_global_prompt.md", +] + def _sanitize_name(raw: str) -> str: """Sanitize a project name for use in filenames. @@ -474,6 +478,7 @@ def update_project(target: Path) -> dict: updated: list[str] = [] already_current: list[str] = [] skipped: list[str] = [] + removed: list[str] = [] aipass_home: str | None = None # Managed directories — create if missing (graceful recovery). @@ -595,11 +600,20 @@ def update_project(target: Path) -> dict: venv_link.symlink_to(aipass_venv) updated.append(f".venv (symlink to AIPass runtime: {aipass_venv})") + # --- Cruft cleanup: remove known-stale AIPass-managed artifacts --- + for rel in _STALE_MANAGED_FILES: + stale_path = target / rel + if stale_path.is_file(): + stale_path.unlink() + removed.append(str(stale_path)) + logger.info("Removed stale managed file: %s", stale_path) + return { "project_name": name, "target": str(target), "updated_files": updated, "already_current": already_current, "skipped_files": skipped, + "removed_files": removed, "aipass_home": aipass_home, } diff --git a/src/aipass/aipass/tests/test_bootstrap.py b/src/aipass/aipass/tests/test_bootstrap.py index 3f5182b5..043cdb04 100644 --- a/src/aipass/aipass/tests/test_bootstrap.py +++ b/src/aipass/aipass/tests/test_bootstrap.py @@ -440,6 +440,7 @@ def test_update_project_return_dict_structure(tmp_path): "updated_files", "already_current", "skipped_files", + "removed_files", "aipass_home", } assert result["project_name"] == "UPD" @@ -1097,3 +1098,113 @@ def test_with_source_header_is_first_line(): lines = result.split("\n") assert lines[0] == "" assert lines[1] == "content" + + +# --------------------------------------------------------------------------- +# GAP 1: AGENTS.md sync on update (#676) +# --------------------------------------------------------------------------- + + +def test_update_project_syncs_agents_md(tmp_path): + """update restores AGENTS.md when its content has been altered.""" + target = tmp_path / "proj" + target.mkdir() + init_project(target, project_name="sync") + + agents_md = target / "AGENTS.md" + agents_md.write_text("# Corrupted\n", encoding="utf-8") + + result = update_project(target) + + assert str(agents_md.resolve()) in result["updated_files"] + restored = agents_md.read_text(encoding="utf-8") + assert "# SYNC" in restored + assert "Startup protocol" in restored + + +def test_update_project_creates_missing_agents_md(tmp_path): + """update creates AGENTS.md if it was deleted from the project.""" + target = tmp_path / "proj" + target.mkdir() + init_project(target, project_name="miss") + + agents_md = target / "AGENTS.md" + agents_md.unlink() + + result = update_project(target) + + assert agents_md.exists() + assert str(agents_md.resolve()) in result["updated_files"] + content = agents_md.read_text(encoding="utf-8") + assert "# MISS" in content + + +def test_update_project_agents_md_already_current(tmp_path): + """update reports AGENTS.md as already_current when unchanged.""" + target = tmp_path / "proj" + target.mkdir() + init_project(target, project_name="cur") + + result = update_project(target) + + assert any("AGENTS.md" in f for f in result["already_current"]) + assert not any("AGENTS.md" in f for f in result["updated_files"]) + + +# --------------------------------------------------------------------------- +# GAP 2: Cruft cleanup on update (#676) +# --------------------------------------------------------------------------- + + +def test_update_project_removes_stale_global_prompt(tmp_path): + """update removes retired .aipass/aipass_global_prompt.md.""" + target = tmp_path / "proj" + target.mkdir() + init_project(target, project_name="cruft") + + stale = target / ".aipass" / "aipass_global_prompt.md" + stale.write_text("# old\n", encoding="utf-8") + + result = update_project(target) + + assert not stale.exists() + assert str(stale) in result["removed_files"] + + +def test_update_project_cleanup_does_not_touch_user_files(tmp_path): + """Cruft cleanup never removes user-owned files.""" + target = tmp_path / "proj" + target.mkdir() + init_project(target, project_name="safe") + + readme = target / "README.md" + registry = target / "SAFE_REGISTRY.json" + + result = update_project(target) + + assert readme.exists() + assert registry.exists() + assert len(result["removed_files"]) == 0 + + +def test_update_project_removed_files_in_result(tmp_path): + """Return dict always contains the removed_files key.""" + target = tmp_path / "proj" + target.mkdir() + init_project(target, project_name="rkey") + + result = update_project(target) + + assert "removed_files" in result + assert isinstance(result["removed_files"], list) + + +def test_update_project_cleanup_no_stale_is_noop(tmp_path): + """When no stale files exist, removed_files is empty.""" + target = tmp_path / "proj" + target.mkdir() + init_project(target, project_name="clean") + + result = update_project(target) + + assert result["removed_files"] == []