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:
+1
-1
@@ -6,7 +6,7 @@ __pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
build/-
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user