Merge pull request #536 from AIOSAI/work/system-dplan-0168-phase-1-2-providermanifestjson-manifest
feat(system): DPLAN-0168 Phase 1-2: provider_manifest.json + manifest-driven doctor checks + dispatch fresh/continue reasoning in local prompt
This commit is contained in:
@@ -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)"}]}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user