feat(flow): feat(flow): S84 fix FPLAN-only hardcoding — support all plan type prefixes

Co-Authored-By: @flow <flow@aipass>
This commit is contained in:
AIOSAI
2026-04-10 01:20:18 -07:00
co-authored by @flow
parent d343affe06
commit 083bdceec0
8 changed files with 78 additions and 36 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ __pycache__/
*.pyc
*.egg-info/
dist/
build/
build/-
.pytest_cache/
.ruff_cache/
+5 -2
View File
@@ -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.
@@ -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
@@ -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({
@@ -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", ""),
@@ -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", ""),
@@ -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:
+14 -7
View File
@@ -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."""