diff --git a/CLAUDE.md b/CLAUDE.md index 26d2cdae..bf409ef2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,11 +6,11 @@ User: user # Startup protocol -On any greeting, silently read these files from CWD and run the commands — no narration, no announcing steps. Just do it and respond with the status. +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`, `STATUS.local.md` - - Check: `drone @ai_mail inbox` — process any mail, don't ask. - - Run: `drone @git status` + - Refresh: `drone @prax dashboard refresh @` — where `` is your branch name (CWD directory name) + - Dashboard: Read `DASHBOARD.local.json` — act on what needs attention (new mail → check inbox, active plans → note them). This is your single status glance. - Refresh: If `STATUS.local.md` is stale (last updated date older than latest session in local.json), update it from your memories. Keep Current Work accurate. Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it. diff --git a/src/aipass/prax/.seedgo/bypass.json b/src/aipass/prax/.seedgo/bypass.json index a35312f5..fa5fd64e 100644 --- a/src/aipass/prax/.seedgo/bypass.json +++ b/src/aipass/prax/.seedgo/bypass.json @@ -497,6 +497,11 @@ "file": "apps/__init__.py", "reason": "Package marker \u2014 not a CLI entry point. --version is in apps/prax.py." }, + { + "file": "tests/test_devpulse_dashboard_plugin.py", + "standard": "architecture", + "reason": "Test file lives in tests/ by convention, not in the 3-layer apps/ structure. This is standard for all prax test files." + }, { "file": "tests/test_logging.py", "standard": "architecture", diff --git a/src/aipass/prax/apps/plugins/__init__.py b/src/aipass/prax/apps/plugins/__init__.py new file mode 100644 index 00000000..41ee34d3 --- /dev/null +++ b/src/aipass/prax/apps/plugins/__init__.py @@ -0,0 +1,9 @@ +# =================== AIPass ==================== +# Name: __init__.py +# Description: Prax plugins directory +# Version: 1.0.0 +# Created: 2026-05-16 +# Modified: 2026-05-16 +# ============================================= + +"""Prax plugins — extension point for cross-branch dashboard contributions.""" diff --git a/src/aipass/prax/apps/plugins/devpulse_dashboard/__init__.py b/src/aipass/prax/apps/plugins/devpulse_dashboard/__init__.py new file mode 100644 index 00000000..41b42c67 --- /dev/null +++ b/src/aipass/prax/apps/plugins/devpulse_dashboard/__init__.py @@ -0,0 +1,20 @@ +# =================== AIPass ==================== +# Name: __init__.py +# Description: Devpulse dashboard plugin for prax +# Version: 1.0.0 +# Created: 2026-05-16 +# Modified: 2026-05-16 +# ============================================= + +"""Devpulse dashboard plugin. + +Populates devpulse's DASHBOARD.local.json with custom sections: +git, session, dispatch (and optionally system_health). + +Each section builder gathers data and calls write_section() to persist. +refresh() orchestrates all section builders in sequence. +""" + +from .refresh import refresh + +__all__ = ["refresh"] diff --git a/src/aipass/prax/apps/plugins/devpulse_dashboard/dispatch_section.py b/src/aipass/prax/apps/plugins/devpulse_dashboard/dispatch_section.py new file mode 100644 index 00000000..3b78d12a --- /dev/null +++ b/src/aipass/prax/apps/plugins/devpulse_dashboard/dispatch_section.py @@ -0,0 +1,85 @@ +# =================== AIPass ==================== +# Name: dispatch_section.py +# Description: Dispatch status section builder for devpulse dashboard +# Version: 1.0.0 +# Created: 2026-05-16 +# Modified: 2026-05-16 +# ============================================= + +"""Dispatch section builder for devpulse dashboard plugin. + +Scans all 12 AIPass branches for .dispatch.lock files to show +which agents are currently active. Writes to dashboard via write_section(). +""" + +import json +from pathlib import Path +from typing import Dict, List + +from aipass.prax.apps.modules.dashboard import write_section +from aipass.prax.apps.modules.logger import system_logger as logger + +BRANCH_NAMES: List[str] = [ + "ai_mail", + "aipass", + "api", + "cli", + "devpulse", + "drone", + "flow", + "memory", + "prax", + "seedgo", + "spawn", + "trigger", +] + + +def _get_aipass_src(branch_path: Path) -> Path: + """Resolve the src/aipass/ root from any branch path.""" + return branch_path.resolve().parent + + +def _read_lock(lock_path: Path) -> Dict: + """Read dispatch lock file, return parsed data or empty dict.""" + try: + data = json.loads(lock_path.read_text()) + return { + "subject": data.get("subject", ""), + "started": data.get("started", ""), + } + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to read dispatch lock %s: %s", lock_path, e) + return {"subject": "unknown", "started": ""} + + +def build_dispatch_section(branch_path: Path) -> bool: + """Build dispatch section data and write to dashboard. + + Scans all 12 branches for active dispatch locks. + + Args: + branch_path: Path to devpulse branch root. + + Returns: + True if write_section succeeded, False otherwise. + """ + aipass_src = _get_aipass_src(branch_path) + + agents_active: List[str] = [] + details: Dict[str, Dict] = {} + + for name in BRANCH_NAMES: + lock_path = aipass_src / name / ".ai_mail.local" / ".dispatch.lock" + if lock_path.exists(): + agents_active.append(name) + details[name] = _read_lock(lock_path) + + section_data: Dict = { + "managed_by": "devpulse", + "agents_active": agents_active, + "agents_active_count": len(agents_active), + "details": details, + } + + return write_section(branch_path, "dispatch", section_data) diff --git a/src/aipass/prax/apps/plugins/devpulse_dashboard/git_section.py b/src/aipass/prax/apps/plugins/devpulse_dashboard/git_section.py new file mode 100644 index 00000000..81949ea2 --- /dev/null +++ b/src/aipass/prax/apps/plugins/devpulse_dashboard/git_section.py @@ -0,0 +1,90 @@ +# =================== AIPass ==================== +# Name: git_section.py +# Description: Git status section builder for devpulse dashboard +# Version: 1.0.0 +# Created: 2026-05-16 +# Modified: 2026-05-16 +# ============================================= + +"""Git section builder for devpulse dashboard plugin. + +Gathers current git state (branch, changes, commits ahead of main) +and writes to devpulse's DASHBOARD.local.json via write_section(). +""" + +from pathlib import Path +from typing import Dict +import subprocess + +from aipass.prax.apps.modules.dashboard import write_section +from aipass.prax.apps.modules.logger import system_logger as logger + + +def _find_repo_root(start: Path) -> Path: + """Walk up from start to find .git directory.""" + current = start.resolve() + while current != current.parent: + if (current / ".git").exists(): + return current + current = current.parent + raise FileNotFoundError("No .git found above " + str(start)) + + +def _run_git(repo_root: Path, *args: str) -> str: + """Run a git command and return stdout. Returns empty string on failure.""" + try: + result = subprocess.run( + ["git"] + list(args), + capture_output=True, + text=True, + cwd=str(repo_root), + timeout=10, + ) + if result.returncode == 0: + return result.stdout.strip() + return "" + except (subprocess.SubprocessError, OSError) as e: + logger.info(f"git command failed: {args} — {e}") + return "" + + +def build_git_section(branch_path: Path) -> bool: + """Build git section data and write to dashboard. + + Args: + branch_path: Path to devpulse branch root. + + Returns: + True if write_section succeeded, False otherwise. + """ + repo_root = _find_repo_root(branch_path) + + # Current branch + branch = _run_git(repo_root, "rev-parse", "--abbrev-ref", "HEAD") or "unknown" + + # Files changed (porcelain counts all modified/untracked) + status_output = _run_git(repo_root, "status", "--porcelain") + files_changed = len(status_output.splitlines()) if status_output else 0 + + # Commits ahead of origin/main + ahead_str = _run_git(repo_root, "rev-list", "--count", "origin/main..HEAD") + try: + ahead_of_main = int(ahead_str) + except (ValueError, TypeError) as e: + logger.info(f"Could not parse ahead count '{ahead_str}': {e}") + ahead_of_main = 0 + + # Last commit message and date + last_commit_msg = _run_git(repo_root, "log", "-1", "--format=%s") or "" + last_commit_date = _run_git(repo_root, "log", "-1", "--format=%cs") or "" + + section_data: Dict = { + "managed_by": "devpulse", + "branch": branch, + "files_changed": files_changed, + "ahead_of_main": ahead_of_main, + "last_commit_msg": last_commit_msg, + "last_commit_date": last_commit_date, + } + + return write_section(branch_path, "git", section_data) diff --git a/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py b/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py new file mode 100644 index 00000000..1722d809 --- /dev/null +++ b/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py @@ -0,0 +1,72 @@ +# =================== AIPass ==================== +# Name: refresh.py +# Description: Orchestrates all devpulse custom dashboard sections +# Version: 1.0.0 +# Created: 2026-05-16 +# Modified: 2026-05-16 +# ============================================= + +"""Refresh orchestrator for devpulse dashboard plugin. + +Calls each section builder in sequence. Each builder gathers its own data +and writes to DASHBOARD.local.json via prax's write_section() API. +Failures in one section don't block others. +""" + +from pathlib import Path +from typing import Dict, List + +from aipass.prax.apps.modules.logger import system_logger as logger + + +# Devpulse branch path — resolved relative to this file's location +_AIPASS_SRC = Path(__file__).resolve().parents[4] # .../src/aipass/ +DEVPULSE_PATH = _AIPASS_SRC / "devpulse" + + +def refresh(branch_path: Path = None) -> Dict: + """Refresh all devpulse custom dashboard sections. + + Args: + branch_path: Override path to devpulse branch root. + Defaults to auto-resolved path. + + Returns: + Dict with results per section: {section_name: {"success": bool, "error": str|None}} + """ + target = branch_path or DEVPULSE_PATH + results: Dict[str, Dict] = {} + + builders: List[tuple] = [ + ("git", _refresh_git), + ("session", _refresh_session), + ("dispatch", _refresh_dispatch), + ] + + for name, builder_fn in builders: + try: + builder_fn(target) + results[name] = {"success": True, "error": None} + except Exception as e: + logger.error("Failed to refresh section '%s': %s", name, e) + results[name] = {"success": False, "error": str(e)} + + return results + + +def _refresh_git(branch_path: Path) -> None: + from .git_section import build_git_section + + build_git_section(branch_path) + + +def _refresh_session(branch_path: Path) -> None: + from .session_section import build_session_section + + build_session_section(branch_path) + + +def _refresh_dispatch(branch_path: Path) -> None: + from .dispatch_section import build_dispatch_section + + build_dispatch_section(branch_path) diff --git a/src/aipass/prax/apps/plugins/devpulse_dashboard/session_section.py b/src/aipass/prax/apps/plugins/devpulse_dashboard/session_section.py new file mode 100644 index 00000000..3c8f914c --- /dev/null +++ b/src/aipass/prax/apps/plugins/devpulse_dashboard/session_section.py @@ -0,0 +1,86 @@ +# =================== AIPass ==================== +# Name: session_section.py +# Description: Session info section builder for devpulse dashboard +# Version: 1.0.0 +# Created: 2026-05-16 +# Modified: 2026-05-16 +# ============================================= + +"""Session section builder for devpulse dashboard plugin. + +Reads .trinity/local.json to extract current session ID, date, +active tasks, and last session summary. Writes to dashboard +via write_section(). +""" + +import json +from pathlib import Path +from typing import Dict + +from aipass.prax.apps.modules.dashboard import write_section +from aipass.prax.apps.modules.logger import system_logger as logger + + +def build_session_section(branch_path: Path) -> bool: + """Build session section data and write to dashboard. + + Args: + branch_path: Path to devpulse branch root. + + Returns: + True if write_section succeeded, False otherwise. + """ + local_json_path = branch_path / ".trinity" / "local.json" + + if not local_json_path.exists(): + section_data: Dict = { + "managed_by": "devpulse", + "current_session": "unknown", + "session_date": "", + "today_focus": "", + "active_tasks": [], + "last_session_summary": "", + } + return write_section(branch_path, "session", section_data) + + try: + data = json.loads(local_json_path.read_text()) + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Failed to read local.json at %s: %s", local_json_path, exc) + section_data = { + "managed_by": "devpulse", + "current_session": "error", + "session_date": "", + "today_focus": "", + "active_tasks": [], + "last_session_summary": "Failed to read local.json", + } + return write_section(branch_path, "session", section_data) + + # Extract latest session (first in list = newest) + sessions = data.get("sessions", []) + current_session = "" + session_date = "" + last_session_summary = "" + + if sessions: + latest = sessions[0] + current_session = latest.get("id", "") + session_date = latest.get("d", "") + last_session_summary = latest.get("sum", "") + + # Extract active tasks + active_tasks_data = data.get("active_tasks", {}) + today_focus = active_tasks_data.get("today_focus", "") + pending = active_tasks_data.get("pending", []) + + section_data = { + "managed_by": "devpulse", + "current_session": current_session, + "session_date": session_date, + "today_focus": today_focus, + "active_tasks": pending, + "last_session_summary": last_session_summary, + } + + return write_section(branch_path, "session", section_data) diff --git a/src/aipass/prax/tests/test_devpulse_dashboard_plugin.py b/src/aipass/prax/tests/test_devpulse_dashboard_plugin.py new file mode 100644 index 00000000..4ccbc5c0 --- /dev/null +++ b/src/aipass/prax/tests/test_devpulse_dashboard_plugin.py @@ -0,0 +1,259 @@ +# =================== AIPass ==================== +# Name: test_devpulse_dashboard_plugin.py +# Description: Tests for devpulse dashboard plugin +# Version: 1.0.0 +# Created: 2026-05-16 +# Modified: 2026-05-16 +# ============================================= + +"""Tests for devpulse dashboard plugin (git, session, dispatch sections + refresh).""" + +import json +from unittest.mock import patch, MagicMock + +import pytest + + +def _git_subprocess_responses(cmd, **kwargs): + """Mock subprocess.run responses for git commands.""" + result = MagicMock() + result.returncode = 0 + args = cmd[1:] # strip 'git' + responses = { + ("rev-parse", "--abbrev-ref", "HEAD"): "dev\n", + ("status", "--porcelain"): " M file1.py\n M file2.py\n", + ("rev-list", "--count", "origin/main..HEAD"): "5\n", + ("log", "-1", "--format=%s"): "feat: add dashboard plugin\n", + ("log", "-1", "--format=%cs"): "2026-05-16\n", + } + result.stdout = responses.get(tuple(args), "") + return result + + +@pytest.fixture +def branch_path(tmp_path): + """Create a minimal branch directory structure.""" + (tmp_path / "DASHBOARD.local.json").write_text( + json.dumps( + { + "_warning": "AUTO-GENERATED", + "branch": "DEVPULSE", + "last_updated": "", + "quick_status": {"action_required": False}, + "sections": {}, + } + ) + ) + return tmp_path + + +@pytest.fixture +def branch_with_trinity(branch_path): + """Branch path with .trinity/local.json.""" + trinity = branch_path / ".trinity" + trinity.mkdir() + local_data = { + "sessions": [{"id": "S162", "d": "2026-05-16", "sum": "Test session summary", "st": "+"}], + "active_tasks": { + "today_focus": "Testing the plugin", + "pending": ["Task 1", "Task 2"], + "recently_completed": [], + }, + } + (trinity / "local.json").write_text(json.dumps(local_data)) + return branch_path + + +@pytest.fixture +def branch_with_git(branch_path): + """Branch path inside a fake git repo.""" + (branch_path / ".git").mkdir() + return branch_path + + +class TestGitSection: + """Tests for git_section.py.""" + + @patch("aipass.prax.apps.plugins.devpulse_dashboard.git_section.subprocess.run") + def test_build_git_section_success(self, mock_run, branch_with_git): + """Test successful git section build with mocked subprocess.""" + from aipass.prax.apps.plugins.devpulse_dashboard.git_section import build_git_section + + mock_run.side_effect = _git_subprocess_responses + result = build_git_section(branch_with_git) + assert result is True + + # Verify dashboard was written + dash = json.loads((branch_with_git / "DASHBOARD.local.json").read_text()) + git = dash["sections"]["git"] + assert git["managed_by"] == "devpulse" + assert git["branch"] == "dev" + assert git["files_changed"] == 2 + assert git["ahead_of_main"] == 5 + assert git["last_commit_msg"] == "feat: add dashboard plugin" + assert git["last_commit_date"] == "2026-05-16" + + @patch("aipass.prax.apps.plugins.devpulse_dashboard.git_section.subprocess.run") + def test_build_git_section_subprocess_failure(self, mock_run, branch_with_git): + """Test git section handles subprocess failures gracefully.""" + from aipass.prax.apps.plugins.devpulse_dashboard.git_section import build_git_section + + mock_run.side_effect = OSError("git not found") + # Should not raise — handles errors internally + result = build_git_section(branch_with_git) + # Still writes section with empty/default values + assert result is True + + @patch("aipass.prax.apps.plugins.devpulse_dashboard.git_section._find_repo_root") + def test_build_git_section_no_git_dir(self, mock_find_root, branch_path): + """Test git section when no .git directory exists.""" + from aipass.prax.apps.plugins.devpulse_dashboard.git_section import build_git_section + + mock_find_root.side_effect = FileNotFoundError("No .git found above " + str(branch_path)) + with pytest.raises(FileNotFoundError): + build_git_section(branch_path) + + +class TestSessionSection: + """Tests for session_section.py.""" + + def test_build_session_section_success(self, branch_with_trinity): + """Test session section reads local.json correctly.""" + from aipass.prax.apps.plugins.devpulse_dashboard.session_section import build_session_section + + result = build_session_section(branch_with_trinity) + assert result is True + + dash = json.loads((branch_with_trinity / "DASHBOARD.local.json").read_text()) + session = dash["sections"]["session"] + assert session["managed_by"] == "devpulse" + assert session["current_session"] == "S162" + assert session["session_date"] == "2026-05-16" + assert session["today_focus"] == "Testing the plugin" + assert session["active_tasks"] == ["Task 1", "Task 2"] + assert session["last_session_summary"] == "Test session summary" + + def test_build_session_section_no_local_json(self, branch_path): + """Test session section when local.json doesn't exist.""" + from aipass.prax.apps.plugins.devpulse_dashboard.session_section import build_session_section + + result = build_session_section(branch_path) + assert result is True + + dash = json.loads((branch_path / "DASHBOARD.local.json").read_text()) + session = dash["sections"]["session"] + assert session["current_session"] == "unknown" + + def test_build_session_section_corrupt_json(self, branch_path): + """Test session section handles corrupt local.json.""" + from aipass.prax.apps.plugins.devpulse_dashboard.session_section import build_session_section + + trinity = branch_path / ".trinity" + trinity.mkdir() + (trinity / "local.json").write_text("not valid json{{{") + + result = build_session_section(branch_path) + assert result is True + + dash = json.loads((branch_path / "DASHBOARD.local.json").read_text()) + session = dash["sections"]["session"] + assert session["current_session"] == "error" + + +class TestDispatchSection: + """Tests for dispatch_section.py.""" + + def test_build_dispatch_section_no_locks(self, branch_path): + """Test dispatch section when no agents are active.""" + from aipass.prax.apps.plugins.devpulse_dashboard.dispatch_section import build_dispatch_section + + # Create sibling branch dirs (dispatch scans parent for all branches) + parent = branch_path.parent + for name in ["ai_mail", "prax", "seedgo"]: + (parent / name / ".ai_mail.local").mkdir(parents=True, exist_ok=True) + + result = build_dispatch_section(branch_path) + assert result is True + + dash = json.loads((branch_path / "DASHBOARD.local.json").read_text()) + dispatch = dash["sections"]["dispatch"] + assert dispatch["managed_by"] == "devpulse" + assert dispatch["agents_active"] == [] + assert dispatch["agents_active_count"] == 0 + + def test_build_dispatch_section_with_locks(self, branch_path): + """Test dispatch section detects active dispatch locks.""" + from aipass.prax.apps.plugins.devpulse_dashboard.dispatch_section import build_dispatch_section + + parent = branch_path.parent + # Create lock for "prax" + prax_mail = parent / "prax" / ".ai_mail.local" + prax_mail.mkdir(parents=True, exist_ok=True) + lock_data = {"pid": 12345, "subject": "Build dashboard view", "started": "2026-05-16T14:00:00"} + (prax_mail / ".dispatch.lock").write_text(json.dumps(lock_data)) + + result = build_dispatch_section(branch_path) + assert result is True + + dash = json.loads((branch_path / "DASHBOARD.local.json").read_text()) + dispatch = dash["sections"]["dispatch"] + assert "prax" in dispatch["agents_active"] + assert dispatch["agents_active_count"] == 1 + assert dispatch["details"]["prax"]["subject"] == "Build dashboard view" + + def test_build_dispatch_section_corrupt_lock(self, branch_path): + """Test dispatch section handles corrupt lock files.""" + from aipass.prax.apps.plugins.devpulse_dashboard.dispatch_section import build_dispatch_section + + parent = branch_path.parent + seedgo_mail = parent / "seedgo" / ".ai_mail.local" + seedgo_mail.mkdir(parents=True, exist_ok=True) + (seedgo_mail / ".dispatch.lock").write_text("broken json{{{") + + result = build_dispatch_section(branch_path) + assert result is True + + dash = json.loads((branch_path / "DASHBOARD.local.json").read_text()) + dispatch = dash["sections"]["dispatch"] + assert "seedgo" in dispatch["agents_active"] + assert dispatch["details"]["seedgo"]["subject"] == "unknown" + + +class TestRefresh: + """Tests for refresh.py orchestrator.""" + + @patch("aipass.prax.apps.plugins.devpulse_dashboard.refresh._refresh_git") + @patch("aipass.prax.apps.plugins.devpulse_dashboard.refresh._refresh_session") + @patch("aipass.prax.apps.plugins.devpulse_dashboard.refresh._refresh_dispatch") + def test_refresh_all_success(self, mock_dispatch, mock_session, mock_git, branch_path): + """Test refresh orchestrator calls all builders.""" + from aipass.prax.apps.plugins.devpulse_dashboard.refresh import refresh + + results = refresh(branch_path) + assert results["git"]["success"] is True + assert results["session"]["success"] is True + assert results["dispatch"]["success"] is True + mock_git.assert_called_once_with(branch_path) + mock_session.assert_called_once_with(branch_path) + mock_dispatch.assert_called_once_with(branch_path) + + @patch("aipass.prax.apps.plugins.devpulse_dashboard.refresh._refresh_git") + @patch("aipass.prax.apps.plugins.devpulse_dashboard.refresh._refresh_session") + @patch("aipass.prax.apps.plugins.devpulse_dashboard.refresh._refresh_dispatch") + def test_refresh_partial_failure(self, mock_dispatch, mock_session, mock_git, branch_path): + """Test refresh continues when one section fails.""" + from aipass.prax.apps.plugins.devpulse_dashboard.refresh import refresh + + mock_git.side_effect = FileNotFoundError("No .git") + results = refresh(branch_path) + assert results["git"]["success"] is False + assert "No .git" in results["git"]["error"] + assert results["session"]["success"] is True + assert results["dispatch"]["success"] is True + + def test_refresh_default_path(self): + """Test refresh uses DEVPULSE_PATH by default.""" + from aipass.prax.apps.plugins.devpulse_dashboard.refresh import DEVPULSE_PATH + + assert DEVPULSE_PATH.name == "devpulse" + assert "src/aipass/devpulse" in str(DEVPULSE_PATH)