feat(flow): feat(flow): template registry system, introspection/help overhaul, no-fallback errors

Co-Authored-By: @flow <flow@aipass>
This commit is contained in:
AIOSAI
2026-03-18 18:53:42 -07:00
co-authored by @flow
parent d6e322619a
commit 8162f43099
12 changed files with 60 additions and 25 deletions
+11 -7
View File
@@ -20,16 +20,20 @@ drone @flow restore FPLAN-0042 # Reopen a closed plan
- `apps/flow.py` -- Entry point. Auto-discovers modules in `apps/modules/` via `handle_command()` convention.
- `apps/modules/` -- Thin orchestrators. No business logic. Route to handlers and display results.
- `apps/handlers/` -- Implementation. Grouped by domain: `plan/`, `registry/`, `template/`, `dashboard/`, `mbank/`, `summary/`.
- `templates/` -- Data-only plugins. Each subdirectory has `plan_type.json` config + Markdown templates.
- `flow_json/` -- Per-type JSON registries (`fplan_registry.json`, `dplan_registry.json`).
- `templates/` -- Plan type directories. Each subdirectory contains Markdown templates. Registered via `drone @flow register`.
- `flow_json/` -- Registries: per-type plan registries + `template_registry.json` (plan type definitions).
## Plan Type Plugins
## Plan Type System
Plan types are DATA, not code. Each plugin directory under `templates/` contains:
- `plan_type.json` -- prefix, digits, registry_file, available_templates, default_template
- Markdown template files (e.g. default.md, master.md)
Plan types are filesystem-driven. Drop a directory with `.md` templates into `templates/`, register it, done:
```bash
drone @flow register testing TPLAN # Register new type
drone @flow unregister testing # Remove type
drone @flow templates # List registered types
drone @flow scan # Find unregistered directories
```
Discovered at runtime by `apps/handlers/template/plan_type_loader.py`. Add a new type by creating a new directory with these files.
Discovered at runtime by `plan_type_loader.py` + `registry_ops.py`. No per-directory JSON config needed.
| Type | Prefix | Registry File | Templates |
|------|--------|---------------|-----------|
+8 -1
View File
@@ -245,7 +245,14 @@ def print_help(modules: List[Any]):
console.print(" [dim]drone @flow close --all[/dim]")
console.print()
console.print(" [yellow]List plans:[/yellow]")
console.print(" [dim]drone @flow list[/dim] [dim]# All plan types[/dim]")
console.print(" [dim]drone @flow list open[/dim] [dim]# Open plans[/dim]")
console.print(" [dim]drone @flow list all[/dim] [dim]# All plans[/dim]")
console.print()
console.print(" [yellow]Templates:[/yellow]")
console.print(" [dim]drone @flow templates[/dim] [dim]# List registered types[/dim]")
console.print(" [dim]drone @flow scan[/dim] [dim]# Find unregistered dirs[/dim]")
console.print(" [dim]drone @flow register testing TPLAN[/dim] [dim]# Register new type[/dim]")
console.print(" [dim]drone @flow unregister testing[/dim] [dim]# Remove type[/dim]")
console.print()
console.print("─" * 70)
console.print()
@@ -170,15 +170,30 @@ def create_plan_impl(
PLAN_FILE = target_dir / f"{prefix}-{formatted_num}_{date_str}.md"
# STEP 7: Get template content
# Resolve template path from plan_type_config when available
# Resolve template path from plan_type_config — no fallback
template_path: Path | None = None
if plan_type_config is not None:
tmpl_name = plan_type_config.get("default_template", "default")
tmpl_dir: Path | None = plan_type_config.get("_directory")
if tmpl_dir is not None:
available = sorted(p for p in tmpl_dir.glob("*.md")) if tmpl_dir.is_dir() else []
candidate = tmpl_dir / f"{tmpl_name}.md"
if candidate.is_file():
# Exact template match
template_path = candidate
elif len(available) == 1:
# Single template in directory — use it regardless of name
template_path = available[0]
elif len(available) > 1:
names = [p.stem for p in available]
error_msg = (
f"Multiple templates in {tmpl_dir.name}/. "
f"Specify which one: {names}"
)
return False, 0, "", "", error_msg, []
else:
error_msg = f"No templates found in {tmpl_dir.name}/"
return False, 0, "", "", error_msg, []
try:
CONTENT = get_template(
@@ -328,7 +328,9 @@ def format_plan_info(plan_key: str, plan_info: Dict[str, Any], prefix: str = "FP
except (ValueError, AttributeError):
pass # Keep original value if parsing fails
return f" {display_prefix}-{plan_key} [{status:>6}] {location:<30} {subject:<40} {created}"
# Use original plan number if available (set by list_ops merge), else use key
display_num = plan_info.get("_plan_num", plan_key)
return f" {display_prefix}-{display_num} [{status:>6}] {location:<30} {subject:<40} {created}"
def format_plans_list(
@@ -90,9 +90,12 @@ def list_plans_impl(
registry = load_registry(registry_file=reg_file)
source_prefix = reg_prefix_map.get(reg_file, "FPLAN")
for plan_num, plan_info in registry.get("plans", {}).items():
# Tag each plan with its source prefix for display
# Tag each plan with its source prefix and original number for display
plan_info["_source_prefix"] = source_prefix
merged_plans[plan_num] = plan_info
plan_info["_plan_num"] = plan_num
# Use prefix-qualified key to avoid collisions across registries
merge_key = f"{source_prefix}-{plan_num}"
merged_plans[merge_key] = plan_info
except Exception:
continue
else:
@@ -66,21 +66,20 @@ def _find_template_file(template_name: str) -> Path:
"""
search_paths = _template_search_dirs()
# Look for the requested template
# Look for the requested template — no fallback
candidate = search_paths[0] / f"{template_name}.md"
if candidate.exists():
return candidate
# Fallback to default template
default_candidate = search_paths[0] / f"{DEFAULT_TEMPLATE}.md"
if default_candidate.exists():
return default_candidate
# Nothing found – raise helpful error
# Not found — error with available templates
available = [
p.stem for p in search_paths[0].iterdir()
if p.suffix == ".md"
] if search_paths[0].is_dir() else []
searched = ", ".join(str(path) for path in search_paths)
error_msg = (
f"Templates not found. Searched for '{template_name}.md' and "
f"'{DEFAULT_TEMPLATE}.md' in: {searched}"
f"Template '{template_name}' not found in: {searched}. "
f"Available: {available if available else 'none'}"
)
raise FileNotFoundError(error_msg)
+8 -3
View File
@@ -44,7 +44,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.flow.apps.handlers.json import json_handler
# CLI services for display
from aipass.cli.apps.modules import console, warning
from aipass.cli.apps.modules import console, error as cli_error, warning
# Registry handlers (cross-domain - OK for modules)
from aipass.flow.apps.handlers.registry.load_registry import load_registry
@@ -264,8 +264,13 @@ def handle_command(command: str, args: List[str]) -> bool:
# STEP 1b: Resolve plan type config (for prefix/digits in display)
try:
plan_type_config = get_plan_type(plan_type_key)
except ValueError:
plan_type_config = None
except ValueError as exc:
cli_error(str(exc))
console.print()
console.print("[dim]Registered types: drone @flow templates[/dim]")
console.print("[dim]Register new: drone @flow register <dir> <PREFIX>[/dim]")
console.print()
return True
# STEP 2: Execute workflow
success, num, loc, tmpl, error = create_plan(
@@ -281,7 +281,7 @@ def handle_command(command: str, args: List[str]) -> bool:
console.print()
return True
console.print(f"[yellow]Found {len(unregistered)} unregistered directory(ies):[/yellow]")
warning(f"Found {len(unregistered)} unregistered directory(ies):")
console.print()
for entry in unregistered: