From 8bd0b5d8148e335d7f322183ec6bff49e51f4211 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Thu, 7 May 2026 20:14:09 -0700 Subject: [PATCH] feat(system): DPLAN-0168 Phase 1-2: provider_manifest.json + manifest-driven doctor checks + dispatch fresh/continue reasoning in local prompt Co-Authored-By: @devpulse --- .claude/hooks/.diagnostics_state.json | 1 - .claude/provider_manifest.json | 73 ++++++++ src/aipass/aipass/apps/modules/doctor.py | 143 +++++++++++++-- src/aipass/aipass/tests/test_doctor.py | 165 ++++++++++++++++++ .../devpulse/.aipass/aipass_local_prompt.md | 13 +- 5 files changed, 378 insertions(+), 17 deletions(-) delete mode 100644 .claude/hooks/.diagnostics_state.json create mode 100644 .claude/provider_manifest.json diff --git a/.claude/hooks/.diagnostics_state.json b/.claude/hooks/.diagnostics_state.json deleted file mode 100644 index d4c1f1b9..00000000 --- a/.claude/hooks/.diagnostics_state.json +++ /dev/null @@ -1 +0,0 @@ -{"file": "/home/patrick/Projects/AIPass/src/aipass/seedgo/tests/test_hooks_track_a.py", "errors": [{"line": 347, "message": "E501: Line too long (148 > 120)"}, {"line": 366, "message": "E501: Line too long (148 > 120)"}]} \ No newline at end of file diff --git a/.claude/provider_manifest.json b/.claude/provider_manifest.json new file mode 100644 index 00000000..764f3e8d --- /dev/null +++ b/.claude/provider_manifest.json @@ -0,0 +1,73 @@ +{ + "version": "1.0.0", + "description": "Single source of truth for provider-level settings per CLI. Doctor reads this to verify user setup.", + "cli": { + "claude": { + "hooks": [ + {"script": "global_prompt_loader.py", "event": "UserPromptSubmit", "source": "repo"}, + {"script": "branch_prompt_loader.py", "event": "UserPromptSubmit", "source": "repo"}, + {"script": "identity_injector.py", "event": "UserPromptSubmit", "source": "repo"}, + {"script": "email_notification.py", "event": "UserPromptSubmit", "source": "repo"}, + {"script": "tool_use_sound.py", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", "source": "repo"}, + {"script": "pre_edit_gate.py", "event": "PreToolUse", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "source": "user"}, + {"script": "git_gate.py", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "source": "user"}, + {"script": "auto_fix_diagnostics.py", "event": "PostToolUse", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "source": "repo"}, + {"script": "auto_watchdog.py", "event": "PostToolUse", "matcher": "Bash", "source": "user"}, + {"script": "subagent_stop_gate.py", "event": "SubagentStop", "source": "repo"}, + {"script": "stop_sound.py", "event": "Stop", "source": "repo"}, + {"script": "notification_sound.py", "event": "Notification", "source": "repo"}, + {"script": "pre_compact.py", "event": "PreCompact", "matcher": "manual", "source": "repo", "timeout": 60}, + {"script": "pre_compact.py", "event": "PreCompact", "matcher": "auto", "source": "repo", "timeout": 60} + ], + "env": { + "AIPASS_HOME": "{{REPO_ROOT}}", + "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1", + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" + }, + "permissions": { + "deny": [ + "Read(~/.secrets/**)", + "Bash(cat ~/.secrets/*)", + "Bash(head ~/.secrets/*)", + "Bash(tail ~/.secrets/*)", + "Bash(less ~/.secrets/*)", + "Bash(git reset --hard*)", + "Bash(git push --force*)", + "Bash(git push -f *)", + "Bash(git rebase*)", + "Bash(git clean*)", + "Bash(rm -rf*)", + "Bash(git reset*)", + "Bash(git merge*)", + "Bash(git config*)", + "Bash(git checkout -- *)", + "Bash(git checkout .*)", + "Bash(git restore --staged*)", + "Bash(git restore .*)", + "Bash(git branch -D*)", + "Bash(git stash drop*)", + "Bash(git stash clear*)", + "Bash(rm -r *)", + "Bash(git checkout -b*)", + "Bash(git switch -c*)", + "Bash(git switch --create*)", + "Bash(git commit*)", + "Bash(git push*)" + ], + "ask": [ + "Edit(~/.claude/**)", + "Write(~/.claude/**)" + ] + }, + "commands": { + "memo.md": ".claude/templates/memo.md" + } + }, + "codex": { + "hooks": [], + "env": {}, + "permissions": {}, + "commands": {} + } + } +} diff --git a/src/aipass/aipass/apps/modules/doctor.py b/src/aipass/aipass/apps/modules/doctor.py index 64455629..1242b3a5 100644 --- a/src/aipass/aipass/apps/modules/doctor.py +++ b/src/aipass/aipass/apps/modules/doctor.py @@ -211,6 +211,131 @@ def _check_identity() -> List[CheckResult]: return results +def _find_manifest() -> Path | None: + """Find provider_manifest.json by walking up from CWD or using AIPASS_HOME.""" + for start in (Path.cwd(), Path(os.environ.get("AIPASS_HOME", ""))): + p = start.resolve() + for parent in (p, *p.parents): + candidate = parent / ".claude" / "provider_manifest.json" + if candidate.exists(): + return candidate + if parent == parent.parent: + break + return None + + +def _check_provider_manifest() -> List[CheckResult]: + """Check provider settings against manifest. Returns hook/env/permission results.""" + results: List[CheckResult] = [] + + manifest_path = _find_manifest() + if manifest_path is None: + results.append( + CheckResult( + "hooks", GLYPH_WARN, "manifest not found", "Run setup.sh or create .claude/provider_manifest.json" + ) + ) + return results + + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception as exc: + logger.warning("[doctor] manifest read error: %s", exc) + results.append(CheckResult("hooks", GLYPH_WARN, "manifest unreadable", "Check .claude/provider_manifest.json")) + return results + + claude_section = manifest.get("cli", {}).get("claude", {}) + if not claude_section: + results.append(CheckResult("hooks", GLYPH_WARN, "manifest has no claude section", "")) + return results + + # --- Hook scripts exist --- + manifest_hooks = claude_section.get("hooks", []) + hook_scripts = {h["script"] for h in manifest_hooks if "script" in h} + repo_hooks_dir = manifest_path.parent / "hooks" + user_hooks_dir = Path.home() / ".claude" / "hooks" + + missing_hooks = [] + for script in sorted(hook_scripts): + source = next((h.get("source", "repo") for h in manifest_hooks if h.get("script") == script), "repo") + check_dir = user_hooks_dir if source == "user" else repo_hooks_dir + if not (check_dir / script).exists(): + missing_hooks.append(script) + + if not missing_hooks: + results.append(CheckResult("hooks", GLYPH_PASS, f"{len(hook_scripts)} provider hooks present", "")) + else: + results.append( + CheckResult( + "hooks", + GLYPH_WARN, + f"{len(missing_hooks)} hook(s) missing: {', '.join(missing_hooks)}", + "Run setup.sh or copy from .claude/hooks/ — see .claude/hooks/README.md", + ) + ) + + # --- Env vars in provider settings --- + manifest_env = claude_section.get("env", {}) + if manifest_env: + provider_settings_path = Path.home() / ".claude" / "settings.json" + provider_env: dict = {} + if provider_settings_path.exists(): + try: + provider_env = json.loads(provider_settings_path.read_text(encoding="utf-8")).get("env", {}) + except Exception as exc: + logger.warning("[doctor] provider settings read error (env): %s", exc) + + missing_env = [k for k in manifest_env if k not in provider_env] + if not missing_env: + results.append(CheckResult("env vars", GLYPH_PASS, f"{len(manifest_env)} provider env vars set", "")) + else: + results.append( + CheckResult( + "env vars", + GLYPH_WARN, + f"{len(missing_env)} env var(s) missing: {', '.join(missing_env)}", + "Run setup.sh to configure provider settings", + ) + ) + + # --- Permissions --- + manifest_perms = claude_section.get("permissions", {}) + manifest_deny = manifest_perms.get("deny", []) + manifest_ask = manifest_perms.get("ask", []) + if manifest_deny or manifest_ask: + provider_settings_path = Path.home() / ".claude" / "settings.json" + provider_perms: dict = {} + if provider_settings_path.exists(): + try: + provider_perms = json.loads(provider_settings_path.read_text(encoding="utf-8")).get("permissions", {}) + except Exception as exc: + logger.warning("[doctor] provider settings read error (permissions): %s", exc) + + provider_deny = set(provider_perms.get("deny", [])) + provider_ask = set(provider_perms.get("ask", [])) + + # Check deny rules (use ~ form only, skip expanded $HOME duplicates) + missing_deny = [r for r in manifest_deny if r not in provider_deny] + # Check ask rules + missing_ask = [r for r in manifest_ask if r not in provider_ask] + total_expected = len(manifest_deny) + len(manifest_ask) + total_missing = len(missing_deny) + len(missing_ask) + + if total_missing == 0: + results.append(CheckResult("permissions", GLYPH_PASS, f"{total_expected} permission rules set", "")) + else: + results.append( + CheckResult( + "permissions", + GLYPH_WARN, + f"{total_missing} permission rule(s) missing", + "Run setup.sh to configure provider permissions", + ) + ) + + return results + + def _check_services(verbose: bool = False) -> List[CheckResult]: """Run Services group checks.""" results: List[CheckResult] = [] @@ -271,21 +396,9 @@ def _check_services(verbose: bool = False) -> List[CheckResult]: logger.warning("[doctor] pytest collect timed out: %s", exc) results.append(CheckResult("pytest collect", GLYPH_WARN, "timed out", "")) - # hooks wired — check provider-level enforcement hooks - provider_hooks_dir = Path("~/.claude/hooks").expanduser() - provider_hooks = ["auto_fix_diagnostics.py", "pre_edit_gate.py", "subagent_stop_gate.py"] - missing = [h for h in provider_hooks if not (provider_hooks_dir / h).exists()] - if not missing: - results.append(CheckResult("hooks", GLYPH_PASS, f"{len(provider_hooks)} provider hooks wired", "")) - else: - results.append( - CheckResult( - "hooks", - GLYPH_WARN, - f"{len(missing)} provider hook(s) missing: {', '.join(missing)}", - "Copy from .claude/hooks/ to ~/.claude/hooks/ — see .claude/hooks/README.md", - ) - ) + # hooks + env + permissions — manifest-driven provider check + manifest_checks = _check_provider_manifest() + results.extend(manifest_checks) return results diff --git a/src/aipass/aipass/tests/test_doctor.py b/src/aipass/aipass/tests/test_doctor.py index c67d426f..d1dcc9a1 100644 --- a/src/aipass/aipass/tests/test_doctor.py +++ b/src/aipass/aipass/tests/test_doctor.py @@ -8,6 +8,7 @@ """Tests for aipass doctor command — Phase 1 (FPLAN-0188).""" +import json from unittest.mock import MagicMock, patch import pytest @@ -380,3 +381,167 @@ class TestRunDoctor: with patch("aipass.aipass.apps.modules.doctor._check_community", return_value=[]): result = run_doctor() assert result == 0 + + +# ============================================================================= +# TestProviderManifest +# ============================================================================= + + +class TestProviderManifest: + """Tests for manifest-driven provider checks (DPLAN-0168).""" + + def test_manifest_not_found_returns_warn(self) -> None: + """Missing manifest → single WARN result.""" + from aipass.aipass.apps.modules.doctor import _check_provider_manifest + + with patch("aipass.aipass.apps.modules.doctor._find_manifest", return_value=None): + results = _check_provider_manifest() + assert len(results) == 1 + assert results[0].glyph == GLYPH_WARN + assert "manifest" in results[0].detail + + def test_manifest_unreadable_returns_warn(self, tmp_path) -> None: + """Corrupt manifest file → WARN.""" + from aipass.aipass.apps.modules.doctor import _check_provider_manifest + + bad_manifest = tmp_path / ".claude" / "provider_manifest.json" + bad_manifest.parent.mkdir(parents=True) + bad_manifest.write_text("not json{{{", encoding="utf-8") + with patch("aipass.aipass.apps.modules.doctor._find_manifest", return_value=bad_manifest): + results = _check_provider_manifest() + assert len(results) == 1 + assert results[0].glyph == GLYPH_WARN + assert "unreadable" in results[0].detail + + def test_manifest_no_claude_section(self, tmp_path) -> None: + """Manifest with no cli.claude → WARN.""" + from aipass.aipass.apps.modules.doctor import _check_provider_manifest + + manifest = tmp_path / ".claude" / "provider_manifest.json" + manifest.parent.mkdir(parents=True) + manifest.write_text(json.dumps({"cli": {}}), encoding="utf-8") + with patch("aipass.aipass.apps.modules.doctor._find_manifest", return_value=manifest): + results = _check_provider_manifest() + assert len(results) == 1 + assert results[0].glyph == GLYPH_WARN + + def test_all_hooks_present(self, tmp_path) -> None: + """All hook scripts exist → PASS for hooks.""" + from aipass.aipass.apps.modules.doctor import _check_provider_manifest + + hooks_dir = tmp_path / ".claude" / "hooks" + hooks_dir.mkdir(parents=True) + (hooks_dir / "hook_a.py").write_text("", encoding="utf-8") + (hooks_dir / "hook_b.py").write_text("", encoding="utf-8") + + manifest = tmp_path / ".claude" / "provider_manifest.json" + manifest.write_text( + json.dumps( + { + "cli": { + "claude": { + "hooks": [ + {"script": "hook_a.py", "event": "Stop", "source": "repo"}, + {"script": "hook_b.py", "event": "Stop", "source": "repo"}, + ] + } + } + } + ), + encoding="utf-8", + ) + with patch("aipass.aipass.apps.modules.doctor._find_manifest", return_value=manifest): + results = _check_provider_manifest() + hooks_result = [r for r in results if r.label == "hooks"][0] + assert hooks_result.glyph == GLYPH_PASS + assert "2" in hooks_result.detail + + def test_missing_hook_detected(self, tmp_path) -> None: + """Missing hook script → WARN with script name.""" + from aipass.aipass.apps.modules.doctor import _check_provider_manifest + + hooks_dir = tmp_path / ".claude" / "hooks" + hooks_dir.mkdir(parents=True) + + manifest = tmp_path / ".claude" / "provider_manifest.json" + manifest.write_text( + json.dumps( + { + "cli": { + "claude": { + "hooks": [ + {"script": "missing.py", "event": "Stop", "source": "repo"}, + ] + } + } + } + ), + encoding="utf-8", + ) + with patch("aipass.aipass.apps.modules.doctor._find_manifest", return_value=manifest): + results = _check_provider_manifest() + hooks_result = [r for r in results if r.label == "hooks"][0] + assert hooks_result.glyph == GLYPH_WARN + assert "missing.py" in hooks_result.detail + + def test_env_vars_all_present(self, tmp_path) -> None: + """All manifest env vars present in provider settings → PASS.""" + from aipass.aipass.apps.modules.doctor import _check_provider_manifest + + manifest = tmp_path / ".claude" / "provider_manifest.json" + manifest.parent.mkdir(parents=True) + manifest.write_text( + json.dumps({"cli": {"claude": {"hooks": [], "env": {"FOO": "1", "BAR": "2"}}}}), + encoding="utf-8", + ) + provider_settings = tmp_path / ".claude" / "settings.json" + provider_settings.write_text( + json.dumps({"env": {"FOO": "1", "BAR": "2", "OTHER": "3"}}), + encoding="utf-8", + ) + with ( + patch("aipass.aipass.apps.modules.doctor._find_manifest", return_value=manifest), + patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path), + ): + results = _check_provider_manifest() + env_results = [r for r in results if r.label == "env vars"] + assert len(env_results) == 1 + assert env_results[0].glyph == GLYPH_PASS + + def test_env_vars_missing(self, tmp_path) -> None: + """Missing env var → WARN with var name.""" + from aipass.aipass.apps.modules.doctor import _check_provider_manifest + + manifest = tmp_path / ".claude" / "provider_manifest.json" + manifest.parent.mkdir(parents=True) + manifest.write_text( + json.dumps({"cli": {"claude": {"hooks": [], "env": {"MISSING_VAR": "1"}}}}), + encoding="utf-8", + ) + provider_settings = tmp_path / ".claude" / "settings.json" + provider_settings.write_text(json.dumps({"env": {}}), encoding="utf-8") + with ( + patch("aipass.aipass.apps.modules.doctor._find_manifest", return_value=manifest), + patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path), + ): + results = _check_provider_manifest() + env_results = [r for r in results if r.label == "env vars"] + assert len(env_results) == 1 + assert env_results[0].glyph == GLYPH_WARN + assert "MISSING_VAR" in env_results[0].detail + + def test_find_manifest_walks_up(self, tmp_path, monkeypatch) -> None: + """_find_manifest finds manifest by walking up from CWD.""" + from aipass.aipass.apps.modules.doctor import _find_manifest + + manifest = tmp_path / ".claude" / "provider_manifest.json" + manifest.parent.mkdir(parents=True) + manifest.write_text("{}", encoding="utf-8") + subdir = tmp_path / "src" / "deep" + subdir.mkdir(parents=True) + monkeypatch.chdir(subdir) + monkeypatch.delenv("AIPASS_HOME", raising=False) + result = _find_manifest() + assert result is not None + assert result == manifest diff --git a/src/aipass/devpulse/.aipass/aipass_local_prompt.md b/src/aipass/devpulse/.aipass/aipass_local_prompt.md index fd243756..ab5e118c 100644 --- a/src/aipass/devpulse/.aipass/aipass_local_prompt.md +++ b/src/aipass/devpulse/.aipass/aipass_local_prompt.md @@ -63,10 +63,21 @@ Read-only git commands are fine: `git status`, `git diff`, `git log`. **Never cd to repo root.** `drone @git system-pr` requires `.trinity/passport.json` in the CWD hierarchy. If you cd to the repo root, it fails. Stage files with relative paths from devpulse: `git add ../../../HERALD.md`. Always run drone commands from this directory. +## Dispatch — Fresh vs Continue + +Default dispatch resumes the agent's last session (`-c` flag). Before dispatching, reason about whether the agent needs prior context: + +- **Did the agent finish its last task?** If yes and the new task is unrelated → use `--fresh`. Stale context is noise. +- **Is this a continuation?** Same DPLAN, follow-up question, same domain → default continue is fine, the context helps. +- **When in doubt, fresh is safer.** Memories carry the important context. The session carries the noise. + +Dispatch uses continue as a fail-safe — if an agent crashed or didn't save memories, the context is recoverable. But for new unrelated tasks, fresh gives cleaner results. + ## Key Commands ``` -drone @ai_mail dispatch @target "Subject" "Body" # Send + wake (one command) +drone @ai_mail dispatch @target "Subject" "Body" # Send + wake (continue, default) +drone @ai_mail dispatch @target "Subject" "Body" --fresh # Send + wake (fresh session) drone @ai_mail email @target "Subject" "Body" # Just mail, no wake drone @flow create . "Subject" # Create FPLAN drone @flow create . "Subject" dplan # Create DPLAN (dplan template)