diff --git a/src/aipass/devpulse/.claude/commands/prep.md b/src/aipass/devpulse/.claude/commands/prep.md deleted file mode 100644 index 4a56e0a3..00000000 --- a/src/aipass/devpulse/.claude/commands/prep.md +++ /dev/null @@ -1,57 +0,0 @@ -# Session Wrap-Up - -Purpose: Button up everything at the end of a session — or before a /compact. Memories, plans, git — all tidy. Works for both closing out a chat and preparing for compaction. - -**Workflow:** `/prep` → review output → close chat or `/compact` - -## Execution - -1. Read `.trinity/passport.json` first — re-absorb your identity before writing anything -2. Do ALL of the following, then confirm what was updated - -## 1. Memories - -Each memory file plays a distinct role. Update based on what actually changed this session. - -- **`.trinity/passport.json`** — IDENTITY. Who you are: role, capabilities, principles. Only update if identity genuinely evolved this session. -- **`.trinity/local.json`** — YOUR MEMORY. Add/update session entry with a summary of work done. Add key_learnings for anything learned. Trim oldest sessions if over 20. -- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points. Skip if nothing new about the user this session. -- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known issues, todos, notepad. Auto-synced to central STATUS.md on PR events — this is how other branches see you. Keep Current Work accurate. - -## 2. Active Plans - -- Check any DPLANs or FPLANs referenced in this session -- Update their execution logs, status, decision logs with current state -- If a plan was completed, note it (but don't close — the user does that) - -## 3. Git State - -- Run `git status` — report uncommitted changes -- If there's a logical commit waiting, suggest it (don't commit without asking) -- Note the current branch and any open PRs - -## 4. Inbox - -- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails -- Close any that were already processed but not formally closed - -## 5. Loose Ends - -- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions -- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to STATUS.local.md Notepad - -## Confirm - -List everything updated. Format: -``` -Prep complete: -- local.json: [what was added] -- observations.json: [updated / skipped] -- STATUS.local.md: [updated / skipped] -- Plans: [which ones updated] -- Git: [branch, uncommitted count, suggestion] -- Inbox: [count, action taken] -- Loose ends: [any flagged] - -Ready to close out or /compact. -``` diff --git a/src/aipass/devpulse/.claude/hooks/README.md b/src/aipass/devpulse/.claude/hooks/README.md deleted file mode 100644 index 5f14a0c9..00000000 --- a/src/aipass/devpulse/.claude/hooks/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Project-Level Hooks - -These hooks are provisioned by `aipass init` and live in the project's -`.claude/settings.json`. They fire when CWD is inside this project. - -## What fires and what doesn't - -**UserPromptSubmit** hooks fire from project settings. These work: -- `branch_prompt_loader.py` — injects branch-specific prompt -- `email_notification.py` — shows unread email count -- `identity_injector.py` — injects branch identity from passport - -**PreToolUse / PostToolUse** hooks are provisioned but **DO NOT FIRE** from -project-level settings. This is a Claude Code limitation (confirmed S122, -GitHub issue #36071). These scripts exist but are dead weight: -- `pre_edit_gate.py` — intended to block cross-branch writes (never runs) -- `auto_fix_diagnostics.py` — intended to run pyright+ruff (never runs) -- `subagent_stop_gate.py` — intended to check subagent files (never runs) - -These same hooks DO fire from provider settings (`~/.claude/settings.json`) -where they are also wired. The provider copies handle all enforcement. - -**PreCompact** hooks fire from project settings: -- `pre_compact.py` — injects recovery context after compaction - -## CWD guard interaction - -When this project has UserPromptSubmit hooks (it does), the provider-level -UserPromptSubmit hooks detect this and exit silently. This prevents the AIPass -global prompt from being injected into projects that manage their own context. - -The provider-level PreToolUse/PostToolUse hooks still fire (they can only run -at provider level) — so enforcement (git_gate, pre_edit_gate, auto_fix) is -always active regardless of CWD. - -## Testing - -Provider-level test harness covers project-level behavior: -```bash -python3 $AIPASS_HOME/.claude/hooks/hook_test.py --direct -``` - -Tests include: -- `direct_provider_guards_for_init_project` — verifies provider hooks are - CWD-guarded when run from an aipass init project -- `direct_project_settings_schema` — validates project settings.json has - expected hooks and all referenced scripts exist - -## Updating hooks - -```bash -drone @cli aipass init update # Refresh managed project files to latest templates -``` - -## Related -See `$AIPASS_HOME/.claude/hooks/README.md` for the full hook system documentation. diff --git a/src/aipass/devpulse/.claude/hooks/auto_fix_diagnostics.py b/src/aipass/devpulse/.claude/hooks/auto_fix_diagnostics.py deleted file mode 100644 index 413ef3e4..00000000 --- a/src/aipass/devpulse/.claude/hooks/auto_fix_diagnostics.py +++ /dev/null @@ -1,366 +0,0 @@ -#!/usr/bin/env python3 -""" -PostToolUse Auto-fix Hook — Detects errors and surfaces them for fixing. - -Two-hook system: - PostToolUse (this file) → runs pyright + ruff on edited file, saves errors to state - PreToolUse (pre_edit_gate.py) → blocks edits to OTHER files until errors fixed - -Key behaviors: -- Runs py_compile (syntax), ruff lint+format, pyright (type errors) on edited file -- Runs seedgo checklist for AIPass standards -- Saves ruff lint AND pyright errors to state file for PreToolUse gate (hard block) -- Surfaces ALL errors in additionalContext so Claude sees them - -Version: 5.2.0 - -CHANGELOG: - - v5.2.0 (2026-04-20): Save ruff lint errors to state file for hard-block enforcement. - Pre-edit gate now blocks on F401/lint just like type errors. - - v5.1.0 (2026-04-19): Added ruff format --check to surface format drift. - - v5.0.0 (2026-03-17): Replaced mcp__ide__getDiagnostics with direct pyright. - Added state file for PreToolUse gate integration. - Single-file pyright (not whole project). - - v4.3.0 (2026-03-17): Added seedgo checklist integration - - v4.0.0 (2025-11-27): Complete rewrite - actual validation, silent operation -""" - -import json -import sys -import subprocess -from pathlib import Path - -EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"] -LAST_FILE_PATH = Path(__file__).parent / ".last_diagnostics_file" -STATE_FILE = Path(__file__).parent / ".diagnostics_state.json" -SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"} - -# AIPass-specific Python patterns to check -PYTHON_PATTERNS = { - "bad_optional": {"pattern": ": str = None", "message": "Optional param should use 'str | None = None' pattern"}, - "logger_debug": { - "pattern": "logger.debug(", - "message": "Use logger.info for SystemLogger (logger.debug not supported)", - }, - "return_error_msg": { - "pattern": "return error_msg", - "message": "Return None for error states, not error_msg string", - }, - "open_no_encoding": { - "pattern": "open(", - "requires_missing": "encoding=", - "message": "open() without encoding='utf-8'", - }, - "log_not_log_operation": { - "pattern": ".log(", - "message": "Use log_operation() with success/error params, not .log()", - }, - "dict_none_no_check": { - "pattern": "Dict | None", - "message": "Dict | None return: Add None check before using (if result is None: return)", - }, -} - -# JSON-specific patterns for emoji corruption -JSON_CORRUPTION_CHARS = ["\ufffd", "\x00"] - - -def run_python_checks(file_path: str) -> list[str]: - """Run actual Python validation - returns list of errors.""" - errors = [] - - # 1. Syntax check with py_compile - try: - result = subprocess.run( - [sys.executable, "-m", "py_compile", file_path], capture_output=True, text=True, timeout=5 - ) - if result.returncode != 0: - errors.append(f"SYNTAX: {result.stderr.strip()}") - except Exception: - pass - - # 2. Ruff check (if available) - fast linter - try: - result = subprocess.run( - ["ruff", "check", "--select=E,F,W", "--output-format=text", file_path], - capture_output=True, - text=True, - timeout=10, - ) - if result.stdout.strip(): - for line in result.stdout.strip().split("\n")[:5]: - errors.append(f"LINT: {line}") - except FileNotFoundError: - pass - except Exception: - pass - - # 3. Ruff format check — detect format drift - try: - result = subprocess.run(["ruff", "format", "--check", file_path], capture_output=True, text=True, timeout=10) - if result.returncode != 0: - errors.append(f"FORMAT: {Path(file_path).name} needs ruff format (run: ruff format {Path(file_path).name})") - except FileNotFoundError: - pass - except Exception: - pass - - # 4. AIPass-specific pattern checks - try: - content = Path(file_path).read_text(encoding="utf-8") - lines = content.split("\n") - - for check in PYTHON_PATTERNS.values(): - pattern = check["pattern"] - message = check["message"] - requires_missing = check.get("requires_missing") - - if requires_missing: - if pattern in content and requires_missing not in content: - errors.append(f"PATTERN: {message}") - continue - - for line in lines: - stripped = line.strip() - if stripped.startswith(("#", '"', "'")): - continue - if f'"{pattern}' in line or f"'{pattern}" in line: - continue - if pattern in line: - errors.append(f"PATTERN: {message}") - break - except Exception: - pass - - return errors - - -def run_ruff_lint_structured(file_path: str) -> list[dict]: - """Run ruff check and return structured violations for the state file. - - Returns list of {line, message} dicts — same format as pyright errors. - Only non-empty when ruff finds real violations (not format drift). - """ - if "/.claude/hooks/" in file_path: - return [] - try: - result = subprocess.run( - ["ruff", "check", "--select=E,F,W", "--output-format=json", file_path], - capture_output=True, - text=True, - timeout=10, - ) - if not result.stdout.strip(): - return [] - violations = json.loads(result.stdout) - if not isinstance(violations, list): - return [] - errors = [] - for v in violations[:10]: - line = v.get("location", {}).get("row", 0) - code = v.get("code", "?") - message = v.get("message", "unknown")[:100] - errors.append({"line": line, "message": f"{code}: {message}"}) - return errors - except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired, Exception): - return [] - - -def run_pyright_check(file_path: str) -> list[dict]: - """Run pyright on a single file. Returns list of error dicts.""" - # Skip hook files - they don't follow project standards - if "/.claude/hooks/" in file_path: - return [] - - try: - result = subprocess.run( - [sys.executable, "-m", "pyright", "--outputjson", file_path], capture_output=True, text=True, timeout=15 - ) - - try: - data = json.loads(result.stdout) - except (json.JSONDecodeError, ValueError): - return [] - - errors = [] - for diag in data.get("generalDiagnostics", []): - severity = diag.get("severity", "") - if severity == "error": - line = diag.get("range", {}).get("start", {}).get("line", 0) - message = diag.get("message", "Unknown error") - errors.append({"line": line, "message": message[:100]}) - - return errors[:10] # Max 10 errors - - except FileNotFoundError: - return [] # pyright not installed - except subprocess.TimeoutExpired: - return [] # Timeout — don't block - except Exception: - return [] - - -def save_diagnostics_state(file_path: str, errors: list[dict]): - """Save type errors to state file for PreToolUse gate.""" - try: - if errors: - state = {"file": str(Path(file_path).resolve()), "errors": errors} - STATE_FILE.write_text(json.dumps(state), encoding="utf-8") - else: - # No errors — clear the state - if STATE_FILE.exists(): - STATE_FILE.unlink() - except Exception: - pass - - -def run_json_checks(file_path: str) -> list[str]: - """Run actual JSON validation - returns list of errors.""" - errors = [] - - try: - content = Path(file_path).read_text(encoding="utf-8") - - for char in JSON_CORRUPTION_CHARS: - if char in content: - errors.append(f"EMOJI CORRUPTION: Found corrupted character '{repr(char)}'") - break - - try: - data = json.loads(content) - - if isinstance(data, dict): - for key in ["allowed_emojis", "emojis", "emoji_list"]: - if key in data and isinstance(data[key], list): - for item in data[key]: - if isinstance(item, str) and len(item) == 1: - if ord(item) < 128 and item not in "\u2713\u2717": - errors.append(f"EMOJI CORRUPTION: Suspicious char '{item}' in {key}") - break - - except json.JSONDecodeError as e: - errors.append(f"JSON SYNTAX: {e.msg} at line {e.lineno}") - - except Exception as e: - errors.append(f"READ ERROR: {e!s}") - - return errors - - -def run_seedgo_checklist(file_path: str) -> list[str]: - """Run seedgo standards checklist — returns violations only.""" - if "/.claude/hooks/" in file_path: - return [] - - try: - result = subprocess.run( - ["drone", "@seedgo", "checklist", file_path], - capture_output=True, - text=True, - timeout=15, - cwd=str(Path.home() / "Projects" / "AIPass"), - ) - - if result.returncode != 0: - return [] - - violations = [] - for line in result.stdout.split("\n"): - line = line.strip() - if line.startswith("\u2717"): - violation = line[1:].strip() - if violation: - violations.append(violation) - - return violations[:5] - - except FileNotFoundError: - return [] - except Exception: - return [] - - -def should_skip_file(file_path: str) -> bool: - """Check if file should be skipped.""" - if not file_path: - return True - ext = Path(file_path).suffix.lower() - return ext in SKIP_EXTENSIONS - - -def is_same_file_as_last(file_path: str) -> bool: - """Smart batching DISABLED — always recheck. - - Previously skipped rechecks on the same file, but this caused - errors introduced on second edit to be missed (state file didn't - exist from first clean edit, so skip triggered). The 1.7s pyright - cost per edit is acceptable for correctness. - """ - return False - - -def main(): - """Main hook entry point.""" - try: - input_data = json.load(sys.stdin) - tool_name = input_data.get("tool_name", "") - tool_input = input_data.get("tool_input", {}) - file_path = tool_input.get("file_path", "") - - if tool_name not in EDIT_TOOLS: - return - - if should_skip_file(file_path): - return - - if is_same_file_as_last(file_path): - return - - # Collect all errors - errors = [] - - if file_path.endswith(".py"): - errors = run_python_checks(file_path) - - # Seedgo standards checklist - seedgo_violations = run_seedgo_checklist(file_path) - for v in seedgo_violations: - errors.append(f"SEEDGO: {v}") - - # Pyright type errors (single file) - type_errors = run_pyright_check(file_path) - for te in type_errors: - errors.append(f"TYPE: L{te['line']}: {te['message']}") - - # Save ruff lint + type errors to state file for PreToolUse gate (hard block) - ruff_lint_errors = run_ruff_lint_structured(file_path) - save_diagnostics_state(file_path, ruff_lint_errors + type_errors) - - elif file_path.endswith(".json"): - errors = run_json_checks(file_path) - else: - return - - # Build output - if errors: - error_text = "\n".join(f" - {e}" for e in errors) - context = f"""[AUTO-FIX] {len(errors)} error(s) in {Path(file_path).name}: -{error_text} - -Fix these errors in {Path(file_path).name} now. Do not skip or defer.""" - - output = { - "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": context}, - "systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing", - } - print(json.dumps(output)) - else: - output = {"systemMessage": "[diagnostics] ok"} - print(json.dumps(output)) - - except Exception: - pass # Silent fail - - -if __name__ == "__main__": - main() diff --git a/src/aipass/devpulse/.claude/hooks/branch_prompt_loader.py b/src/aipass/devpulse/.claude/hooks/branch_prompt_loader.py deleted file mode 100644 index a38d03f9..00000000 --- a/src/aipass/devpulse/.claude/hooks/branch_prompt_loader.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -""" -Branch Prompt Loader — AIPass Public Repo - -Injects branch-specific prompts based on CWD. When working in a branch -directory, loads .aipass/aipass_local_prompt.md and outputs it so the -AI sees branch-specific context. - -Version: 1.0.0 -""" - -from pathlib import Path - - -def find_branch_root() -> Path | None: - """ - Find the branch root directory. - Looks for .trinity/ or .aipass/ as branch indicators. - Stops at the repo root (has pyproject.toml or .git). - """ - cwd = Path.cwd() - search_path = cwd - - while search_path.parent != search_path: - # Branch indicators: has .trinity/ (memory files) or apps/ (code) - has_trinity = (search_path / ".trinity").is_dir() - has_apps = (search_path / "apps").is_dir() - - if has_trinity or has_apps: - return search_path - - # Stop at repo root - if (search_path / "pyproject.toml").exists() or (search_path / ".git").is_dir(): - return None - - search_path = search_path.parent - - return None - - -def main(): - branch_root = find_branch_root() - - if branch_root: - prompt_file = branch_root / ".aipass" / "aipass_local_prompt.md" - if prompt_file.exists(): - content = prompt_file.read_text().strip() - branch_name = branch_root.name.upper() - print(f"\n# Branch Context: {branch_name}\n\n{content}") - - -if __name__ == "__main__": - main() diff --git a/src/aipass/devpulse/.claude/hooks/email_notification.py b/src/aipass/devpulse/.claude/hooks/email_notification.py deleted file mode 100644 index 401663cd..00000000 --- a/src/aipass/devpulse/.claude/hooks/email_notification.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -""" -Email Notification Hook - Notifies of new emails on prompt submit. - -Checks the current branch's inbox for unread emails and displays -a notification if any exist. - -Version: 1.0.0 -""" - -import json -from pathlib import Path - - -def find_repo_root() -> Path | None: - """Find the repo root (contains pyproject.toml or .git).""" - search = Path.cwd() - while search.parent != search: - if (search / "pyproject.toml").exists() or (search / ".git").is_dir(): - return search - search = search.parent - return None - - -def find_branch_root() -> Path | None: - """Find the branch root directory by walking up from CWD.""" - cwd = Path.cwd() - repo_root = find_repo_root() - if not repo_root: - return None - - search_path = cwd - for _ in range(10): - has_trinity = (search_path / ".trinity").is_dir() - has_id = list(search_path.glob("*.id.json")) - has_apps = (search_path / "apps").is_dir() - has_mail = (search_path / ".ai_mail.local").is_dir() or (search_path / "ai_mail.local").is_dir() - - if (has_trinity or has_id or has_apps or has_mail) and search_path != repo_root: - return search_path - - if search_path == repo_root: - break - - parent = search_path.parent - if parent == search_path: - break - search_path = parent - - return None - - -def count_new_emails(branch_root: Path) -> int: - """Count new (unread) emails in the branch's inbox.""" - # Check both patterns: .ai_mail.local (canonical) and ai_mail.local (legacy) - inbox_path = branch_root / ".ai_mail.local" / "inbox.json" - if not inbox_path.exists(): - inbox_path = branch_root / "ai_mail.local" / "inbox.json" - - if not inbox_path.exists(): - return 0 - - try: - with open(inbox_path, "r", encoding="utf-8") as f: - data = json.load(f) - - # Handle both formats: {"messages": [...]} and bare [...] - messages = data if isinstance(data, list) else data.get("messages", []) - count = 0 - for msg in messages: - if msg.get("status") == "new": - count += 1 - elif msg.get("status") is None and not msg.get("read", False): - count += 1 - - return count - - except (json.JSONDecodeError, OSError): - return 0 - - -def main(): - branch_root = find_branch_root() - if not branch_root: - return - - new_count = count_new_emails(branch_root) - if new_count > 0: - plural = "s" if new_count != 1 else "" - print( - f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view | close with: drone @ai_mail close " - ) - - -if __name__ == "__main__": - main() diff --git a/src/aipass/devpulse/.claude/hooks/identity_injector.py b/src/aipass/devpulse/.claude/hooks/identity_injector.py deleted file mode 100644 index 4eaa4463..00000000 --- a/src/aipass/devpulse/.claude/hooks/identity_injector.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -""" -Identity Injector - Injects branch identity on every prompt. - -Reads from [BRANCH].id.json and outputs core identity fields. -Finds the branch root by walking up from CWD looking for apps/ or *.id.json. - -Version: 1.0.0 -""" - -import json -from pathlib import Path - - -def find_repo_root() -> Path | None: - """Find the repo root (contains pyproject.toml or .git).""" - search = Path.cwd() - while search.parent != search: - if (search / "pyproject.toml").exists() or (search / ".git").is_dir(): - return search - search = search.parent - return None - - -def find_branch_root() -> Path | None: - """Find the branch root directory by walking up from CWD.""" - cwd = Path.cwd() - repo_root = find_repo_root() - if not repo_root: - return None - - search_path = cwd - while search_path >= repo_root: - has_trinity = (search_path / ".trinity").is_dir() - has_id = list(search_path.glob("*.id.json")) - - if has_trinity or has_id: - return search_path - - if search_path == repo_root: - break - search_path = search_path.parent - - return None - - -def find_id_file(branch_root: Path) -> Path | None: - """Find the identity file for a branch (.trinity/passport.json or *.id.json).""" - # AIPass pattern: .trinity/passport.json - passport = branch_root / ".trinity" / "passport.json" - if passport.exists(): - return passport - # Dev-Pass fallback: *.id.json - id_files = list(branch_root.glob("*.id.json")) - if id_files: - return id_files[0] - return None - - -def format_identity(data: dict) -> str: - """Format branch_info + identity for injection.""" - lines = [] - - # Try branch_info first (enriched passports), fall back to identity block (setup.sh passports) - branch = data.get("branch_info", {}) - identity = data.get("identity", {}) - name = branch.get("branch_name") or identity.get("name", "UNKNOWN") - lines.append(f"# {name} Identity") - lines.append(f"Path: {branch.get('path', 'unknown')}") - lines.append(f"Email: {branch.get('email', 'unknown')}") - - identity = data.get("identity", {}) - if identity.get("role"): - lines.append(f"Role: {identity['role']}") - traits = identity.get("traits") or data.get("traits") - if traits: - if isinstance(traits, list): - lines.append("Traits: " + " | ".join(traits)) - else: - lines.append(f"Traits: {traits}") - if identity.get("purpose"): - lines.append(f"Purpose: {identity['purpose']}") - - what_i_do = identity.get("what_i_do", []) - if what_i_do: - lines.append("Do: " + " | ".join(what_i_do[:4])) - - what_i_dont_do = identity.get("what_i_dont_do", []) - if what_i_dont_do: - lines.append("Don't: " + " | ".join(what_i_dont_do[:3])) - - principles = data.get("principles", []) - if principles: - lines.append("Principles: " + " * ".join(principles)) - - return "\n".join(lines) - - -def main(): - branch_root = find_branch_root() - if not branch_root: - return - - id_file = find_id_file(branch_root) - if not id_file or not id_file.exists(): - return - - try: - data = json.loads(id_file.read_text(encoding="utf-8")) - output = format_identity(data) - if output: - print(f"\n{output}") - except (json.JSONDecodeError, KeyError): - pass - - -if __name__ == "__main__": - main() diff --git a/src/aipass/devpulse/.claude/hooks/pre_compact.py b/src/aipass/devpulse/.claude/hooks/pre_compact.py deleted file mode 100644 index 717ebb09..00000000 --- a/src/aipass/devpulse/.claude/hooks/pre_compact.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python3 -""" -Pre-Compact Hook - Inject live state for post-compact recovery. - -Reads STATUS.local.md, last session from local.json, and git branch -to give the model real context after compaction — not generic advice. - -Version: 3.0.0 -""" - -import json -import subprocess -import sys -from pathlib import Path - - -def _find_branch_dir(): - """Find the current branch directory from CWD.""" - cwd = Path.cwd() - - # Check if we're in a branch dir or subdirectory of one - # Pattern: .../src/aipass/{branch}/... - parts = cwd.parts - for i, part in enumerate(parts): - if part == "aipass" and i > 0 and parts[i - 1] == "src": - branch_dir = Path(*parts[: i + 2]) - if branch_dir.is_dir(): - return branch_dir - - # Check if CWD itself has .trinity/ - if (cwd / ".trinity").is_dir(): - return cwd - - return None - - -def _read_status_local(branch_dir): - """Read STATUS.local.md if it exists.""" - for name in ["STATUS.local.md", "dev.local.md"]: - path = branch_dir / name - if path.is_file(): - try: - return path.read_text(encoding="utf-8")[:3000] - except Exception: - pass - return None - - -def _read_last_session(branch_dir): - """Read the most recent session and key_learnings from local.json.""" - local_path = branch_dir / ".trinity" / "local.json" - if not local_path.is_file(): - return None - - try: - data = json.loads(local_path.read_text(encoding="utf-8")) - result = [] - - # Last session - sessions = data.get("sessions", []) - if sessions: - last = sessions[0] - result.append( - f"Last session (#{last.get('session_number', '?')}, " - f"{last.get('date', '?')}): {last.get('summary', 'no summary')}" - ) - - # Key learnings (just the keys, not full values — breadcrumbs) - learnings = data.get("key_learnings", {}) - if learnings: - keys = list(learnings.keys())[-10:] # last 10 - result.append(f"Key learnings available: {', '.join(keys)}") - - return "\n".join(result) if result else None - except Exception: - return None - - -def _get_git_info(): - """Get current git branch and short status.""" - try: - branch = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - capture_output=True, - text=True, - timeout=5, - ) - subprocess.run( - ["git", "diff", "--stat", "--cached", "HEAD"], - capture_output=True, - text=True, - timeout=5, - ) - dirty = subprocess.run( - ["git", "status", "--porcelain"], - capture_output=True, - text=True, - timeout=5, - ) - - result = [] - if branch.returncode == 0: - result.append(f"Git branch: {branch.stdout.strip()}") - if dirty.returncode == 0 and dirty.stdout.strip(): - lines = dirty.stdout.strip().split("\n") - result.append(f"Uncommitted changes: {len(lines)} files") - - return "\n".join(result) if result else None - except Exception: - return None - - -def _get_branch_name(branch_dir): - """Extract branch name from directory.""" - return branch_dir.name if branch_dir else "unknown" - - -def main(): - """Main hook entry point.""" - try: - json.load(sys.stdin) - - branch_dir = _find_branch_dir() - branch_name = _get_branch_name(branch_dir) - - sections = [] - - sections.append(f"""POST-COMPACT RECOVERY — @{branch_name} - -Context just compacted. Below is your live state. Use it to continue seamlessly.""") - - # Git info - git_info = _get_git_info() - if git_info: - sections.append(f"## Git\n{git_info}") - - # Last session from local.json - if branch_dir: - session_info = _read_last_session(branch_dir) - if session_info: - sections.append(f"## Last Session\n{session_info}") - - # STATUS.local.md — the main context - if branch_dir: - status = _read_status_local(branch_dir) - if status: - sections.append(f"## STATUS.local.md\n{status}") - - # Recovery instructions (lean) - sections.append("""## Recovery Protocol -- Continue where the summary left off — don't restart or ask generic questions -- .trinity/local.json has full session history and key_learnings — read it if you need more context -- STATUS.local.md has current work, known issues, and todos -- Save memories proactively — compaction just proved you need to -- Match the conversation tone from before compaction""") - - print("\n\n".join(sections), file=sys.stdout) - print("Pre-compact: live state injected", file=sys.stderr) - - except Exception as e: - # Fail silently — never block compaction - print(f"Pre-compact hook error: {e}", file=sys.stderr) - - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/src/aipass/devpulse/.claude/hooks/pre_edit_gate.py b/src/aipass/devpulse/.claude/hooks/pre_edit_gate.py deleted file mode 100644 index c9c85035..00000000 --- a/src/aipass/devpulse/.claude/hooks/pre_edit_gate.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -""" -PreToolUse Gate — Blocks unsafe edits at the hook layer. - -Rules (checked in order): - 1. Inbox lock — any write targeting *.ai_mail.local/inbox.json is BLOCKED. - Use `drone @ai_mail email` instead. - 2. Cross-branch — writes to src/aipass/X/** from a CWD inside src/aipass/Y/** - are BLOCKED unless the calling branch is in TRUSTED_CROSS_WRITERS. - 3. State-file — edits to OTHER .py files while the current branch has unresolved - type errors are BLOCKED. (original v1.2.0 logic) - -Track E additions: rules 1 + 2 (DPLAN-0139). -Version: 1.3.0 -""" - -import json -import os -import sys -from pathlib import Path - -STATE_FILE = Path(__file__).parent / ".diagnostics_state.json" -EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} - -# Single source of truth lives in permissions.py — inline here as fallback -# so the hook works even when aipass package is not on sys.path. -TRUSTED_CROSS_WRITERS: tuple[str, ...] = ("devpulse", "seedgo", "spawn") - - -def _get_branch(file_path: str) -> str: - """Extract AIPass branch name from a file path (src/aipass/{branch}/ pattern).""" - parts = Path(file_path).parts - for i, part in enumerate(parts): - if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts): - return parts[i + 1] - return "" - - -def _block(reason: str) -> None: - # codeql[py/clear-text-logging-sensitive-data] - print(json.dumps({"decision": "block", "reason": reason})) - sys.exit(2) - - -def main(): - try: - input_data = json.load(sys.stdin) - tool_name = input_data.get("tool_name", "") - tool_input = input_data.get("tool_input", {}) - file_path = tool_input.get("file_path", "") - - if tool_name not in EDIT_TOOLS: - return - - if not file_path: - return - - # ------------------------------------------------------------------ - # Rule 1: Inbox lock — block all writes to *.ai_mail.local/inbox.json - # ------------------------------------------------------------------ - fp = Path(file_path) - if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts: - _block('Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @ "Subject" "Body"') - - # ------------------------------------------------------------------ - # Rule 1.5: Dispatched-agent path confinement (DPLAN-0155 M3) - # Daemon-spawned agents can only write inside their own branch dir. - # Breaks the prompt-injection amplifier chain — even if injected, - # a dispatched agent cannot write to other agents' inboxes or code. - # ------------------------------------------------------------------ - cwd = input_data.get("cwd", "") or os.getcwd() - cwd_branch = _get_branch(cwd) - - session_type = os.environ.get("AIPASS_SESSION_TYPE", "interactive") - if session_type == "daemon" and cwd_branch: - target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp)) - if target_branch and target_branch != cwd_branch: - _block( - f"Dispatched agent confined to own branch: '{cwd_branch}' " - f"cannot write to '{target_branch}' in daemon mode." - ) - repo_root = None - for parent in Path(cwd).parents: - if (parent / ".git").exists(): - repo_root = parent - break - if repo_root and not target_branch: - allowed_prefix = str(repo_root / "src" / "aipass" / cwd_branch) - resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp) - if not resolved.startswith(allowed_prefix): - _block(f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}") - - # ------------------------------------------------------------------ - # Rule 2: Cross-branch write enforcement - # ------------------------------------------------------------------ - target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp)) - - if cwd_branch and target_branch and cwd_branch != target_branch: - if cwd_branch not in TRUSTED_CROSS_WRITERS: - _block( - f"Cross-branch write blocked: '{cwd_branch}' cannot write to '{target_branch}'.\n" - f"Trusted cross-writers: {', '.join(TRUSTED_CROSS_WRITERS)}" - ) - - # ------------------------------------------------------------------ - # Rule 3: State-file (original v1.2.0) — .py files only - # ------------------------------------------------------------------ - if not file_path.endswith(".py"): - return - - if not STATE_FILE.exists(): - return - - try: - state = json.loads(STATE_FILE.read_text(encoding="utf-8")) - except (json.JSONDecodeError, IOError): - return - - errored_file = state.get("file", "") - errors = state.get("errors", []) - - if not errors: - return - - try: - current = str(Path(file_path).resolve()) - errored = str(Path(errored_file).resolve()) - except (OSError, ValueError): - return - - if current == errored: - return - - current_branch = _get_branch(current) - errored_branch = _get_branch(errored) - if not errored_branch: - return - if current_branch and errored_branch and current_branch != errored_branch: - return - - error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5]) - _block(f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}") - - except Exception: - pass # Silent fail → allow - - -if __name__ == "__main__": - main() diff --git a/src/aipass/devpulse/.claude/hooks/subagent_stop_gate.py b/src/aipass/devpulse/.claude/hooks/subagent_stop_gate.py deleted file mode 100644 index b903b885..00000000 --- a/src/aipass/devpulse/.claude/hooks/subagent_stop_gate.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -""" -SubagentStop Gate — Checks files modified by subagents before allowing them to finish. - -Runs seedgo checklist + basic validation on any .py files the subagent touched. -If violations found, blocks the stop and tells the subagent to fix them. - -Version: 1.0.0 -""" - -import json -import os -import sys -import subprocess -from pathlib import Path - - -def _find_repo_root() -> Path | None: - """Walk up from CWD or AIPASS_HOME to find the git repo root.""" - for start in (os.environ.get("AIPASS_HOME", ""), os.getcwd()): - p = Path(start) - while p != p.parent: - if (p / ".git").exists(): - return p - p = p.parent - return None - - -AIPASS_ROOT = _find_repo_root() - - -def get_modified_py_files() -> list[str]: - """Get Python files modified in the working tree (unstaged + staged).""" - if AIPASS_ROOT is None: - return [] - try: - result = subprocess.run( - ["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, cwd=str(AIPASS_ROOT) - ) - files = [] - for line in result.stdout.strip().split("\n"): - line = line.strip() - if line.endswith(".py") and not line.startswith(".claude/"): - full = AIPASS_ROOT / line - if full.exists(): - files.append(str(full)) - return files - except Exception: - return [] - - -def run_seedgo_checklist(file_path: str) -> list[str]: - """Run seedgo checklist on a single file.""" - if AIPASS_ROOT is None: - return [] - if "/.claude/" in file_path: - return [] - try: - result = subprocess.run( - ["drone", "@seedgo", "checklist", file_path], - capture_output=True, - text=True, - timeout=15, - cwd=str(AIPASS_ROOT), - ) - if result.returncode != 0: - return [] - violations = [] - for line in result.stdout.split("\n"): - line = line.strip() - if line.startswith("\u2717"): - v = line[1:].strip() - if v: - violations.append(v) - return violations[:5] - except Exception: - return [] - - -def main(): - try: - json.load(sys.stdin) - - modified = get_modified_py_files() - if not modified: - return # Nothing to check - - all_violations = {} - for f in modified: - vs = run_seedgo_checklist(f) - if vs: - name = Path(f).name - all_violations[name] = vs - - if not all_violations: - return # All clear - - # Build the block reason - lines = ["Standards violations found in files you modified:\n"] - for fname, vs in all_violations.items(): - lines.append(f" {fname}:") - for v in vs: - lines.append(f" - {v}") - lines.append("\nFix these violations before finishing.") - - output = {"decision": "block", "reason": "\n".join(lines)} - print(json.dumps(output)) - - except Exception: - pass # Silent fail — don't block on errors - - -if __name__ == "__main__": - main() diff --git a/src/aipass/devpulse/.claude/settings.json b/src/aipass/devpulse/.claude/settings.json deleted file mode 100644 index 58f965ff..00000000 --- a/src/aipass/devpulse/.claude/settings.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "hooks": { - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "python3 .claude/hooks/branch_prompt_loader.py" - } - ] - }, - { - "hooks": [ - { - "type": "command", - "command": "python3 .claude/hooks/email_notification.py" - } - ] - }, - { - "hooks": [ - { - "type": "command", - "command": "python3 .claude/hooks/identity_injector.py" - } - ] - } - ], - "PreCompact": [ - { - "hooks": [ - { - "type": "command", - "command": "python3 .claude/hooks/pre_compact.py" - } - ] - } - ] - }, - "env": { - "AIPASS_HOME": "/home/patrick/Projects/AIPass" - } -} diff --git a/src/aipass/devpulse/CLAUDE.md b/src/aipass/devpulse/CLAUDE.md deleted file mode 100644 index 0de000d9..00000000 --- a/src/aipass/devpulse/CLAUDE.md +++ /dev/null @@ -1,41 +0,0 @@ - -# DEVPULSE - -**User:** (your name here) - -## What is AIPass - -AIPass is a multi-agent framework. This project was created with `aipass init`. - -**Key concepts:** -- **Project** — this directory. Contains a registry and one or more agents. -- **Agent** — a citizen that lives inside the project. Has identity (`.trinity/`), memory, mailbox, and its own apps/ directory. -- **Registry** — `DEVPULSE_REGISTRY.json` tracks all agents in this project. - -## Getting Started - -Create your first agent: -``` -aipass init agent -``` - -This creates a full agent scaffold inside `src//` (`apps/`, `.trinity/`, `.ai_mail.local/`) and registers it in your project registry. - -## Available Commands - -``` -aipass init agent # Create a new agent -drone @spawn create # Create agent (alternative) -drone @seedgo audit # Run standards audit -drone @ai_mail inbox # Check mailbox (per-agent) -drone systems # List all available infrastructure -``` - -## Startup Protocol - -On any greeting, silently read these files — no narration, just do it and respond with the status. - -**Read:** `DEVPULSE_REGISTRY.json`, `README.md`, `STATUS.local.md` -**Run:** `git status` - -Then check the registry for agents and report status. diff --git a/src/aipass/devpulse/DEVPULSE_REGISTRY.json b/src/aipass/devpulse/DEVPULSE_REGISTRY.json deleted file mode 100644 index 992429dc..00000000 --- a/src/aipass/devpulse/DEVPULSE_REGISTRY.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "metadata": { - "id": "6e3e877e-56ed-4ec5-892f-81073257dc90", - "name": "DEVPULSE", - "version": "1.0.0", - "created": "2026-05-12", - "last_updated": "2026-05-12", - "total_branches": 0 - }, - "branches": [] -} diff --git a/src/aipass/drone/apps/handlers/git/commit_handler.py b/src/aipass/drone/apps/handlers/git/commit_handler.py index 3481eecb..df5d00dc 100644 --- a/src/aipass/drone/apps/handlers/git/commit_handler.py +++ b/src/aipass/drone/apps/handlers/git/commit_handler.py @@ -57,16 +57,28 @@ def commit_changes( message: str, branch_dir: Path | None = None, all_files: bool = False, + files: list[str] | None = None, ) -> dict: """Commit changes. With --all, stages the entire repo (not CWD-scoped). Post-DPLAN-0173: only devpulse commits, agents don't PR. Repo-wide staging is the correct default since dispatched agents work across multiple branch directories. + + With file paths, stages only those specific files (selective commit). """ repo_root = find_repo_root() - if all_files: + if files: + add_result = subprocess.run( + ["git", "add", "--"] + files, + capture_output=True, + text=True, + cwd=str(repo_root), + ) + if add_result.returncode != 0: + return {"stdout": "", "stderr": f"Failed to stage: {add_result.stderr.strip()}", "exit_code": 1} + elif all_files: import shutil ruff_bin = shutil.which("ruff") @@ -124,7 +136,7 @@ def commit_changes( json_handler.log_operation( "commit_changes", - {"message": message, "all_files": all_files, "exit_code": result.returncode}, + {"message": message, "all_files": all_files, "files": files, "exit_code": result.returncode}, ) return { diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index fb94515e..e7fa34d0 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -447,13 +447,22 @@ def _handle_commit(args: list[str]) -> dict: if not args: return { "stdout": "", - "stderr": "Usage: drone @git commit [--all]", + "stderr": "Usage: drone @git commit [--all | file1 file2 ...]", "exit_code": 1, } all_files = "--all" in args - msg_parts = [a for a in args if a != "--all"] - message = " ".join(msg_parts) + clean_args = [a for a in args if a != "--all"] + + if not clean_args: + return { + "stdout": "", + "stderr": "Commit message cannot be empty", + "exit_code": 1, + } + + message = clean_args[0] + files = clean_args[1:] if len(clean_args) > 1 else None if not message: return { @@ -462,7 +471,7 @@ def _handle_commit(args: list[str]) -> dict: "exit_code": 1, } - return commit_handler.commit_changes(message, all_files=all_files) + return commit_handler.commit_changes(message, all_files=all_files, files=files) def _handle_checkout(args: list[str]) -> dict: