diff --git a/.gitignore b/.gitignore index b683ef68..127dbab2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ __pycache__/ *.pyc *.egg-info/ dist/ -build/ +build/- .pytest_cache/ .ruff_cache/ diff --git a/STATUS.md b/STATUS.md index f55bae73..65bee475 100644 --- a/STATUS.md +++ b/STATUS.md @@ -2,7 +2,7 @@ > Auto-generated by `drone @prax status sync`. Do not edit manually. -**Last sync:** 2026-04-10 00:14 +**Last sync:** 2026-04-10 00:40 **Summary:** 8 operational | 0 in-progress | 0 not started --- @@ -240,7 +240,10 @@ - Test count reality: ~3,501 not 4,900+ (split branches took their tests) - PR count reality: ~179 not 192+ - PR #211 created with all S83 work -- Wave 2 audit next (verification pass with 22 more agents) +- Wave 3 complete: 170+ new findings, all written directly to APLANs by agents +- API is NOT on public repo — local/private only. Docs need correcting (say 10 public + api local) +- PR #211 (Wave 1 + hook-sounds) and PR #212 (Wave 3 APLAN updates) both shipped +- Next: fix CRITICALs via branch dispatches, correct api count in public docs ### S82 (2026-04-09) - Core split: daemon/commons/skills moved. API reinstated (LLM access layer). Backup pending move. diff --git a/src/aipass/devpulse/.aipass/aipass_local_prompt.md b/src/aipass/devpulse/.aipass/aipass_local_prompt.md index 5317f955..5f186ff7 100644 --- a/src/aipass/devpulse/.aipass/aipass_local_prompt.md +++ b/src/aipass/devpulse/.aipass/aipass_local_prompt.md @@ -75,21 +75,29 @@ You have a personal project: **Compass** at `~/Projects/compass/`. It's a vector - **STATUS.local.md for friction notes.** When something feels off or could be improved, drop a quick note in the Notepad section. Address in batches later. - **Know your limits.** You're great at planning, coordinating, seeing the big picture. You're bad at hands-on branch-level code tasks. Dispatch, don't do. - **Git awareness as a natural habit.** After completing a feature, merging something, or wrapping up a chunk of work — take a moment to think: "we've been working for a while, what's changed?" Run `git status`, see what's accumulated. If it looks like a coherent set of changes (an upgrade, a fix cycle, a config update), suggest a commit or PR. Don't force it every turn, but don't let 60+ files pile up silently either. Think of it like tidying your desk at the end of a work session — not obsessive, just mindful. -## Autonomous Monitoring ⚡ LEARNING +## Watchdog — Autonomous Mail Wait -After dispatching branches, actively monitor for replies and keep them working without human intervention. +After dispatching branches, use a background bash wait that exits when mail arrives. This wakes you like a sub-agent completing. -**Option A: Task Agent** (costs tokens, gets reasoning) -- Spawn a background agent that checks `drone @ai_mail inbox` periodically -- Agent reads replies, evaluates quality, drafts responses -- Use when results need judgment before re-dispatching +**Pattern:** +```bash +# 1. Dispatch work +drone @ai_mail dispatch @target "Subject" "Body" -**Option B: Bash Polling** (zero tokens, detection only) -- Run `drone @ai_mail inbox 2>/dev/null | grep -q "📨"` in a background bash loop -- Get notified when new mail arrives, then process manually -- Use when just waiting for replies to come in +# 2. Clear inbox first, then arm watchdog (run_in_background: true, timeout: 600000) +drone @ai_mail close all +INBOX="path/to/inbox.json"; while true; do sleep 10; UNREAD=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); if [ "$UNREAD" -gt "0" ]; then echo "WOKE: $UNREAD unread"; exit 0; fi; done -**The loop:** Dispatch → monitor → read reply → respond/re-dispatch → monitor again. No human needed until you hit a decision point. +# 3. Stop — do nothing until notified +# 4. Wake notification arrives → read mail → process → dispatch next → repeat +``` + +**Key:** Use `unread_count > 0` (not total_messages — close resets totals). Always `close all` before arming. 10s poll interval. `run_in_background: true` so the completion notification wakes you. On timeout, wake anyway to check if agent crashed — then either restart watchdog or re-dispatch. + +**Watchdog one-liner (copy-paste ready):** +``` +INBOX="/home/patrick/Projects/AIPass/src/aipass/devpulse/.ai_mail.local/inbox.json"; C=0; while [ $C -lt 60 ]; do sleep 10; C=$((C+1)); UNREAD=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); if [ "$UNREAD" -gt "0" ]; then echo "WOKE: $UNREAD unread"; exit 0; fi; done; echo "TIMEOUT: 10min no reply — check if agent crashed"; exit 0 +``` ## Memory & Tracking diff --git a/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py b/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py index b29326be..33dbb689 100644 --- a/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py +++ b/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py @@ -42,6 +42,7 @@ Usage: """ import json +import re from datetime import datetime, timezone, timedelta from pathlib import Path from typing import Dict, Any, List, Tuple @@ -245,7 +246,12 @@ def _filter_branch_plans( continue branch_total += 1 - plan_id = f"FPLAN-{plan_num.zfill(4)}" + # Extract plan prefix from file_path (e.g., DPLAN, FPLAN, TDPLAN) + file_path = plan_data.get("file_path", "") + filename = Path(file_path).name if file_path else "" + prefix_match = re.match(r'^([A-Z]+PLAN)', filename) + prefix = prefix_match.group(1) if prefix_match else "FPLAN" + plan_id = f"{prefix}-{plan_num.zfill(4)}" if plan_data.get("status") == "open": active_plans.append({ diff --git a/src/aipass/flow/apps/handlers/dashboard/push_central.py b/src/aipass/flow/apps/handlers/dashboard/push_central.py index be41c299..b5b42851 100644 --- a/src/aipass/flow/apps/handlers/dashboard/push_central.py +++ b/src/aipass/flow/apps/handlers/dashboard/push_central.py @@ -27,6 +27,7 @@ Usage: """ import json +import re from pathlib import Path from datetime import datetime, timezone from typing import Dict, Any, List @@ -101,9 +102,15 @@ def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict], List[Dict if location != str(FLOW_ROOT): continue + # Extract plan prefix from file_path (e.g., DPLAN, FPLAN, TDPLAN) + file_path_str = plan_data.get("file_path", "") + filename = Path(file_path_str).name if file_path_str else "" + prefix_match = re.match(r'^([A-Z]+PLAN)', filename) + prefix = prefix_match.group(1) if prefix_match else "FPLAN" + # Build plan entry plan_entry = { - "plan_id": f"FPLAN-{plan_num.zfill(4)}", + "plan_id": f"{prefix}-{plan_num.zfill(4)}", "subject": plan_data.get("subject", ""), "status": plan_data.get("status", "open"), "created": plan_data.get("created", ""), diff --git a/src/aipass/flow/apps/handlers/dashboard/update_local.py b/src/aipass/flow/apps/handlers/dashboard/update_local.py index 259909ef..1923340f 100644 --- a/src/aipass/flow/apps/handlers/dashboard/update_local.py +++ b/src/aipass/flow/apps/handlers/dashboard/update_local.py @@ -66,6 +66,7 @@ Usage: """ import json +import re from pathlib import Path from datetime import datetime, timezone from typing import Dict, Any, List, Optional @@ -125,8 +126,14 @@ def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict[str, Any]], if "flow" not in location.lower(): continue + # Extract plan prefix from file_path (e.g., DPLAN, FPLAN, TDPLAN) + file_path_str = plan_data.get("file_path", "") + filename = Path(file_path_str).name if file_path_str else "" + prefix_match = re.match(r'^([A-Z]+PLAN)', filename) + prefix = prefix_match.group(1) if prefix_match else "FPLAN" + # Build plan entry - plan_id = f"FPLAN-{plan_num}" + plan_id = f"{prefix}-{plan_num}" entry = { "plan_id": plan_id, "subject": plan_data.get("subject", ""), diff --git a/src/aipass/flow/apps/handlers/registry/monitor_ops.py b/src/aipass/flow/apps/handlers/registry/monitor_ops.py index 331f60b1..9e1c4730 100644 --- a/src/aipass/flow/apps/handlers/registry/monitor_ops.py +++ b/src/aipass/flow/apps/handlers/registry/monitor_ops.py @@ -39,8 +39,8 @@ from aipass.flow.apps.handlers.json import json_handler MODULE_NAME = "registry_monitor" -# PLAN file pattern -PLAN_PATTERN = re.compile(r'^FPLAN-\d{4}\.md$') +# PLAN file pattern — matches any plan prefix (FPLAN, DPLAN, APLAN, RPLAN, TDPLAN, etc.) +PLAN_PATTERN = re.compile(r'^[A-Z]+PLAN-\d{4}\.md$') # Directories to ignore during monitoring IGNORE_FOLDERS = { @@ -115,8 +115,8 @@ class PlanFileWatcher(FileSystemEventHandler): return PLAN_PATTERN.match(Path(file_path).name) is not None def _get_plan_number(self, file_path: Path) -> Optional[str]: - """Extract plan number from filename (e.g., FPLAN-0001.md -> 0001)""" - match = re.search(r'FPLAN-(\d{4})\.md$', file_path.name) + """Extract plan number from filename (e.g., FPLAN-0001.md -> 0001, DPLAN-0005.md -> 0005)""" + match = re.search(r'[A-Z]+PLAN-(\d{4})\.md$', file_path.name) return match.group(1) if match else None def _is_duplicate_event(self, event_type: str, plan_num: str) -> bool: @@ -247,7 +247,7 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ for filename in files: if PLAN_PATTERN.match(filename): file_path = Path(root) / filename - match = re.search(r'FPLAN-(\d{4})\.md$', filename) + match = re.search(r'[A-Z]+PLAN-(\d{4})\.md$', filename) if match: plan_number = match.group(1) @@ -256,7 +256,7 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ if plan_number not in duplicates: duplicates[plan_number] = [plan_files[plan_number]] duplicates[plan_number].append(file_path) - logger.warning(f"[{MODULE_NAME}] Duplicate FPLAN-{plan_number} found: {file_path}") + logger.warning(f"[{MODULE_NAME}] Duplicate plan {filename} found: {file_path}") else: plan_files[plan_number] = file_path @@ -273,8 +273,11 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ # Keep first occurrence, renumber the rest for dup_path in paths[1:]: # Skip first path (already in plan_files) old_name = dup_path.name + # Preserve original plan prefix (FPLAN, DPLAN, TDPLAN, etc.) + prefix_match = re.match(r'^([A-Z]+PLAN)', old_name) + dup_prefix = prefix_match.group(1) if prefix_match else "FPLAN" new_num = f"{next_available:04d}" - new_name = f"FPLAN-{new_num}.md" + new_name = f"{dup_prefix}-{new_num}.md" new_path = dup_path.parent / new_name try: @@ -309,7 +312,7 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ # File exists but not in registry - fire created event if _fire_event('plan_file_created', path=str(file_path)): added.append(plan_number) - logger.info(f"[{MODULE_NAME}] Fired plan_file_created for FPLAN-{plan_number}") + logger.info(f"[{MODULE_NAME}] Fired plan_file_created for {file_path.name}") else: # Check if location changed (file moved) current_path = plans[plan_number].get("file_path", "") @@ -317,16 +320,17 @@ def scan_plan_files_impl(ecosystem_root: Path, load_registry: Callable[[], Dict[ # Fire moved event if _fire_event('plan_file_moved', src_path=current_path, dest_path=str(file_path)): updated.append(plan_number) - logger.info(f"[{MODULE_NAME}] Fired plan_file_moved for FPLAN-{plan_number}") + logger.info(f"[{MODULE_NAME}] Fired plan_file_moved for {file_path.name}") # Fire events for orphaned registry entries (in registry but file doesn't exist) for plan_number in list(plans.keys()): if plan_number not in plan_files: # Registry entry but no file - fire deleted event - file_path = plans[plan_number].get("file_path", f"FPLAN-{plan_number}.md") - if _fire_event('plan_file_deleted', path=file_path): + orphan_path = plans[plan_number].get("file_path", "") + orphan_name = Path(orphan_path).name if orphan_path else f"PLAN-{plan_number}.md" + if _fire_event('plan_file_deleted', path=orphan_path or f"PLAN-{plan_number}.md"): removed.append(plan_number) - logger.info(f"[{MODULE_NAME}] Fired plan_file_deleted for FPLAN-{plan_number}") + logger.info(f"[{MODULE_NAME}] Fired plan_file_deleted for {orphan_name}") # Log event results if added or updated or removed or renumbered: diff --git a/src/aipass/flow/tests/test_monitor_registry.py b/src/aipass/flow/tests/test_monitor_registry.py index e71ef98a..77072a98 100644 --- a/src/aipass/flow/tests/test_monitor_registry.py +++ b/src/aipass/flow/tests/test_monitor_registry.py @@ -131,21 +131,22 @@ class TestScanPlanFilesImpl: assert "0010" in result["added"] def test_ignores_non_plan_files(self, tmp_path): - """Non-FPLAN files should be ignored even if they look similar.""" + """Non-plan files should be ignored even if they look similar.""" mod = _import_monitor_ops() - # Valid plan file + # Valid plan files (any prefix: FPLAN, DPLAN, etc.) _make_plan_file(tmp_path, "0001") + (tmp_path / "DPLAN-0002.md").write_text("also a plan", encoding="utf-8") # Invalid files that should not match - (tmp_path / "DPLAN-0002.md").write_text("not a plan", encoding="utf-8") (tmp_path / "FPLAN-ABC.md").write_text("bad number", encoding="utf-8") (tmp_path / "README.md").write_text("readme", encoding="utf-8") + (tmp_path / "NOTES-0003.md").write_text("not a plan prefix", encoding="utf-8") with patch.object(mod, "_fire_event", return_value=True): result = mod.scan_plan_files_impl( ecosystem_root=tmp_path, load_registry=lambda: {"plans": {}}, ) - assert result["added"] == ["0001"] + assert sorted(result["added"]) == ["0001", "0002"] def test_skips_ignored_folders(self, tmp_path): """Directories in IGNORE_FOLDERS should be skipped.""" @@ -721,29 +722,35 @@ class TestPlanFileWatcherInternals: """Tests for PlanFileWatcher helper methods.""" def test_is_plan_file_valid(self): - """Valid FPLAN filenames should return True.""" + """Valid plan filenames (any prefix) should return True.""" mod = _import_monitor_ops() watcher = mod.PlanFileWatcher() assert watcher._is_plan_file("/some/path/FPLAN-0001.md") is True assert watcher._is_plan_file("/some/path/FPLAN-9999.md") is True + assert watcher._is_plan_file("/some/path/DPLAN-0001.md") is True + assert watcher._is_plan_file("/some/path/APLAN-0042.md") is True + assert watcher._is_plan_file("/some/path/RPLAN-0100.md") is True + assert watcher._is_plan_file("/some/path/TDPLAN-0002.md") is True def test_is_plan_file_invalid(self): """Invalid filenames should return False.""" mod = _import_monitor_ops() watcher = mod.PlanFileWatcher() - assert watcher._is_plan_file("/some/path/DPLAN-0001.md") is False assert watcher._is_plan_file("/some/path/FPLAN-ABC.md") is False assert watcher._is_plan_file("/some/path/FPLAN-00001.md") is False assert watcher._is_plan_file("/some/path/README.md") is False assert watcher._is_plan_file("/some/path/FPLAN-0001.txt") is False + assert watcher._is_plan_file("/some/path/plan-0001.md") is False def test_get_plan_number(self): - """Should extract the 4-digit number from FPLAN filename.""" + """Should extract the 4-digit number from plan filename.""" mod = _import_monitor_ops() watcher = mod.PlanFileWatcher() assert watcher._get_plan_number(Path("FPLAN-0042.md")) == "0042" assert watcher._get_plan_number(Path("FPLAN-0001.md")) == "0001" assert watcher._get_plan_number(Path("/deep/path/FPLAN-1234.md")) == "1234" + assert watcher._get_plan_number(Path("DPLAN-0005.md")) == "0005" + assert watcher._get_plan_number(Path("TDPLAN-0002.md")) == "0002" def test_get_plan_number_invalid(self): """Invalid filenames should return None."""