The agent factory and branch lifecycle manager for AIPass.
Module: aipass.spawn | Version: 1.0.0 | Created: 2026-03-05
- Create new branches from the one citizen template — class (
manager/specialist) is decided at mint - Update branches from templates (single or batch by class, with --dry-run)
- Delete branches (archive + deregister)
- Sync registry against filesystem
- Regenerate template registries with fresh file hashes
- Replace all
{{PLACEHOLDER}}patterns with branch-specific values - Register new citizens in
AIPASS_REGISTRY.json - Mint each citizen's own
citizen_idat birth — and a brand-new external project's own registry credential
# Create a new branch
drone @spawn create /path/to/my_agent --role "Analyst" --purpose "Data reports"
# Preview an update before applying
drone @spawn update @my_agent
# Apply the update
drone @spawn update @my_agent --apply
# Check registry health
drone @spawn sync-registryEvery branch belongs to a citizen class, which determines its template:
| Class | What It Means |
|---|---|
manager |
A project's first citizen (citizen #1) — manages the project. ai_mail's wake-block keys on it: managers are emailed, never dispatched |
specialist (default) |
Every citizen minted after the first |
Both classes mint from the one template, templates/citizen/ — full 3-layer scaffold:
.trinity/, .aipass/, apps/ (modules/ + handlers/ incl. json shim), tests/, docs/, logs/ —
50 files, 24 dirs. The class is a behavioral label in identity.citizen_class, not a
choice of scaffold shape (DPLAN-0319), which is why the template directory is named for
what it is rather than for a class.
The class is decided at mint, not typed. Citizen #1 of a project is born manager,
everyone after is specialist. An explicit class still wins if you pass one. The retired
names aipass_framework, project_agent and builder refuse loudly at every entry
point — they are never silently remapped, because a passport that quietly disagrees with
the value a caller typed is the exact drift the rename ends.
admin is permanently refused as a class or --template value — see Grant Admin below.
Class is resolved from the passport, not guessed. A leading positional is read
as a target path only when it is either a known class (create <class> <path>) or
carries an explicit path marker (a separator, ~, ./.., or @). A bare token
with neither — create wizard — refuses by name instead of silently making a
branch called WIZARD in ./wizard (APLAN-0007, fixed).
All commands run through drone @spawn <command>.
drone @spawn create <path> # Class decided at mint (manager if first, else specialist)
drone @spawn create <path> --role "Analyst" --purpose "Reports" # With identity
drone @spawn create <path> --dry-run # Preview without touching disk
drone @spawn create @existing # Adopt pre-existing agent
drone @spawn create ~/Projects/MyProject/agent_name # External project (targets that project's own registry)Update is preview-only by default — --apply required to execute changes.
drone @spawn update @branch_name # Preview changes (dry-run default)
drone @spawn update @branch_name --apply # Execute changes
drone @spawn update specialist --all --apply # All specialist-class branches
drone @spawn update @branch_name --dry-run # Explicit preview (same as default)drone @spawn delete @branch_name --dry-run # Preview
drone @spawn delete @branch_name --yes # Archive + deregisterDelete refuses protected branches, and every live citizen is protected.
is_protected() guards three layers, any one sufficient: the hardcoded floor
(spawn, devpulse, drone), a registry entry carrying owner: true, and a passport
with citizenship.registered: true. Since create writes registered: true, a
branch is protected from the moment it exists. There is no --force — retiring a
citizen means clearing that passport flag first.
Verified live 2026-08-13 (APLAN-0007): the infrastructure (devpulse, drone, spawn)
and active citizen refusals both fire and exit 1. The registry owner layer is
unreachable in the live fleet — devpulse is the only entry carrying
owner: true and it short-circuits at layer 1, so no branch can reach layer 2.
It is covered by unit tests against a synthetic registry, not by live behaviour.
drone @spawn sync-registry # Report healthy/stale/unregistered (registry found from CWD)
drone @spawn sync-registry <project_path> # Same report against another project's registry
drone @spawn sync-registry --fix # Rebuild .spawn/ tracking, register strays, fix passport registry_ids
drone @spawn sync-registry --fix --dry-run # Preview what --fix would change
drone @spawn sync-registry --check [--json] # Owner/identity health check only — never writes
drone @spawn regenerate-registry # Regenerate the citizen template hashes
drone @spawn regenerate-registry specialist # Named class (both classes share one template)
drone @spawn regenerate-registry --all # Every template directory
# Passport 2.0 migration — one-shot, dry-run by default (DPLAN-0319)
drone @spawn migrate-passports # Measure every fleet passport, write nothing
drone @spawn migrate-passports --only @canary # Restrict to one branch
drone @spawn migrate-passports --confirm # Execute: backup to passport.json.pre_v2_backup, then write
# Repair — the bare scan is read-only ALWAYS; only --relocate and --clean-pollution execute, and both need --apply
drone @spawn repair <project_path> # Scan: pollution + registry path mismatches (read-only)
drone @spawn repair --relocate @branch src/pkg/branch --apply # Move branch to new location
drone @spawn repair --relocate @branch path --relocate-artifacts --apply # Move + .chroma/
drone @spawn repair <project_path> --clean-pollution --apply # Archive + remove duplicate dirsrepair <project_path> --apply is not an execute mode — repair_project() reports and
never writes, so the flag changes nothing on the scan path. It is the two submodes that
act, and each refuses to act without --apply.
The devpulse-only admin privilege (DPLAN-0288). Spawn owns exactly one leg of it:
the admin: true flag on the devpulse registry entry. The flag grants nothing on
its own — the dispatch lane verifies five legs (verified caller, cert path, cert
content, HMAC signature, this flag).
drone @spawn grant-admin # Write admin:true on the devpulse entry
drone @spawn grant-admin --registry /path/AIPASS_REGISTRY.json # Explicit registryThere is no branch argument: the seat is a constant. admin is also permanently
refused as a citizen class or template value — create, update and sync all
say no by name. Admin is never minted from a template; only Patrick's ceremony
grants it.
drone @spawn # No args — lists connected modules
drone @spawn --help # Full help text
drone @spawn --version # Version stringapps/handlers/ is internal to this branch; its __init__ refuses cross-branch
imports and points callers here. Other branches that need to resolve a
citizen_class read spawn's registry through the modules gateway rather than
mirroring the class table — a mirror makes the reader a fleet-wide single point
of failure the moment spawn renames a class.
from aipass.spawn.apps.modules import get_template_dir, refuse_legacy_class
get_template_dir("specialist") # -> Path(.../templates/citizen)
get_template_dir("aipass_framework") # -> ValueError naming the retired name AND 'specialist'
get_template_dir("admin") # -> ValueError, permanent refusal
get_template_dir("wizard") # -> ValueError listing the registered classes
refuse_legacy_class("aipass_framework") # -> the rename message
refuse_legacy_class("specialist") # -> "" (not a retired name)get_template_dir already refuses forbidden, retired and unknown values by
name, so "resolve, or tell me why not" is one call plus try/except ValueError.
refuse_legacy_class is the separate lane for callers that must distinguish
"this passport has not been migrated yet" from a hard error.
from aipass.spawn import spawn_agent
result = spawn_agent(
"/path/to/new/agent",
role="Data Analyst",
purpose="Process incoming reports",
traits="Precise, thorough"
)
# Returns on success: { success, branch_name, path, files_copied, dirs_created,
# files_skipped, renamed, registry_updated, registry_path,
# citizen_number, validation_issues }
# Returns on failure: { success: False, error, ...the same counters, zeroed }spawn/
├── __init__.py # Public API (exports spawn_agent)
├── apps/
│ ├── spawn.py # Entry point — CLI routing, version, help
│ ├── modules/
│ │ ├── core.py # Create orchestrator (_spawn_agent, handle_command)
│ │ ├── update.py # Update CLI — single/batch by class
│ │ ├── delete.py # Delete CLI — archive + deregister
│ │ ├── sync_registry.py # Registry repair CLI
│ │ ├── regenerate_registry.py # Template registry regeneration CLI
│ │ ├── migrate_passports.py # One-shot fleet passport 2.0 migration CLI
│ │ ├── export_seeds.py # Tracked passport seeds CLI — live → .aipass/passport.seed.json, dry-run default
│ │ ├── repair.py # Structural repair CLI — scan, relocate, clean pollution
│ │ └── grant_admin.py # Admin flag ceremony CLI (devpulse-only)
│ ├── handlers/
│ │ ├── class_registry.py # Citizen class → template directory mapping
│ │ ├── file_ops.py # Template copy, path renaming, registry regeneration
│ │ ├── metadata.py # Branch name extraction, profile detection
│ │ ├── placeholders.py # {{PLACEHOLDER}} replacement engine
│ │ ├── passport_migration.py # Passport 1.x → 2.0 structure migration
│ │ ├── seed_ops.py # Passport seeds — build/validate/mint-from-seed, machine-local strip, stamp
│ │ ├── registry.py # Registry CRUD, find_registry(), project credential mint
│ │ ├── meta_ops.py # Branch metadata generation, hash computation
│ │ ├── mint_verify.py # Read-only completeness check of a mint vs the template manifest
│ │ ├── receipt_ops.py # Birth receipt — stamps .trinity/.template_version.json at mint
│ │ ├── update_ops.py # Update workflow (path-based template walk)
│ │ ├── delete_ops.py # Delete workflow (resolve → archive → cleanup → deregister)
│ │ ├── sync_registry_ops.py # Registry sync (CWD-aware, external project support)
│ │ ├── regenerate_registry_ops.py # Template registry hash regeneration
│ │ ├── repair_ops.py # Pollution detection, branch relocation, registry path repair
│ │ ├── json_ops.py # JSON deep merge, backup utilities
│ │ ├── atomic_write.py # Atomic text write primitive (stage → fsync → os.replace)
│ │ └── json/
│ │ └── json_handler.py # JSON I/O + operation logging — 9 functions over aipass.aipass.shared
│ ├── json_templates/ # Package marker for JSON template assets
│ └── plugins/ # Package marker — no plugins shipped
├── templates/
│ ├── citizen/ # The one citizen template (50 files, 24 dirs)
│ └── .archive/ # Retired templates (aipass_framework, project_agent, birthright)
├── tests/ # 27 test files, 707 tests
├── spawn_json/ # JSON tracking directory
├── tools/ # Branch verification utilities
├── docs/ # Documentation
└── logs/ # Prax log output
- Entry point (
spawn.py) — Routes CLI commands, never imports handlers directly - Modules (
modules/) — Business logic coordinators, parse arguments, delegate to handlers - Handlers (
handlers/) — Implementation details, pure functions where possible
- Resolve — Extract the branch name from the target path and refuse a target that sits inside another citizen's tree (any parent holding
.trinity/passport.json). An existing directory that already has a passport is adopted instead of refused (see Adopt Existing) - Lookup — Resolve the citizen class to a template directory via class_registry
- Credential — Resolve the target project's registry credential (
metadata.id) before anything is written.load_registryMINTS a fresh uuid4 when the registry file is missing — that is what a brand-new external project is, and it stops its first citizen inheriting AIPass's own id. A registry that exists but will not parse deliberately gets no mint (id-less schema plus a logged warning): that is a live project whose credential we failed to READ, and inventing a replacement would re-credential it and orphan every passport already carrying the real one. The resolved value is handed toadd_to_registry, which adopts it only when it is creating the registry file — keyed offregistry_path.exists()captured BEFORE the load, because aload_registrythat always returns an id makes "is it already set?" useless as a guard (2026-08-24) - Copy — Recursive copy of the class template to target (skips
__pycache__) - Rename — Replace
{{BRANCH}}in directory and file names - Replace — Substitute all
{{PLACEHOLDER}}patterns in file contents, including{{CITIZEN_CLASS}}(sourced from the create call, not a baked literal) - Identity ids — Mint the citizen's own UUID ONCE and use it twice: stamped into the passport as
citizenship.citizen_id(the citizen's unique id, rendered by faces as the passport number) and written as theregistry_idof itsbranches[]registry entry. Minting it at registration time instead would be too late — the passport is written before the registry, so the two copies would be different UUIDs for one citizen. Distinct fromcitizenship.registry_id, which is the id of the REGISTRY holding the citizen and is shared by every citizen in a project (Patrick's ruling, 2026-08-24) - Meta — Generate
.branch_meta.json(meta tabs load from@memorywhen available, degrading gracefully to empty when it's not) - Verify — Compare the minted tree against the template's own manifest (
.spawn/.template_registry.json) and its on-disk contents. A file the template claims but the mint never produced REFUSES the create, names every missing path, and never reaches the registry — a gitignored template file used to mint a citizen with an emptyartifacts/and noinbox.jsonwhile printing "Agent created" (2026-08-17). Custom--template <dir>trees carry no manifest and are verified against their own contents only. The partial tree is deliberately left on disk for inspection - Receipt — Stamp
.trinity/.template_version.json: which trinity template version this citizen carries, in @memory's four-key shape (template_versions,stamped,stamped_by: "spawn birth",config_rendered). The versions are read from the fleet's GOLD source (memory/templates/*.template.json→document_metadata.schema_version), never from spawn's own seeds — reading the seeds would let a drifted copy mint a receipt claiming a version the fleet never issued, and the lie would score green. Shape copied, never imported: birth must not fail because another branch's package does not import. A gold source that cannot be read stamps NOTHING and surfaces the miss invalidation_issues— a receipt naming an unverifiable version is worse than an absent one, but a citizen unborn because @memory's files are unreadable is worse than both - Registry — Register in the target project's own
AIPASS_REGISTRY.json. Placed after step 10 deliberately: a registered citizen always carries a receipt - Owner — Ensure at least one citizen in that project carries
owner: true - Validate — Scan for any remaining
{{...}}patterns
The ID-based change-detection engine was replaced (P1 rewrite, TDPLAN-0006). The current engine walks the template and decides per path — no renames, no pruning, no snapshot phase.
- Resolve — Branch path from the registry, citizen class from its passport, template directory from the class
- Directories — Walk the template's directories and create any the branch is missing
- Files — Walk the template's files and decide per file: missing → add (placeholders replaced, atomically written) · existing
.py→ skip by design · existing.json→ deep merge (existing values win) ·passport.json→ heal against a narrow allowlist only (DPLAN-0262) · create-only paths → skip entirely - Refresh — Regenerate
.branch_meta.jsonwith current state
Create-only (never re-added, never overwritten): everything under .trinity/
except the passport heal, everything under .ai_mail.local/ (a live mailbox is
@ai_mail's data, not spawn's), plus DASHBOARD.local.json,
artifacts/birth_certificate.json, .seedgo/bypass.json and
tests/test_scaffold.py.
- Fix — Repair
registry_idin passport if stale (from registry recreation) - Register — Add to project registry, then ensure the project has an owner
- Receipt — Stamp
.trinity/.template_version.jsononly if the directory has none. Adoption fills a hole; it never restamps. A branch @memory's push already stamped carries"memory push", and overwriting that with"spawn birth"would replace a true record of which lane last touched those files with a false one - Update — Run template update to sync scaffold files
764 passed | 1 skipped | 0 failed across 27 test files (765 collected — parametrized cases expand),
measured 2026-08-28 from the repo root and from the branch directory (same tally both ways). The one skip is test_scaffold.py: the shipped
scaffold smoke test skips by design once a branch has a real conftest (see Known Issues).
| File | Focus |
|---|---|
test_lifecycle.py |
End-to-end spawn lifecycle workflows |
test_json_handler.py |
JSON I/O, operation logging, standard API |
test_handlers.py |
Handler function behavior and integration |
test_modules_gateway.py |
The modules-package gateway other branches import through |
test_passport_migration.py |
Passport 1.x → 2.0 fleet migration: order, drops, renames, idempotency |
test_passport_birth_schema.py |
2.0 block and key order on a newly minted passport |
test_regenerate_registry_ops.py |
Template registry regeneration |
test_update.py |
Branch update mechanics |
test_citizen_classes.py |
Citizen class validation and template discovery |
test_file_ops.py |
File copy, rename, placeholder replacement |
test_cli_routing.py |
Command routing and argument parsing |
test_contracts.py |
Handler contracts and interface compliance |
test_spawn.py |
Basic CLI routing and help |
test_error_resilience.py |
Error handling and edge cases |
test_check_fix_identity.py |
Owner/identity check and fix (DPLAN-0239 P4) |
test_admin_fence.py |
Admin grant ceremony + permanent admin-class refusal (DPLAN-0288) |
test_owner_resolver.py |
Owner resolution + is_protected() protection layers |
test_passport_drift.py |
Fleet passport drift canary |
test_template_hygiene.py |
Template content invariants |
test_output_streams.py |
stdout/stderr routing |
test_repair.py |
Structural repair + relocation |
test_citizen_id.py |
citizen_id minted once — passport and registry entry always agree |
test_registry_credential.py |
Credential mint asymmetry: missing registry mints, unreadable never does |
test_json_durability.py |
Torn-write durability — atomic writes across every JSON/text path |
test_birth_receipt.py |
Birth receipt lane — gold versions, receipt shape, seed-vs-gold drift, retire carries .trinity |
test_scaffold.py |
Shipped scaffold smoke test (skips once a real conftest exists) |
conftest.py |
Fixtures: mock templates, registry protection |
Public functions: 60 total, 60 tested (100%)
- aipass.prax — Logging via
system_logger - aipass.cli — Console output (header, error, warning)
- aipass.aipass.shared —
json_handler(the real implementation behind spawn's shim),json_ops(deep_merge,backup_json),registry_discovery.find_registry - aipass.memory (optional) —
tab_renderer.render_all_meta_tabsfor meta tabs at create; import is guarded and degrades to empty - Python stdlib (
pathlib,json,shutil,hashlib,re,argparse,uuid)
- All branches — creation, template updates, registry management, citizenship
- Registry: CRUD operations on
AIPASS_REGISTRY.jsonand*_REGISTRY.json
A citizen minted from templates/citizen/ audits 100% against the CI gate on
its first day — verified 2026-08-22 by minting one and running
.venv/bin/python .github/scripts/seedgo_audit.py, the real gate, floor 100.
Before this the same mint scored 79% and failed the gate, having earned none of
it (@canary's finding: their entry point was byte-identical to the template
apart from name substitution).
Trinity: 100/100 on both classes, live-measured 2026-08-27 by minting a
citizen and running @seedgo's trinity checker against it. It scored 77 before
this: the receipt group at 0 and the file set at 80 (no
.trinity/.template_version.json existed until birth stamped one), top-level
keys at 78 (the seeds carried a document_metadata.status block the standard
deletes, and stamped managed_by in the wrong case), and meta lines at 0 (the
seeds' _usage prose and meta lines had drifted from @memory's gold templates).
The .trinity seeds are now derived from those gold templates, and
tests/test_birth_receipt.py pins them byte-for-byte — the pin goes red the
moment @memory bumps, which is the only honest way to hold a copy.
Two starter test suites ship at birth (tests/test_cli_routing.py,
tests/test_json_handler.py) because test_quality cannot reach 100 without
real tests. They are listed in the template's .spawn/.registry_ignore.json:
seedgo's architecture baseline treats every template file as a structural
requirement of every branch of that class, so adding them without that entry
dropped 9 existing branches to 99% and red-boarded the gate. Measured, not
assumed — the exclusion is what keeps a template addition from being a fleet-wide
mandate.
.pyfiles never auto-update duringdrone @spawn update(by design) — template .py changes need individual branch dispatchtests/test_scaffold.pyships at create and is never re-added on update (_NEVER_UPDATE_FILES). In a branch with a real conftest it can only skip, so it cannot inform — @seedgo ruling, DPLAN-0291
- Seedgo: 100% with bypasses, 98% without — both re-measured 2026-08-25 (17 live bypass rules; the two newest,
atomic_write.pyandmint_verify.py, date from 2026-08-16/17) - Tests: 764 passed, 1 skipped, 0 failed (2026-08-28, both rootdirs)
- Module coverage: 27/27 files (100%)
- Template registry: 50 files, 24 dirs (citizen — the one template both classes mint from)
- Live command sweep: 29/29 paths pass, incl. error and refusal paths (APLAN-0007, 2026-08-13)
Last Updated: 2026-08-25