Purpose: Unified plan lifecycle management for AIPass. Creates, tracks, closes, and archives numbered work plans across multiple plan types via a filesystem-driven template registry.
Module: aipass.flow
Version: 2.2.1
Created: 2025-11-15
Last Updated: 2026-08-25
Flow is AIPass's plan management system. Every branch uses flow to create, track, close, and archive work plans. Plans are numbered markdown files (FPLAN-0042_subject_2026-04-22.md) organized by type, with per-type registries tracking status and metadata.
- Create numbered plans from type-specific templates
- Close plans with foreground archival, then hand vectorisation to a detached background runner
- List and filter plans across all registered types
- Reopen closed plans, pulling the file back from the
.backup/processed_plans/archive when it is no longer at its registered location — which after a normal close it never is (see Known Issues) - Manage plan types via filesystem-driven template registry
- Aggregate plans across branches for central reporting
- Self-heal registries (orphan detection, auto-close missing files, auto-register new template dirs)
- Preview close operations with
--dry-run
drone @flow create . "My task description" # Create a plan in the current directory
drone @flow list open # See all open plans
drone @flow close FPLAN-0042 # Close a completed plan
drone @flow create . "Design topic" dplan # Create a design plan (DPLAN)
drone @flow templates # List available plan types# Create plans
drone @flow create . "Subject" # Create FPLAN (default)
drone @flow create . "Subject" master # Create FPLAN master template
drone @flow create . "Design topic" dplan # Create DPLAN
drone @flow create . "Field note" cplan # Create CPLAN (any registered shorthand)
# Close plans
drone @flow close FPLAN-0042 # Close specific plan
drone @flow close DPLAN-0005 # Close a DPLAN
drone @flow close --all # Close every open plan in YOUR project
drone @flow close --all --dry-run # Preview what would close
drone @flow close --all --exclude-type APLAN # Hold a whole plan type back (repeatable)
drone @flow close --dry-run FPLAN-0042 # Preview single close
# List plans
drone @flow list open # List open plans (all types)
drone @flow list all # List all plans
# Template management
drone @flow templates # List registered types
drone @flow scan # Find unregistered directories
drone @flow register <dir> <PREFIX> # Register new plan type
drone @flow unregister <dir> # Remove plan type
# Registry
drone @flow registry scan # Scan filesystem, detect mismatches
drone @flow registry status # Show registry health
# Other
drone @flow restore FPLAN-0042 # Reopen a closed plan
drone @flow aggregate # Cross-branch plan aggregation
drone @flow post # Background post-close processing
drone @flow --help # Full help
drone @flow --version # Version stringUse the short verb. Only the short form executes: list, close, create,
restore, registry, aggregate, and — all four owned by template_manager —
templates, scan, register, unregister. The module's full name
(list_plans, close_plan, …) resolves for --help but is rejected by the
dispatcher — post/post_close_runner is the sole module accepting both. The
--help screen currently claims otherwise; see Known Issues.
A bare number is not an identity. Every per-type registry numbers from
0001, so 0012 names a row in each of them and a bare number resolves against
fplan_registry.json by default. Pass the typed ID (close TDPLAN-0012) when
the plan is not an FPLAN. The prefix is read by an anchored match
(^([A-Z]+PLAN)- in apps/handlers/plan/registry_routing.py), so TDPLAN-0012
resolves to tdplan_registry.json and never collides with DPLAN-0012. A row
whose file_path carries no prefix offers no type evidence at all; the bulk and
restore paths refuse such a row rather than guess.
flow/
├── apps/
│ ├── flow.py # Entry point (auto-discovers modules)
│ ├── modules/ # Thin orchestrators (8 modules)
│ │ ├── create_plan.py # Plan creation with template support
│ │ ├── close_plan.py # Closure: foreground archival, background vectorisation
│ │ ├── list_plans.py # Plan listing and filtering
│ │ ├── restore_plan.py # Reopen closed plans (+ backup recovery path)
│ │ ├── registry_monitor.py # Registry scanning and auto-healing
│ │ ├── aggregate_central.py # Cross-branch plan aggregation
│ │ ├── post_close_runner.py # Background post-processing with lock management
│ │ └── template_manager.py # Template registry management
│ └── handlers/ # Implementation details
│ ├── plan/ # Lifecycle: create, close, list, restore, display, validation, project scope
│ ├── cli/ # Shared --help flag detection (help_flags.py)
│ ├── registry/ # Load, save, auto-heal registries
│ ├── template/ # Plan type loader, template resolution, registry CRUD
│ ├── dashboard/ # Status push to local, central, branch dashboards
│ ├── mbank/ # Memory archival and plan processing
│ ├── runner/ # Lock file operations for background processes
│ ├── json/ # Auto-creating JSON handler
│ ├── json_templates/ # Seed JSON payloads for the auto-creating handler
│ ├── summary/ # EMPTY — only generate.py(disabled) remains
│ ├── config/ # EMPTY — package marker only, no code
│ └── events/ # EMPTY — package marker only, no code
├── templates/ # Plan type plugins (data, not code)
│ ├── flow_plans/ # FPLAN templates (default, master)
│ ├── dev_plans/ # DPLAN templates (default)
│ ├── research_plans/ # RPLAN templates (default)
│ ├── team_dev_plans/ # TDPLAN templates (default)
│ ├── audit_plans/ # APLAN templates (default)
│ ├── playbook_plans/ # PPLAN templates (SOPs: merge, weekly_update, …)
│ └── capture_plans/ # CPLAN templates (default)
├── flow_json/ # Per-type registries + template_registry.json
├── tests/ # 950 tests across 27 files
└── .archive/ # Archived legacy code + orphaned registries
- Modules are thin orchestrators — no business logic, route to handlers and display results
- Handlers are stateless — modules inject dependencies (registry loader, paths, config)
- Plan types are filesystem-driven — drop a template dir, register a prefix, done
- Auto-discovery —
flow.pyfinds modules viahandle_command()convention;plan_type_loader.pydiscovers types fromtemplate_registry.json
| Type | Prefix | Registry | Templates |
|---|---|---|---|
| flow_plans | FPLAN | fplan_registry.json | default, master |
| dev_plans | DPLAN | dplan_registry.json | default |
| research_plans | RPLAN | rplan_registry.json | default |
| team_dev_plans | TDPLAN | tdplan_registry.json | default |
| audit_plans | APLAN | aplan_registry.json | default |
| playbook_plans | PPLAN | pplan_registry.json | default, merge, prompt_change, weekly_update |
| capture_plans | CPLAN | cplan_registry.json | default |
Plans follow the naming convention {PREFIX}-{NNNN}_topic_slug_YYYY-MM-DD.md where NNNN auto-increments per type.
- Create a directory in
templates/with one or more.mdtemplate files - Run
drone @flow register <dirname> <PREFIX>(or let auto-registration detect it on next command) - Use
drone @flow create . "Subject" <shorthand>to create plans of the new type
- Template registry auto-prunes orphaned types (directory deleted → entry + plan registry JSON removed)
- Plan registries auto-close entries for missing files
- New template directories auto-register on next command
Auto-prune only fires while the type is still registered and its directory
has gone missing. unregister <dir> deliberately leaves the plan registry JSON
in place (see remove_type()), so unregistering and then deleting the
directory slips past the prune and strands a <shorthand>_registry.json
forever. flow_json/pbplan_registry.json is one such orphan.
IGNORE_FOLDERS (apps/handlers/registry/monitor_ops.py) is the set of directory
names the registry scan never descends into — dev/VCS tooling, backups, archives,
and system paths that legitimately contain files matching the PLAN filename
pattern but should never be registered as live plans.
Exact-match only, never substring/pattern matching. A folder name is skipped
only when it equals an entry in the set exactly. Substring matching was tried
historically and broke: a folder named dev would substring-match inside
devpulse, silently skipping the entire devpulse/ tree from scanning (see
key_learning #33, registry_monitor_runaway_log_fix). Exact-match avoids that
trap entirely — adding dropbox only ever matches a folder literally named
dropbox, never devpulse-dropbox-clone or similar.
dropbox is in the set because every branch has one as its received-files
inbox — anything can land there, including old snapshot/backup copies of plan
files with real PLAN-NNNN filenames, so no live plan should ever be scanned
out of a dropbox/ tree.
The current folder list lives in monitor_ops.py itself (IGNORE_FOLDERS) —
that file is the source of truth; this README doesn't duplicate the list to
avoid drift. Both registry_monitor's scan pass and heal_registry's doctrine
self-heal (collisions / unregistered files / wrong-prefix rows) import this
same set, so a folder added here is skipped by both in lockstep.
Branches get tested, moved and re-seated constantly, so plan rows pointing at
stale paths are expected debris, not an anomaly (ruling 2026-08-16). Hand-
editing the JSON is the wrong fix: it does not stick while code elsewhere still
writes the stale value. drone @flow registry scan re-attributes them instead,
as part of a normal scan.
A row is orphaned when its location is not where its citizen lives — either
the path is gone from disk, or it exists but merely contains the seat (a
project root holding records that belong to the branch inside it). Detection is
deliberately those two signals only: a plan filed at the repo root or inside a
citizen's own subdirectory is a normal filing, and treating every non-seat path
as debris buried the real orphans under ~30 false positives when first tried.
Attribution runs on evidence, in order: a directory containing exactly one live seat is that citizen's ground; failing that, exactly one live citizen sharing the directory name within the same repository. A bare name match across repositories is a coincidence, not an identity, and is refused.
Anything unattributable is quarantined, never guessed and never dropped —
the row stays untouched and drone @flow registry status lists it with the
reason, for a human ruling. Re-running the healer changes nothing the second
time.
On drone @flow close — the console prints five numbered steps, with vector
intake fired unlabelled between steps 3 and 4:
[1/5]Template check — reports only, never deletes. An empty template gets the warning "looks like an empty template — closing and archiving normally" and then flows through the identical pipeline. The old fast-delete branch was removed deliberately:is_template_content()is a heuristic, and its false positives permanently destroyed FPLAN-0370 and FPLAN-0371.[2/5]Mark closed — setsstatusand theclosedtimestamp, saves the type's registry. Close always succeeds from this point; every later step is non-blocking.[3/5]Archive — move to.backup/processed_plans/(foreground; setsprocessed/processed_date/cleanup_completed/cleanup_dateand saves in one write)- (unlabelled) Vector intake — spawns
apps/modules/post_close_runner.pydetached; console shows only "Vectorizing in background" [4/5]Dashboard updates — local, central, and branch dashboards[5/5]Finalizing — append toCLOSED_PLANS.local.json, fire theplan_closedtrigger event
Close does not verify vectorisation, and cannot report it. The runner is
launched with subprocess.Popen(..., stdout=DEVNULL, stderr=DEVNULL, start_new_session=True) (_spawn_background_runner, close_helpers.py), so
its result is unreadable by the closing process by construction — a failed
vectorisation is silent. Nothing in flow calls is_plan_vectorized(); that
function lives in @memory and is reached only by the separate
drone @memory verify <label> command, which is where a real answer comes from.
Closed plans are archived to <repo-root>/.backup/processed_plans/, a shared runtime namespace managed by @backup (see src/aipass/backup/README.md) and consumed by @memory for vectorization.
DASHBOARD.local.json has one quick_status block and more than one writer.
@prax's dashboard refresh contributes todo_count (read straight from
.trinity/local.json); Flow's plan push contributes active_plans and
commons_mentions. Whole-block replacement means last-writer-wins silently
deletes the other's fields — a plan close used to zero the todo count on every
branch card until the next prax refresh.
Flow's push therefore merges (_calculate_quick_status in
apps/handlers/dashboard/push_branch_dashboard.py):
| Keys | Behaviour |
|---|---|
active_plans, commons_mentions |
recomputed — Flow is the authority |
new_mail, opened_mail |
preserved if already set, seeded only when absent (@prax reads inbox.json first-hand; we only see the possibly-stale ai_mail section) |
action_required, summary |
recomputed over every counter present, foreign ones included |
| anything else | carried through untouched |
A foreign key named *_count holding an integer is additionally read as a
counter, so it still reaches action_required and the summary line
(todo_count: 9 → "9 todos"). Every other foreign key is passed through
without interpretation.
Every branch's DASHBOARD.local.json carries a flow section, managed_by
flow, written by push_flow_to_branch_dashboard():
| Field | Shape | Meaning |
|---|---|---|
active_plans |
int | count of all open plans for this branch |
open_recent |
list of {plan_id, subject, created} |
the 5 newest open plans by created date, newest first |
recently_closed |
list of {id, subject, closed} |
last 5 closed within 7 days |
total_plans |
int | every plan ever filed for this branch |
open_recent is the bounded reading window: agents get their bearings from 5
named plans plus a total, never from a wall of rows. The cap is enforced in
the renderer (_build_open_recent, OPEN_RECENT_LIMIT = 5), not in the
consumer — a reader that has to remember to slice will eventually forget. The
full list lives behind drone @flow list open, on request.
active_plans is a count, not a list — ruling of 2026-08-16 (Patrick, via
@devpulse). Flow's push used to publish every open-plan row here; on a branch
with 23 open plans that was 6,096 of the section's 8,552 bytes, and it was the
unbounded context the ruling exists to kill. The list left the section
entirely, and active_count collapsed into this one name. The int also matches
what @prax's refresh already writes, so the field means the same thing no
matter which writer built the section.
Card values are written per-branch on plan events, so a change to this contract
only reaches a branch that files a plan afterwards — a quiet branch keeps the
old shape indefinitely. push_flow_to_all_branch_dashboards() sweeps every
branch Flow holds plans for and is how a contract change lands fleet-wide.
Paths without a dashboard are skipped, never created.
Two writers, one section.
@prax's dashboard refresh also builds this section, wholesale (recently_closedas{plan_id, subject}, and noopen_recent/total_plans). Whichever writer ran last wins the whole section — the per-key merge above protectsquick_statusonly, not section level.active_plansnow agrees across both writers;open_recentshould still be read as present-or-absent until@prax's side is aligned.
aipass.cli— Rich terminal formatting (console,header,success,error,warning)aipass.prax— Structured logging viasystem_loggeraipass.memory— Vector intake on plan closeaipass.trigger— Error reporting (optional)
- All branches — plan creation, tracking, closure, and archival
aipass.devpulse— plan status aggregation for system dashboards- Central reporting —
PLANS.central.jsonwith per-branch plan sections (all branches, not just flow)
In PLANS.central.json, statistics.total_closed is a real count of every
plan a branch has ever closed, not the size of the recently_closed window
beside it. The two are different numbers and only coincide on branches with
five or fewer closures. recently_closed is capped at 5; total_closed is
measured by push_central from the full registry and carried through
aggregation untouched, plus anything auto-closed during the run.
- Seedgo: 100% (46 standards, 44 files, no type errors)
- Tests: 950 tests in 27 files — 969 cases collected after parametrisation, 968 pass / 1 skip. 98/98 public functions tested (100%,
drone @seedgo test_map @flow) - Source files: 44 tracked by seedgo (61
.pyfiles underapps/in total; seedgo excludes__init__.pymarkers) - Bypass rules: 59 (74 before the 2026-08-13 audit — 15 dead + 1 false-reason removed)
- Registries: 7 registered plan types + 1 orphan; 798 plans on disk, 23 open, 775 closed
- Last audit: 2026-08-25 (every figure on this list re-measured, not carried forward)
- 315 of 775 closed plans have no archived copy and cannot be restored.
Fixed 2026-08-22:
restorenow falls back to.backup/processed_plans/when the file is not at the registeredfile_path. Note the correction — the row'sfile_pathis not emptied by close; it is left pointing at where the file used to be. Measured 2026-08-25: all 775 closed rows carry afile_path, and 0 of 775 have a file there. Before that fix restore failed for every closed plan while the archive sat intact beside it. Coverage by close month: 2026-03 (198 rows) and 04 (97) are 0%, 05 is 89%, 06 is 100%, 07 is 94%, 08 is 98%. The 295 pre-May rows have no artifact to recover — that is a gap in the archive, not in restore, and it is not recoverable by code. A second, narrower refusal also applies: restore copies the archived file back to its registered directory, so a row whose original directory no longer exists is refused by name rather than re-homed. --helpadvertises full module names that the dispatcher rejects. It prints "Commands can be called by short name or full name", but 7 of 8 modules match only their short verb. It also liststemplate, which no module accepts — the working verb istemplates, absent from that list.registry statuscounts only FPLAN. It reports the default registry's totals under a system-wide label — measured 2026-08-25 it prints 401 total / 4 open, which isfplan_registry.jsonexactly, where the true figures across every registry on disk are 798 / 23. Cause:get_status_implcalls a bareload_registry(). Its quarantine list andIgnored folders: 33are branch-wide and correct; only the two totals are scoped to one type.flow_json/PLAN_REGISTRY.jsonis legacy but NOT unread. No flow code touches it, but@trigger'sapps/handlers/events/plan_file.pyboth reads and writes it (_load_registry/_save_registry), and the file's own contents are the evidence — 1 plan row againstnext_number: 402, last written 2026-07-27. An earlier edition of this README claimed "zero readers anywhere in the tree"; that was wrong. Whether @trigger's handler should be pointed at the typed registries is a question for @trigger, not a flow-side fix.flow_json/pbplan_registry.jsonis an orphaned type registry (see Auto-healing)- Registry scan fires trigger events that are never handled (by design — foreground close handles everything)
- Dashboard push warns on some closes
mbank/process.pyat 718 lines (over the 700 limit)CLOSED_PLANS.local.jsoncarries foreign keys on every branch that has one. Measured 2026-08-25: 16 of the 18 core citizens hold the file, and all 16 carry adocument_metadatablock whosedocument_typeissession_history—local.json's schema, not this file's — plus an emptykey_learningsandtodos.append_to_closed_plans()only ever appends to theclosed_planslist; it round-trips foreign keys but never creates them, and no other writer exists insrc/aipass/. @devpulse's DPLAN-0318 brief attributes it to a past push from@memory's pusher — stated there, not verifiable from flow's side. Known and deliberately NOT cleaned: a rebuild is scoped in DPLAN-0318.close_ops.pywas split intoclose_ops.py+close_helpers.py(257 lines), butclose_ops.pyhas since grown back to 848 lines — over the 700 limit, and now the longest file in the branch (mbank/process.pyis 718)push_central.pycomprehensive rewrite (2026-06-02): now pushes all branches' plans, not just flow's — fixed dashboard refresh zeroing other branches' plan counts
Last Updated: 2026-08-25