Merge pull request #631 from AIOSAI/dev

Seedgo hook-cruft purge + raise CI standards floor to 100%

Two changes:
1. refactor(seedgo): archive pre-DPLAN-0184 hook cruft (FPLAN-0241) — orphaned bridge/probe/manifest modules + tests moved to .archive/; README/bypass/prompts updated. 1045 tests green.
2. ci(seedgo-audit): raise the standards floor 80%->100%.

NOTE — the seedgo-audit check will go RED by design. With the 100% floor, the 6 branches at 99% (aipass/api/drone/flow/prax/seedgo) are now caught. This PR is to OBSERVE the gate enforcing in CI; it is not merge-bound until those 6 branches reach a genuine 100%.
This commit is contained in:
AIPass
2026-06-08 00:18:23 -07:00
committed by GitHub
209 changed files with 5377 additions and 8847 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ Goal: signal density over prose. Prompts are injected every turn — every line
# What NOT to put in a prompt
- Session state, current work, in-flight issues. That goes in `STATUS.local.md` and `.trinity/local.json`.
- Session state, current work, in-flight issues. That goes in `.trinity/local.json` (todos[]) and `DASHBOARD.local.json`.
- Long explanations of how a system works. Plant a breadcrumb ("see `@branch --help`") and move on.
- Personal notes ("remember, you like short replies"). That goes in `.trinity/observations.json`.
- Version numbers, PR numbers, dates. Those rot within days.
+7 -10
View File
@@ -11,7 +11,7 @@ Patterns here are exact. Don't guess command syntax — examples are the API. Mi
# AIPL — Terse Writing Convention
When writing .trinity/, ai_mail, STATUS.local.md, plans: use AIPL. Human-facing output (CLI, logs, README): use English.
When writing .trinity/, ai_mail, plans: use AIPL. Human-facing output (CLI, logs, README): use English.
Rules:
- Drop grammar: the, a, an, for, with, on, in, at, to, from, of, by, and, but, or, was, were, been
@@ -151,19 +151,16 @@ Never create plan files manually. Always `drone @flow create`. Flow handles numb
`.trinity/` files are your memories — experiential, personal, yours. How you persist across sessions.
`STATUS.local.md` is different — live status beacon for ecosystem. Auto-synced to central `STATUS.md` on PR create/merge. Other agents read STATUS to see your state without digging into memories. Crossover with `local.json` fine — same fact, different purpose: `local.json` for you, `STATUS.local.md` for ecosystem.
Four files:
Three files:
- `passport.json` — IDENTITY. Role, purpose, principles. Update only when identity genuinely evolves.
- `local.json` — YOUR MEMORY. Session log (`sessions[]`) + `key_learnings`. What happened, what learned, what matters next.
- `local.json` — YOUR MEMORY. Session log (`sessions[]`) + `key_learnings` + `todos[]`. What happened, what learned, what matters next.
- `observations.json` — MEMORY OF THE USER. Preferences, style, friction, breakthroughs. Skip if nothing new this session.
- `STATUS.local.md` — PUBLIC BEACON. Current work, issues, todos, recently completed. Notepad for quick captures.
Where to put what:
- "Worked on DPLAN-0125, learned about peak hours" → `local.json`
- "User prefers short replies" → `observations.json`
- "PR #266 needs merge, Track G blocked" → `STATUS.local.md`
- "Fix drone help formatting" as reminder → `STATUS.local.md` Notepad
- "PR #266 needs merge, Track G blocked" → `local.json` todos[]
- "Fix drone help formatting" as reminder → `local.json` todos[]
- "Role shifted from builder to orchestrator" → `passport.json`
Save proactively. Triggers: after milestone, decision, learning, before switching topics.
@@ -195,7 +192,7 @@ Use sub-agents for:
Do it yourself only when:
- User explicitly asks you to read or look at something
- Tiny edits — fix a typo, update a memory file, small config change
- Writing memories, STATUS, plan updates (your own files)
- Writing memories, plan updates (your own files)
- Quick one-line commands — drone status, inbox check
How to use them:
@@ -247,7 +244,7 @@ Small knowledge traces trigger awareness. Not full knowledge — enough to know
Prompts: plant breadcrumbs, not encyclopedias. Two lines ("this exists, look here") beat twenty explaining how.
Prompts are signposts, not journals. Injected every turn — keep minimal. Never track state/sessions/context in prompts. State → `.trinity/` + `STATUS.local.md`. Prompts guide; memories record; registries catalog.
Prompts are signposts, not journals. Injected every turn — keep minimal. Never track state/sessions/context in prompts. State → `.trinity/` + `DASHBOARD.local.json`. Prompts guide; memories record; registries catalog.
If `drone` can't find the AIPass registry, set `AIPASS_HOME=/path/to/AIPass` in shell profile and `~/.claude/settings.json` env block.
+12
View File
@@ -22,6 +22,12 @@
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.global_loader.handle",
"matcher": ""
},
"auto_process": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
"matcher": "",
"timeout": 120
}
},
@@ -104,6 +110,12 @@
"handler": "aipass.hooks.apps.handlers.lifecycle.rollover.handle",
"matcher": "",
"timeout": 120
},
"auto_process": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
"matcher": "",
"timeout": 120
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ Agent workspace powered by AIPass.
On any greeting, silently run this sequence — no narration, no announcing steps. Just do it and respond with the status.
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
+2 -4
View File
@@ -14,9 +14,8 @@ Purpose: Button up everything at the end of a session — or before a /compact.
Each memory file plays a distinct role. Update based on what actually changed this session.
- **`.trinity/passport.json`** — IDENTITY. Who you are: role, capabilities, principles. Only update if identity genuinely evolved this session.
- **`.trinity/local.json`** — YOUR MEMORY. Add/update session entry with a summary of work done. Add key_learnings for anything learned. Trim oldest sessions if over 20.
- **`.trinity/local.json`** — YOUR MEMORY. Add/update session entry with a summary of work done. Add key_learnings for anything learned. Update todos[] with current in-flight items. Trim oldest sessions if over 20.
- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points. Skip if nothing new about the user this session.
- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known issues, todos, notepad. Auto-synced to central STATUS.md on PR events — this is how other branches see you. Keep Current Work accurate.
## 2. Active Plans
@@ -38,7 +37,7 @@ Each memory file plays a distinct role. Update based on what actually changed th
## 5. Loose Ends
- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions
- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to STATUS.local.md Notepad
- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to local.json todos[]
## Confirm
@@ -47,7 +46,6 @@ List everything updated. Format:
Prep complete:
- local.json: [what was added]
- observations.json: [updated / skipped]
- STATUS.local.md: [updated / skipped]
- Plans: [which ones updated]
- Git: [branch, uncommitted count, suggestion]
- Inbox: [count, action taken]
+1 -2
View File
@@ -14,9 +14,8 @@ Purpose: Update branch memory files after completing work this session.
Each memory file plays a distinct role. Update based on what actually changed this session.
- **`.trinity/passport.json`** — IDENTITY. Who you are: role, capabilities, principles. Only update if identity genuinely evolved this session. Don't touch it just to touch it.
- **`.trinity/local.json`** — YOUR MEMORY. Session history and key_learnings. Add a session entry for significant work. Add key_learnings for facts you'd need next time. Trim oldest sessions if over 20.
- **`.trinity/local.json`** — YOUR MEMORY. Session history, key_learnings, and todos[]. Add a session entry for significant work. Add key_learnings for facts you'd need next time. Update todos[] with open items. Trim oldest sessions if over 20.
- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points, flow states. Skip entirely if nothing new about the user this session.
- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known issues, todos, notepad. Auto-synced to central STATUS.md on PR events — this is how other branches see you. Keep Current Work accurate and drop quick notes in the Notepad section.
## If Relevant
-1
View File
@@ -25,4 +25,3 @@ Purpose: Update branch memory files after completing work this session.
- **.trinity/passport.json** — Evolve identity when the branch's role, capabilities, or principles have genuinely changed. Don't update just to update — but don't leave placeholders forever either.
- **README.md** — Does it reflect current state? Update if stale.
- **STATUS.local.md** — Drop quick notes on issues, todos, or ideas in the Notepad section.
+1 -1
View File
@@ -38,7 +38,7 @@ Purpose: Button up everything at the end of a session — or before a /compact.
## 5. Loose Ends
- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions
- If anything can't survive compaction, write it to STATUS.local.md Notepad
- If anything can't survive compaction, write it to local.json todos[]
## Confirm
+18 -3
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from aipass.seedgo.apps.handlers.audit.branch_audit import audit_branch
from aipass.seedgo.apps.handlers.bypass.bypass_handler import load_bypass_rules
THRESHOLD = 80
THRESHOLD = 100
src = Path("src/aipass")
pack = src / "seedgo/apps/handlers/aipass_standards"
@@ -30,12 +30,27 @@ for branch in branches:
avg = result.get("average", 0)
print(f" {branch['name']:>12}: {avg:.0f}%")
if avg < THRESHOLD:
failed.append((branch["name"], avg))
failed.append((branch["name"], avg, result))
if failed:
print(f"\nFAILED: {len(failed)} branch(es) below {THRESHOLD}%")
for name, score in failed:
for name, score, result in failed:
print(f" {name}: {score:.0f}%")
# Name the failing standards + the specific checks that did not pass,
# so CI logs say WHY (not just the percentage). Critical for diagnosing
# working-tree-vs-clean-checkout divergence.
scores = result.get("scores", {})
results = result.get("results", {})
for std, sc in scores.items():
if sc < 100:
checks = results.get(std, {}).get("checks", [])
msgs = [
c.get("message", "")
for c in checks
if not c.get("passed", True)
]
detail = " | ".join(m for m in msgs if m)[:400]
print(f" └ {std}: {sc:.0f}% {detail}")
sys.exit(1)
else:
print(f"\nAll {len(branches)} branches pass (>={THRESHOLD}%)")
+14 -1
View File
@@ -47,12 +47,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Full history: the README-freshness check reads `git log` to find the
# last commit touching each branch's .py. A shallow (depth-1) checkout
# makes every file look born at HEAD, so every README false-fails as
# "stale". Full history makes CI match a local audit exactly.
fetch-depth: 0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"
- run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
# Install the `memory` extra (numpy/chromadb/fastembed) alongside dev:
# the diagnostics standard runs pyright over every branch, and memory's
# handlers import chromadb/numpy. Without these deps installed, pyright
# reports them as unresolved imports (reportMissingImports=error) and
# memory scores <100 — a false failure from a missing CI dep, not a code
# defect. Installing the declared extra lets pyright resolve them so the
# audit measures real type-correctness (and matches a local audit).
pip install -e ".[dev,memory]"
- name: Run seedgo standards audit
run: python .github/scripts/seedgo_audit.py
+5 -10
View File
@@ -2,19 +2,14 @@ name: macOS Test
on:
workflow_dispatch:
# Run on every push/PR to main/dev — NOT path-filtered. This is a branch-
# protection *required* check; a path filter makes it skip on unrelated PRs,
# which GitHub then parks as "Expected — waiting for status" forever, blocking
# the merge. Required checks must run on every PR to report a status.
push:
branches: [main, dev]
paths:
- 'setup.sh'
- 'src/aipass/*/apps/handlers/__init__.py'
- 'src/aipass/drone/cli.py'
- 'pyproject.toml'
pull_request:
paths:
- 'setup.sh'
- 'src/aipass/*/apps/handlers/__init__.py'
- 'src/aipass/drone/cli.py'
- 'pyproject.toml'
branches: [main, dev]
permissions:
contents: read
+9 -2
View File
@@ -22,10 +22,17 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"
- run: pip install pip-audit
# Upgrade pip first: pip-audit scans the whole environment, and the
# runner's bundled pip (26.1.1) carries advisory PYSEC-2026-196 (fixed in
# 26.1.2). Upgrading removes the vulnerable version outright rather than
# suppressing it — and 26.1.2 also resolves CVE-2026-3219 / CVE-2026-6357,
# which is why those two stale --ignore-vuln entries are no longer needed.
- run: |
python -m pip install --upgrade pip
pip install pip-audit
- run: pip install -e .
- name: Pip audit
run: pip-audit --skip-editable --ignore-vuln CVE-2026-3219 --ignore-vuln CVE-2026-6357
run: pip-audit --skip-editable
codeql:
runs-on: ubuntu-latest
+5 -10
View File
@@ -2,19 +2,14 @@ name: Windows Test
on:
workflow_dispatch:
# Run on every push/PR to main/dev — NOT path-filtered. This is a branch-
# protection *required* check; a path filter makes it skip on unrelated PRs,
# which GitHub then parks as "Expected — waiting for status" forever, blocking
# the merge. Required checks must run on every PR to report a status.
push:
branches: [main, dev]
paths:
- 'setup.sh'
- 'src/aipass/*/apps/handlers/__init__.py'
- 'src/aipass/drone/cli.py'
- 'pyproject.toml'
pull_request:
paths:
- 'setup.sh'
- 'src/aipass/*/apps/handlers/__init__.py'
- 'src/aipass/drone/cli.py'
- 'pyproject.toml'
branches: [main, dev]
permissions:
contents: read
+2 -2
View File
@@ -110,7 +110,6 @@ src/aipass/*/apps/integrations/**
!src/aipass/spawn/templates/builder/docs.local/
!src/aipass/spawn/templates/builder/docs.local/**
!src/aipass/spawn/templates/builder/DASHBOARD.local.json
!src/aipass/spawn/templates/builder/STATUS.local.md
# CI artifacts
windows-pytest-results/
@@ -125,4 +124,5 @@ branch_audits/
claude_4_7_transition_notes.md
README_ORIGINAL_DISABLED.md
*.bak
test/
test/
sandbox_test/
+1 -1
View File
@@ -8,7 +8,7 @@ User: user
On any greeting, silently read these files from CWD and run the commands — no narration, no announcing steps. Just do it and respond with the status.
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
- Check: `drone @ai_mail inbox` — process any mail, don't ask.
- Run: `drone @git status`
+206
View File
@@ -10,8 +10,151 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
## [2026.W23] - 2026-06-02
### Fixed
- **`aipass init` scaffold correctness.** A fresh `aipass init` now generates a
project-specific `AGENTS.md` (new `agents_md()` generator) instead of falling
back to copying AIPass's own repo-root `AGENTS.md` boilerplate — Codex users
were getting the wrong file. Project `README.md` quick-start/structure paths
now reflect the real `src/<package>/<agent>/` layout.
- **First-agent default name `my-agent` → `my_agent`.** `aipass init` seeded its
default agent with a hyphen, the lone source of a long-standing dir-vs-module
mismatch (the directory kept the hyphen while the importable module, `@address`
and registry name all normalize to underscore). Defaulting to `my_agent` makes
directory, module, `@address` and the README example all consistent.
- **Dead `citizenship.registry_path` removed from spawn templates.** The field
pointed at a non-existent `.aipass/registry.json`; it was never read anywhere
(registry is located by `find_registry()` glob), so it's dropped from the
`builder` and `birthright` passport templates.
### Removed
- **The entire STATUS flow is decommissioned (TDPLAN-0007).** The per-branch
hand-maintained `STATUS.local.md` beacon and the auto-aggregated central
`STATUS.md` (853 lines / 70 KB nobody read) are gone — deleted from disk
across all 13 branches and scrubbed from every prompt, doc, startup protocol,
`/prep` + `/memo` skill, the compact-recovery hook, the email footer, and
`aipass init` / spawn scaffolding. Live branch state was already fully covered
by `DASHBOARD.local.json` (prax) and history by `.trinity/local.json`. The
status-sync engine is kept **intact but inert** — made dormant by unwiring its
3-line trigger registration (`trigger registry.py`), so the code stays
revivable. The one thing STATUS uniquely gave us — a quick scratch todo — is
replaced by an operational `todos[]` section in `.trinity/local.json`
(@memory-owned schema, capped, never vectorized by rollover), pushed to all 13
branches and surfaced as a `todo_count` on the dashboard. Shipped as one
coordinated cross-branch change (memory, prax, trigger, aipass, spawn, hooks,
ai_mail, seedgo + devpulse).
### Changed
- **All 13 branches at seedgo 100% under the new introspection standard.**
Wrapped `print_introspection()` output in Rich markup across ai_mail, drone,
spawn, trigger, prax and devpulse (the rest were already compliant) —
presentation only, no logic change — so `drone @branch` with no args renders
consistent styled output everywhere.
- **CLI polish for human-facing output.** `drone @hooks --help` rewritten (Rich,
with `hooksound on/off/status` now surfaced); `drone @spawn` repair help
clarified as distinct from `update` and showing the preview/`--apply` flow;
drone restores Rich colour on human-facing routed output (`--help`,
introspection, `status`) via the inherit path.
- **Spawn backups land in one namespace `.spawn/.recovery/` (TDPLAN-0006 P4).**
Spawn's pre-merge JSON backups previously dropped a `.recovery/` directory at
each branch root (which had accumulated 242 stale auto-generated `DASHBOARD`
backups across 10 branches). `aipass.common.json_ops.backup_json` gained an
optional `backup_dir` parameter (default unchanged), and spawn's update engine
now directs backups to `{branch}/.spawn/.recovery/` — tucked under the
spawn-managed `.spawn/` dir instead of cluttering the branch root. Memory stays
in the safety net (the engine simply never touches `.trinity/`/`DASHBOARD` on
update, so it never needs to back them up). Stale `.recovery/` backups cleaned
up. (315 tests, seedgo 100%.)
- **No more cross-branch engine imports — `aipass init update` calls spawn via
subprocess (TDPLAN-0006 P3).** `init_flow.py` previously did
`from aipass.spawn.apps.modules.sync_registry import sync_registry` — the one
place aipass reached directly into spawn's Python. Replaced with a subprocess
call to the already-existing `drone @spawn sync-registry --fix` (same pattern as
`aipass init agent` → `drone @spawn create`), preserving graceful degradation
(a missing `drone`, non-zero exit, or timeout is silently skipped — registry
sync never hard-fails an update). The aipass branch now has **zero** direct
imports of another branch's engine code; the remaining cross-branch imports are
shared service layers only (cli Rich UI, prax logging, trigger events). (438
tests, seedgo 100%.)
- **`aipass.common` shared library — dedup spawn/aipass scaffold machinery
(TDPLAN-0006 P2).** `@spawn` and `@aipass` each carried their own copy of the
JSON merge/handler utilities and registry discovery. Extracted them into a new
branch-free package `src/aipass/common/` (`json_ops` = `deep_merge` +
`backup_json`; `json_handler.JsonHandler`; `registry_discovery.find_registry`)
that both branches now import. `aipass.common` imports **zero** branch code, so
`aipass/bootstrap.py` (which runs before the drone runtime exists) can depend on
it without breaking the pre-infrastructure constraint. The duplicated copies are
deleted (spawn keeps a thin re-export shim; aipass's `json_handler` shrank
254 → 88 lines). The `save_json` contract is unified to **raise `ValueError`**
on invalid structure across both branches. (313 spawn + 434 aipass tests, both
seedgo 100%.)
### Fixed
- **Flow plan-type self-serve UX — register override, help, orphan cleanup.**
Explicit `drone @flow register <dir> <PREFIX>` now overrides an auto-derived
prefix instead of silently failing (guarded — refuses if the auto-registered
type already holds plans), so custom prefixes are settable when adding a new
plan type. `create`/`templates --help` rewritten to dynamically list registered
types + templates and document the add-a-new-type workflow. Stale orphan plan
registries removed; dead `prefix_exists()` dropped. (728 tests, seedgo 100%.)
- **`drone @spawn update` no longer scrambles branches (#636, critical — TDPLAN-0006
P0+P1).** The update engine compared a freshly-created branch against the class
template by *content hash* with rename-detection, and because the CREATE path
regenerated template-registry IDs in filesystem-walk order (≠ the master's
hand-crafted IDs), a branch created seconds earlier produced **30 proposed renames**
that rotated identity/memory dirs into each other
(`apps→.trinity→.seedgo→.claude→.archive→.aipass`), turned `README` into
`DASHBOARD`, and deep-merged stale template into live `.trinity/` memory —
`update <class> --all` would have destroyed every citizen in one command. Rebuilt
`update_ops.py` (v2.0) on an explicit **named-managed-files + path-based** model:
`.trinity/*`, `DASHBOARD.local.json`, `artifacts/birth_certificate.json` and
`.seedgo/bypass.json` are delivered on **create only** and never touched on update;
the create==update invariant now yields **0 renames / 0 merges** on a fresh branch.
The old ID-based engine (`change_detection.py`, `reconcile.py`) is deleted.
- **Destructive spawn ops are now dry-run by default (TDPLAN-0006 P0).** `drone @spawn
update` and `drone @spawn repair` preview by default and require an explicit
`--apply` to write — forgetting a flag is now a safe no-op instead of irreversible
damage (`--dry-run` kept as an alias). `aipass doctor` repair suggestions emit the
matching `--apply` form.
### Added
- **Introspection Rich-formatting standard (seedgo).** New
`check_introspection_rich_formatting` checker enforces that each branch's
`print_introspection()` output uses Rich markup (delegation-aware — it walks
`_`-prefixed helper functions), keeping no-arg `drone @branch` output styled and
consistent. Documented in `introspection.md`; all 13 branches brought into
compliance (see Changed).
- **Playbook plan type (`PBPLAN`) — reusable SOP checklists (flow).** A new
`playbook_plans` template family for throwaway, vectorize-on-close operational
runbooks (first SOP: the Sunday merge). Drop a `.md` under
`templates/playbook_plans/`, register once, then
`drone @flow create . "subject" <sop>` stamps a run to tick through and close.
- **Memory-pool auto-processing (TDPLAN-0005)** — dropped files in
`memory/memory_pool/` are now vectorized and archived automatically on
session-start and pre-compact, instead of requiring a manual
`drone @memory pool process`. A 3-branch build: `@memory` gains an intake
handler + `pool` module (processes then empties the pool, `keep_recent=0`),
`@hooks` adds a `lifecycle/auto_process` handler (session-guarded via
`CLAUDE_CODE_SESSION_ID`, since Claude Code has no SessionStart hook), and
`@trigger` gains event #15 (`memory_pool_auto_processed`) with a Medic error
path. Runtime pool dirs (`memory_pool/`, `memory_pool_archive/`) are now
gitignored.
- **HVTracker badge** added to the README badge cluster, linking to the public
agent profile at hvtracker.net (closes #628).
- **`git_gate` read-verb allowlist — raw read-only git for every branch.** The
PreToolUse `git_gate` previously blocked *all* raw git (forcing `drone @git`
even for harmless reads), which left agents unable to inspect what git ships —
the exact forensics needed to diagnose the audit gap above. It now allows 22
read-only verbs raw (`ls-files`, `ls-tree`, `show`, `cat-file`, `rev-parse`,
`rev-list`, `log`, `status`, `diff`, `blame`, `archive`, `grep`, …) while
write operations stay `drone`-gated. Global options (`-C`, `-c`, `--git-dir`,
…) are skipped when extracting the verb, and chained commands are split on
`&&`/`||`/`;`/`|` so a read piped into a write still blocks the whole line.
(81 tests)
- **Cross-OS end-to-end WIRING test (`tests/e2e/`, `e2e-wheel.yml`)** — the first
CI gate that proves real AIPass *wiring* (not units-with-mocks) by building the
wheel, installing it into a clean venv, and asserting a 4-tier ladder: package
@@ -53,6 +196,22 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
### Changed
- **Standards floor raised to genuine 100% across all 13 branches** — completed
the campaign that lifted the seedgo gate threshold from 80 to 100. Rather than
bypass failing files, two check *flaws* were fixed at the root: (1) the
**file-size / architecture check is now advisory** (warn-only for 700–1500 line
files with no docstring nudge, hard-fail only above 1500) — large files are a
smell, not a defect; (2) **readme-freshness now compares against git history,
not file mtime** — `git checkout`/`merge` reset mtimes without any semantic
change, so the old check false-positived (flow + prax shared an identical
mtime from one git event, not real edits). It now diffs the README's "Last
Updated" against the last commit that touched `.py`. Genuine content fixes
where warranted (aipass requirements template + handler routing; honest README
content refreshes on flow, prax, devpulse). The readme-freshness **failure
message now teaches** the right fix ("update README content, then set the date
— don't just bump it"). Also optimized the devpulse watchdog poll cadence
(2s → 5s; the loop is cheap, so the tighter interval was wasted CPU). (#631)
- **Retired the blanket `rm` deny from provider settings** — `setup.sh` and
`aipass init` no longer ship `Bash(rm -rf*)` / `Bash(rm -r *)` deny rules
(they were mis-filed among git rules, blocked all `/tmp` cleanup, and gave a
@@ -64,6 +223,43 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
### Fixed
- **`Windows Test` / `macOS Test` are no longer path-filtered — they were
stalling PRs as required checks.** Both workflows only triggered when
`setup.sh`/`drone/cli.py`/`handlers/__init__.py`/`pyproject.toml` changed, but
branch protection lists `windows-setup`/`macos-setup` as *required*. On any PR
that didn't touch those paths the workflows never ran, so GitHub parked the
required checks as "Expected — waiting for status" indefinitely, blocking the
merge (the tests themselves were green — they simply didn't fire). They now run
on every push/PR to main/dev, like the other required lanes. (A required check
must never be path-filtered.)
- **`seedgo-audit` CI gate was red despite 100% local audits — four checkers
validated the working tree instead of committed source.** CI audits a clean
`git checkout` (tracked files only — git ships no empty or gitignored dirs),
but the working tree carries runtime dirs (`logs/`, `*_json/`, `artifacts/`,
`.trinity/`, `passport.json`), so every branch scored ~97% in CI while passing
at 100% locally. Reproduced exactly with a tracked-only tree (`git archive HEAD`
audits to CI's 97%). Four checkers now measure what git actually ships:
`log_structure` no longer fails when the gitignored `logs/` dir is absent (it
still enforces no-hardcoded-paths); `readme` cross-references `.gitignore`
(via `git check-ignore` with a fallback list) and skips gitignored dirs/links
in the directory-tree and dead-link checks; `encapsulation` infers the branch
from the path when the gitignored `AIPASS_REGISTRY.json` is unavailable (and no
longer collides on the `aipass` branch); `architecture` skips cleanly when the
gitignored `passport.json` is absent. A follow-up refined `readme`'s
`git check-ignore` use: `.gitignore` dir-only patterns (trailing slash —
`logs/`, `**/*_json/`, `.trinity/`) don't match a clean checkout's
non-existent paths unless directory intent is signalled, so the check now
also tests the trailing-slash form (this was the last 1% — `readme` flagged
`cli_json`/`logs`/`artifacts` as "missing on disk" in CI only). The CI gate
(`.github/scripts/seedgo_audit.py`) now also prints the failing standards and
their check messages, so a sub-100 result says *why*, not just the percentage.
Finally, the `seedgo-audit` CI job now installs the `memory` extra
(`pip install -e ".[dev,memory]"`): the `diagnostics` standard runs pyright over
every branch, and memory's handlers import `chromadb`/`numpy` at module level —
without those declared deps installed, pyright reported them as unresolved
(`reportMissingImports=error`) and memory scored 55%, a false failure from a
missing CI dep rather than a code defect. Clean-tree and working-tree audits
both report 13/13 = 100%. (DPLAN-0195)
- **Two latent Windows portability bugs caught by the new e2e harness** — both
were always present in the code; they only surfaced now because this is the
first CI to run `aipass init` scaffolding and real-branch `drone` routing on
@@ -128,6 +324,16 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
### Security
- **`dependency-scan` (pip-audit) green again — upgrade pip, drop stale ignores.**
The `Security Scan` workflow's `dependency-scan` job had gone red: pip-audit
scans the whole environment, and the runner's bundled pip (26.1.1) carries
advisory PYSEC-2026-196 (fixed in 26.1.2). The job now runs
`python -m pip install --upgrade pip` before auditing (it was the only CI job
not upgrading pip), removing the vulnerable version outright rather than
suppressing it. 26.1.2 also resolves CVE-2026-3219 and CVE-2026-6357, so the
two now-stale `--ignore-vuln` entries were removed — verified against a clean
reproduction of the job's environment, which audits to "No known
vulnerabilities found" with nothing ignored.
- **Pinned the `requests` floor to a non-vulnerable version** — raised
`requests` to `>=2.34.2` in `pyproject.toml` and the API branch's
`requirements.project.txt` (which previously listed it unconstrained). This
+1 -2
View File
@@ -10,10 +10,9 @@ On any greeting, silently run this sequence — no narration, no announcing step
These steps are sequential and dependent — run each ONCE, wait for the result, then proceed. Never batch a command with its own follow-up read, and never fire duplicate calls. If output looks blank, wait — don't retry.
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
- Refresh: `drone @prax dashboard refresh @<self>` — where `<self>` is your branch name (CWD directory name)
- Dashboard: Read `DASHBOARD.local.json` — act on what needs attention (new mail → check inbox, active plans → note them). This is your single status glance.
- Refresh: If `STATUS.local.md` is stale (last updated date older than latest session in local.json), update it from your memories. Keep Current Work accurate.
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
+2 -1
View File
@@ -6,6 +6,7 @@
[![codecov](https://codecov.io/gh/AIOSAI/AIPass/graph/badge.svg)](https://codecov.io/gh/AIOSAI/AIPass)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/AIOSAI/AIPass/badge)](https://scorecard.dev/viewer/?uri=github.com/AIOSAI/AIPass)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13095/badge)](https://www.bestpractices.dev/projects/13095)
[![HVTrust](https://hvtracker.net/badge/aipass.svg)](https://hvtracker.net/agents/aipass)
<p align="center">
<img src="assets/logo.png" alt="AIPass" width="400" />
@@ -263,7 +264,7 @@ AIPass stores everything locally in your project directory. To remove it:
```bash
# Remove AIPass files from your project
rm -rf .aipass/ .claude/ .ai_mail.local/ hooks/ src/
rm -f CLAUDE.md AGENTS.md STATUS.local.md *_REGISTRY.json .gitignore
rm -f CLAUDE.md AGENTS.md *_REGISTRY.json .gitignore
# If you installed via pip
pip uninstall aipass
+5 -2
View File
@@ -540,15 +540,16 @@ else:
settings = {}
# Build hooks config — bridge pattern
# UserPromptSubmit: 4 separate entries (EventType:hook_name) to avoid output merging
# UserPromptSubmit: 5 separate entries (EventType:hook_name) to avoid output merging
# PreToolUse, PostToolUse, SubagentStop, Stop, Notification: single aggregate entries
# PreCompact: 2 hooks x 2 matchers (manual + auto) = 4 entries
# PreCompact: 3 hooks x 2 matchers (manual + auto) = 6 entries
settings["hooks"] = {
"UserPromptSubmit": [
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:global_prompt"}]},
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:branch_prompt"}]},
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:identity_injector"}]},
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:email_notification"}]},
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:auto_process", "timeout": 120}]},
],
"PreToolUse": [
{"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task",
@@ -572,6 +573,8 @@ settings["hooks"] = {
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact", "timeout": 60}]},
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact_rollover", "timeout": 120}]},
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact_rollover", "timeout": 120}]},
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]},
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]},
],
}
+1 -1
View File
@@ -4,4 +4,4 @@ pip install aipass
https://github.com/AIOSAI/AIPass
"""
__version__ = "2.5.0"
__version__ = "2.5.1"
@@ -22,7 +22,6 @@ STANDARD_FOOTER = """
⚠️ TASK CHECKLIST (before marking complete):
□ SEEDGO CHECK → drone @seedgo audit @branch (80%+)
□ UPDATE MEMORIES → Your .trinity/local.json records this work
□ UPDATE STATUS → Your STATUS.local.md reflects current state
□ CLOSE FPLAN → drone @flow close <plan_id>
□ EMAIL SENDER → drone @ai_mail email @<sender> "Subject" "Summary"
+17 -14
View File
@@ -408,23 +408,26 @@ def _orchestrate_daemon() -> bool:
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("dispatch Module")
console.print("[bold cyan]dispatch Module[/bold cyan]")
console.print(
"Orchestrates dispatch commands: combined send+wake, status tracking, daemon management, and manual wake."
"[dim]Orchestrates dispatch commands: combined send+wake,"
" status tracking, daemon management, and manual wake.[/dim]"
)
console.print()
console.print("Connected Handlers:")
console.print(" handlers/dispatch/")
console.print(" - status.py (load_dispatch_log — load dispatch log entries)")
console.print(" - status.py (check_pid_status — check if a spawned process is still running)")
console.print(" - status.py (calculate_age — calculate age string from timestamp)")
console.print(" - wake.py (wake_branch — manually wake a branch by spawning an agent)")
console.print(" - daemon.py (run_daemon — start the continuous dispatch daemon)")
console.print(" handlers/email/ (used by combined dispatch)")
console.print(" - send.py (resolve_sender_info, send_to_single — send email pipeline)")
console.print(" - create.py (create_email_file, load_email_file — email file creation)")
console.print(" - delivery.py (deliver_email_to_branch — inbox delivery)")
console.print(" - header.py (prepend_dispatch_header — dispatch header injection)")
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/dispatch/[/cyan]")
console.print(" - [cyan]status.py[/cyan] [dim](load_dispatch_log — load dispatch log entries)[/dim]")
console.print(
" - [cyan]status.py[/cyan] [dim](check_pid_status — check if a spawned process is still running)[/dim]"
)
console.print(" - [cyan]status.py[/cyan] [dim](calculate_age — calculate age string from timestamp)[/dim]")
console.print(" - [cyan]wake.py[/cyan] [dim](wake_branch — manually wake a branch by spawning an agent)[/dim]")
console.print(" - [cyan]daemon.py[/cyan] [dim](run_daemon — start the continuous dispatch daemon)[/dim]")
console.print(" [cyan]handlers/email/[/cyan] [dim](used by combined dispatch)[/dim]")
console.print(" - [cyan]send.py[/cyan] [dim](resolve_sender_info, send_to_single — send email pipeline)[/dim]")
console.print(" - [cyan]create.py[/cyan] [dim](create_email_file, load_email_file — email file creation)[/dim]")
console.print(" - [cyan]delivery.py[/cyan] [dim](deliver_email_to_branch — inbox delivery)[/dim]")
console.print(" - [cyan]header.py[/cyan] [dim](prepend_dispatch_header — dispatch header injection)[/dim]")
console.print()
+11 -11
View File
@@ -250,18 +250,18 @@ def _send_broadcast(subject, message, user_info, auto_execute, no_memory_save, r
def print_introspection():
"""Print module introspection for seedgo compliance."""
console.print("\n" + "=" * 70)
console.print("EMAIL SEND ORCHESTRATION")
console.print("=" * 70)
console.print("\nFunctions provided:")
console.print(" - handle_send(args) -> bool")
console.print(" - _send_direct(...) -> bool")
console.print(" - _send_interactive() -> bool")
console.print(" - _send_broadcast(...) -> bool")
console.print(" - _fire_dispatch_trigger(to_branch, subject) -> None")
console.print(" - _delivery_callback(branch_path, new_count, opened_count, total)")
console.print()
console.print("=" * 70 + "\n")
console.print("[bold cyan]email_send Module[/bold cyan]")
console.print("[dim]Send orchestration — direct, interactive, and broadcast email delivery.[/dim]")
console.print()
console.print("[yellow]Functions provided:[/yellow]")
console.print(" - [cyan]handle_send[/cyan][dim](args) -> bool[/dim]")
console.print(" - [cyan]_send_direct[/cyan][dim](...) -> bool[/dim]")
console.print(" - [cyan]_send_interactive[/cyan][dim]() -> bool[/dim]")
console.print(" - [cyan]_send_broadcast[/cyan][dim](...) -> bool[/dim]")
console.print(" - [cyan]_fire_dispatch_trigger[/cyan][dim](to_branch, subject) -> None[/dim]")
console.print(" - [cyan]_delivery_callback[/cyan][dim](branch_path, new_count, opened_count, total)[/dim]")
console.print()
if __name__ == "__main__":
@@ -1708,7 +1708,7 @@ class TestEmailSendIntrospection:
print_introspection()
combined = "\n".join(printed)
assert "EMAIL SEND ORCHESTRATION" in combined
assert "email_send Module" in combined
assert "handle_send" in combined
assert "_send_direct" in combined
assert "_send_broadcast" in combined
@@ -1,6 +1,6 @@
# AIPASS — Branch Prompt
*Injected every turn. Breadcrumbs only — details: README, --help, .trinity/ memories, STATUS.local.md.*
*Injected every turn. Breadcrumbs only — details: README, --help, .trinity/ memories.*
## Identity
+5
View File
@@ -270,6 +270,11 @@
"file": "apps/modules/profile.py",
"standard": "introspection",
"reason": "aipass is binary-invoked: aipass profile runs the command; introspection via --info"
},
{
"file": "apps/handlers/json/json_handler.py",
"standard": "test_quality",
"reason": "save_json now raises ValueError on invalid structure (aipass.common contract, TDPLAN-0006 P2). Tested via pytest.raises — no False return path to test."
}
]
}
+7 -5
View File
@@ -17,9 +17,9 @@ aipass/
│ ├── modules/
│ │ ├── doctor.py # System health aggregation
│ │ ├── doctor_fix.py # Remediation report (--fix, --json)
│ │ ├── doctor_wire.py # Auto-wire prompt helpers
│ │ ├── doctor_wire.py # Auto-wire provider settings + stale-deny re-export
│ │ ├── handoff.py # CLI handoff (placeholder)
│ │ ├── help_chat.py # README-backed Q&A
│ │ ├── help_chat.py # README-backed Q&A (reads via readme_map handler)
│ │ ├── init_flow.py # 12-stage guided setup
│ │ └── profile.py # User profile read/write
│ ├── handlers/
@@ -27,12 +27,14 @@ aipass/
│ │ ├── init/ # bootstrap.py, scaffold_content.py
│ │ ├── json/ # JSON read/write utilities
│ │ ├── ping_sweep/ # Branch reachability verification
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
│ │ ├── readme_map/ # Live file reads + branch routing
│ │ ├── structure_scan/ # Agent placement + pollution detection
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
│ │ └── ui/ # Progress bars, menus, banners
│ └── plugins/
├── tests/ # 412 passing
├── tests/ # 432 passing
├── requirements.project.txt # Project-specific Python dependencies
├── .trinity/ # Identity + session history + observations
└── README.md
```
@@ -68,7 +70,7 @@ Humans only. Nothing in AIPass depends on this branch.
## Tests
412 passing — `pytest src/aipass/aipass/tests/`
432 passing — `pytest src/aipass/aipass/tests/`
## Known Issues
@@ -76,4 +78,4 @@ Humans only. Nothing in AIPass depends on this branch.
## Last Updated
Last Updated: 2026-05-28
Last Updated: 2026-06-05
@@ -15,11 +15,10 @@ Business logic for `aipass init`. Creates the project scaffold:
3. CLAUDE.md — project prompt (Claude Code reads this)
4. AGENTS.md — Codex equivalent of CLAUDE.md
5. README.md — getting started guide
6. STATUS.local.md — project status
7. .gitignore — standard AIPass ignores
8. .claude/settings.json — Claude Code hooks configuration
9. src/ — directory where agents live
10. .ai_mail.local/inbox.json — empty project mailbox
6. .gitignore — standard AIPass ignores
7. .claude/settings.json — Claude Code hooks configuration
8. src/ — directory where agents live
9. .ai_mail.local/inbox.json — empty project mailbox
Projects are NOT citizens — no .trinity/ directory. Identity lives in the
registry JSON. Init is re-runnable: existing files are skipped, not errors.
@@ -349,6 +348,9 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
content = template.read_text(encoding="utf-8").replace("{name}", name)
dest.write_text(content, encoding="utf-8")
created.append(str(dest))
elif md_name == "AGENTS.md":
dest.write_text(sc.agents_md(name), encoding="utf-8")
created.append(str(dest))
else:
source = Path(aipass_home) / md_name if aipass_home else None
if source and source.is_file():
@@ -364,16 +366,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
readme_md_path.write_text(readme_content, encoding="utf-8")
created.append(str(readme_md_path))
# 7. STATUS.local.md
status_md_path = target / "STATUS.local.md"
if not status_md_path.exists():
status_md_path.write_text(
f"# {name}\n\n**State:** New\n**Last update:** {today}\n\n## Current Work\n\n## Known Issues\n- None\n",
encoding="utf-8",
)
created.append(str(status_md_path))
# 8. .gitignore
# 7. .gitignore
gitignore_path = target / ".gitignore"
if not gitignore_path.exists():
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
@@ -450,7 +443,7 @@ def update_project(target: Path) -> dict:
"""Update managed scaffold files in an existing AIPass project.
Overwrites managed prompt and config files with the latest templates while
leaving all user-owned files (registry, README, STATUS.local.md, .gitignore,
leaving all user-owned files (registry, README, .gitignore,
src/) untouched.
Args:
@@ -586,7 +579,6 @@ def update_project(target: Path) -> dict:
for skip_name in (
str(registry_path),
str(target / "README.md"),
str(target / "STATUS.local.md"),
str(target / ".gitignore"),
):
skipped.append(skip_name)
@@ -37,11 +37,11 @@ def readme_md(name: str) -> str:
"aipass init agent my_agent\n"
"\n"
"# 2. Start a session\n"
"cd src/my_agent/\n"
f"cd src/{name.lower()}/my_agent/\n"
"claude # or your preferred AI CLI\n"
"\n"
"# 3. Check project status\n"
"cat STATUS.local.md\n"
"# 3. Check project health\n"
"drone @seedgo audit .\n"
"```\n"
"\n"
"## Project Structure\n"
@@ -52,8 +52,7 @@ def readme_md(name: str) -> str:
" .aipass/ # Prompts (injected per-turn)\n"
" CLAUDE.md # Claude Code instructions\n"
" AGENTS.md # Codex instructions\n"
" STATUS.local.md # Project status\n"
" src/ # Agent directories live here\n"
f" src/{name.lower()}/ # Project package\n"
" <agent_name>/ # Created via aipass init agent\n"
"```\n"
"\n"
@@ -83,6 +82,30 @@ def readme_md(name: str) -> str:
)
def agents_md(name: str) -> str:
"""Generate AGENTS.md content — Codex equivalent of CLAUDE.md for projects."""
return (
f"# {name}\n"
"\n"
"Agent workspace powered by AIPass.\n"
"\n"
"# Startup protocol\n"
"\n"
"On any greeting, silently run this sequence — no narration, no announcing "
"steps. Just do it and respond with the status.\n"
"\n"
" - Read: `.trinity/passport.json`, `.trinity/local.json`, "
"`.trinity/observations.json`, `README.md`\n"
"\n"
"Use drone commands for all operations. Never raw git, gh, or file access "
"when drone provides it.\n"
"\n"
"# Memories\n"
"\n"
"Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.\n"
)
def global_prompt_md(name: str) -> str:
"""Generate .aipass/aipass_global_prompt.md — injected every turn."""
return (
@@ -212,7 +235,6 @@ def gitignore() -> str:
".trinity/\n"
".ai_mail.local/\n"
"*.local.*\n"
"!STATUS.local.md\n"
"\n"
"# Plans (local working docs)\n"
"DPLAN-*\n"
@@ -279,9 +301,6 @@ def prep_md() -> str:
"- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. "
"Collaboration insights, preferences, friction points. Skip if nothing "
"new about the user this session.\n"
"- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known "
"issues, todos, notepad. Auto-synced to central STATUS.md on PR events "
"— this is how other branches see you. Keep Current Work accurate.\n"
"\n"
"## 2. Active Plans\n"
"\n"
@@ -308,7 +327,7 @@ def prep_md() -> str:
"- Flag anything in-flight: running background agents, dispatched "
"branches waiting for replies, pending decisions\n"
"- If anything can't survive compaction (e.g., agent IDs needed for "
"resume), write it to STATUS.local.md Notepad\n"
"resume), write it to local.json key_learnings\n"
"\n"
"## Confirm\n"
"\n"
@@ -317,7 +336,6 @@ def prep_md() -> str:
"Prep complete:\n"
"- local.json: [what was added]\n"
"- observations.json: [updated / skipped]\n"
"- STATUS.local.md: [updated / skipped]\n"
"- Plans: [which ones updated]\n"
"- Git: [branch, uncommitted count, suggestion]\n"
"- Inbox: [count, action taken]\n"
@@ -1,254 +1,88 @@
# =================== AIPass ====================
# Name: json_handler.py
# Description: Auto-Creating JSON Handler for aipass branch
# Version: 1.0.0
# Description: Branch-local shim — delegates to aipass.common.json_handler
# Version: 2.0.0
# Created: 2026-04-16
# Modified: 2026-04-16
# Modified: 2026-06-06
# =============================================
"""
JSON Handler - Auto-Creating & Self-Healing JSON System
"""Branch-local JSON handler — thin shim over the shared ``aipass.common`` library.
Handles default JSON files (config, data, log) for aipass modules.
Never manually create JSONs - they build themselves.
All logic lives in ``aipass.common.json_handler.JsonHandler``.
This module binds a ``JsonHandler`` instance to the aipass branch's
``aipass_json/`` directory and re-exports the public API as module-level
functions so existing callers (``json_handler.log_operation(...)``) keep working.
"""
from __future__ import annotations
import inspect
import json
import os
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from aipass.prax import logger
from aipass.common.json_handler import JsonHandler
def _get_caller_module_name() -> str:
"""Auto-detect calling module name from call stack."""
stack = inspect.stack()
if len(stack) > 2:
caller_path = Path(stack[2].filename)
module_name = caller_path.stem
if module_name and not module_name.startswith("_"):
return module_name
return "unknown"
# =============================================================================
# INFRASTRUCTURE SETUP
# =============================================================================
# json_handler.py lives at: src/aipass/aipass/apps/handlers/json/json_handler.py
# parents[0] = json/, [1] = handlers/, [2] = apps/, [3] = aipass/, [4] = src/aipass/
_PKG_ROOT = Path(__file__).resolve().parents[4]
# Constants
AIPASS_BRANCH_ROOT = _PKG_ROOT / "aipass"
AIPASS_JSON_DIR = AIPASS_BRANCH_ROOT / "aipass_json"
# =============================================================================
# INTERNAL HELPERS
# =============================================================================
def _handler() -> JsonHandler:
"""Create a handler bound to the current AIPASS_JSON_DIR."""
return JsonHandler(AIPASS_JSON_DIR)
def _get_caller_module_name() -> str:
"""Auto-detect calling module name from call stack.
Returns:
Module name (e.g., "doctor" from doctor.py)
"""
try:
stack = inspect.stack()
# Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller
if len(stack) > 2:
caller_frame = stack[2]
caller_path = Path(caller_frame.filename)
module_name = caller_path.stem
if module_name and not module_name.startswith("_"):
return module_name
return "unknown"
except Exception as exc:
logger.warning("[json_handler] Failed to detect caller module name: %s", exc)
return "unknown"
def load_path(file_path: Path) -> Optional[dict]:
"""Load JSON from an arbitrary file path."""
return JsonHandler.read_json(file_path)
def _default_template(json_type: str, module_name: str) -> Any:
"""Return inline default structure for a JSON type — no file templates needed."""
today = datetime.now().date().isoformat()
if json_type == "config":
return {
"module_name": module_name,
"version": "1.0.0",
"config": {
"max_log_entries": 100,
},
"created": today,
}
if json_type == "data":
return {
"created": today,
"last_updated": today,
}
if json_type == "log":
return []
return None
def _atomic_write_json(target_path: Path, data: Any) -> None:
"""Write JSON data atomically via temp file + rename.
Prevents corruption from concurrent processes writing the same file.
"""
fd, tmp_path = tempfile.mkstemp(dir=str(target_path.parent), suffix=".tmp", prefix=target_path.stem)
succeeded = False
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, str(target_path))
succeeded = True
finally:
if not succeeded and Path(tmp_path).exists():
logger.warning("[json_handler] Cleaning up temp file after write failure: %s", tmp_path)
os.unlink(tmp_path)
# =============================================================================
# VALIDATION
# =============================================================================
def save_path(file_path: Path, data: Any, indent: int = 2) -> bool:
"""Write JSON data to an arbitrary file path atomically."""
return JsonHandler.write_json(file_path, data, indent)
def validate_json_structure(data: Any, json_type: str) -> bool:
"""Validate JSON structure matches expected type."""
if json_type == "config":
if not isinstance(data, dict):
return False
required = ["module_name", "version", "config"]
return all(key in data for key in required)
elif json_type == "data":
if not isinstance(data, dict):
return False
required = ["created", "last_updated"]
return all(key in data for key in required)
elif json_type == "log":
return isinstance(data, list)
return False
# =============================================================================
# PUBLIC API
# =============================================================================
def load_path(path: Path) -> Any:
"""Load JSON from an arbitrary file path with consistent error handling."""
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as exc:
logger.warning("[json_handler] Failed to load %s: %s", path, exc)
return None
def save_path(path: Path, data: Any) -> bool:
"""Write JSON data to an arbitrary file path atomically."""
os.makedirs(path.parent, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp", prefix=path.stem)
succeeded = False
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
os.replace(tmp_path, str(path))
succeeded = True
return True
except OSError as exc:
logger.warning("[json_handler] Failed to save %s: %s", path, exc)
return False
finally:
if not succeeded and Path(tmp_path).exists():
os.unlink(tmp_path)
"""Validate that data matches the expected shape for json_type."""
return JsonHandler.validate_json_structure(data, json_type)
def get_json_path(module_name: str, json_type: str) -> Path:
"""Get path for module JSON file."""
filename = f"{module_name}_{json_type}.json"
return AIPASS_JSON_DIR / filename
"""Return the filesystem path for a module's JSON file."""
return _handler().get_json_path(module_name, json_type)
def ensure_json_exists(module_name: str, json_type: str) -> bool:
"""Ensure JSON file exists, create from template if missing."""
AIPASS_JSON_DIR.mkdir(parents=True, exist_ok=True)
json_path = get_json_path(module_name, json_type)
if json_path.exists():
try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
if validate_json_structure(data, json_type):
return True
except Exception as exc:
logger.warning(
"[json_handler] Corrupted JSON file for '%s/%s', regenerating: %s",
module_name,
json_type,
exc,
)
template = _default_template(json_type, module_name)
if template is None:
return False
try:
_atomic_write_json(json_path, template)
return True
except Exception as exc:
logger.error(
"[json_handler] Failed to write JSON template for '%s/%s': %s",
module_name,
json_type,
exc,
)
return False
def load_json(module_name: str, json_type: str) -> Optional[Any]:
"""Load JSON file, auto-create if missing."""
if not ensure_json_exists(module_name, json_type):
return None
json_path = get_json_path(module_name, json_type)
try:
with open(json_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as exc:
logger.error("[json_handler] Failed to load JSON for '%s/%s': %s", module_name, json_type, exc)
return None
def save_json(module_name: str, json_type: str, data: Any) -> bool:
"""Save JSON file."""
json_path = get_json_path(module_name, json_type)
if not validate_json_structure(data, json_type):
return False
if json_type == "data" and isinstance(data, dict):
data["last_updated"] = datetime.now().date().isoformat()
try:
_atomic_write_json(json_path, data)
return True
except Exception as exc:
logger.error("[json_handler] Failed to save JSON for '%s/%s': %s", module_name, json_type, exc)
return False
"""Ensure a single JSON file exists; create with defaults if missing."""
return _handler().ensure_json_exists(module_name, json_type)
def ensure_module_jsons(module_name: str) -> bool:
"""Ensure all 3 JSON files exist for a module."""
ensure_json_exists(module_name, "config")
ensure_json_exists(module_name, "data")
ensure_json_exists(module_name, "log")
return True
"""Ensure all three JSON files (config, data, log) exist for a module."""
return _handler().ensure_module_jsons(module_name)
def load_json(module_name: str, json_type: str) -> Optional[Any]:
"""Load a module's JSON file, auto-creating it if missing."""
return _handler().load_json(module_name, json_type)
def save_json(module_name: str, json_type: str, data: Any) -> bool:
"""Save JSON file. Raises ValueError on invalid structure."""
return _handler().save_json(module_name, json_type, data)
def log_operation(
@@ -256,42 +90,7 @@ def log_operation(
data: Dict[str, Any] | None = None,
module_name: str | None = None,
) -> bool:
"""Add entry to module log with automatic rotation.
Auto-detects calling module if module_name not provided.
Implements config-controlled log limits to prevent unbounded growth.
When max_log_entries is reached, removes oldest entries (FIFO).
Args:
operation: Operation name to log
data: Optional data dict
module_name: Optional module name (auto-detected if not provided)
Returns:
True if successful, False otherwise
"""
"""Add entry to module operation log with automatic rotation."""
if module_name is None:
module_name = _get_caller_module_name()
ensure_module_jsons(module_name)
config = load_json(module_name, "config")
max_entries = 100
if config and "config" in config:
max_entries = config["config"].get("max_log_entries", 100)
log = load_json(module_name, "log")
if log is None:
log = []
entry: Dict[str, Any] = {"timestamp": datetime.now().isoformat(), "operation": operation}
if data:
entry["data"] = data
log.append(entry)
if len(log) > max_entries:
log = log[-max_entries:]
return save_json(module_name, "log", log)
return _handler().log_operation(operation, data, module_name)
@@ -0,0 +1,81 @@
# =================== AIPass ====================
# Name: provider_reconcile.py
# Description: Detect and fix stale rules in provider settings
# Version: 1.0.0
# Created: 2026-06-05
# Modified: 2026-06-05
# =============================================
"""provider_reconcile — detect and fix stale rules in ~/.claude/settings.json."""
from __future__ import annotations
from pathlib import Path
from aipass.prax import logger
from aipass.aipass.apps.handlers.json import json_handler
_MODULE_NAME = "provider_reconcile"
_STALE_RM_DENY_RULES = frozenset({"Bash(rm -rf*)", "Bash(rm -r *)"})
GLYPH_PASS = "[green]✓[/green]"
GLYPH_WARN = "[yellow]![/yellow]"
def reconcile_stale_deny(fix: bool = False) -> list:
"""Detect and optionally remove stale rm deny rules from provider settings.
Returns list of (label, glyph, detail, remediation) tuples matching
doctor.CheckResult shape — imported as tuples to avoid circular import.
"""
results: list = []
settings_path = Path.home() / ".claude" / "settings.json"
if not settings_path.exists():
json_handler.log_operation(
"reconcile_stale_deny",
data={"fix": fix, "skipped": "no settings file"},
module_name=_MODULE_NAME,
)
return results
data = json_handler.load_path(settings_path)
if data is None:
json_handler.log_operation(
"reconcile_stale_deny",
data={"fix": fix, "skipped": "could not load settings"},
module_name=_MODULE_NAME,
)
return results
deny = data.get("permissions", {}).get("deny", [])
stale = [r for r in deny if r in _STALE_RM_DENY_RULES]
if not stale:
results.append(("rm deny migration", GLYPH_PASS, "no stale rules", ""))
elif fix:
deny_cleaned = [r for r in deny if r not in _STALE_RM_DENY_RULES]
data.setdefault("permissions", {})["deny"] = deny_cleaned
json_handler.save_path(settings_path, data)
removed = ", ".join(stale)
results.append(("rm deny migration", GLYPH_PASS, f"removed: {removed}", ""))
logger.info("[doctor] removed stale deny rules: %s", stale)
else:
found = ", ".join(stale)
results.append(
(
"rm deny migration",
GLYPH_WARN,
f"stale rules: {found}",
"Run aipass doctor --fix to remove (rm_gate + drone rm replace these)",
)
)
json_handler.log_operation(
"reconcile_stale_deny",
data={"fix": fix, "stale_found": len(stale)},
module_name=_MODULE_NAME,
)
return results
@@ -117,3 +117,18 @@ def list_branches() -> list[str]:
Reflects the filesystem state at the time the map was first built.
"""
return list(_get_map().keys())
def read_readme_lines(branch: str) -> list[str] | None:
"""Live-read README.md for a branch. Returns list of lines, or None on error.
Content is NEVER cached — every call reads the current file.
"""
readme_path = get_readme_path(branch)
if readme_path is None:
return None
try:
with open(readme_path, encoding="utf-8") as fh:
return fh.readlines()
except OSError:
return None
@@ -283,12 +283,6 @@ def detect_pollution(agents: List[AgentInfo]) -> List[PollutionHit]:
# =============================================================================
def find_registry(project_root: Path) -> Optional[Path]:
"""Find *_REGISTRY.json under project_root."""
candidates = list(project_root.glob("*_REGISTRY.json"))
return candidates[0] if candidates else None
def check_registry_consistency(
registry_path: Path,
agents: List[AgentInfo],
+7 -16
View File
@@ -20,6 +20,8 @@ from typing import Dict, List, NamedTuple
from aipass.cli.apps.modules import console
from aipass.prax import logger
from aipass.common.registry_discovery import find_registry as _discover_registry
from aipass.aipass.apps.handlers.json import json_handler
from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
check_placement,
@@ -28,7 +30,6 @@ from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
check_root_artifacts,
detect_pollution,
find_project_root,
find_registry,
scan_agents,
)
from aipass.aipass.apps.modules.doctor_fix import (
@@ -70,19 +71,9 @@ class CheckResult(NamedTuple):
def _find_registry() -> Path | None:
"""Walk up from CWD first (user's project), then branch root."""
cwd = Path.cwd()
for parent in (cwd, *cwd.parents):
candidates = list(parent.glob("*_REGISTRY.json"))
if candidates:
return candidates[0]
if parent == parent.parent:
break
for parent in (_BRANCH_ROOT, *_BRANCH_ROOT.parents):
candidate = parent / "AIPASS_REGISTRY.json"
if candidate.exists():
return candidate
return None
"""Find *_REGISTRY.json via shared discovery (walk-up from CWD + branch root)."""
result = _discover_registry(package_root=str(_BRANCH_ROOT))
return result if result.exists() else None
def _check_system() -> List[CheckResult]:
@@ -521,8 +512,8 @@ def _check_structure() -> List[CheckResult]:
results.append(CheckResult("pollution", GLYPH_PASS, "no duplicates", ""))
# Registry consistency
reg_path = find_registry(project_root)
if reg_path:
reg_path = _discover_registry(start_path=project_root)
if reg_path and reg_path.exists():
reg_issues = check_registry_consistency(reg_path, agents)
if reg_issues:
for issue in reg_issues:
+13 -11
View File
@@ -25,6 +25,8 @@ from typing import List, NamedTuple
from aipass.cli.apps.modules import console
from aipass.prax import logger
from aipass.common.registry_discovery import find_registry as _discover_registry
from aipass.aipass.apps.handlers.json import json_handler
from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
check_placement,
@@ -32,7 +34,6 @@ from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
check_registry_consistency,
check_root_artifacts,
detect_pollution,
find_registry,
scan_agents,
)
@@ -58,8 +59,8 @@ class RemediationItem(NamedTuple):
def detect_project_name(project_root: Path) -> str:
"""Derive project name from registry filename or directory name."""
reg = find_registry(project_root)
if reg:
reg = _discover_registry(start_path=project_root)
if reg and reg.exists():
name = reg.stem.replace("_REGISTRY", "").lower()
if name:
return name
@@ -83,7 +84,7 @@ def _build_pollution_items(agents: list, project: str) -> List[RemediationItem]:
f"Registry pollution: {len(hit.locations)} copies of "
f"{hit.agent_name} share registry_id {hit.registry_id}"
),
fix_command=f"drone @spawn repair @{project} --clean-pollution",
fix_command=f"drone @spawn repair @{project} --clean-pollution --apply",
)
)
return items
@@ -104,7 +105,7 @@ def _build_placement_items(agents: list, project_root: Path, project: str) -> Li
severity="warning",
category="placement",
description=f"Misplaced agent: {issue.agent_name} at {rel_path}",
fix_command=f"drone @spawn repair @{project} --relocate {rel_path} {suggested}",
fix_command=f"drone @spawn repair @{project} --relocate {rel_path} {suggested} --apply",
)
)
return items
@@ -113,8 +114,8 @@ def _build_placement_items(agents: list, project_root: Path, project: str) -> Li
def _build_registry_items(project_root: Path, agents: list, project: str) -> List[RemediationItem]:
"""Build remediation items for registry consistency issues."""
items: List[RemediationItem] = []
reg_path = find_registry(project_root)
if not reg_path:
reg_path = _discover_registry(start_path=project_root)
if not reg_path or not reg_path.exists():
return items
for issue in check_registry_consistency(reg_path, agents):
items.append(
@@ -122,7 +123,7 @@ def _build_registry_items(project_root: Path, agents: list, project: str) -> Lis
severity="warning",
category="registry",
description=f"Registry {issue.problem}: {issue.branch_name} at {issue.registered_path}",
fix_command=f"drone @spawn repair @{project} --dedup-registry",
fix_command=f"drone @spawn repair @{project} --dedup-registry --apply",
)
)
return items
@@ -145,7 +146,7 @@ def generate_remediation(project_root: Path) -> List[RemediationItem]:
severity="info",
category="pyproject",
description="Missing pyproject.toml",
fix_command=f"drone @spawn repair @{project} --add-pyproject",
fix_command=f"drone @spawn repair @{project} --add-pyproject --apply",
)
)
@@ -156,7 +157,7 @@ def generate_remediation(project_root: Path) -> List[RemediationItem]:
severity=severity,
category="root_artifact",
description=f"{hit.description}: {hit.name}/",
fix_command=f"drone @spawn repair @{project} --relocate-root {hit.name}",
fix_command=f"drone @spawn repair @{project} --relocate-root {hit.name} --apply",
)
)
@@ -184,7 +185,8 @@ def format_text_report(items: List[RemediationItem], project_name: str) -> str:
lines.append(f"[{item.severity.upper()}] {item.description}")
lines.append(f" Fix: {item.fix_command}")
lines.append("")
lines.append(f"Preview all fixes: drone @spawn repair @{project_name} --dry-run")
lines.append(f"Preview all fixes: drone @spawn repair @{project_name}")
lines.append(f"Apply all fixes: drone @spawn repair @{project_name} --apply")
return "\n".join(lines)
+2 -47
View File
@@ -51,55 +51,10 @@ ENV_DESCRIPTIONS: Dict[str, str] = {
# =============================================================================
# STALE DENY RULE MIGRATION
# STALE DENY RULE MIGRATION (implementation in handler; re-exported here)
# =============================================================================
_STALE_RM_DENY_RULES = frozenset({"Bash(rm -rf*)", "Bash(rm -r *)"})
def reconcile_stale_deny(fix: bool = False) -> list:
"""Detect and optionally remove stale rm deny rules from provider settings.
Returns list of (label, glyph, detail, remediation) tuples matching
doctor.CheckResult shape — imported as tuples to avoid circular import.
"""
from aipass.aipass.apps.handlers.ui.progress import GLYPH_PASS, GLYPH_WARN
results: list = []
settings_path = Path.home() / ".claude" / "settings.json"
if not settings_path.exists():
return results
data = json_handler.load_path(settings_path)
if data is None:
return results
deny = data.get("permissions", {}).get("deny", [])
stale = [r for r in deny if r in _STALE_RM_DENY_RULES]
if not stale:
results.append(("rm deny migration", GLYPH_PASS, "no stale rules", ""))
return results
if fix:
deny_cleaned = [r for r in deny if r not in _STALE_RM_DENY_RULES]
data.setdefault("permissions", {})["deny"] = deny_cleaned
json_handler.save_path(settings_path, data)
removed = ", ".join(stale)
results.append(("rm deny migration", GLYPH_PASS, f"removed: {removed}", ""))
logger.info("[doctor] removed stale deny rules: %s", stale)
else:
found = ", ".join(stale)
results.append(
(
"rm deny migration",
GLYPH_WARN,
f"stale rules: {found}",
"Run aipass doctor --fix to remove (rm_gate + drone rm replace these)",
)
)
return results
from aipass.aipass.apps.handlers.provider_reconcile import reconcile_stale_deny # noqa: E402, F401
# =============================================================================
+7 -9
View File
@@ -27,7 +27,7 @@ from __future__ import annotations
from pathlib import Path
from aipass.aipass.apps.handlers.json import json_handler
from aipass.aipass.apps.handlers.readme_map import get_readme_path, list_branches
from aipass.aipass.apps.handlers.readme_map import get_readme_path, list_branches, read_readme_lines
from aipass.cli.apps.modules import console, error, header
from aipass.prax import logger
@@ -166,17 +166,15 @@ def _match_branches(keywords: list[str]) -> list[str]:
# =============================================================================
def _search_readme(readme_path: Path, keywords: list[str]) -> list[tuple[int, str]]:
"""Live-read readme_path. Return (line_num, line_text) for matching lines.
def _search_readme(branch: str, keywords: list[str]) -> list[tuple[int, str]]:
"""Live-read branch README via handler. Return (line_num, line_text) for matching lines.
Reads every call — never cached. Scores lines by number of keyword hits.
Returns up to 5 best matches.
"""
try:
with open(readme_path, encoding="utf-8") as fh:
lines = fh.readlines()
except OSError as exc:
logger.warning("[help_chat] Could not read README %s: %s", readme_path, exc)
lines = read_readme_lines(branch)
if lines is None:
logger.warning("[help_chat] Could not read README for branch %s", branch)
return []
scored: list[tuple[int, int, str]] = [] # (score, line_num, line_text)
@@ -262,7 +260,7 @@ def handle_command(command: str, args: list[str]) -> bool:
readme_path = get_readme_path(branch)
if not readme_path:
continue
matches = _search_readme(readme_path, keywords)
matches = _search_readme(branch, keywords)
if matches:
found_any = True
answer = _format_answer(branch, readme_path, matches)
+12 -10
View File
@@ -454,9 +454,9 @@ def stage_8_first_agent(non_interactive: bool = False, dry_run: bool = False) ->
console.print("Let's create your first AI agent (citizen).")
if non_interactive:
agent_name = "my-agent"
agent_name = "my_agent"
else:
agent_name = _prompt("Agent name (letters, hyphens, no spaces)", "my-agent") or "my-agent"
agent_name = _prompt("Agent name (letters, underscores, no spaces)", "my_agent") or "my_agent"
package_dir = _resolve_package_dir()
if package_dir:
@@ -560,7 +560,7 @@ def stage_10_smoke_test(non_interactive: bool = False, dry_run: bool = False) ->
def stage_11_handoff(
cli_choice: str = "claude",
flag_variant: str = "default",
agent_path: str = "src/my-agent",
agent_path: str = "src/my_agent",
non_interactive: bool = False,
dry_run: bool = False,
accumulated: Dict[str, Any] | None = None,
@@ -771,7 +771,7 @@ def run_init(
lambda: stage_11_handoff(
accumulated.get("cli", "claude"),
accumulated.get("flag_variant", "default"),
accumulated.get("agent_path", "src/my-agent"),
accumulated.get("agent_path", "src/my_agent"),
non_interactive,
dry_run=dry_run,
accumulated=accumulated,
@@ -893,12 +893,14 @@ def _handle_init_update(args: list[str]) -> int:
console.print(f" ({len(current)} already up to date)")
# Heal registry: prune stale entries (e.g. cross-project ../paths)
try:
from aipass.spawn.apps.modules.sync_registry import sync_registry
sync_result = sync_registry(fix=True)
pruned = sync_result.get("stale", [])
if pruned:
console.print(f" [green]Registry healed:[/green] removed {len(pruned)} stale entry(ies)")
sync_proc = subprocess.run(
["drone", "@spawn", "sync-registry", "--fix"],
capture_output=True,
text=True,
timeout=30,
)
if sync_proc.returncode == 0:
console.print(" [green]Registry synced.[/green]")
except Exception as sync_exc:
logger.warning("[init_flow] registry sync during update skipped: %s", sync_exc)
@@ -0,0 +1,3 @@
# Project-specific Python dependencies beyond the base AIPass install.
# Add packages here that the aipass branch requires but are not in the root pyproject.toml.
# Install with: pip install -r requirements.project.txt
+11 -17
View File
@@ -102,7 +102,6 @@ def test_init_project_creates_all_expected_files(tmp_path):
target / "CLAUDE.md",
target / "AGENTS.md",
target / "README.md",
target / "STATUS.local.md",
target / ".gitignore",
target / ".claude" / "settings.json",
target / ".claude" / "commands" / "prep.md",
@@ -127,7 +126,7 @@ def test_init_project_creates_all_expected_files(tmp_path):
created_basenames = [Path(f).name for f in result["created_files"]]
for f in expected_files:
assert f.name in created_basenames or f.exists(), f"Expected {f.name} in created_files"
assert len(result["created_files"]) >= 12
assert len(result["created_files"]) >= 11
def test_init_project_return_dict_structure(tmp_path):
@@ -235,15 +234,15 @@ def test_init_project_raises_on_empty_name(tmp_path):
def test_init_project_agents_md_content(tmp_path):
"""AGENTS.md is copied from AIPass source of truth."""
"""AGENTS.md contains project-specific content from generator."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="alpha")
content = (target / "AGENTS.md").read_text(encoding="utf-8")
assert "# AIPass" in content
assert "Multi-agent framework" in content
assert "# ALPHA" in content
assert "AIPass" in content
def test_init_project_gitignore_content(tmp_path):
@@ -325,7 +324,7 @@ def test_init_project_auto_creates_target_dir(tmp_path):
assert target.is_dir()
assert result["project_name"] == "NESTED"
assert len(result["created_files"]) >= 12
assert len(result["created_files"]) >= 11
def test_init_project_defaults_name_from_directory(tmp_path):
@@ -364,7 +363,6 @@ def test_init_project_skips_existing_optional_files(tmp_path):
(target / "CLAUDE.md").write_text("# Custom CLAUDE\n", encoding="utf-8")
(target / "AGENTS.md").write_text("# Custom AGENTS\n", encoding="utf-8")
(target / "README.md").write_text("# Custom README\n", encoding="utf-8")
(target / "STATUS.local.md").write_text("# Custom status\n", encoding="utf-8")
(target / ".gitignore").write_text("# Custom\n", encoding="utf-8")
claude_dir = target / ".claude"
@@ -413,16 +411,15 @@ def test_init_project_returns_dict(tmp_path):
def test_init_project_agents_md_no_trinity(tmp_path):
"""AGENTS.md is copied from AIPass source (may reference .trinity/ as part of agent docs)."""
"""AGENTS.md references .trinity/ as part of startup protocol docs."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="keep")
content = (target / "AGENTS.md").read_text(encoding="utf-8")
# Source file legitimately references .trinity/ as part of startup protocol docs
assert "# AIPass" in content
assert "Multi-agent framework" in content
assert "# KEEP" in content
assert ".trinity/" in content
# ---------------------------------------------------------------------------
@@ -518,7 +515,6 @@ def test_update_project_never_touches_user_owned_files(tmp_path):
# Modify user-owned files
(target / "README.md").write_text("# My custom README\n", encoding="utf-8")
(target / "STATUS.local.md").write_text("# Custom status\n", encoding="utf-8")
(target / ".gitignore").write_text("# custom\n", encoding="utf-8")
result = update_project(target)
@@ -526,12 +522,10 @@ def test_update_project_never_touches_user_owned_files(tmp_path):
skipped = result["skipped_files"]
assert any("REGISTRY" in s for s in skipped)
assert any("README.md" in s for s in skipped)
assert any("STATUS.local.md" in s for s in skipped)
assert any(".gitignore" in s for s in skipped)
# User customisations are preserved
assert (target / "README.md").read_text(encoding="utf-8") == "# My custom README\n"
assert (target / "STATUS.local.md").read_text(encoding="utf-8") == "# Custom status\n"
def test_update_project_creates_missing_managed_dirs(tmp_path):
@@ -556,15 +550,15 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
def test_update_project_skipped_files_count(tmp_path):
"""update_project skips 4 user-owned files + existing mailbox = 5 total."""
"""update_project skips 3 user-owned files."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="count")
result = update_project(target)
# 4 user-owned (registry, README, STATUS, .gitignore)
assert len(result["skipped_files"]) == 4
# 3 user-owned (registry, README, .gitignore)
assert len(result["skipped_files"]) == 3
# ---------------------------------------------------------------------------
+13 -4
View File
@@ -65,8 +65,14 @@ class TestDetectProjectName:
def test_fallback_to_dirname(self, tmp_path: Path) -> None:
"""Falls back to directory name when no registry."""
result = detect_project_name(tmp_path)
assert result == tmp_path.name.lower()
no_reg = tmp_path / "empty_project"
no_reg.mkdir()
with patch(
"aipass.aipass.apps.modules.doctor_fix._discover_registry",
return_value=no_reg / "MISSING_REGISTRY.json",
):
result = detect_project_name(no_reg)
assert result == "empty_project"
def test_registry_name_lowered(self, tmp_path: Path) -> None:
"""Registry name is lowercased."""
@@ -248,10 +254,13 @@ class TestFormatTextReport:
assert "drone @spawn repair @test --relocate a b" in result
def test_dry_run_hint(self) -> None:
"""Report ends with dry-run suggestion."""
"""Report shows preview (dry-run default) and explicit --apply hints."""
items = [RemediationItem("info", "pyproject", "missing", "fix")]
result = format_text_report(items, "myproj")
assert "drone @spawn repair @myproj --dry-run" in result
# Repair is dry-run by default now: preview form has no flag, apply form is explicit
assert "Preview all fixes:" in result
assert "drone @spawn repair @myproj" in result
assert "drone @spawn repair @myproj --apply" in result
def test_critical_sorted_first(self) -> None:
"""Critical items appear before warning and info."""
+22 -19
View File
@@ -240,54 +240,57 @@ class TestMatchBranches:
class TestSearchReadme:
"""Tests for _search_readme: live file reads, scoring, and error handling."""
"""Tests for _search_readme: live file reads via handler, scoring, and error handling."""
def _mock_lines(self, content):
"""Return a patch that makes read_readme_lines return content as lines."""
lines = content.splitlines(keepends=True)
return patch("aipass.aipass.apps.modules.help_chat.read_readme_lines", return_value=lines)
def test_returns_matching_lines_with_line_numbers(self):
"""Matching lines must be returned as (int, str) tuples."""
with patch("builtins.open", mock_open(read_data=_SAMPLE_README)):
results = _search_readme(_FAKE_README_PATH, ["drone"])
with self._mock_lines(_SAMPLE_README):
results = _search_readme("drone", ["drone"])
assert len(results) > 0
assert all(isinstance(ln, int) for ln, _ in results)
def test_line_numbers_are_1_indexed(self):
"""Line numbers in results must start at 1, not 0."""
with patch("builtins.open", mock_open(read_data=_SAMPLE_README)):
results = _search_readme(_FAKE_README_PATH, ["drone"])
with self._mock_lines(_SAMPLE_README):
results = _search_readme("drone", ["drone"])
assert all(ln >= 1 for ln, _ in results)
def test_returns_at_most_5_matches(self):
"""Result list must contain no more than 5 entries."""
content = "\n".join([f"drone line {i}" for i in range(10)])
with patch("builtins.open", mock_open(read_data=content)):
results = _search_readme(_FAKE_README_PATH, ["drone"])
with self._mock_lines(content):
results = _search_readme("drone", ["drone"])
assert len(results) <= 5
def test_no_keyword_match_returns_empty(self):
"""Keyword with no hits in the README must return an empty list."""
with patch("builtins.open", mock_open(read_data=_SAMPLE_README)):
results = _search_readme(_FAKE_README_PATH, ["xyzzy999"])
with self._mock_lines(_SAMPLE_README):
results = _search_readme("drone", ["xyzzy999"])
assert results == []
def test_oserror_returns_empty_and_logs_warning(self):
"""OSError on open must return [] and call logger.warning exactly once."""
with patch("builtins.open", side_effect=OSError("not found")):
def test_handler_returns_none_returns_empty_and_logs(self):
"""None from handler must return [] and call logger.warning."""
with patch("aipass.aipass.apps.modules.help_chat.read_readme_lines", return_value=None):
with patch("aipass.aipass.apps.modules.help_chat.logger") as mock_logger:
results = _search_readme(_FAKE_README_PATH, ["drone"])
results = _search_readme("nonexistent", ["drone"])
assert results == []
mock_logger.warning.assert_called_once()
def test_matching_is_case_insensitive(self):
"""Uppercase keyword in README must still match a lowercase query keyword."""
content = "DRONE does routing\n"
with patch("builtins.open", mock_open(read_data=content)):
results = _search_readme(_FAKE_README_PATH, ["drone"])
with self._mock_lines("DRONE does routing\n"):
results = _search_readme("drone", ["drone"])
assert len(results) == 1
def test_higher_scoring_lines_ranked_first(self):
"""Lines matching more keywords must appear before lines matching fewer."""
content = "drone flow spawn\ndrone only\nflow only\n"
with patch("builtins.open", mock_open(read_data=content)):
results = _search_readme(_FAKE_README_PATH, ["drone", "flow"])
with self._mock_lines("drone flow spawn\ndrone only\nflow only\n"):
results = _search_readme("drone", ["drone", "flow"])
first_text = results[0][1]
assert "drone" in first_text and "flow" in first_text
+88 -3
View File
@@ -19,6 +19,7 @@ from aipass.aipass.apps.modules.init_flow import (
TOTAL_STAGES,
_get_last_completed_stage,
_get_setup_progress,
_handle_init_update,
_save_stage,
handle_command,
print_help,
@@ -506,14 +507,14 @@ class TestStages:
assert result["docker"] == "skipped"
def test_stage_8_non_interactive_creates_my_agent(self, tmp_local_json) -> None:
"""non_interactive=True uses 'my-agent' as default name."""
"""non_interactive=True uses 'my_agent' as default name."""
mock_proc = MagicMock(returncode=0)
with patch(f"{_MOD}.console"):
with patch(f"{_MOD}.subprocess.run", return_value=mock_proc):
with patch(f"{_MOD}._resolve_package_dir", return_value=None):
result = stage_8_first_agent(non_interactive=True)
assert result["agent_name"] == "my-agent"
assert result["agent_path"] == "src/my-agent"
assert result["agent_name"] == "my_agent"
assert result["agent_path"] == "src/my_agent"
def test_stage_8_drone_not_found(self, tmp_local_json) -> None:
"""FileNotFoundError from drone is handled gracefully."""
@@ -574,3 +575,87 @@ class TestStages:
assert result == {}
stored = json.loads(tmp_local_json.read_text())
assert stored["setup_progress"]["last_completed_stage"] == 12
# =============================================================================
# init_update_registry_sync: subprocess_sync
# =============================================================================
_MOD_UPDATE = "aipass.aipass.apps.modules.init_flow"
class TestInitUpdateRegistrySync:
"""Tests for registry sync subprocess call in _handle_init_update."""
def test_sync_success_prints_message(self, tmp_path: Path) -> None:
"""Successful drone sync-registry prints 'Registry synced.'"""
mock_result = MagicMock(returncode=0)
with (
patch(
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
return_value={"updated_files": [], "already_current": []},
),
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=mock_result) as mock_run,
patch(f"{_MOD_UPDATE}.console") as mock_console,
patch(f"{_MOD_UPDATE}.json_handler"),
):
rc = _handle_init_update([str(tmp_path)])
assert rc == 0
mock_run.assert_called_once_with(
["drone", "@spawn", "sync-registry", "--fix"],
capture_output=True,
text=True,
timeout=30,
)
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
assert len(sync_calls) == 1
def test_sync_failure_degrades_silently(self, tmp_path: Path) -> None:
"""Non-zero exit from drone sync-registry is silently skipped."""
mock_result = MagicMock(returncode=1)
with (
patch(
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
return_value={"updated_files": [], "already_current": []},
),
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=mock_result),
patch(f"{_MOD_UPDATE}.console") as mock_console,
patch(f"{_MOD_UPDATE}.json_handler"),
):
rc = _handle_init_update([str(tmp_path)])
assert rc == 0
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
assert len(sync_calls) == 0
def test_sync_missing_drone_degrades_silently(self, tmp_path: Path) -> None:
"""FileNotFoundError (no drone binary) degrades gracefully."""
with (
patch(
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
return_value={"updated_files": [], "already_current": []},
),
patch(f"{_MOD_UPDATE}.subprocess.run", side_effect=FileNotFoundError("drone not found")),
patch(f"{_MOD_UPDATE}.console") as mock_console,
patch(f"{_MOD_UPDATE}.json_handler"),
):
rc = _handle_init_update([str(tmp_path)])
assert rc == 0
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
assert len(sync_calls) == 0
def test_sync_timeout_degrades_silently(self, tmp_path: Path) -> None:
"""subprocess.TimeoutExpired degrades gracefully."""
import subprocess as _sp
with (
patch(
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
return_value={"updated_files": [], "already_current": []},
),
patch(f"{_MOD_UPDATE}.subprocess.run", side_effect=_sp.TimeoutExpired(cmd="drone", timeout=30)),
patch(f"{_MOD_UPDATE}.console"),
patch(f"{_MOD_UPDATE}.json_handler"),
):
rc = _handle_init_update([str(tmp_path)])
assert rc == 0
+52 -23
View File
@@ -9,11 +9,12 @@
"""Tests for json_handler — default_factory, validate, get_path, ensure_exists, load, save, ensure_module."""
import json
import pytest
from unittest.mock import patch
from aipass.aipass.apps.handlers.json.json_handler import (
AIPASS_JSON_DIR,
_default_template,
ensure_json_exists,
ensure_module_jsons,
get_json_path,
@@ -30,31 +31,39 @@ from aipass.aipass.apps.handlers.json.json_handler import (
class TestDefaultFactory:
"""Tests for _default_template factory function."""
"""Tests for default JSON creation via ensure_json_exists."""
def test_config_template(self):
def test_config_template(self, tmp_path):
"""Config template includes module_name, version, config, created."""
result = _default_template("config", "test_mod")
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
ensure_json_exists("test_mod", "config")
result = json.loads((tmp_path / "test_mod_config.json").read_text())
assert result["module_name"] == "test_mod"
assert result["version"] == "1.0.0"
assert "config" in result
assert "created" in result
def test_data_template(self):
def test_data_template(self, tmp_path):
"""Data template includes created and last_updated."""
result = _default_template("data", "test_mod")
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
ensure_json_exists("test_mod", "data")
result = json.loads((tmp_path / "test_mod_data.json").read_text())
assert "created" in result
assert "last_updated" in result
def test_log_template(self):
def test_log_template(self, tmp_path):
"""Log template is an empty list."""
result = _default_template("log", "test_mod")
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
ensure_json_exists("test_mod", "log")
result = json.loads((tmp_path / "test_mod_log.json").read_text())
assert result == []
def test_unknown_type_returns_none(self):
"""Unknown json_type returns None."""
result = _default_template("unknown_type", "test_mod")
assert result is None
def test_unknown_type_raises(self):
"""Unknown json_type raises ValueError."""
from aipass.common.json_handler import JsonHandler
with pytest.raises(ValueError):
JsonHandler._create_default("unknown_type", "test_mod")
# =============================================================================
@@ -200,10 +209,20 @@ class TestSave:
assert saved["module_name"] == "s"
def test_save_invalid_structure_rejected(self, tmp_path):
"""Invalid structure is rejected with False."""
"""Invalid structure raises ValueError."""
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
result = save_json("s", "config", {"bad": True})
assert result is False
with pytest.raises(ValueError):
save_json("s", "config", {"bad": True})
def test_save_unknown_returns_false(self, tmp_path):
"""save_json returns False when write fails (e.g. read-only dir)."""
ro_dir = tmp_path / "readonly"
ro_dir.mkdir()
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", ro_dir):
data = {"module_name": "s", "version": "1.0.0", "config": {}, "created": "2026-01-01"}
with patch("aipass.common.json_handler.JsonHandler.write_json", return_value=False):
result = save_json("s", "config", data)
assert result is False
# =============================================================================
@@ -239,7 +258,7 @@ class TestLoadPath:
result = load_path(f)
assert result == {"key": "value"}
def test_load_missing_file(self, tmp_path):
def test_unknown_file_returns_none(self, tmp_path):
"""Missing file returns None."""
result = load_path(tmp_path / "nope.json")
assert result is None
@@ -326,14 +345,14 @@ class TestExceptionContracts:
"""Tests that invalid inputs raise appropriate exceptions."""
def test_invalid_mode_raises(self, tmp_path):
"""save_json with invalid structure returns False (not silent pass)."""
"""save_json with invalid structure raises ValueError."""
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
result = save_json("x", "config", [])
assert result is False
result = save_json("x", "data", "string")
assert result is False
result = save_json("x", "log", {"not": "a list"})
assert result is False
with pytest.raises(ValueError):
save_json("x", "config", [])
with pytest.raises(ValueError):
save_json("x", "data", "string")
with pytest.raises(ValueError):
save_json("x", "log", {"not": "a list"})
# =============================================================================
@@ -357,3 +376,13 @@ class TestInfrastructureMocking:
assert callable(jh_mod.load_json)
assert callable(jh_mod.save_json)
assert callable(jh_mod.load_path)
# =============================================================================
# success_failure_paths: unknown_returns_false
# =============================================================================
def test_unknown_returns_false():
"""validate_json_structure returns False for unrecognized json_type."""
assert validate_json_structure({}, "bogus") is False
+12 -7
View File
@@ -20,7 +20,6 @@ from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
check_root_artifacts,
detect_pollution,
find_project_root,
find_registry,
scan_agents,
)
from aipass.aipass.apps.handlers.ui.progress import GLYPH_FAIL, GLYPH_WARN
@@ -357,16 +356,22 @@ class TestRegistryConsistency:
class TestFindRegistry:
def test_finds_registry(self, tmp_path: Path) -> None:
"""Finds *_REGISTRY.json in project root."""
"""Shared find_registry finds *_REGISTRY.json from start_path."""
from aipass.common.registry_discovery import find_registry
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
result = find_registry(tmp_path)
result = find_registry(start_path=tmp_path)
assert result is not None
assert result.name == "AIPASS_REGISTRY.json"
def test_returns_none(self, tmp_path: Path) -> None:
"""Returns None when no registry file."""
result = find_registry(tmp_path)
assert result is None
def test_fallback_when_missing(self, tmp_path: Path) -> None:
"""Shared find_registry returns fallback when no registry in isolated dir."""
from aipass.common.registry_discovery import find_registry
isolated = tmp_path / "no_registry"
isolated.mkdir()
result = find_registry(start_path=isolated)
assert result.parent != isolated or not result.exists()
# =============================================================================
+1
View File
@@ -0,0 +1 @@
# aipass.common — shared leaf utilities (no branch dependencies)
+303
View File
@@ -0,0 +1,303 @@
# =================== AIPass ====================
# Name: json_handler.py
# Description: Shared JSON handler with injectable storage directory
# Version: 1.0.0
# Created: 2026-06-06
# Modified: 2026-06-06
# =============================================
"""Shared JSON handler — auto-creating, self-healing JSON system.
Dependency-free: uses only stdlib. Importable before drone/prax exist.
Each branch creates a JsonHandler instance with its own json_dir.
Contract: save_json raises ValueError on validation failure.
"""
import inspect
import json
import logging
import os
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
_JSON_TYPES: tuple[str, ...] = ("config", "data", "log")
class JsonHandler:
"""JSON file handler with injectable storage directory.
Provides JSON I/O utilities, validation, and operation logging
for the three-JSON system (config, data, log).
Args:
json_dir: Directory for module JSON files (config/data/log).
"""
MAX_LOG_ENTRIES = 100
def __init__(self, json_dir: Path):
self._json_dir = Path(json_dir)
@staticmethod
def read_json(file_path: Path) -> Optional[dict]:
"""Read and parse a JSON file.
Args:
file_path: Path to the JSON file.
Returns:
Parsed dict, or None on failure.
"""
try:
return json.loads(Path(file_path).read_text(encoding="utf-8"))
except (json.JSONDecodeError, FileNotFoundError) as e:
logger.warning("Failed to read JSON from %s: %s", file_path, e)
return None
@staticmethod
def write_json(file_path: Path, data: Any, indent: int = 2) -> bool:
"""Write data to a JSON file atomically (temp file + os.replace).
Args:
file_path: Target path.
data: JSON-serializable data.
indent: JSON indentation level.
Returns:
True on success, False on OS error.
"""
file_path = Path(file_path)
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
content = json.dumps(data, indent=indent) + "\n"
fd, tmp_path = tempfile.mkstemp(dir=file_path.parent, suffix=".tmp")
closed = False
try:
os.write(fd, content.encode("utf-8"))
os.fsync(fd)
os.close(fd)
closed = True
os.replace(tmp_path, file_path)
except BaseException:
if not closed:
os.close(fd)
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
return True
except OSError as e:
logger.error("Failed to write JSON to %s: %s", file_path, e)
return False
@staticmethod
def validate_json_structure(data: Any, json_type: str) -> bool:
"""Validate that data matches the expected shape for json_type.
Args:
data: Parsed JSON data to validate.
json_type: One of "config", "data", "log".
Returns:
True when the structure is valid, False otherwise.
"""
if json_type == "config":
if not isinstance(data, dict):
return False
return all(key in data for key in ("module_name", "version", "config"))
if json_type == "data":
if not isinstance(data, dict):
return False
return all(key in data for key in ("created", "last_updated"))
if json_type == "log":
return isinstance(data, list)
return False
@staticmethod
def _create_default(json_type: str, module_name: str) -> Any:
"""Return default content for a given JSON type.
Args:
json_type: One of "config", "data", "log".
module_name: Logical module name.
Returns:
Default data structure.
Raises:
ValueError: For unknown json_type.
"""
today = datetime.now().date().isoformat()
if json_type == "config":
return {
"module_name": module_name,
"version": "1.0.0",
"config": {
"max_log_entries": JsonHandler.MAX_LOG_ENTRIES,
},
"created": today,
"last_updated": today,
}
if json_type == "data":
return {
"created": today,
"last_updated": today,
}
if json_type == "log":
return []
raise ValueError(f"Unknown json_type: {json_type!r}")
def get_json_path(self, module_name: str, json_type: str) -> Path:
"""Return the filesystem path for a module's JSON file.
Args:
module_name: Logical module name.
json_type: One of "config", "data", "log".
Returns:
Absolute Path to the JSON file.
"""
return self._json_dir / f"{module_name}_{json_type}.json"
def ensure_json_exists(self, module_name: str, json_type: str) -> bool:
"""Ensure a single JSON file exists; create with defaults if missing.
If the file exists but fails validation it is regenerated.
Args:
module_name: Logical module name.
json_type: One of "config", "data", "log".
Returns:
True after the file is confirmed present and valid.
"""
self._json_dir.mkdir(parents=True, exist_ok=True)
json_path = self.get_json_path(module_name, json_type)
if json_path.exists():
try:
if json_path.stat().st_size == 0:
logger.warning("ensure_json_exists: empty file at %s, regenerating", json_path)
else:
data = json.loads(json_path.read_text(encoding="utf-8"))
if self.validate_json_structure(data, json_type):
return True
except Exception as exc:
logger.warning("ensure_json_exists: failed to read %s, regenerating: %s", json_path, exc)
default = self._create_default(json_type, module_name)
self.write_json(json_path, default)
return True
def ensure_module_jsons(self, module_name: str) -> bool:
"""Ensure all three JSON files (config, data, log) exist for a module.
Args:
module_name: Logical module name.
Returns:
True when all files are present and valid.
"""
for json_type in _JSON_TYPES:
self.ensure_json_exists(module_name, json_type)
return True
def load_json(self, module_name: str, json_type: str) -> Any | None:
"""Load a module's JSON file, auto-creating it if missing.
Args:
module_name: Logical module name.
json_type: One of "config", "data", "log".
Returns:
Parsed JSON data, or None on failure.
"""
if not self.ensure_json_exists(module_name, json_type):
return None
json_path = self.get_json_path(module_name, json_type)
try:
return json.loads(json_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
logger.warning("load_json: failed to read %s: %s", json_path, exc)
return self._create_default(json_type, module_name)
def save_json(self, module_name: str, json_type: str, data: Any) -> bool:
"""Write data to a module's JSON file after validation.
For "data" type files the last_updated field is refreshed automatically.
Args:
module_name: Logical module name.
json_type: One of "config", "data", "log".
data: The data structure to persist.
Returns:
True on success.
Raises:
ValueError: When data fails structure validation.
"""
if not self.validate_json_structure(data, json_type):
raise ValueError(f"Invalid structure for {json_type} JSON")
if json_type == "data" and isinstance(data, dict):
data["last_updated"] = datetime.now().date().isoformat()
json_path = self.get_json_path(module_name, json_type)
return self.write_json(json_path, data)
def log_operation(self, operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool:
"""Add entry to module operation log with automatic rotation.
Auto-detects calling module if module_name not provided.
Args:
operation: Operation name to log.
data: Optional data dict.
module_name: Optional module name (auto-detected if not provided).
Returns:
True if successful, False otherwise.
"""
if module_name is None:
module_name = _get_caller_module_name()
try:
self.ensure_module_jsons(module_name)
log = self.load_json(module_name, "log")
if log is None:
log = []
entry: Dict[str, Any] = {
"timestamp": datetime.now().isoformat(),
"operation": operation,
}
if data:
entry["data"] = data
log.append(entry)
if len(log) > self.MAX_LOG_ENTRIES:
log = log[-self.MAX_LOG_ENTRIES :]
return self.save_json(module_name, "log", log)
except Exception as exc:
logger.warning("log_operation: failed for %s/%s: %s", module_name, operation, exc)
return False
def _get_caller_module_name() -> str:
"""Auto-detect calling module name from call stack."""
stack = inspect.stack()
if len(stack) > 2:
caller_path = Path(stack[2].filename)
module_name = caller_path.stem
if module_name and not module_name.startswith("_"):
return module_name
return "unknown"
+115
View File
@@ -0,0 +1,115 @@
# =================== AIPass ====================
# Name: json_ops.py
# Description: Shared JSON operations — deep merge and backup
# Version: 1.0.0
# Created: 2026-06-06
# Modified: 2026-06-06
# =============================================
"""Shared JSON operations — deep merge and backup utilities.
Dependency-free: uses only stdlib. Importable before drone/prax exist.
"""
import logging
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def deep_merge(template_data: Any, existing_data: Any) -> Any:
"""Recursively merge template structure with existing data.
Merge strategy:
- Both dicts: merge keys. Template defines structure, existing fills values.
- Template has key that existing doesn't: add from template (default).
- Existing has key that template doesn't: KEEP existing key (don't prune).
- Both lists: keep existing list (don't overwrite user data).
- Scalar values: keep existing value (don't overwrite).
- If existing is None/empty but template has value: use template value.
Args:
template_data: Template structure (provides fields and defaults).
existing_data: Existing data (provides values to preserve).
Returns:
Merged result combining template structure with existing values.
"""
if existing_data is None:
return template_data
if template_data is None:
return existing_data
if isinstance(template_data, dict) and isinstance(existing_data, dict):
result = {}
for key in template_data:
if key in existing_data:
result[key] = deep_merge(template_data[key], existing_data[key])
else:
result[key] = template_data[key]
for key in existing_data:
if key not in result:
result[key] = existing_data[key]
return result
if isinstance(template_data, list) and isinstance(existing_data, list):
if len(existing_data) > 0:
return existing_data
if len(template_data) > 0:
return template_data
return []
if existing_data is not None:
if isinstance(existing_data, str) and existing_data == "" and template_data:
return template_data
return existing_data
return template_data
def backup_json(file_path: Path, backup_dir: Path | None = None) -> Path:
"""Create a timestamped backup of a JSON file.
By default the backup is placed in a ``.recovery/`` directory alongside the
file. Callers can override with ``backup_dir`` to consolidate backups
elsewhere (e.g. ``.spawn/.recovery/``).
Args:
file_path: Path to the JSON file to back up.
backup_dir: Optional override for the backup destination directory.
Defaults to ``file_path.parent / ".recovery"``.
Returns:
Path to the backup file.
Raises:
FileNotFoundError: If the source file doesn't exist.
IOError: If the backup copy fails.
"""
file_path = Path(file_path)
if not file_path.exists():
raise FileNotFoundError(f"Cannot backup — file not found: {file_path}")
if backup_dir is None:
backup_dir = file_path.parent / ".recovery"
backup_dir = Path(backup_dir)
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"{file_path.name}.{timestamp}.backup"
backup_path = backup_dir / backup_name
try:
shutil.copy2(file_path, backup_path)
return backup_path
except (IOError, OSError) as exc:
logger.error("Backup failed for %s: %s", file_path.name, exc)
raise
+67
View File
@@ -0,0 +1,67 @@
# =================== AIPass ====================
# Name: registry_discovery.py
# Description: Shared registry file discovery (walk-up search)
# Version: 1.0.0
# Created: 2026-06-06
# Modified: 2026-06-06
# =============================================
"""Registry discovery — find *_REGISTRY.json by walking up the directory tree.
Dependency-free: uses only stdlib. Importable before drone/prax exist.
"""
import os
from pathlib import Path
def _glob_registry(directory):
"""Find *_REGISTRY.json in a single directory.
Args:
directory: Path to search in.
Returns:
Path to the registry file, or None if not found.
"""
matches = sorted(directory.glob("*_REGISTRY.json"))
return matches[0] if matches else None
def find_registry(start_path=None, package_root=None):
"""Find *_REGISTRY.json — walks up from start_path/cwd, then package_root.
The first *_REGISTRY.json found while walking up IS the project boundary.
If multiple exist in the same directory, picks the first alphabetically.
Priority:
1. AIPASS_REGISTRY environment variable
2. Walk up from start_path/cwd — first dir containing *_REGISTRY.json
3. Walk up from package_root (caller's __file__ location) — fallback
4. Last resort: cwd / AIPASS_REGISTRY.json (backwards compat)
Args:
start_path: Directory to start searching from (default: cwd).
package_root: Optional fallback directory for package-relative search.
Returns:
Path to *_REGISTRY.json.
"""
env_path = os.environ.get("AIPASS_REGISTRY")
if env_path:
return Path(env_path)
current = Path(start_path).resolve() if start_path else Path.cwd()
for parent in [current] + list(current.parents):
found = _glob_registry(parent)
if found:
return found
if package_root:
pkg_dir = Path(package_root).resolve()
for parent in [pkg_dir] + list(pkg_dir.parents):
found = _glob_registry(parent)
if found:
return found
return Path.cwd() / "AIPASS_REGISTRY.json"
@@ -1,6 +1,6 @@
# DEVPULSE — Branch Prompt
Injected every turn. Breadcrumbs only — details in README, --help, .trinity/, STATUS.local.md.
Injected every turn. Breadcrumbs only — details in README, --help, .trinity/, DASHBOARD.local.json.
## Identity
@@ -9,7 +9,7 @@ DEVPULSE — Patrick's primary AI collaborator, orchestration hub. Design, plan,
## How You Work
- DRONE FOR EVERYTHING. Never raw git, gh, or python -m. `drone` is on PATH — run it directly. No which, no path lookup, no verification. Just `drone @git ...`, `drone @flow ...`, `drone @ai_mail ...`. If blocked, drone is the fix — not a workaround.
- Build own directly: modules, DPLANs, FPLANs, memories, STATUS — yours, edit freely.
- Build own directly: modules, DPLANs, FPLANs, memories — yours, edit freely.
- Prototype to explore shape, hand real build to sub-agent.
- Investigate other branches freely: read, debug, test, fix small bugs. CWD stays devpulse.
- Full multi-file implementations → `drone @ai_mail dispatch @branch`.
@@ -35,9 +35,15 @@ Task belongs to specialist domain → ask them. Investigate/fix small things you
| User onboarding, init | @aipass | Concierge, aipass init, doctor, scanner |
| Hooks, engine, gates | @hooks | Hook engine, bridges, per-project config, sound |
## Git — Dev Branch, Drone Only, You Are Gatekeeper
## Git — Dev Branch, You Are Gatekeeper
Only branch with git write access. All git/gh blocked at project level. Drone bypasses via subprocess — tier system grants write to devpulse only.
Only branch with git WRITE access. WRITE git (commit, push, checkout, merge, reset, rebase, clean, pull, fetch, tag, branch -D, clone, worktree…) is blocked raw → use `drone @git` (tier grants write to devpulse only).
**READ git is allowed RAW** (S193, git_gate read allowlist) — just run it, no drone needed. Use this for investigation/forensics instead of reaching for drone or `find` fallbacks:
- Allowed verbs: `ls-files, ls-tree, show, cat-file, rev-parse, rev-list, log, status, diff, blame, describe, for-each-ref, show-ref, symbolic-ref, shortlog, grep, archive, count-objects, var, help, version`.
- NOT yet allowed (gap, S193): `check-ignore` → use `git ls-files <path>` (empty = ignored/untracked) or read `.gitignore` directly.
- Reproduce a clean checkout (tracked-only, like CI): `git archive HEAD | tar -x -C /tmp/<dir>` (`drone rm` the dir first; `rm -rf` is gated).
- Chained read+write blocks the whole command (e.g. `git log && git push` → blocked). Keep read and write in separate invocations.
Three rules:
1. Work on dev, merge to main when satisfied. `drone @git merge dev` squash-merges.
@@ -91,7 +97,7 @@ drone, seedgo, prax, cli, ai_mail, api, flow, spawn, trigger, memory, aipass, ho
- Lean on branches for expertise. Email for architecture questions. Investigate/debug/test freely.
- Use memories freely. Rollover to @memory by design. Update .trinity/ often.
- STATUS.local.md Notepad for friction notes. Address in batches.
- local.json todos[] for friction notes. Address in batches.
- Own things → build directly. Heavy refactors → delegate sub-agent.
- CWD = identity. Visit other branches, don't move in.
- Git awareness: after completing work, `drone @git status`. Suggest commit if coherent. Don't force, don't let pile up.
@@ -122,10 +128,10 @@ Find agent via `.trinity/passport.json`. Use `dangerouslyDisableSandbox: true`.
## Memory & Tracking
- `.trinity/local.json` — session history, key learnings
- `.trinity/local.json` — session history, key learnings, todos[]
- `.trinity/observations.json` — collaboration patterns
- `STATUS.local.md` — current work, issues, todos, notepad. Feeds central STATUS.md.
- `DASHBOARD.local.json` — live state glance (refreshed by prax)
Update proactively — after milestones, /memo, topic shifts, 5+ actions without saving.
This prompt = lightweight signposts. State → .trinity/ + STATUS.local.md.
This prompt = lightweight signposts. State → .trinity/ + DASHBOARD.local.json.
+5 -5
View File
@@ -2,7 +2,7 @@
# DevPulse
> Orchestration hub for AIPass. The user's primary AI collaborator — designs, plans, debugs, coordinates all 11 branches, and builds its own modules.
> Orchestration hub for AIPass. The user's primary AI collaborator — designs, plans, debugs, coordinates all 12 other branches, and builds its own modules.
DevPulse handles the day-to-day: working with the user to plan, design, troubleshoot, and adjust. It builds its own modules directly (watchdog, feedback, json_handler), manages all git operations for the project, dispatches heavy multi-file builds to sub-agents, and ventures into other branches to investigate, debug, and fix small bugs. The only branch with git write access.
@@ -10,7 +10,7 @@ DevPulse handles the day-to-day: working with the user to plan, design, troubles
| You want to | Read |
|---|---|
| What's happening right now | [STATUS.local.md](STATUS.local.md) |
| What's happening right now | `DASHBOARD.local.json` |
| Identity, memory, session history | [`.trinity/`](.trinity/) |
| Active plans | `drone @flow list open` |
| Branch list | `drone systems` |
@@ -42,11 +42,11 @@ src/aipass/devpulse/
│ │ └── watchdog/ # Agent, timer, schedule, registry
│ └── plugins/ # Plugin extension point
├── devpulse_json/ # JSON handler storage (config, data, logs per module)
├── tests/ # 252 tests
├── tests/ # 236 tests
├── artifacts/ # Birth certificate, reports
├── dropbox/ # Received files, archived plans, install audit
├── docs/ # Transition notes
└── STATUS.local.md # Current work beacon
└── DASHBOARD.local.json # Live state (refreshed by prax)
```
## Commands
@@ -107,7 +107,7 @@ drone @git log # Recent commits
All branches via dispatch orchestration. Watchdog monitoring for any dispatched agent. Feedback channel for cross-branch communication. Git operations (commit, PR, merge) for the entire project.
*Last Updated: 2026-05-16*
*Last Updated: 2026-06-05*
---
-225
View File
@@ -1,225 +0,0 @@
[← Back to DevPulse](README.md)
# DevPulse Setup, Uninstall, Troubleshooting
Everything you need to install, run, maintain, or remove DevPulse (and AIPass as a whole). Kept here so the DevPulse README can stay lean and loads quickly on every session startup.
---
## Platform support at a glance
| Platform | Install status | Notes |
|---|---|---|
| **Linux** (Ubuntu, Debian, Fedora, Arch) | Supported | Primary development target. `setup.sh` works out of the box. |
| **macOS** (Intel and Apple Silicon) | Supported | `setup.sh` works with minor caveats (see macOS section). |
| **Windows 10 / 11** | **In progress** | Native Windows support is actively being built. Track progress in [issue #261](https://github.com/AIOSAI/AIPass/issues/261). For now: use WSL2 (Ubuntu), or wait for the cross-platform `setup.py` landing in a PR soon. |
---
## Linux install
### Requirements
- Python 3.10 or newer (`python3 --version`)
- `git`, `bash`, `sudo`
- Claude Code CLI installed and authenticated (`claude --version`)
- ~500 MB disk for the venv and dependencies
### Install
```bash
git clone https://github.com/AIOSAI/AIPass.git ~/Projects/AIPass
cd ~/Projects/AIPass
bash setup.sh
```
`setup.sh` will:
1. Create a Python venv at `.venv/`
2. Install AIPass in editable mode (`pip install -e .`)
3. Verify the `drone` and `aipass` CLI entry points
4. Create `~/.secrets/aipass/` with `chmod 700` and seed an `.env.example`
5. Generate the AIPass branch registry
6. Bootstrap branch identity files (`.trinity/passport.json` per branch)
7. Wire Claude Code hooks into `~/.claude/settings.json`
8. Create a global symlink at `/usr/local/bin/drone` (asks for `sudo`)
### Post-install
```bash
# Verify
drone systems
# Enter the DevPulse branch
cd ~/Projects/AIPass/src/aipass/devpulse
claude
```
You should see DevPulse greet you, read its memory, and be ready.
### Optional
- Add API keys to `~/.secrets/aipass/.env` if you want LLM routing beyond Claude Code
- Set `AIPASS_HOME=~/Projects/AIPass` in your shell rc if you plan to use AIPass from other projects
- Add `export AIPASS_HOME=~/Projects/AIPass` to `~/.bashrc` **and** `~/.claude/settings.json` (the `env` section) — both are needed for full cross-project access
---
## macOS install
Same as Linux. `setup.sh` uses bash and runs on macOS out of the box.
**Caveats**:
- `chmod 700` and `chown` work correctly on macOS's HFS+ and APFS
- `sudo ln -sf /usr/local/bin/drone` works but may prompt for your admin password
- Homebrew users: if you have multiple Python installs, make sure `python3` points to Python 3.10+ before running `setup.sh`
---
## Windows install
**Short version**: use [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) (Ubuntu) and follow the Linux instructions. Full native Windows support is landing in a PR soon — follow [issue #261](https://github.com/AIOSAI/AIPass/issues/261) for status.
**Why it's in progress**: the current `setup.sh` uses bash, `sudo`, and `ln -sf /usr/local/bin/drone`, none of which translate to Windows. The `aipass init` command also writes a shell loop into `.claude/settings.json` that assumes Unix root `/`. Fixes are in flight:
- A cross-platform `setup.py` that replaces `setup.sh` on Windows
- A Python-based directory traversal replacing the bash loop in `aipass init`
- OS detection in `setup.sh` to skip the symlink step on Windows and print PATH instructions instead
**Interim workaround**: install WSL2 with an Ubuntu distribution, then clone and run `setup.sh` inside WSL. Claude Code also runs well inside WSL.
---
## Uninstall
### Full removal (Linux / macOS)
```bash
# 1. Remove the venv and repo
rm -rf ~/Projects/AIPass
# 2. Remove the global drone symlink
sudo rm /usr/local/bin/drone
# 3. Remove secrets (if you won't reinstall)
rm -rf ~/.secrets/aipass
# 4. Clean Claude Code hooks
# Edit ~/.claude/settings.json and remove any "hooks" sections that reference AIPass paths.
# Safer: back up the file first.
cp ~/.claude/settings.json ~/.claude/settings.json.bak
nano ~/.claude/settings.json # or your editor of choice
# 5. Clean your shell rc
# Remove any AIPASS_HOME export from ~/.bashrc, ~/.zshrc, etc.
```
### Partial removal (keeping secrets for reinstall)
Skip step 3 above. Your `~/.secrets/aipass/.env` will persist and be reused on next install.
### Windows (WSL2)
Same as Linux, inside the WSL distribution. To also remove the WSL distribution itself: `wsl --unregister Ubuntu` from PowerShell.
---
## Troubleshooting
### `drone: command not found`
Your venv is not activated or the `/usr/local/bin/drone` symlink is missing.
```bash
# Option A: activate the venv
source ~/Projects/AIPass/.venv/bin/activate
drone systems
# Option B: run via full path
~/Projects/AIPass/.venv/bin/drone systems
# Option C: reinstall the symlink
sudo ln -sf ~/Projects/AIPass/.venv/bin/drone /usr/local/bin/drone
```
### `AIPASS_HOME not set` warnings
```bash
# In your shell rc (~/.bashrc or ~/.zshrc)
export AIPASS_HOME=~/Projects/AIPass
# Then restart the shell or:
source ~/.bashrc
```
Also add it to `~/.claude/settings.json` under the `env` block for Claude Code sessions to pick it up.
### DevPulse greets you but doesn't read its memory
Check that `.trinity/passport.json`, `.trinity/local.json`, and `.trinity/observations.json` exist in `src/aipass/devpulse/`. If they don't, run `bash setup.sh` again to re-bootstrap the identity files.
### `drone @git system-pr` fails with a lock error
```bash
drone @git lock # check the lock state
drone @git fix # attempt to fix broken git state
```
Do NOT use raw `git reset --hard` — merge conflicts are easier to resolve than lost work.
### Branch mail not arriving
```bash
drone @ai_mail inbox # check your inbox
drone @prax watch # watch the monitoring dashboard
```
A known issue at the end of S90 affected wake delivery; see the wake investigation in DPLAN-0125 Track E if you're running a recent build.
### Tests fail on a fresh clone
```bash
cd ~/Projects/AIPass
source .venv/bin/activate
python -m pytest src/aipass/<branch>/tests/
```
If tests fail because `AIPASS_HOME` leaks the real registry into test results, that's a known pattern — the tests need `monkeypatch.delenv("AIPASS_HOME")`. See S90 notes for the fixture pattern.
### `.claude/settings.json` has hardcoded absolute paths
You pulled an old clone. The hardcoded paths were removed in commit `867dad0` (April 5, 2026). Pull the latest main and re-run `setup.sh`, which generates the settings dynamically from your local repo root.
---
## Environment variables
| Variable | Purpose | Set where |
|---|---|---|
| `AIPASS_HOME` | Lets external projects find the AIPass registry | `~/.bashrc` + `~/.claude/settings.json` env block |
| `AIPASS_CALLER_BRANCH` | Auto-set by dispatch; identifies the sending branch for feedback/mail | Runtime only, do not set manually |
| `AIPASS_CALLER_CWD` | Auto-set by dispatch; identifies the caller's project directory | Runtime only, do not set manually |
Sensitive values (API keys, tokens, recovery codes) belong in `~/.secrets/aipass/.env`, not in shell rc or repo files.
---
## Reporting bugs
File issues at https://github.com/AIOSAI/AIPass/issues.
Helpful info to include:
- OS and version
- Python version (`python3 --version`)
- Claude Code version (`claude --version`)
- The exact command you ran and the full error output
- Whether you cloned recently or have been on the same checkout for a while (clone age helps us distinguish current bugs from fixed-but-stale-clone issues)
The first external bug report was [#261 by Gavin Rooney](https://github.com/AIOSAI/AIPass/issues/261) — that template is a good example of a useful report.
---
## See also
- [DevPulse README](README.md) — the lean entry point
- [AIPass root README](../../../README.md) — the whole framework
- [STATUS.local.md](STATUS.local.md) — current work and loose ends
- [issue #261](https://github.com/AIOSAI/AIPass/issues/261) — Windows compat tracking
@@ -319,7 +319,7 @@ def _classify_exit(
def watch_agent(
agent_id: str,
timeout_seconds: int = 600,
poll_interval: float = 2.0,
poll_interval: float = 5.0,
) -> dict:
"""Block until the dispatched agent at `agent_id` exits.
@@ -327,7 +327,10 @@ def watch_agent(
agent_id: Branch token like ``@drone`` (or bare ``drone``).
timeout_seconds: Maximum wait. Default 10 min — catches crashes + silent-finishes
fast; long agent watches should pass an explicit ``--timeout``.
poll_interval: Seconds between checks. Default 2.0.
poll_interval: Seconds between checks. Default 5.0 — the per-tick work (lock
stat, PID liveness, one-dir JSONL size scan) is cheap, so a tight cadence
just burns CPU. 5s keeps completion latency invisible on multi-minute
dispatches while the 120s stall threshold has ample resolution.
Returns:
dict with keys: woke, reason, elapsed, agent_state, exit_code, agent_id.
+4 -4
View File
@@ -50,11 +50,11 @@ HELP_TEXT = """\
def print_introspection() -> None:
"""Display module introspection info."""
console.print()
console.print("feedback Module")
console.print("DevPulse personal feedback mailbox. Receives cross-project")
console.print("feedback messages from any agent via drone routing.")
console.print("[bold cyan]feedback Module[/bold cyan]")
console.print("[dim]DevPulse personal feedback mailbox. Receives cross-project[/dim]")
console.print("[dim]feedback messages from any agent via drone routing.[/dim]")
console.print()
console.print("Subcommands: inbox, view, reply, send, clear")
console.print("[yellow]Subcommands:[/yellow] [cyan]inbox, view, reply, send, clear[/cyan]")
console.print()
+10 -9
View File
@@ -97,18 +97,18 @@ Examples:
def print_introspection() -> None:
"""Display module introspection info."""
console.print()
console.print("watchdog Module")
console.print("Devpulse-local directed wake system. Wakes devpulse when a")
console.print("watched condition fires (agent exit, timer, schedule).")
console.print("[bold cyan]watchdog Module[/bold cyan]")
console.print("[dim]Devpulse-local directed wake system. Wakes devpulse when a[/dim]")
console.print("[dim]watched condition fires (agent exit, timer, schedule).[/dim]")
console.print()
console.print("Subcommands:")
console.print("[yellow]Subcommands:[/yellow]")
for sub in _VALID_SUBCOMMANDS:
marker = "active" if sub in ("agent", "status") else f"phase {_PHASE_BY_SUB.get(sub, '?')}"
console.print(f" {sub:<10} ({marker})")
console.print(f" [cyan]{sub:<10}[/cyan] [dim]({marker})[/dim]")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/watchdog/")
console.print(" - agent.py (watch_agent — block until dispatched agent exits)")
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/watchdog/[/cyan]")
console.print(" [dim]- agent.py (watch_agent — block until dispatched agent exits)[/dim]")
console.print()
@@ -360,7 +360,8 @@ def _handle_agent(sub_args: List[str]) -> bool:
if state == "completed_silent":
console.print(
f"watchdog: {agent_id} stopped (state={state}) -- CHECK DELIVERABLES. "
f'Next: drone @ai_mail dispatch {agent_id} "check in" "You finished your last task but did not send a reply. '
f'Next: drone @ai_mail dispatch {agent_id} "check in" '
'"You finished your last task but did not send a reply. '
f'Please reply with your results now via drone @ai_mail email @devpulse."'
)
elif state == "completed_replied":
+39 -5
View File
@@ -140,8 +140,8 @@ class TestEscapedQuoteBypass:
assert _is_blocked(_bash(cmd)) == should_block, f"{'Should block' if should_block else 'Should allow'}: {cmd}"
class TestReadOnlyBlocked:
"""New handler blocks ALL raw git — read-only included. Use drone."""
class TestReadVerbsAllowed:
"""Read-only git verbs in the allowlist run raw (S193, DPLAN-0195)."""
@pytest.mark.parametrize(
"cmd",
@@ -149,15 +149,49 @@ class TestReadOnlyBlocked:
"git status",
"git log --oneline",
"git diff",
"git show HEAD",
"git ls-files",
"git ls-tree HEAD",
"git rev-parse --show-toplevel",
"git blame README.md",
"git grep TODO",
"git archive HEAD",
"git for-each-ref",
"git -C /tmp/x log",
],
)
def test_allows_read_verbs(self, cmd):
"""Allowlisted read verbs are not blocked — raw is fine."""
assert not _is_blocked(_bash(cmd)), f"Read verb should be allowed: {cmd}"
class TestNonAllowlistedGitBlocked:
"""Git verbs outside the read allowlist stay blocked — conservative."""
@pytest.mark.parametrize(
"cmd",
[
"git fetch",
"git branch",
"git tag",
"git remote -v",
],
)
def test_blocks_read_only_raw_git(self, cmd):
"""Read-only raw git is also blocked — use drone instead."""
assert _is_blocked(_bash(cmd)), f"Should block raw git (use drone): {cmd}"
def test_blocks_non_allowlisted(self, cmd):
"""Reads not on the allowlist (fetch/branch/tag/remote) still block."""
assert _is_blocked(_bash(cmd)), f"Should block (use drone): {cmd}"
@pytest.mark.parametrize(
"cmd",
[
"git log && git push",
"git status; git commit -m x",
"git diff | git apply",
],
)
def test_blocks_chained_read_then_write(self, cmd):
"""A read chained with a write blocks the whole command."""
assert _is_blocked(_bash(cmd)), f"Chained read+write should block: {cmd}"
class TestDroneNotBlocked:
+6
View File
@@ -199,6 +199,12 @@
"standard": "unused_function",
"lines": [108],
"reason": "create_pr() is deprecated per FPLAN-0210 — pr command blocked at auth tier. Handler kept for backwards compatibility; still tested in test_git_module.py."
},
{
"file": "apps/modules/git_module.py",
"standard": "unused_function",
"lines": [655],
"reason": "get_introspective() called dynamically via getattr() by module_registry_handler.py:219 for internal module introspection. Also tested in test_git_module, test_system_pr, test_devpulse_plugins, test_git_access."
}
],
"notes": {
+10 -1
View File
@@ -243,6 +243,14 @@ By default, drone captures subprocess output (`capture_output=True`) with a 30s
Commands in the interactive tuple bypass capture and inherit the terminal directly — enabling live Rich output, colors, and no timeout.
**Always interactive** — these presentational commands always inherit the terminal for Rich color on a TTY, plain when piped:
| Pattern | Reason |
|----------------|---------------------------------------------|
| `@branch` | No-args introspection (branch overview) |
| `@branch --help` | Help output with Rich formatting |
| `@branch -h` | Short help flag (same as --help) |
**Per-command allowlist** (in `apps/drone.py`):
| Command | Reason |
@@ -250,6 +258,7 @@ Commands in the interactive tuple bypass capture and inherit the terminal direct
| `monitor` | Prax real-time monitoring (live TUI) |
| `audit` | Seedgo audit (Rich progress bars) |
| `watchdog` | Devpulse watchdog (live monitoring) |
| `status` | Branch status with Rich formatted output |
**Per-branch allowlist** — all commands from these branches get interactive mode:
@@ -339,7 +348,7 @@ Run tests: `cd src/aipass/drone && python -m pytest tests/ -q`
---
**Seedgo:** 99% | **Tests:** 704 pass, 4 skip | **Last Updated:** 2026-05-12
**Seedgo:** 100% | **Tests:** 775 pass, 4 skip | **Last Updated:** 2026-06-07
---
[← Back to AIPass](../../../README.md)
+12 -25
View File
@@ -25,7 +25,6 @@ from rich.text import Text
from aipass.prax import logger
from aipass.cli.apps.modules import console, err_console
from aipass.drone.apps.modules import BranchNotFoundError, CommandExecutionError, RegistryError
from aipass.drone.apps.modules.discovery import get_help
from aipass.drone.apps.modules.resolver import get_all_branches
from aipass.drone.apps.modules.router import route_command
from aipass.drone.apps.modules.module_registry import (
@@ -41,7 +40,7 @@ VERSION = "1.1.0"
MODULES_DIR = Path(__file__).parent / "modules"
# Interactive mode — commands/branches that bypass capture + timeout for live terminal output.
INTERACTIVE_COMMANDS = ("monitor", "audit", "watchdog")
INTERACTIVE_COMMANDS = ("monitor", "audit", "watchdog", "status")
INTERACTIVE_BRANCHES = ("cli",)
@@ -111,12 +110,7 @@ def print_help() -> None:
def print_introspection() -> None:
"""Alias for seedgo standard compliance (audit expects print_introspection)."""
show_introspection()
def show_introspection() -> None:
"""Show discovery view (no args) — auto-discovers modules."""
"""Display branch overview — auto-discovers modules."""
console.print()
console.print("[bold cyan]Drone - Command Router & Discovery[/bold cyan]")
console.print()
@@ -413,8 +407,9 @@ def _handle_target(args: List[str]) -> int:
rest = args[1:]
module_name = target.lstrip("@").lower()
first_cmd = rest[0] if rest and rest[0] != "--help" else None
needs_interactive = first_cmd in INTERACTIVE_COMMANDS or module_name in INTERACTIVE_BRANCHES
first_cmd = rest[0] if rest and rest[0] not in ("--help", "-h") else None
is_presentational = not rest or first_cmd is None
needs_interactive = is_presentational or first_cmd in INTERACTIVE_COMMANDS or module_name in INTERACTIVE_BRANCHES
# Route to internal module — unless command needs interactive terminal,
# in which case fall through to branch (subprocess) routing so Rich
@@ -422,10 +417,10 @@ def _handle_target(args: List[str]) -> int:
if is_module(module_name) and not needs_interactive:
return _handle_module(module_name, rest)
# No args = pass through to branch (introspection)
# No args = pass through to branch (introspection — inherit terminal for color)
if not rest:
try:
result = route_command(target)
result = route_command(target, interactive=True)
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
if isinstance(exc, BranchNotFoundError) and is_module(module_name):
logger.info("Falling back to module routing for @%s (not in local registry)", module_name)
@@ -435,30 +430,22 @@ def _handle_target(args: List[str]) -> int:
if isinstance(exc, BranchNotFoundError) and not os.environ.get("AIPASS_HOME"):
err_console.print(" Tip: set AIPASS_HOME=/path/to/AIPass to access core branches.")
return 1
if result.stdout:
console.print(result.stdout, end="", highlight=False)
if result.stderr:
err_console.print(result.stderr, end="", highlight=False)
return result.exit_code
# --help = show help
if rest == ["--help"]:
# --help / -h = help (inherit terminal for color)
if rest in (["--help"], ["-h"]):
try:
result = get_help(target)
if result.text:
console.print(result.text, end="", highlight=False)
else:
console.print(f"No help available for {target}.")
result = route_command(target, rest[0], interactive=True)
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
if isinstance(exc, BranchNotFoundError) and is_module(module_name):
logger.info("Falling back to module routing for @%s --help (not in local registry)", module_name)
logger.info("Falling back to module routing for @%s %s (not in local registry)", module_name, rest[0])
return _handle_module(module_name, rest)
logger.warning("Help lookup failed for %s: %s", target, exc)
err_console.print(f"drone: {exc}")
if isinstance(exc, BranchNotFoundError) and not os.environ.get("AIPASS_HOME"):
err_console.print(" Tip: set AIPASS_HOME=/path/to/AIPass to access core branches.")
return 1
return 0
return result.exit_code
# drone @branch command [args...]
command = rest[0]
+8 -6
View File
@@ -155,13 +155,15 @@ def print_introspection() -> None:
console = Console()
console.print()
console.print("commands Module")
console.print("Custom command shortcuts — map short names to full drone commands.")
console.print("[bold cyan]commands Module[/bold cyan]")
console.print("[dim]Custom command shortcuts — map short names to full drone commands.[/dim]")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/command_registry/")
console.print(" - ops.py (add_command, remove_command, update_command, command_exists)")
console.print(" - lookup.py (lookup_command, match_command, list_commands, list_commands_by_branch)")
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/command_registry/[/cyan]")
console.print(" - [cyan]ops.py[/cyan] [dim](add_command, remove_command, update_command, command_exists)[/dim]")
console.print(
" - [cyan]lookup.py[/cyan] [dim](lookup_command, match_command, list_commands, list_commands_by_branch)[/dim]"
)
console.print()
+13 -7
View File
@@ -37,14 +37,20 @@ def print_introspection():
console = Console()
console.print()
console.print("config Module")
console.print("Registry configuration management — path resolution and overrides.")
console.print("[bold cyan]config Module[/bold cyan]")
console.print("[dim]Registry configuration management — path resolution and overrides.[/dim]")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/")
console.print(" - registry_handler.py (get_registry_path — return current registry file path)")
console.print(" - registry_handler.py (set_registry_path — override registry file location)")
console.print(" - registry_handler.py (reset_registry_path — restore default registry path)")
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/[/cyan]")
console.print(
" - [cyan]registry_handler.py[/cyan] [dim](get_registry_path — return current registry file path)[/dim]"
)
console.print(
" - [cyan]registry_handler.py[/cyan] [dim](set_registry_path — override registry file location)[/dim]"
)
console.print(
" - [cyan]registry_handler.py[/cyan] [dim](reset_registry_path — restore default registry path)[/dim]"
)
console.print()
+17 -11
View File
@@ -93,19 +93,25 @@ def print_introspection():
console = Console()
console.print()
console.print("discovery Module")
console.print("Module and command discovery for AIPass branch introspection.")
console.print("[bold cyan]discovery Module[/bold cyan]")
console.print("[dim]Module and command discovery for AIPass branch introspection.[/dim]")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/")
console.print(" - discovery_handler.py (HelpResult — structured help query result)")
console.print(" - discovery_handler.py (discover_modules — list available commands for a branch)")
console.print(" - discovery_handler.py (get_help — get structured help for a branch/command)")
console.print(" - discovery_handler.py (get_system_help — aggregate help across all branches)")
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/[/cyan]")
console.print(" - [cyan]discovery_handler.py[/cyan] [dim](HelpResult — structured help query result)[/dim]")
console.print(
" - [cyan]discovery_handler.py[/cyan] [dim](discover_modules — list available commands for a branch)[/dim]"
)
console.print(
" - [cyan]discovery_handler.py[/cyan] [dim](get_help — get structured help for a branch/command)[/dim]"
)
console.print(
" - [cyan]discovery_handler.py[/cyan] [dim](get_system_help — aggregate help across all branches)[/dim]"
)
console.print()
console.print("Connected Modules:")
console.print(" modules/")
console.print(" - resolver.py (resolve_branch, list_branches — branch name resolution)")
console.print("[yellow]Connected Modules:[/yellow]")
console.print(" [cyan]modules/[/cyan]")
console.print(" - [cyan]resolver.py[/cyan] [dim](resolve_branch, list_branches — branch name resolution)[/dim]")
console.print()
+27 -1
View File
@@ -683,7 +683,33 @@ def _get_console():
def print_introspection() -> None:
"""Print introspection (seedgo compliance)."""
_get_console().print(get_introspective())
c = _get_console()
c.print()
c.print("[bold cyan]@git[/bold cyan] [dim]— Tier-based git workflow, dev branch model (v3.0.0)[/dim]")
c.print("[yellow]Connected Handlers:[/yellow]")
c.print(" [cyan]handlers/git/[/cyan]")
c.print(
" - [cyan]lock_handler.py[/cyan], [cyan]status_handler.py[/cyan],"
" [cyan]diff_handler.py[/cyan], [cyan]log_handler.py[/cyan]"
)
c.print(" - [cyan]commit_handler.py[/cyan], [cyan]checkout_handler.py[/cyan], [cyan]sync_handler.py[/cyan]")
c.print(
" - [cyan]dev_pr_handler.py[/cyan], [cyan]branches_handler.py[/cyan],"
" [cyan]delete_branch_handler.py[/cyan], [cyan]close_pr_handler.py[/cyan]"
)
c.print(" [cyan]plugins/devpulse_ops/[/cyan]")
c.print(
" - [cyan]auth.py[/cyan], [cyan]merge_plugin.py[/cyan],"
" [cyan]sync_plugin.py[/cyan], [cyan]fix_plugin.py[/cyan]"
)
c.print(" [dim]gh passthrough: issue, run, workflow[/dim]")
c.print(
"[yellow]Tiers:[/yellow] [dim]global[/dim]"
" [dim](status,diff,log,lock,branches,prune-temp,issue,run,workflow)[/dim]"
" | [dim]owner[/dim]"
" [dim](pr,commit,checkout,dev-pr,delete-branch,close-pr,sync,unlock,merge,smart-sync,fix)[/dim]"
)
c.print()
def print_help() -> None:
@@ -54,19 +54,34 @@ def print_introspection():
console = Console()
console.print()
console.print("module_registry Module")
console.print("Internal module registry for drone — dynamic module loading and command delegation.")
console.print("[bold cyan]module_registry Module[/bold cyan]")
console.print("[dim]Internal module registry for drone — dynamic module loading and command delegation.[/dim]")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/")
console.print(" - module_registry_handler.py (ModuleInfo — module metadata dataclass)")
console.print(" - module_registry_handler.py (list_modules — list registered module names)")
console.print(" - module_registry_handler.py (is_module — check if a module is registered)")
console.print(" - module_registry_handler.py (get_module_info — retrieve module metadata)")
console.print(" - module_registry_handler.py (route_module_command — delegate command to module)")
console.print(" - module_registry_handler.py (get_module_help — get help text for a module)")
console.print(" - module_registry_handler.py (get_module_introspective — introspect module adapter)")
console.print(" - module_registry_handler.py (register_module — register a new module adapter)")
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/[/cyan]")
console.print(" - [cyan]module_registry_handler.py[/cyan] [dim](ModuleInfo — module metadata dataclass)[/dim]")
console.print(
" - [cyan]module_registry_handler.py[/cyan] [dim](list_modules — list registered module names)[/dim]"
)
console.print(
" - [cyan]module_registry_handler.py[/cyan] [dim](is_module — check if a module is registered)[/dim]"
)
console.print(
" - [cyan]module_registry_handler.py[/cyan] [dim](get_module_info — retrieve module metadata)[/dim]"
)
console.print(
" - [cyan]module_registry_handler.py[/cyan] [dim](route_module_command — delegate command to module)[/dim]"
)
console.print(
" - [cyan]module_registry_handler.py[/cyan] [dim](get_module_help — get help text for a module)[/dim]"
)
console.print(
" - [cyan]module_registry_handler.py[/cyan]"
" [dim](get_module_introspective — introspect module adapter)[/dim]"
)
console.print(
" - [cyan]module_registry_handler.py[/cyan] [dim](register_module — register a new module adapter)[/dim]"
)
console.print()
+13 -7
View File
@@ -37,14 +37,20 @@ def print_introspection():
console = Console()
console.print()
console.print("registry Module")
console.print("Registry operations for branch management — loading and querying AIPASS_REGISTRY.json.")
console.print("[bold cyan]registry Module[/bold cyan]")
console.print("[dim]Registry operations for branch management — loading and querying AIPASS_REGISTRY.json.[/dim]")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/")
console.print(" - registry_handler.py (load_registry — load and parse the registry file)")
console.print(" - registry_handler.py (get_all_branches — list branches with type/status filters)")
console.print(" - registry_handler.py (get_branch_by_name — look up a single branch by name)")
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/[/cyan]")
console.print(
" - [cyan]registry_handler.py[/cyan] [dim](load_registry — load and parse the registry file)[/dim]"
)
console.print(
" - [cyan]registry_handler.py[/cyan] [dim](get_all_branches — list branches with type/status filters)[/dim]"
)
console.print(
" - [cyan]registry_handler.py[/cyan] [dim](get_branch_by_name — look up a single branch by name)[/dim]"
)
console.print()
+14 -8
View File
@@ -112,15 +112,21 @@ def print_introspection():
console = Console()
console.print()
console.print("resolver Module")
console.print("Branch resolution logic — resolves symbolic @branch names to paths and metadata.")
console.print("[bold cyan]resolver Module[/bold cyan]")
console.print("[dim]Branch resolution logic — resolves symbolic @branch names to paths and metadata.[/dim]")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/")
console.print(" - registry_handler.py (load_registry — load and parse AIPASS_REGISTRY.json)")
console.print(" - registry_handler.py (get_all_branches — list branches with optional filters)")
console.print(" - registry_handler.py (get_branch_by_name — look up a single branch)")
console.print(" - exceptions.py (BranchNotFoundError — raised when branch not in registry)")
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/[/cyan]")
console.print(
" - [cyan]registry_handler.py[/cyan] [dim](load_registry — load and parse AIPASS_REGISTRY.json)[/dim]"
)
console.print(
" - [cyan]registry_handler.py[/cyan] [dim](get_all_branches — list branches with optional filters)[/dim]"
)
console.print(" - [cyan]registry_handler.py[/cyan] [dim](get_branch_by_name — look up a single branch)[/dim]")
console.print(
" - [cyan]exceptions.py[/cyan] [dim](BranchNotFoundError — raised when branch not in registry)[/dim]"
)
console.print()
+11 -9
View File
@@ -126,17 +126,19 @@ def print_introspection():
console = Console()
console.print()
console.print("router Module")
console.print("Command routing logic for the AIPass drone module.")
console.print("[bold cyan]router Module[/bold cyan]")
console.print("[dim]Command routing logic for the AIPass drone module.[/dim]")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/")
console.print(" - router_handler.py (execute_branch_command — resolves and executes branch commands)")
console.print(" - executor.py (CommandResult — subprocess execution result dataclass)")
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/[/cyan]")
console.print(
" - [cyan]router_handler.py[/cyan] [dim](execute_branch_command — resolves and executes branch commands)[/dim]"
)
console.print(" - [cyan]executor.py[/cyan] [dim](CommandResult — subprocess execution result dataclass)[/dim]")
console.print()
console.print("Connected Modules:")
console.print(" modules/")
console.print(" - resolver.py (resolve_branch, list_branches — branch name resolution)")
console.print("[yellow]Connected Modules:[/yellow]")
console.print(" [cyan]modules/[/cyan]")
console.print(" - [cyan]resolver.py[/cyan] [dim](resolve_branch, list_branches — branch name resolution)[/dim]")
console.print()
+9 -9
View File
@@ -76,17 +76,17 @@ def print_introspection() -> None:
console = Console()
console.print()
console.print("scan Module")
console.print("Branch command scanning -- discover available commands in a branch.")
console.print("[bold cyan]scan Module[/bold cyan]")
console.print("[dim]Branch command scanning -- discover available commands in a branch.[/dim]")
console.print()
console.print("Connected Handlers:")
console.print(" handlers/scanning/")
console.print(" - scanner.py (scan_branch, scan_help_output, scan_module_files)")
console.print(" - formatters.py (format_scan_results, format_no_commands)")
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/scanning/[/cyan]")
console.print(" - [cyan]scanner.py[/cyan] [dim](scan_branch, scan_help_output, scan_module_files)[/dim]")
console.print(" - [cyan]formatters.py[/cyan] [dim](format_scan_results, format_no_commands)[/dim]")
console.print()
console.print("Connected Modules:")
console.print(" modules/")
console.print(" - resolver.py (resolve_branch -- branch name resolution)")
console.print("[yellow]Connected Modules:[/yellow]")
console.print(" [cyan]modules/[/cyan]")
console.print(" - [cyan]resolver.py[/cyan] [dim](resolve_branch -- branch name resolution)[/dim]")
console.print()
+60 -8
View File
@@ -597,17 +597,17 @@ class TestHandleTarget:
patch(f"{_DRONE}.is_module", return_value=True),
patch(f"{_DRONE}._handle_module", return_value=0) as mock_hm,
):
result = _handle_target(["@git", "status"])
result = _handle_target(["@git", "diff"])
assert result == 0
mock_hm.assert_called_once_with("git", ["status"])
mock_hm.assert_called_once_with("git", ["diff"])
def test_no_args_introspection(self) -> None:
"""@target with no args routes via route_command for introspection."""
"""@target with no args routes via route_command with interactive=True."""
from aipass.drone.apps.drone import _handle_target
from aipass.drone.apps.handlers.executor import CommandResult
mock_result = CommandResult(
stdout="introspection",
stdout="",
stderr="",
exit_code=0,
branch="seedgo",
@@ -615,22 +615,31 @@ class TestHandleTarget:
)
with (
patch(f"{_DRONE}.is_module", return_value=False),
patch(f"{_DRONE}.route_command", return_value=mock_result),
patch(f"{_DRONE}.route_command", return_value=mock_result) as mock_route,
):
result = _handle_target(["@seedgo"])
assert result == 0
mock_route.assert_called_once_with("@seedgo", interactive=True)
def test_help_flag(self) -> None:
"""@target --help routes via get_help."""
"""@target --help routes via route_command with interactive=True."""
from aipass.drone.apps.drone import _handle_target
from aipass.drone.apps.handlers.executor import CommandResult
mock_help = type("H", (), {"text": "Help text"})()
mock_result = CommandResult(
stdout="",
stderr="",
exit_code=0,
branch="seedgo",
command="--help",
)
with (
patch(f"{_DRONE}.is_module", return_value=False),
patch(f"{_DRONE}.get_help", return_value=mock_help),
patch(f"{_DRONE}.route_command", return_value=mock_result) as mock_route,
):
result = _handle_target(["@seedgo", "--help"])
assert result == 0
mock_route.assert_called_once_with("@seedgo", "--help", interactive=True)
def test_command_routing(self) -> None:
"""@target command routes via route_command."""
@@ -651,6 +660,49 @@ class TestHandleTarget:
result = _handle_target(["@seedgo", "audit", "aipass"])
assert result == 0
def test_short_help_flag(self) -> None:
"""@target -h routes via route_command with interactive=True."""
from aipass.drone.apps.drone import _handle_target
from aipass.drone.apps.handlers.executor import CommandResult
mock_result = CommandResult(stdout="", stderr="", exit_code=0, branch="seedgo", command="-h")
with (
patch(f"{_DRONE}.is_module", return_value=False),
patch(f"{_DRONE}.route_command", return_value=mock_result) as mock_route,
):
result = _handle_target(["@seedgo", "-h"])
assert result == 0
mock_route.assert_called_once_with("@seedgo", "-h", interactive=True)
def test_status_routes_interactive(self) -> None:
"""status command routes with interactive=True for Rich color output."""
from aipass.drone.apps.drone import _handle_target
from aipass.drone.apps.handlers.executor import CommandResult
mock_result = CommandResult(stdout="", stderr="", exit_code=0, branch="hooks", command="status")
with (
patch(f"{_DRONE}.is_module", return_value=False),
patch(f"{_DRONE}.route_command", return_value=mock_result) as mock_route,
):
result = _handle_target(["@hooks", "status"])
assert result == 0
call_kwargs = mock_route.call_args.kwargs
assert call_kwargs["interactive"] is True
def test_help_flag_module_fallback(self) -> None:
"""--help BranchNotFoundError for a module falls back to _handle_module."""
from aipass.drone.apps.drone import _handle_target
from aipass.drone.apps.modules import BranchNotFoundError
with (
patch(f"{_DRONE}.is_module", side_effect=[False, True]),
patch(f"{_DRONE}.route_command", side_effect=BranchNotFoundError("not found")),
patch(f"{_DRONE}._handle_module", return_value=0) as mock_hm,
):
result = _handle_target(["@seedgo", "--help"])
assert result == 0
mock_hm.assert_called_once_with("seedgo", ["--help"])
def test_branch_not_found_module_fallback(self) -> None:
"""BranchNotFoundError for a module falls back to _handle_module."""
from aipass.drone.apps.drone import _handle_target
+9 -8
View File
@@ -6,7 +6,7 @@
**Module:** `aipass.flow`
**Version:** 2.2.1
**Created:** 2025-11-15
**Last Updated:** 2026-05-16
**Last Updated:** 2026-06-05
---
@@ -98,7 +98,7 @@ flow/
│ ├── team_dev_plans/ # TDPLAN templates (default)
│ └── audit_plans/ # APLAN templates (default)
├── flow_json/ # Per-type registries + template_registry.json
├── tests/ # 733 tests, 24 test files
├── tests/ # 734 tests, 22 test files
└── .archive/ # Archived legacy code
```
@@ -159,27 +159,28 @@ Vector verification displays in console: "Vectorized: N chunks in chroma" or "NO
### Provides To
- All branches — plan creation, tracking, closure, and archival
- `aipass.devpulse` — plan status aggregation for system dashboards
- Central reporting — `PLANS.central.json` via aggregate
- Central reporting — `PLANS.central.json` with per-branch plan sections (all branches, not just flow)
---
## Quality
- **Seedgo:** 100% (35/35 standards)
- **Tests:** 733 passed, 87/87 public functions tested (100%)
- **Source files:** 39 tracked by seedgo
- **Last audit:** 2026-05-16
- **Tests:** 734 passed, 87/87 public functions tested (100%)
- **Source files:** 40 tracked by seedgo
- **Last audit:** 2026-06-05
- **Battle test:** 16/16 commands pass via drone CLI (2026-04-22)
### Known Issues
- Registry scan fires trigger events that are never handled (by design — foreground close handles everything)
- Dashboard push warns on some closes
- `mbank/process.py` at 669 lines (nearing 700 limit)
- `close_ops.py` split into `close_ops.py` (647 lines) + `close_helpers.py` (260 lines) in 2026-05-16
- `close_ops.py` split into `close_ops.py` (647 lines) + `close_helpers.py` (260 lines)
- `push_central.py` comprehensive rewrite (2026-06-02): now pushes all branches' plans, not just flow's — fixed dashboard refresh zeroing other branches' plan counts
---
*Last Updated: 2026-05-16*
*Last Updated: 2026-06-05*
---
[← Back to AIPass](../../../README.md)
@@ -278,6 +278,45 @@ def save_registry(data: Dict[str, Any]) -> bool:
return False
def _try_override_auto_entry(registry: Dict[str, Any], dir_name: str, new_prefix: str) -> str | None:
"""Remove an auto-registered entry so add_type() can re-add with explicit prefix.
Returns an error message on failure, or None on success.
"""
existing = registry["types"][dir_name]
old_shorthand = existing.get("shorthand", existing.get("prefix", "").lower())
old_prefix = existing.get("prefix", "")
if old_prefix.upper() != new_prefix.upper():
old_reg = FLOW_ROOT / "flow_json" / f"{old_shorthand}_registry.json"
if old_reg.exists():
has_plans = _auto_reg_has_plans(old_reg)
if has_plans:
return f"Cannot override auto-registered '{dir_name}' — {old_reg.name} has existing plans"
old_reg.unlink()
del registry["types"][dir_name]
logger.info(
"[%s] Overriding auto-registered type '%s' (%s -> %s)",
MODULE_NAME,
dir_name,
old_prefix,
new_prefix,
)
return None
def _auto_reg_has_plans(reg_path: Path) -> bool:
"""Check whether a plan registry file contains any plans."""
try:
with open(reg_path, "r", encoding="utf-8") as fh:
data = json.load(fh)
return bool(data.get("plans"))
except (json.JSONDecodeError, OSError) as exc:
logger.warning("[%s] Could not read plan registry %s: %s", MODULE_NAME, reg_path.name, exc)
return False
def add_type(
dir_name: str,
prefix: str,
@@ -304,17 +343,26 @@ def add_type(
"""
registry = load_registry()
# Validate: dir_name not already registered
if dir_name in registry["types"]:
# Allow override of auto-registered entries with explicit prefix
existing = registry["types"].get(dir_name)
if existing and existing.get("registered_by") != "auto":
logger.error(
"[%s] Type '%s' is already registered",
"[%s] Type '%s' is already registered (by %s)",
MODULE_NAME,
dir_name,
existing.get("registered_by", "unknown"),
)
return False
# Validate: prefix not already taken (case-insensitive)
if prefix_exists(prefix):
if existing and existing.get("registered_by") == "auto":
override_err = _try_override_auto_entry(registry, dir_name, prefix)
if override_err:
logger.error("[%s] %s", MODULE_NAME, override_err)
return False
# Validate: prefix not already taken by another type (case-insensitive)
upper = prefix.upper()
if any(entry.get("prefix", "").upper() == upper for d, entry in registry["types"].items() if d != dir_name):
logger.error(
"[%s] Prefix '%s' is already in use by another type",
MODULE_NAME,
@@ -427,20 +475,6 @@ def remove_type(dir_name: str) -> bool:
# ---------------------------------------------------------------------------
def prefix_exists(prefix: str) -> bool:
"""Check whether any registered type uses *prefix* (case-insensitive).
Args:
prefix: The prefix to look for.
Returns:
True if the prefix is already in use.
"""
registry = load_registry()
upper = prefix.upper()
return any(entry.get("prefix", "").upper() == upper for entry in registry["types"].values())
def get_prefix_map() -> Dict[str, str]:
"""Return ``{dir_name: prefix}`` for all registered types.
+31 -5
View File
@@ -33,6 +33,7 @@ import sys
from pathlib import Path
from typing import Any, Dict, Tuple, List
# ruff: noqa: E402
# INFRASTRUCTURE IMPORT PATTERN
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
FLOW_ROOT = _PKG_ROOT / "flow"
@@ -124,26 +125,51 @@ def print_introspection():
def print_help():
"""Print help information for create_plan module"""
from aipass.flow.apps.handlers.template.registry_ops import load_registry
console.print()
console.print("[bold cyan]create_plan[/bold cyan] — Create new PLAN file")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(' drone @flow create <location> "Subject" [type]')
console.print(' drone @flow create <location> "Subject" [template] [type]')
console.print()
console.print("[yellow]TEMPLATES:[/yellow]")
console.print(" default Single task [dim](default)[/dim]")
console.print(" master Multi-phase project")
console.print()
registry = load_registry()
types = registry.get("types", {})
console.print("[yellow]TYPES:[/yellow]")
console.print(" (none) FPLAN [dim](default)[/dim]")
console.print(" dplan DPLAN")
for dir_name, entry in sorted(types.items()):
prefix = entry.get("prefix", "???")
shorthand = entry.get("shorthand", prefix.lower())
if dir_name == "flow_plans":
continue
templates_dir = FLOW_ROOT / "templates" / dir_name
templates = sorted(p.stem for p in templates_dir.glob("*.md")) if templates_dir.is_dir() else []
tmpl_hint = f" [dim]templates: {', '.join(templates)}[/dim]" if len(templates) > 1 else ""
console.print(f" {shorthand:<12} {prefix}{tmpl_hint}")
console.print()
console.print("[yellow]TEMPLATE SELECTION:[/yellow]")
console.print(" The 4th arg selects a non-default template within a type.")
console.print(" Any .md file stem in the type's templates/ dir works.")
console.print(' [dim]drone @flow create . "Subject" sunday_merge pplan[/dim]')
console.print(' [dim]drone @flow create . "Subject" master[/dim] # FPLAN master')
console.print()
console.print("[yellow]EXAMPLES:[/yellow]")
console.print(' [dim]drone @flow create . "Implementation task"[/dim] # FPLAN default')
console.print(' [dim]drone @flow create . "Multi-phase project" master[/dim] # FPLAN master')
console.print(' [dim]drone @flow create . "Design investigation" dplan[/dim] # DPLAN')
console.print()
console.print("[bold]ADD A NEW PLAN TYPE:[/bold]")
console.print(" 1. Create templates/<dirname>/ with .md template files")
console.print(" 2. drone @flow register <dirname> <PREFIX>")
console.print(' 3. drone @flow create . "Subject" <shorthand>')
console.print(" [dim]See: drone @flow templates --help[/dim]")
console.print()
# =============================================
# ORCHESTRATION WORKFLOWS (thin wrappers)
@@ -26,6 +26,7 @@ import sys
from pathlib import Path
from typing import List
# ruff: noqa: E402
# INFRASTRUCTURE IMPORT PATTERN
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
FLOW_ROOT = _PKG_ROOT / "flow"
@@ -85,6 +86,19 @@ def print_help():
console.print(" drone @flow unregister <dir> Remove plan type registration")
console.print(" drone @flow scan Find unregistered template directories")
console.print()
console.print("[bold]HOW TO ADD A NEW PLAN TYPE / SOP TEMPLATE:[/bold]")
console.print(" 1. Create a directory under templates/ (e.g. templates/playbook_plans/)")
console.print(" 2. Add one or more .md template files (e.g. default.md, sunday_merge.md)")
console.print(" 3. Register with your chosen prefix:")
console.print(" drone @flow register playbook_plans PBPLAN")
console.print(" 4. Create plans:")
console.print(' drone @flow create . "Subject" pbplan')
console.print(' drone @flow create . "Subject" sunday_merge pbplan')
console.print()
console.print(" [dim]Auto-registration runs on any flow command if you skip step 3,[/dim]")
console.print(" [dim]but derives the prefix automatically. Use register to choose your own.[/dim]")
console.print(" [dim]Register overrides an auto-derived prefix if no plans exist yet.[/dim]")
console.print()
console.print("[yellow]EXAMPLES:[/yellow]")
console.print(" [dim]# Register testing/ as TPLAN[/dim]")
console.print(" drone @flow register testing TPLAN")
@@ -0,0 +1,67 @@
# {plan_number} - {subject} (PLAYBOOK)
**Created**: {today}
**Branch**: {location}
**Status**: Active
**Type**: Playbook (SOP run)
---
## What Are Playbooks?
Playbooks (PBPLANs) are **throwaway SOP runs** — a checklist stamped from a reusable
template for a recurring operation (Sunday merge, release cut, branch onboarding,
incident response). You tick steps off as you go, log what happened, then close.
- **The template = the SOP.** Stable. Refine it over time as the process improves.
- **The instance (this file) = one run.** Disposable. Close when the run is done.
Closing vectorizes the run to @memory — so the **Run Summary** below (with PR numbers,
tags, anything that broke) becomes a searchable trail. Costs nothing, gives history.
**This is NOT for:** building features (FPLAN), design/investigation (DPLAN),
research (RPLAN), or multi-branch builds (TDPLAN). Playbooks are for *operating the
system*, not changing it.
**Add a new SOP:** drop `templates/playbook_plans/<sop_name>.md`, then
`drone @flow create . "Subject" <sop_name>`. No registration needed (the type is
already registered; the file stem is the shorthand).
---
## Steps
Replace with the actual checklist for this SOP.
- [ ] Step 1
- [ ] Step 2
- [ ] Step 3
---
## Run Summary
Fill as you go — this is the vectorized trail. Be specific: PR numbers, tags, SHAs,
anything that broke and how it was handled.
- **Date:** {today}
- **Outcome:**
- **PRs / tags / commits:**
- **Issues hit:**
- **Notes for next run:**
---
## Listen (TTS-friendly summary)
Write a plain English summary of this run here. No markdown, no symbols, no tables,
no code blocks, no asterisks, no bullet points. Just natural sentences for text to speech.
---
## Close Command
When all steps are ticked and the Run Summary is filled:
```bash
drone @flow close {plan_number}
```
@@ -0,0 +1,122 @@
# {plan_number} - {subject} (SUNDAY MERGE)
**Created**: {today}
**Branch**: {location}
**Status**: Active
**Type**: Playbook — Sunday Merge SOP
---
## Purpose
The weekly `dev → main` merge + release tag. Run by **devpulse** (only branch with git
write). Tick each step as you go; fill the **Run Summary** with PR numbers and tags for
the vectorized trail. Close when done.
> All git writes go through `drone @git` — **run drone from a branch dir** (it needs
> `.trinity/passport.json` in the cwd; running from the repo root fails with "No
> passport found"). Read git (`status`, `log`, `diff`, `rev-parse`) is allowed raw.
> ⚠️ `drone @git` has **no `tag` verb** — pushing the release tag is a MANUAL step
> (Patrick, or raw `git tag`/`push` via `!`). All other writes go through drone.
---
## 1. Pre-flight
- [ ] On `dev`, working tree understood: `drone @git status --all`
- [ ] Confirm what's shipping this week — scan uncommitted changes + already-pushed dev commits ahead of main: `git rev-list --count main..dev` (read git, raw ok)
- [ ] No surprise files (stray `/tmp` artifacts, test pollution, `.recovery`/`.archive` churn). Clean = archive, never delete.
- [ ] Decide: **release tag this week?** (tag = PyPI publish + GitHub Release). If yes, note target version.
## 2. Verify, commit, CHANGELOG
- [ ] **Run the CI audit gate LOCALLY before pushing** (local == CI, S199 parity — catches red before the PR): `cd <repo-root> && .venv/bin/python .github/scripts/seedgo_audit.py` → expect all 13 branches `>=100%`, exit 0. Uses a relative `src/aipass` path, so run from the repo **root**, not a branch dir.
- [ ] Update `CHANGELOG.md` — add entries under the current week's `[YYYY.WNN]` section (don't batch; mostly done as work landed). Sort into Added / Changed / Fixed.
- [ ] Commit: `drone @git commit "msg" --all` (from a branch dir, e.g. devpulse). New/untracked files (e.g. new templates) — confirm they got staged: `git ls-files <path>` after; `--all` may not pick up untracked.
- [ ] Every commit pushed — local-only commits are invisible
## 3. Open / update the PR
- [ ] `drone @git dev-pr "Week summary: what's shipping"`
- [ ] "PR already open" in output = push succeeded onto the existing PR (expected on re-runs)
- [ ] Record the PR number → Run Summary
## 4. Wait for CI green (ALL required checks)
The PR gate (verified against `.github/workflows/`):
- [ ] `ci.yml` → **lint**, **test**, **standards** (= seedgo-audit / the README + 100%-floor check, runs `.github/scripts/seedgo_audit.py`), **coverage**
- [ ] `security.yml` → Security Scan / dependency-scan
- [ ] `e2e-wheel.yml` → 3-OS wheel smoke (path-filtered: fires on `src/**`, `tests/e2e/**`, `pyproject.toml`)
- [ ] `windows-test.yml` / `macos-test.yml` → required checks, run on every PR (must NEVER be path-filtered or they park as "Expected/waiting" forever and block merge)
- [ ] If "all green but can't merge": it's usually post-push mergeability **lag**. Confirm ground truth via the public API (no gh, no gate):
- `curl -s https://api.github.com/repos/AIOSAI/AIPass/commits/<sha>/check-runs` → all check-runs success (incl. app checks: codecov, CodeQL)
- `curl -s https://api.github.com/repos/AIOSAI/AIPass/pulls/<n>` → `mergeable_state: clean`
## 5. Merge to main
- [ ] **User's call to merge** — confirm GO
- [ ] `drone @git merge <PR#>` (squash-merge)
- [ ] ⚠️ **Verify `dev` SURVIVES the merge** (the #625 scar — empirical, every time): `drone @git branches` → `dev` still present; `git rev-parse dev` resolves
## 6. Post-merge realign
- [ ] Pull main locally: `drone @git sync`
- [ ] If merged via GitHub UI (bypassing `drone @git merge`), fast-forward dev to main so dev doesn't fall behind / revert main-only commits (e.g. Dependabot): dev is an ancestor → `git merge --ff-only main` is clean (via `drone @git`)
- [ ] Dependabot / other PRs targeting main: they go green once main has the fix + bots rebase — check after the push
## 7. Release tag (only if cutting a release)
**Versioning rule — bump by SIGNIFICANCE, not cadence** (keeps the version from inflating weekly):
- **PATCH** (`x.y.Z+1`) = fix / internal / standards / UX only → the default, most weeks
- **MINOR** (`x.Y+1.0`) = a new backward-compatible user-facing feature shipped
- **MAJOR** (`X+1.0.0`) = breaking public-API change
(aipass is a 2.x library others pin → keep SemVer; the CHANGELOG keeps its `YYYY.WNN` header as a date index.)
How the release fires (verified `publish.yml`): a `v*` **git tag push** runs build → PyPI publish → GitHub Release. Key facts:
- PyPI version = `pyproject.toml [project] version` at the tagged commit — **NOT** the tag string (the tag only *triggers* the build).
- Tag and `pyproject` version **must match** (`v2.5.2` ⇄ `version = "2.5.2"`), or PyPI publishes the wrong number while the Release is named the tag.
- PyPI **rejects a duplicate version** → if shipping, you MUST bump.
- GitHub Release notes = the **topmost `## [...]` CHANGELOG block** (awk-extracted).
Steps:
- [ ] Bump `pyproject.toml` version per the rule above, **on dev so it rides into the PR** (then main's merge commit carries the right version)
- [ ] Confirm the CHANGELOG top section is the release notes you want
- [ ] **Push the tag — MANUAL (drone has no `tag` verb):** Patrick, or raw `git tag v<version> <main-sha>` + `git push origin v<version>` via `!`, on the merged main commit
- [ ] Verify PyPI shows the new version + the GitHub Release appeared
- [ ] Record the tag → Run Summary
## 8. Wrap
- [ ] Update `.trinity/` memories (session log: what merged, PR#, tag)
- [ ] Fill **Run Summary** below (PR numbers, tag, anything that broke)
- [ ] Close this playbook → vectorizes the run
---
## Run Summary
- **Date:** {today}
- **Outcome:** (merged clean / issues / no-merge)
- **PR(s) merged:** #
- **Release tag:** v
- **CI notes:** (any flaky/red checks + how cleared)
- **dev survived merge:** yes / no
- **Issues hit:**
- **Notes for next run:** (refine this SOP — what was missing or wrong?)
---
## Listen (TTS-friendly summary)
Write a plain English summary of this Sunday merge here when done. No markdown, no symbols,
no tables, no code blocks, no asterisks, no bullet points. Just natural sentences for text to speech.
---
## Close Command
When all steps are ticked and the Run Summary is filled:
```bash
drone @flow close {plan_number}
```
@@ -939,89 +939,6 @@ class TestDiscoverPlanTypes:
assert result == {}
# ===================================================================
# 9. template/registry_ops.py — prefix_exists
# ===================================================================
class TestPrefixExists:
def test_existing_prefix_returns_true(self, tmp_path):
"""prefix_exists returns True for a registered prefix."""
registry = {
"types": {
"flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"},
},
"metadata": {"version": "1.0.0", "last_updated": "2026-03-18", "type_count": 1},
}
reg_path = tmp_path / "template_registry.json"
reg_path.write_text(json.dumps(registry), encoding="utf-8")
# Also create the templates dir so auto-heal does not prune
templates_dir = tmp_path / "templates" / "flow_plans"
templates_dir.mkdir(parents=True)
with (
patch(
"aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH",
reg_path,
),
patch("aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path),
):
from aipass.flow.apps.handlers.template.registry_ops import prefix_exists
assert prefix_exists("FPLAN") is True
def test_case_insensitive_match(self, tmp_path):
"""prefix_exists is case-insensitive."""
registry = {
"types": {
"flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"},
},
"metadata": {"version": "1.0.0", "last_updated": "2026-03-18", "type_count": 1},
}
reg_path = tmp_path / "template_registry.json"
reg_path.write_text(json.dumps(registry), encoding="utf-8")
templates_dir = tmp_path / "templates" / "flow_plans"
templates_dir.mkdir(parents=True)
with (
patch(
"aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH",
reg_path,
),
patch("aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path),
):
from aipass.flow.apps.handlers.template.registry_ops import prefix_exists
assert prefix_exists("fplan") is True
def test_nonexistent_prefix_returns_false(self, tmp_path):
"""prefix_exists returns False for an unregistered prefix."""
registry = {
"types": {
"flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"},
},
"metadata": {"version": "1.0.0", "last_updated": "2026-03-18", "type_count": 1},
}
reg_path = tmp_path / "template_registry.json"
reg_path.write_text(json.dumps(registry), encoding="utf-8")
templates_dir = tmp_path / "templates" / "flow_plans"
templates_dir.mkdir(parents=True)
with (
patch(
"aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH",
reg_path,
),
patch("aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path),
):
from aipass.flow.apps.handlers.template.registry_ops import prefix_exists
assert prefix_exists("ZPLAN") is False
# ===================================================================
# 10. template/registry_ops.py — get_prefix_map
# ===================================================================
@@ -441,46 +441,6 @@ class TestRemoveType:
assert result is False
# =============================================================================
# prefix_exists
# =============================================================================
class TestPrefixExists:
"""Tests for prefix_exists() — case-insensitive prefix lookup."""
def test_finds_existing_prefix_exact_case(self, setup_flow_root):
"""Finds prefix with exact case match."""
mod = _import_mod()
_create_template_dir(setup_flow_root, "flow_plans", ["default.md"])
_create_template_dir(setup_flow_root, "dev_plans", ["default.md"])
data = _valid_registry()
_write_registry(setup_flow_root, data)
assert mod.prefix_exists("FPLAN") is True
def test_finds_existing_prefix_case_insensitive(self, setup_flow_root):
"""Finds prefix regardless of case."""
mod = _import_mod()
_create_template_dir(setup_flow_root, "flow_plans", ["default.md"])
_create_template_dir(setup_flow_root, "dev_plans", ["default.md"])
data = _valid_registry()
_write_registry(setup_flow_root, data)
assert mod.prefix_exists("fplan") is True
assert mod.prefix_exists("Fplan") is True
def test_returns_false_for_unknown_prefix(self, setup_flow_root):
"""Returns False for a prefix not in the registry."""
mod = _import_mod()
_create_template_dir(setup_flow_root, "flow_plans", ["default.md"])
_create_template_dir(setup_flow_root, "dev_plans", ["default.md"])
data = _valid_registry()
_write_registry(setup_flow_root, data)
assert mod.prefix_exists("ZPLAN") is False
# =============================================================================
# get_prefix_map
# =============================================================================
@@ -1,6 +1,6 @@
# HOOKS -- Branch Prompt
Injected every turn. Breadcrumbs only -- details in README, --help, .trinity/, STATUS.local.md.
Injected every turn. Breadcrumbs only -- details in README, --help, .trinity/.
## Identity
+10 -1
View File
@@ -60,6 +60,10 @@
{"file": "apps/handlers/lifecycle/rollover.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover — fires during compaction events."},
{"file": "apps/handlers/lifecycle/rollover.py", "standard": "json_structure", "reason": "Uses stdlib json.loads for registry and memory file checks — no JSON file ops needing json_handler."},
{"file": "apps/handlers/lifecycle/auto_process.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_process.handle' — not statically imported by design. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process."},
{"file": "apps/handlers/lifecycle/auto_process.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process."},
{"file": "apps/handlers/lifecycle/auto_process.py", "standard": "json_structure", "reason": "Delegates to @memory's auto_process() via importlib — no direct JSON file ops needing json_handler."},
{"file": "apps/handlers/notification/announce.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' — not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl."},
{"file": "apps/handlers/notification/announce.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (Notification.notification_sound)."},
{"file": "apps/handlers/notification/announce.py", "standard": "json_structure", "reason": "Sound handler — no JSON operations, plays WAV files."},
@@ -194,7 +198,12 @@
{"file": "tests/test_sound.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_sound.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_sound.py", "standard": "encapsulation", "reason": "Tests import sound module directly to test implementation details."},
{"file": "tests/test_sound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}
{"file": "tests/test_sound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_auto_process.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_auto_process.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_auto_process.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_auto_process.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}
],
"notes": {
"removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.",
+1 -3
View File
@@ -10,7 +10,6 @@ Every hook event flows through one engine. Platform bridges normalize the event
| You want to | Read |
|---|---|
| What's happening right now | [STATUS.local.md](STATUS.local.md) |
| Identity, memory, session history | [`.trinity/`](.trinity/) |
| Hook engine design | `DPLAN-0184` |
| Per-project config | `.aipass/hooks.json` |
@@ -78,8 +77,7 @@ src/aipass/hooks/
│ └── diagnostics.py # JSONL logging for hook execution
├── logs/
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
├── tests/ # 314 tests across 20 test files
└── STATUS.local.md
└── tests/ # 385 tests across 20 test files
```
## How It Works
@@ -0,0 +1,73 @@
# =================== AIPass ====================
# Name: auto_process.py
# Version: 1.1.0
# Description: Fires @memory's auto-process once per session and on pre-compact (TDPLAN-0005)
# Branch: hooks
# Layer: apps/handlers/lifecycle
# Created: 2026-06-06
# Modified: 2026-06-06
# =============================================
"""Calls @memory's auto_process() to vectorize pool drops and run rollover."""
import importlib
import os
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
_GUARD_DIR = Path("/tmp")
def _session_guard_path() -> Path | None:
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
if not session_id:
return None
return _GUARD_DIR / f"aipass-auto-process-{session_id}"
def _already_ran_this_session() -> bool:
guard = _session_guard_path()
return guard is not None and guard.exists()
def _mark_session_ran() -> None:
guard = _session_guard_path()
if guard is not None:
try:
guard.touch()
except OSError as exc:
logger.info("[HOOKS] auto_process: guard write failed: %s", exc)
def handle(hook_data: dict) -> dict:
"""Invoke @memory's auto_process entry point. Idempotent, fast no-op when nothing to do."""
_ = hook_data
if _already_ran_this_session():
return {"stdout": "", "exit_code": 0}
try:
module = importlib.import_module("aipass.memory.apps.handlers.intake.auto_process")
result = module.auto_process()
pool = result.get("pool", {})
rollover = result.get("rollover", {})
pool_files = pool.get("files_processed", 0)
rollover_processed = rollover.get("processed", 0)
if pool_files or rollover_processed:
logger.info(
"[HOOKS] auto_process: pool=%d files, rollover=%d processed",
pool_files,
rollover_processed,
)
else:
logger.info("[HOOKS] auto_process: no-op (nothing to process)")
_mark_session_ran()
return {"stdout": "", "exit_code": 0}
except Exception as exc:
logger.error("[HOOKS] auto_process: error: %s", exc)
return {"stdout": "", "exit_code": 1}
@@ -31,17 +31,6 @@ def _find_branch_dir(cwd: str) -> Path | None:
return None
def _read_status_local(branch_dir: Path) -> str | None:
for name in ("STATUS.local.md", "dev.local.md"):
path = branch_dir / name
if path.is_file():
try:
return path.read_text(encoding="utf-8")[:3000]
except Exception as exc:
logger.info("[HOOKS] compact: read status failed: %s", exc)
return None
def _read_last_session(branch_dir: Path) -> str | None:
local_path = branch_dir / ".trinity" / "local.json"
if not local_path.is_file():
@@ -115,18 +104,13 @@ def handle(hook_data: dict) -> dict:
if session_info:
sections.append(f"## Last Session\n{session_info}")
status = _read_status_local(branch_dir)
if status:
sections.append(f"## STATUS.local.md\n{status}")
is_dispatched = os.environ.get("AIPASS_SESSION_TYPE") == "dispatched"
if is_dispatched:
sections.append(
"## DISPATCHED AGENT — SAVE STATE NOW\n"
"Before continuing work, you MUST update your memories:\n"
"1. Update .trinity/local.json — add/update current session with work done so far\n"
"2. Update STATUS.local.md — ensure Current Work reflects what you've accomplished\n"
"3. Then continue your task from where the summary left off\n\n"
"2. Then continue your task from where the summary left off\n\n"
"This is non-optional. Compaction just happened — if you don't save now, work history is lost."
)
else:
@@ -134,7 +118,6 @@ def handle(hook_data: dict) -> dict:
"## Recovery Protocol\n"
"- Continue where the summary left off — don't restart or ask generic questions\n"
"- .trinity/local.json has full session history and key_learnings — read it if you need more context\n"
"- STATUS.local.md has current work, known issues, and todos\n"
"- Save memories proactively — compaction just proved you need to\n"
"- Match the conversation tone from before compaction"
)
@@ -24,6 +24,34 @@ RAW_GH_RE = re.compile(r"(?<![@\w/.])gh\s")
GH_ALLOWED_SUBCOMMANDS = ("api",)
READ_ALLOWED_GIT_SUBCOMMANDS = frozenset(
{
"ls-files",
"ls-tree",
"show",
"cat-file",
"rev-parse",
"rev-list",
"log",
"status",
"diff",
"blame",
"describe",
"for-each-ref",
"show-ref",
"symbolic-ref",
"shortlog",
"grep",
"archive",
"count-objects",
"var",
"help",
"version",
}
)
_GIT_OPTS_WITH_ARG = frozenset({"-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace"})
BLOCKED_EDIT_PATTERNS = [
re.compile(r"/\.claude/settings(\.local)?\.json$"),
re.compile(r"/\.claude/hooks/"),
@@ -35,10 +63,8 @@ EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
TRUSTED_HOOK_EDITORS = ("devpulse", "seedgo")
GIT_GH_REDIRECT = (
"All git/gh commands are blocked. Use drone instead:\n"
" drone @git status # working tree status\n"
" drone @git diff # see changes\n"
" drone @git log # commit history\n"
"Write git commands are blocked. Read-only verbs (status, log, diff, show, etc.) are allowed raw.\n"
"For write operations, use drone:\n"
" drone @git smart-sync # fetch + rebase\n"
" drone @git sync # checkout main + pull\n"
" drone @git issue list # GitHub issues\n"
@@ -70,6 +96,42 @@ def _is_allowed_gh(cmd: str) -> bool:
return False
def _split_clauses(cmd: str) -> list[str]:
"""Split on compound operators and subshell boundaries."""
parts = re.split(r"&&|\|\||[;|]", cmd)
clauses: list[str] = []
for part in parts:
clauses.extend(re.split(r"[$()`]", part))
return clauses
def _extract_git_verb(tokens: list[str]) -> str | None:
"""Extract the git subcommand verb, skipping global options."""
i = 0
while i < len(tokens):
tok = tokens[i]
if not tok.startswith("-"):
return tok
if tok in _GIT_OPTS_WITH_ARG:
i += 2
continue
i += 1
return None
def _all_git_reads(scan: str) -> bool:
"""Return True only if every git invocation in scan is a read-only verb."""
found_any = False
for clause in _split_clauses(scan):
for m in RAW_GIT_RE.finditer(clause):
found_any = True
after = clause[m.end() :].split()
verb = _extract_git_verb(after)
if verb is None or verb not in READ_ALLOWED_GIT_SUBCOMMANDS:
return False
return found_any
def _block(reason: str) -> dict:
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
@@ -80,7 +142,7 @@ def _check_bash(tool_input: dict) -> dict:
return _BLOCK_ALLOW
scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd)
scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan)
if RAW_GIT_RE.search(scan):
if RAW_GIT_RE.search(scan) and not _all_git_reads(scan):
return _block(GIT_GH_REDIRECT)
if RAW_GH_RE.search(scan) and not _is_allowed_gh(cmd):
return _block(GIT_GH_REDIRECT)
+68 -15
View File
@@ -5,7 +5,7 @@
# Branch: hooks
# Layer: apps
# Created: 2026-05-18
# Modified: 2026-05-19
# Modified: 2026-06-07
# =============================================
"""
@@ -78,33 +78,86 @@ def discover_modules() -> list[Any]:
def print_introspection():
"""Print branch introspection — discovered modules and capabilities."""
modules = discover_modules()
CONSOLE.print("[bold cyan]HOOKS[/bold cyan] — Hook Infrastructure for AIPass")
CONSOLE.print(f" Modules discovered: {len(modules)}")
CONSOLE.print()
CONSOLE.print("[bold cyan]HOOKS — Hook Infrastructure for AIPass[/bold cyan]")
CONSOLE.print()
CONSOLE.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
CONSOLE.print()
CONSOLE.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
CONSOLE.print()
for module in modules:
name = module.__name__.split(".")[-1]
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
CONSOLE.print(f" {name:20} {desc}")
CONSOLE.print(f" [cyan]•[/cyan] {name:20} [dim]{desc}[/dim]")
CONSOLE.print()
CONSOLE.print("Run [green]'drone @hooks --help'[/green] for usage information")
CONSOLE.print()
def print_help():
"""Print CLI help — usage instructions and available commands."""
modules = discover_modules()
CONSOLE.print("[bold cyan]HOOKS[/bold cyan] — Usage")
CONSOLE.print()
CONSOLE.print(" drone @hooks <command> [args...]")
CONSOLE.print("[bold cyan]HOOKS[/bold cyan] [dim]v1.1.0[/dim] — Hook Infrastructure for AIPass")
CONSOLE.print()
CONSOLE.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
CONSOLE.print()
CONSOLE.print("─" * 70)
CONSOLE.print()
CONSOLE.print("[bold cyan]USAGE:[/bold cyan]")
CONSOLE.print()
CONSOLE.print(" [dim]drone @hooks <command> [args...][/dim]")
CONSOLE.print(" [dim]drone @hooks --help[/dim]")
CONSOLE.print()
CONSOLE.print("─" * 70)
CONSOLE.print()
CONSOLE.print("[bold cyan]COMMANDS:[/bold cyan]")
CONSOLE.print()
CONSOLE.print("[bold]COMMANDS:[/bold]")
for module in modules:
name = module.__name__.split(".")[-1]
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
CONSOLE.print(f" {name:20} {desc}")
commands = getattr(module, "HELP_COMMANDS", None)
if commands:
for cmd, desc in commands:
CONSOLE.print(f" [green]{cmd:26}[/green] [dim]{desc}[/dim]")
else:
name = module.__name__.split(".")[-1]
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
CONSOLE.print(f" [green]{name:26}[/green] [dim]{desc}[/dim]")
CONSOLE.print()
CONSOLE.print("[bold]BRIDGES:[/bold]")
CONSOLE.print(" claude Claude Code bridge (provider settings entry point)")
CONSOLE.print("─" * 70)
CONSOLE.print()
CONSOLE.print("[bold cyan]BRIDGES:[/bold cyan]")
CONSOLE.print()
CONSOLE.print(
" [green]claude[/green] [dim]Claude Code bridge (provider settings entry point)[/dim]"
)
CONSOLE.print()
CONSOLE.print("─" * 70)
CONSOLE.print()
CONSOLE.print("[bold cyan]EXAMPLES:[/bold cyan]")
CONSOLE.print()
CONSOLE.print(" [dim]drone @hooks status[/dim] [dim]# Show hook config for current project[/dim]")
CONSOLE.print(" [dim]drone @hooks log[/dim] [dim]# Tail recent hook activity[/dim]")
CONSOLE.print(" [dim]drone @hooks hooksound off[/dim] [dim]# Mute all hook sounds[/dim]")
CONSOLE.print(" [dim]drone @hooks hooksound on[/dim] [dim]# Unmute all hook sounds[/dim]")
CONSOLE.print()
CONSOLE.print("─" * 70)
CONSOLE.print()
CONSOLE.print("[bold cyan]FLAGS:[/bold cyan]")
CONSOLE.print()
CONSOLE.print(" [green]--help, -h[/green] [dim]Show this help message[/dim]")
CONSOLE.print(" [green]--version, -V[/green] [dim]Show version[/dim]")
CONSOLE.print()
CONSOLE.print("[bold]TIP:[/bold] For command-specific help:")
CONSOLE.print(" [dim]drone @hooks <command> --help[/dim]")
CONSOLE.print()
CONSOLE.print("[bold]FLAGS:[/bold]")
CONSOLE.print(" --help, -h Show this help message")
CONSOLE.print(" --version, -V Show version")
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
+4
View File
@@ -24,6 +24,10 @@ from aipass.hooks.apps.handlers.config.diagnostics import log_entry as _log, tai
CONSOLE = err_console
BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent
HELP_COMMANDS = [
("log", "Tail recent hook activity (last 20 entries)"),
]
def _run_hook(hook_cmd: str, stdin_data: str, timeout_s: int = 30) -> dict:
"""Run a single hook subprocess, capture output and timing."""
@@ -16,6 +16,12 @@ from aipass.prax.apps.modules.logger import system_logger as logger # noqa: F40
CONSOLE = err_console
HELP_COMMANDS = [
("hooksound on", "Unmute all hook sounds"),
("hooksound off", "Mute all hook sounds"),
("hooksound", "Show current sound status"),
]
def print_introspection():
"""Print module structure for drone routing."""
@@ -16,6 +16,10 @@ from aipass.prax.apps.modules.logger import system_logger as logger # noqa: F40
CONSOLE = err_console
HELP_COMMANDS = [
("status", "Show current project hook config"),
]
EVENT_TYPES = [
"UserPromptSubmit",
"PreToolUse",
+264
View File
@@ -0,0 +1,264 @@
# =================== AIPass ====================
# Name: test_auto_process.py
# Version: 1.1.0
# Description: Tests for auto_process lifecycle handler (TDPLAN-0005)
# Branch: hooks
# Created: 2026-06-06
# Modified: 2026-06-06
# =============================================
"""Tests for handlers/lifecycle/auto_process.py."""
import logging
from unittest.mock import patch, MagicMock
MODULE = "aipass.hooks.apps.handlers.lifecycle.auto_process"
def _make_mock_module(**auto_process_return):
mock_module = MagicMock()
mock_module.auto_process.return_value = auto_process_return or {
"success": True,
"pool": {},
"rollover": {},
}
return mock_module
class TestAutoProcessHandler:
def test_success_returns_exit_code_0(self):
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
mock_module = _make_mock_module(
success=True,
pool={"success": True, "files_processed": 0, "total_chunks": 0},
rollover={"skipped": True},
)
with patch(f"{MODULE}._already_ran_this_session", return_value=False):
with patch(f"{MODULE}._mark_session_ran"):
with patch(f"{MODULE}.importlib.import_module", return_value=mock_module):
result = handle({})
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_calls_memory_auto_process_module(self):
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
mock_module = _make_mock_module(success=True, pool={"skipped": True}, rollover={"skipped": True})
with patch(f"{MODULE}._already_ran_this_session", return_value=False):
with patch(f"{MODULE}._mark_session_ran"):
with patch(f"{MODULE}.importlib.import_module", return_value=mock_module) as mock_import:
handle({})
mock_import.assert_called_once_with("aipass.memory.apps.handlers.intake.auto_process")
mock_module.auto_process.assert_called_once()
def test_logs_when_pool_files_processed(self, caplog):
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
mock_module = _make_mock_module(
success=True,
pool={"success": True, "files_processed": 3, "total_chunks": 42},
rollover={"skipped": True},
)
with patch(f"{MODULE}._already_ran_this_session", return_value=False):
with patch(f"{MODULE}._mark_session_ran"):
with patch(f"{MODULE}.importlib.import_module", return_value=mock_module):
with caplog.at_level(logging.INFO):
handle({})
assert "pool=3 files, rollover=0 processed" in caplog.text
def test_logs_when_rollover_processed(self, caplog):
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
mock_module = _make_mock_module(
success=True,
pool={"success": True, "files_processed": 0, "total_chunks": 0},
rollover={"success": True, "processed": 2, "triggers": 2},
)
with patch(f"{MODULE}._already_ran_this_session", return_value=False):
with patch(f"{MODULE}._mark_session_ran"):
with patch(f"{MODULE}.importlib.import_module", return_value=mock_module):
with caplog.at_level(logging.INFO):
handle({})
assert "pool=0 files, rollover=2 processed" in caplog.text
def test_logs_noop_when_nothing_processed(self, caplog):
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
mock_module = _make_mock_module(success=True, pool={"skipped": True}, rollover={"skipped": True})
with patch(f"{MODULE}._already_ran_this_session", return_value=False):
with patch(f"{MODULE}._mark_session_ran"):
with patch(f"{MODULE}.importlib.import_module", return_value=mock_module):
with caplog.at_level(logging.INFO):
handle({})
assert "no-op (nothing to process)" in caplog.text
def test_import_error_surfaces_with_exit_code_1(self, caplog):
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
with patch(f"{MODULE}._already_ran_this_session", return_value=False):
with patch(f"{MODULE}.importlib.import_module", side_effect=ImportError("no module")):
with caplog.at_level(logging.ERROR):
result = handle({})
assert result["exit_code"] == 1
assert result["stdout"] == ""
assert "no module" in caplog.text
def test_runtime_error_surfaces_with_exit_code_1(self, caplog):
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
mock_module = MagicMock()
mock_module.auto_process.side_effect = RuntimeError("chromadb down")
with patch(f"{MODULE}._already_ran_this_session", return_value=False):
with patch(f"{MODULE}.importlib.import_module", return_value=mock_module):
with caplog.at_level(logging.ERROR):
result = handle({})
assert result["exit_code"] == 1
assert "chromadb down" in caplog.text
def test_fires_on_precompact_event_key(self):
"""Verify auto_process is wired in hooks.json under PreCompact."""
import json
from pathlib import Path
hooks_json = Path(__file__).resolve().parent.parent.parent.parent.parent / ".aipass" / "hooks.json"
config = json.loads(hooks_json.read_text(encoding="utf-8"))
precompact = config.get("PreCompact", {})
assert "auto_process" in precompact
assert precompact["auto_process"]["enabled"] is True
assert precompact["auto_process"]["handler"] == "aipass.hooks.apps.handlers.lifecycle.auto_process.handle"
def test_fires_on_user_prompt_submit_event_key(self):
"""Verify auto_process is wired in hooks.json under UserPromptSubmit (with session guard)."""
import json
from pathlib import Path
hooks_json = Path(__file__).resolve().parent.parent.parent.parent.parent / ".aipass" / "hooks.json"
config = json.loads(hooks_json.read_text(encoding="utf-8"))
ups = config.get("UserPromptSubmit", {})
assert "auto_process" in ups
assert ups["auto_process"]["enabled"] is True
assert ups["auto_process"]["handler"] == "aipass.hooks.apps.handlers.lifecycle.auto_process.handle"
def test_hook_data_dict_accepted(self):
"""Handler accepts any hook_data dict without error."""
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
mock_module = _make_mock_module(success=True, pool={}, rollover={})
with patch(f"{MODULE}._already_ran_this_session", return_value=False):
with patch(f"{MODULE}._mark_session_ran"):
with patch(f"{MODULE}.importlib.import_module", return_value=mock_module):
result = handle({"tool_name": "Bash", "cwd": "/tmp"})
assert result["exit_code"] == 0
class TestSessionGuard:
def test_skips_when_already_ran(self):
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
with patch(f"{MODULE}._already_ran_this_session", return_value=True):
with patch(f"{MODULE}.importlib.import_module") as mock_import:
result = handle({})
assert result["exit_code"] == 0
mock_import.assert_not_called()
def test_runs_when_not_yet_ran(self):
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
mock_module = _make_mock_module(success=True, pool={}, rollover={})
with patch(f"{MODULE}._already_ran_this_session", return_value=False):
with patch(f"{MODULE}._mark_session_ran"):
with patch(f"{MODULE}.importlib.import_module", return_value=mock_module) as mock_import:
handle({})
mock_import.assert_called_once()
def test_marks_session_after_success(self):
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
mock_module = _make_mock_module(success=True, pool={}, rollover={})
with patch(f"{MODULE}._mark_session_ran") as mock_mark:
with patch(f"{MODULE}._already_ran_this_session", return_value=False):
with patch(f"{MODULE}.importlib.import_module", return_value=mock_module):
handle({})
mock_mark.assert_called_once()
def test_does_not_mark_session_on_error(self):
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
with patch(f"{MODULE}._mark_session_ran") as mock_mark:
with patch(f"{MODULE}._already_ran_this_session", return_value=False):
with patch(f"{MODULE}.importlib.import_module", side_effect=ImportError("boom")):
handle({})
mock_mark.assert_not_called()
def test_guard_path_uses_session_id(self):
from aipass.hooks.apps.handlers.lifecycle.auto_process import _session_guard_path
with patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "abc-123"}):
path = _session_guard_path()
assert path is not None
assert "abc-123" in str(path)
assert "aipass-auto-process-" in str(path)
def test_guard_path_none_without_session_id(self):
from aipass.hooks.apps.handlers.lifecycle.auto_process import _session_guard_path
with patch.dict("os.environ", {}, clear=True):
path = _session_guard_path()
assert path is None
def test_already_ran_false_without_session_id(self):
from aipass.hooks.apps.handlers.lifecycle.auto_process import _already_ran_this_session
with patch.dict("os.environ", {}, clear=True):
assert not _already_ran_this_session()
def test_already_ran_false_when_guard_missing(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.auto_process import _already_ran_this_session
with patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-no-file"}):
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
assert not _already_ran_this_session()
def test_already_ran_true_when_guard_exists(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.auto_process import _already_ran_this_session
(tmp_path / "aipass-auto-process-test-exists").touch()
with patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-exists"}):
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
assert _already_ran_this_session()
def test_mark_creates_guard_file(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.auto_process import _mark_session_ran
with patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-mark"}):
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
_mark_session_ran()
assert (tmp_path / "aipass-auto-process-test-mark").exists()
+3 -4
View File
@@ -29,9 +29,6 @@ class TestCompactHandler:
),
encoding="utf-8",
)
status = tmp_path / "STATUS.local.md"
status.write_text("# Status\nCurrent work here", encoding="utf-8")
with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"):
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value="Git branch: dev"):
result = handle({"cwd": str(tmp_path)})
@@ -40,7 +37,7 @@ class TestCompactHandler:
assert "POST-COMPACT RECOVERY" in result["stdout"]
assert "Git branch: dev" in result["stdout"]
assert "did stuff" in result["stdout"]
assert "Current work here" in result["stdout"]
assert "STATUS.local.md" not in result["stdout"]
def test_returns_recovery_when_no_branch_dir(self):
from aipass.hooks.apps.handlers.lifecycle.compact import handle
@@ -64,6 +61,7 @@ class TestCompactHandler:
result = handle({"cwd": str(tmp_path)})
assert "SAVE STATE NOW" in result["stdout"]
assert "STATUS.local.md" not in result["stdout"]
def test_interactive_gets_recovery_protocol(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.compact import handle
@@ -77,6 +75,7 @@ class TestCompactHandler:
result = handle({"cwd": str(tmp_path)})
assert "Recovery Protocol" in result["stdout"]
assert "STATUS.local.md" not in result["stdout"]
def test_empty_hook_data(self):
from aipass.hooks.apps.handlers.lifecycle.compact import handle
+40 -1
View File
@@ -290,6 +290,7 @@ class TestFindProjectConfig:
with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir):
with patch("aipass.hooks.apps.handlers.config.loader.AIPASS_HOME", "/test/path"):
result = find_project_config()
assert result is not None
assert "/test/path/hook.py" in result["Stop"]["sound"]["command"]
@@ -354,7 +355,7 @@ class TestHooksEntryPoint:
print_introspection()
captured = capsys.readouterr()
assert "HOOKS" in captured.err
assert "Modules discovered" in captured.err
assert "Discovered Modules" in captured.err
def test_handle_command_returns_bool(self):
from aipass.hooks.apps.hooks import handle_command
@@ -564,6 +565,44 @@ class TestCliRouting:
assert "HOOKS" in captured.err
assert "drone @hooks" in captured.err
def test_print_help_surfaces_subcommands(self, capsys):
from aipass.hooks.apps.hooks import print_help
print_help()
captured = capsys.readouterr()
assert "hooksound on" in captured.err
assert "hooksound off" in captured.err
assert "status" in captured.err
assert "log" in captured.err
def test_print_help_has_examples_section(self, capsys):
from aipass.hooks.apps.hooks import print_help
print_help()
captured = capsys.readouterr()
assert "EXAMPLES" in captured.err
assert "drone @hooks status" in captured.err
assert "drone @hooks hooksound off" in captured.err
def test_print_help_has_usage_section(self, capsys):
from aipass.hooks.apps.hooks import print_help
print_help()
captured = capsys.readouterr()
assert "USAGE" in captured.err
assert "drone @hooks <command>" in captured.err
def test_help_commands_auto_discovered(self, capsys):
from aipass.hooks.apps.hooks import print_help
from aipass.hooks.apps.modules.hooksound import HELP_COMMANDS as hs_cmds
from aipass.hooks.apps.modules.hookstatus import HELP_COMMANDS as hst_cmds
from aipass.hooks.apps.modules.engine import HELP_COMMANDS as eng_cmds
print_help()
captured = capsys.readouterr()
for cmd, _ in hs_cmds + hst_cmds + eng_cmds:
assert cmd in captured.err
def test_output_capture_status(self, capsys):
from aipass.hooks.apps.hooks import handle_command
+281 -78
View File
@@ -1,69 +1,287 @@
# =================== AIPass ====================
# Name: test_git_gate.py
# Version: 1.0.0
# Version: 2.0.0
# Description: Tests for git_gate security handler
# Branch: hooks
# Created: 2026-05-21
# Modified: 2026-05-21
# Modified: 2026-06-05
# =============================================
"""Tests for handlers/security/git_gate.py."""
import json
CWD = "/home/patrick/Projects/AIPass/src/aipass/api"
class TestGitGateHandler:
def test_block_raw_git(self):
from aipass.hooks.apps.handlers.security.git_gate import handle
result = handle(
{
"tool_name": "Bash",
"tool_input": {"command": "git status"},
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
}
)
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
assert "drone" in parsed["reason"]
def _bash(cmd: str) -> dict:
from aipass.hooks.apps.handlers.security.git_gate import handle
return handle({"tool_name": "Bash", "tool_input": {"command": cmd}, "cwd": CWD})
def _assert_allowed(result: dict) -> None:
assert result["exit_code"] == 0
assert result["stdout"] == ""
def _assert_blocked(result: dict) -> None:
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
class TestGitGateReadAllowed:
"""Read-only git verbs are allowed raw."""
def test_git_status(self):
_assert_allowed(_bash("git status"))
def test_git_log(self):
_assert_allowed(_bash("git log --oneline -10"))
def test_git_diff(self):
_assert_allowed(_bash("git diff HEAD~1"))
def test_git_show(self):
_assert_allowed(_bash("git show HEAD:README.md"))
def test_git_ls_files(self):
_assert_allowed(_bash("git ls-files"))
def test_git_ls_tree(self):
_assert_allowed(_bash("git ls-tree HEAD"))
def test_git_cat_file(self):
_assert_allowed(_bash("git cat-file -p HEAD"))
def test_git_rev_parse(self):
_assert_allowed(_bash("git rev-parse HEAD"))
def test_git_rev_list(self):
_assert_allowed(_bash("git rev-list --count HEAD"))
def test_git_blame(self):
_assert_allowed(_bash("git blame README.md"))
def test_git_describe(self):
_assert_allowed(_bash("git describe --tags"))
def test_git_for_each_ref(self):
_assert_allowed(_bash("git for-each-ref refs/heads"))
def test_git_show_ref(self):
_assert_allowed(_bash("git show-ref --heads"))
def test_git_symbolic_ref(self):
_assert_allowed(_bash("git symbolic-ref HEAD"))
def test_git_shortlog(self):
_assert_allowed(_bash("git shortlog -sn"))
def test_git_grep(self):
_assert_allowed(_bash("git grep TODO"))
def test_git_archive(self):
_assert_allowed(_bash("git archive HEAD"))
def test_git_archive_with_args(self):
_assert_allowed(_bash("git archive --format=tar HEAD"))
def test_git_count_objects(self):
_assert_allowed(_bash("git count-objects -v"))
def test_git_var(self):
_assert_allowed(_bash("git var GIT_EDITOR"))
def test_git_help(self):
_assert_allowed(_bash("git help status"))
def test_git_version(self):
_assert_allowed(_bash("git version"))
class TestGitGateGlobalOptions:
"""Read verbs with global options before the subcommand."""
def test_git_C_path_ls_files(self):
_assert_allowed(_bash("git -C /some/path ls-files"))
def test_git_C_path_push_blocked(self):
_assert_blocked(_bash("git -C /some/path push"))
def test_git_no_pager_log(self):
_assert_allowed(_bash("git --no-pager log"))
def test_git_paginate_diff(self):
_assert_allowed(_bash("git --paginate diff"))
def test_git_c_config_status(self):
_assert_allowed(_bash("git -c core.pager=less status"))
def test_git_git_dir_log(self):
_assert_allowed(_bash("git --git-dir=/foo/.git log"))
def test_git_work_tree_status(self):
_assert_allowed(_bash("git --work-tree /foo status"))
def test_git_multiple_opts_ls_files(self):
_assert_allowed(_bash("git -C /foo -c key=val --no-pager ls-files"))
def test_git_multiple_opts_push_blocked(self):
_assert_blocked(_bash("git -C /foo -c key=val --no-pager push"))
class TestGitGateWriteBlocked:
"""Write/ambiguous git verbs are blocked."""
def test_git_push(self):
_assert_blocked(_bash("git push"))
def test_git_commit(self):
_assert_blocked(_bash("git commit -m 'msg'"))
def test_git_checkout(self):
_assert_blocked(_bash("git checkout main"))
def test_git_switch(self):
_assert_blocked(_bash("git switch main"))
def test_git_merge(self):
_assert_blocked(_bash("git merge feature"))
def test_git_rebase(self):
_assert_blocked(_bash("git rebase main"))
def test_git_reset(self):
_assert_blocked(_bash("git reset --hard HEAD"))
def test_git_clone(self):
_assert_blocked(_bash("git clone https://example.com/repo"))
def test_git_pull(self):
_assert_blocked(_bash("git pull"))
def test_git_fetch(self):
_assert_blocked(_bash("git fetch origin"))
def test_git_clean(self):
_assert_blocked(_bash("git clean -fd"))
def test_git_stash(self):
_assert_blocked(_bash("git stash"))
def test_git_cherry_pick(self):
_assert_blocked(_bash("git cherry-pick abc123"))
def test_git_revert(self):
_assert_blocked(_bash("git revert HEAD"))
def test_git_rm(self):
_assert_blocked(_bash("git rm file.py"))
def test_git_mv(self):
_assert_blocked(_bash("git mv old.py new.py"))
def test_git_init(self):
_assert_blocked(_bash("git init"))
def test_git_restore(self):
_assert_blocked(_bash("git restore file.py"))
def test_git_add(self):
_assert_blocked(_bash("git add ."))
def test_git_tag(self):
_assert_blocked(_bash("git tag v1.0"))
def test_git_branch(self):
_assert_blocked(_bash("git branch -D main"))
def test_git_worktree(self):
_assert_blocked(_bash("git worktree add ../tmp"))
def test_git_gc(self):
_assert_blocked(_bash("git gc"))
def test_git_prune(self):
_assert_blocked(_bash("git prune"))
def test_git_am(self):
_assert_blocked(_bash("git am patch.mbox"))
def test_git_apply(self):
_assert_blocked(_bash("git apply patch.diff"))
def test_bare_git(self):
_assert_blocked(_bash("git "))
class TestGitGateChaining:
"""Compound commands with mixed git verbs."""
def test_chained_read_then_write_blocked(self):
_assert_blocked(_bash("git ls-files && git push"))
def test_chained_reads_allowed(self):
_assert_allowed(_bash("git ls-files && git log"))
def test_piped_read_write_blocked(self):
_assert_blocked(_bash("git ls-files | git push"))
def test_semicolon_read_write_blocked(self):
_assert_blocked(_bash("git status; git commit -m 'msg'"))
def test_or_read_write_blocked(self):
_assert_blocked(_bash("git status || git push"))
def test_read_with_non_git_allowed(self):
_assert_allowed(_bash("git ls-files && echo done"))
def test_non_git_then_read_allowed(self):
_assert_allowed(_bash("echo start && git status"))
def test_three_reads_allowed(self):
_assert_allowed(_bash("git status && git log && git diff"))
def test_two_reads_one_write_blocked(self):
_assert_blocked(_bash("git status && git log && git push"))
class TestGitGateWordBoundary:
"""Word-boundary and quote handling."""
def test_gitfoo_not_matched(self):
_assert_allowed(_bash("gitfoo status"))
def test_git_in_quoted_string(self):
_assert_allowed(_bash('echo "git push"'))
def test_git_in_single_quoted_string(self):
_assert_allowed(_bash("echo 'git push'"))
def test_drone_git_allowed(self):
_assert_allowed(_bash("drone @git status"))
def test_path_git_not_matched(self):
_assert_allowed(_bash("/usr/bin/git push"))
def test_dotgit_not_matched(self):
_assert_allowed(_bash("cat .git/config"))
class TestGitGateGhCommands:
"""gh command handling (unchanged behavior)."""
def test_block_raw_gh(self):
from aipass.hooks.apps.handlers.security.git_gate import handle
result = handle(
{
"tool_name": "Bash",
"tool_input": {"command": "gh pr list"},
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
}
)
assert result["exit_code"] == 2
def test_allow_drone_git(self):
from aipass.hooks.apps.handlers.security.git_gate import handle
result = handle(
{
"tool_name": "Bash",
"tool_input": {"command": "drone @git status"},
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
}
)
assert result["exit_code"] == 0
assert result["stdout"] == ""
_assert_blocked(_bash("gh pr list"))
def test_allow_gh_api(self):
from aipass.hooks.apps.handlers.security.git_gate import handle
_assert_allowed(_bash("gh api repos/owner/repo/pulls"))
result = handle(
{
"tool_name": "Bash",
"tool_input": {"command": "gh api repos/owner/repo/pulls"},
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
}
)
assert result["exit_code"] == 0
class TestGitGateEditProtection:
"""Protected file edit handling."""
def test_block_edit_settings(self):
from aipass.hooks.apps.handlers.security.git_gate import handle
@@ -72,12 +290,10 @@ class TestGitGateHandler:
{
"tool_name": "Edit",
"tool_input": {"file_path": "/home/patrick/.claude/settings.json"},
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
"cwd": CWD,
}
)
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
_assert_blocked(result)
def test_allow_edit_settings_from_devpulse(self):
from aipass.hooks.apps.handlers.security.git_gate import handle
@@ -89,7 +305,7 @@ class TestGitGateHandler:
"cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse",
}
)
assert result["exit_code"] == 0
_assert_allowed(result)
def test_block_edit_hooks_dir(self):
from aipass.hooks.apps.handlers.security.git_gate import handle
@@ -98,38 +314,25 @@ class TestGitGateHandler:
{
"tool_name": "Edit",
"tool_input": {"file_path": "/home/patrick/Projects/AIPass/.claude/hooks/some_hook.py"},
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
"cwd": CWD,
}
)
assert result["exit_code"] == 2
_assert_blocked(result)
class TestGitGateMisc:
"""Miscellaneous edge cases."""
def test_allow_normal_bash(self):
from aipass.hooks.apps.handlers.security.git_gate import handle
result = handle(
{
"tool_name": "Bash",
"tool_input": {"command": "ls -la"},
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
}
)
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_git_in_quoted_string(self):
from aipass.hooks.apps.handlers.security.git_gate import handle
result = handle(
{
"tool_name": "Bash",
"tool_input": {"command": 'echo "git status"'},
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
}
)
assert result["exit_code"] == 0
_assert_allowed(_bash("ls -la"))
def test_empty_hook_data(self):
from aipass.hooks.apps.handlers.security.git_gate import handle
result = handle({})
assert result["exit_code"] == 0
def test_block_message_mentions_read_verbs(self):
result = _bash("git push")
parsed = json.loads(result["stdout"])
assert "Read-only verbs" in parsed["reason"]
+2
View File
@@ -13,3 +13,5 @@ build/
*.log
*.tmp
*.swp
memory_pool
memory_pool_archive
+25
View File
@@ -86,6 +86,11 @@
"standard": "handlers",
"reason": "Architectural: rollover orchestrator coordinates monitor, tracking, storage, dashboard, intake, and trigger handlers. Central pipeline hub."
},
{
"file": "apps/handlers/intake/auto_process.py",
"standard": "handlers",
"reason": "Architectural: auto-process entry point coordinates pool (intake), rollover (orchestrator), and detection (monitor) for session-start hook. TDPLAN-0005."
},
{
"file": "apps/handlers/learnings/manager.py",
"standard": "handlers",
@@ -605,6 +610,26 @@
"file": "tests/test_orchestrator_exec.py",
"standard": "meta",
"reason": "Test file — META block present at lines 1-8; hook false-positive on test file format."
},
{
"file": "tests/test_auto_process.py",
"standard": "architecture",
"reason": "Test file — lives in tests/ by design, not in 3-layer apps/ structure."
},
{
"file": "tests/test_auto_process.py",
"standard": "encapsulation",
"reason": "Test file — direct handler imports are correct for unit testing handler internals."
},
{
"file": "tests/test_auto_process.py",
"standard": "meta",
"reason": "Test file — META block present at lines 1-7; hook false-positive on test file format."
},
{
"file": "tests/test_auto_process.py",
"standard": "documentation",
"reason": "Test file — test functions don't require docstrings."
}
],
"notes": {
@@ -0,0 +1,160 @@
# =================== AIPass ====================
# Name: auto_process.py
# Description: Automated pool + rollover entry point
# Version: 1.0.0
# Created: 2026-06-06
# Modified: 2026-06-06
# =============================================
"""
Auto-process handler — session-start pool + rollover entry point.
Single callable the hook engine fires each session to:
1. Process any files dropped into memory_pool/ (vectorize + archive)
2. Check/run rollover for .trinity/ files exceeding limits
Idempotent: safe to call every session. Fast no-op when nothing to do.
Pool uses upsert with content-hash IDs — re-processing same files is a no-op.
HOOK ENGINE CONTRACT:
Module: aipass.memory.apps.handlers.intake.auto_process
Function: auto_process()
Invocation: importlib.import_module('aipass.memory.apps.handlers.intake.auto_process').auto_process()
Returns: dict with success, pool, and rollover results
"""
import json
from pathlib import Path
from typing import Any, Dict
from aipass.prax import logger
from aipass.memory.apps.handlers.json import json_handler
_MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent.parent
CONFIG_PATH = _MEMORY_ROOT / "config" / "memory.config.json"
def _load_pool_enabled() -> bool:
try:
with open(CONFIG_PATH, encoding="utf-8") as f:
config = json.load(f)
return config.get("memory_pool", {}).get("enabled", False)
except Exception as e:
logger.warning(f"[auto_process] Failed to load config: {e}")
return False
def run_pool_processing() -> Dict[str, Any]:
"""
Process memory pool files if enabled.
Checks config, calls process_memory_pool(), returns summary.
Fast no-op when pool is empty or disabled.
Returns:
dict with success/skipped, files_processed, total_chunks
"""
if not _load_pool_enabled():
return {"skipped": True, "reason": "memory_pool disabled in config"}
try:
from aipass.memory.apps.handlers.intake.pool_processor import process_memory_pool
pool_result = process_memory_pool()
result = {
"success": pool_result.get("success", False),
"files_processed": pool_result.get("files_processed", 0),
"total_chunks": pool_result.get("total_chunks", 0),
}
if pool_result.get("files_processed", 0) > 0:
logger.info(
f"[auto_process] Pool: {pool_result['files_processed']} files, "
f"{pool_result.get('total_chunks', 0)} chunks"
)
json_handler.log_operation(
"run_pool_processing",
{
"files_processed": result.get("files_processed", 0),
"success": result.get("success", False),
},
)
return result
except Exception as e:
logger.warning(f"[auto_process] Pool processing failed: {e}")
return {"success": False, "error": str(e)}
def _run_rollover_check() -> Dict[str, Any]:
"""
Check all branches for rollover triggers and execute if needed.
Returns:
dict with success/skipped and rollover details
"""
try:
from aipass.memory.apps.handlers.monitor.detector import check_all_branches
check_result = check_all_branches()
triggers = check_result.get("triggers", []) if check_result else []
if not triggers:
return {"skipped": True, "reason": "no rollover triggers"}
from aipass.memory.apps.handlers.rollover.orchestrator import execute_rollover
rollover_result = execute_rollover()
result = {
"success": rollover_result.get("success", False),
"triggers": rollover_result.get("triggers_count", 0),
"processed": rollover_result.get("success_count", 0),
}
logger.info(f"[auto_process] Rollover: {result['processed']}/{result['triggers']} triggers processed")
return result
except Exception as e:
logger.warning(f"[auto_process] Rollover check failed: {e}")
return {"success": False, "error": str(e)}
def auto_process() -> Dict[str, Any]:
"""
Single idempotent entry point for session-start auto-processing.
Processes memory pool files and checks/runs rollover if needed.
Fast no-op when pool is empty and no rollover triggers.
Safe to call every session.
Returns:
dict with success, pool, and rollover results
"""
result: Dict[str, Any] = {"success": True, "pool": None, "rollover": None}
if not _load_pool_enabled():
result["pool"] = {"skipped": True, "reason": "memory_pool disabled in config"}
result["rollover"] = {"skipped": True}
logger.info("[auto_process] Skipped — memory_pool disabled in config")
return result
# 1. Process pool files
pool_result = run_pool_processing()
result["pool"] = pool_result
if pool_result.get("success") is False:
result["success"] = False
# 2. Check/run rollover
rollover_result = _run_rollover_check()
result["rollover"] = rollover_result
if rollover_result.get("success") is False:
result["success"] = False
json_handler.log_operation(
"auto_process",
{
"pool_files": result.get("pool", {}).get("files_processed", 0),
"rollover_triggered": not result.get("rollover", {}).get("skipped", False),
"success": result["success"],
},
)
return result
@@ -221,6 +221,11 @@ def _apply_template_to_local(current: dict, template: dict, branch_name: str) ->
data["key_learnings"] = {}
changes.append("key_learnings: added (empty)")
# Todos: add if missing (operational list, not rolled over)
if "todos" not in data:
data["todos"] = []
changes.append("todos: added (empty)")
# Active tasks: ensure recently_completed exists
if "active_tasks" in data and isinstance(data["active_tasks"], dict):
if "recently_completed" not in data["active_tasks"]:
+5 -1
View File
@@ -117,6 +117,8 @@ def print_help():
table.add_row("search <query>", "Semantic search across all branch memories")
table.add_row("symbolic <subcommand>", "Symbolic/fragmented memory extraction and search")
table.add_row("templates <subcommand>", "Living template push, diff, and status")
table.add_row("pool process", "Process pool files + check/run rollover")
table.add_row("pool status", "Show pool file count, config, vector stats")
table.add_row("verify <plan_label>", "Check if a plan is vectorized in ChromaDB")
table.add_row("watch", "Start memory watcher (auto-rollover on changes)")
@@ -163,7 +165,9 @@ def print_help():
console.print("-" * 70)
console.print()
console.print("Commands: search, rollover [run|status|check|sync-lines], symbolic, templates, verify, watch")
console.print(
"Commands: search, rollover [run|status|check|sync-lines], pool [process|status], symbolic, templates, verify, watch"
)
console.print()
+192
View File
@@ -0,0 +1,192 @@
# =================== AIPass ====================
# Name: pool.py
# Description: Pool Module — drone CLI for pool commands
# Version: 1.0.0
# Created: 2026-06-06
# Modified: 2026-06-06
# =============================================
"""
Pool Module — drone CLI routing for memory pool commands.
Thin delegation layer. All implementation lives in handlers/intake/auto_process.py.
"""
from typing import List, Any
from rich.panel import Panel
from rich import box
from aipass.prax import logger # noqa: F401
from aipass.cli.apps.modules import console, error
from aipass.memory.apps.handlers.json import json_handler
# =============================================================================
# COMMAND HANDLERS
# =============================================================================
_SUBCOMMANDS = {
"process": "Process memory pool files (vectorize + archive)",
"status": "Show memory pool status",
}
def handle_command(command: str, args: List[Any]) -> bool:
"""
Handle pool commands.
Routing:
pool (no args) -> print_introspection()
pool --help/-h/help -> print_help()
pool process -> run auto_process()
pool status -> show pool status
Args:
command: Command name
args: Additional arguments
Returns:
True if command handled, False otherwise
"""
if command == "pool":
if not args:
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
print_help()
return True
sub = args[0]
if sub == "process":
_run_process_command()
return True
if sub == "status":
_run_status_command()
return True
error(
f"Unknown subcommand: '{sub}'",
suggestion="Available: " + ", ".join(_SUBCOMMANDS.keys()),
)
return True
return False
# =============================================================================
# CLI DISPLAY
# =============================================================================
def _run_process_command() -> None:
"""Execute pool processing + rollover check and display results."""
from ..handlers.intake.auto_process import auto_process
console.print()
console.print("[bold cyan]Processing memory pool...[/bold cyan]")
console.print()
result = auto_process()
json_handler.log_operation(
"pool_process_command",
{"success": result.get("success", False)},
)
# Pool results
pool = result.get("pool", {})
if pool.get("skipped"):
console.print(f"[dim]Pool: skipped — {pool.get('reason', 'unknown')}[/dim]")
elif pool.get("success") is False:
console.print(f"[red]Pool: failed — {pool.get('error', 'unknown')}[/red]")
else:
files = pool.get("files_processed", 0)
chunks = pool.get("total_chunks", 0)
if files > 0:
console.print(f"[green]>[/green] Pool: {files} files processed, {chunks} chunks vectorized")
else:
console.print("[dim]Pool: no files to process[/dim]")
# Rollover results
rollover = result.get("rollover", {})
if rollover.get("skipped"):
console.print("[dim]Rollover: no triggers[/dim]")
elif rollover.get("success") is False:
console.print(f"[red]Rollover: failed — {rollover.get('error', 'unknown')}[/red]")
else:
processed = rollover.get("processed", 0)
total = rollover.get("triggers", 0)
console.print(f"[green]>[/green] Rollover: {processed}/{total} triggers processed")
console.print()
def _run_status_command() -> None:
"""Display memory pool status."""
from ..handlers.intake.pool_processor import get_pool_status
console.print()
status = get_pool_status()
json_handler.log_operation(
"pool_status_command",
{"files_in_pool": status.get("files_in_pool", 0)},
)
enabled = "[green]enabled[/green]" if status.get("enabled") else "[red]disabled[/red]"
console.print(f"[bold cyan]Memory Pool Status[/bold cyan] ({enabled})")
console.print()
console.print(f" Files in pool: {status.get('files_in_pool', 0)}")
console.print(f" Keep recent: {status.get('keep_recent', 0)}")
console.print(f" Vectors stored: {status.get('vectors_stored', 0)}")
console.print(f" Collection: {status.get('collection_name', 'unknown')}")
newest = status.get("newest_file")
oldest = status.get("oldest_file")
if newest:
console.print(f" Newest file: {newest}")
if oldest and oldest != newest:
console.print(f" Oldest file: {oldest}")
console.print()
def print_introspection() -> None:
"""Display pool module introspection."""
console.print()
console.print("[bold cyan]Pool Module - Memory Pool Processing[/bold cyan]")
console.print()
console.print("[dim]Processes memory_pool/ files and checks rollover triggers[/dim]")
console.print()
for sub, desc in _SUBCOMMANDS.items():
console.print(f" [cyan]*[/cyan] {sub} — {desc}")
console.print()
def print_help() -> None:
"""Display pool module help."""
console.print()
console.print(
Panel.fit(
"[bold cyan]Pool Module - Memory Pool & Auto-Processing[/bold cyan]\n"
"[dim]Vectorize pool files, check rollover, manual or hook-driven[/dim]",
border_style="cyan",
box=box.ROUNDED,
)
)
console.print()
console.print("[bold cyan]COMMANDS:[/bold cyan]")
console.print()
console.print(" [green]pool process[/green] Process pool files + check/run rollover")
console.print(" [green]pool status[/green] Show pool file count, config, vector stats")
console.print()
console.print("[bold cyan]USAGE:[/bold cyan]")
console.print()
console.print(" [dim]drone @memory pool process[/dim]")
console.print(" [dim]drone @memory pool status[/dim]")
console.print()
+3 -2
View File
@@ -1,8 +1,9 @@
{
"memory_pool": {
"enabled": false,
"enabled": true,
"process_on_startup": false,
"extensions": [".md", ".txt"]
"keep_recent": 0,
"supported_extensions": [".md", ".txt"]
},
"rollover": {
"defaults": {
@@ -1,60 +0,0 @@
# API Module Recon
**Date:** 2026-03-06
## Summary
LLM access and Telegram multi-bot system. **Heaviest path debt** (31 Path.home()). 46 Python files.
## Structure
```
api/
├── apps/
│ ├── api.py # Entry point (auto-discovers modules)
│ ├── modules/
│ │ ├── api_key.py # Key retrieval, validation
│ │ ├── openrouter_client.py # LLM API calls, model listing
│ │ ├── telegram_bot.py # Multi-bot management (PUBLIC)
│ │ ├── telegram_service.py # Systemd service control
│ │ └── usage_tracker.py # Cost tracking
│ ├── handlers/
│ │ ├── auth/ # Key management, .env fallback
│ │ ├── config/ # Configuration
│ │ ├── openrouter/ # OpenRouter client + retry
│ │ ├── telegram/ # 12 files (BaseBot, factory, registry, plugins)
│ │ ├── telegram_service/ # Service control
│ │ ├── usage/ # Usage tracking
│ │ └── json/ # JSON tracking
│ └── json_templates/
└── tests/ # Empty
```
## Commands
```
drone @api get-key|validate|test|models
drone @api track|stats
drone @api telegram start|stop|status|logs
drone @api telegram_bot list|create|delete|status|start|stop
```
## Path.home() Debt: 31 instances (CRITICAL)
**Telegram handlers (23/31):**
- base_bot.py — 7 hits
- bot_factory.py — 5 hits
- config.py — 2 hits
- branch_plugin.py, response_router.py, notifier.py, tmux_manager.py, botfather_client.py
**Other:**
- json_handler.py:29 — `API_ROOT = Path.home() / "aipass_core" / "api"` (import-time) [stale: aipass_core]
- log_streamer.py:54 — `SYSTEM_LOGS_DIR = Path("/home/aipass/system_logs")` (CRITICAL, import-time)
- auth/env.py:54, telegram_service/service.py:26
## Key Insight
23 of 31 Path.home() issues are in **Telegram handlers** — this is legacy AIPass infrastructure. DPLAN-047 recommends stripping it for v1.0 (Option B).
## Disabled Legacy Files
- `spawner.py(disabled)` — old Claude session spawner
- `output_parser.py(disabled)` — old JSON stream parser
## Notes
- Entry point is `api.py` not `branch.py` (naming deviation)
- No .trinity files, no tests
- OpenRouter integration is stdlib-only BaseBot (no python-telegram-bot dep)
@@ -1,45 +0,0 @@
# CLI Module Recon
**Date:** 2026-03-06
## Summary
Display/formatting service provider using Rich. Clean public API. LOW path debt.
## Structure
```
cli/
├── apps/
│ ├── cli.py # Entry point - showroom & help (v0.2.0)
│ ├── modules/
│ │ ├── display.py # header(), success(), error(), warning(), section() (v0.4.0)
│ │ └── templates.py # operation_start(), operation_complete() (v0.3.0)
│ ├── handlers/
│ │ ├── json/json_handler.py # JSON auto-create (PATH.HOME BUG)
│ │ └── templates/ # Empty
│ ├── extensions/ # Stub
│ └── plugins/ # Stub
├── __init__.py # Exports: console, header, success, error, warning, section, operation_start, operation_complete
├── .seed/bypass.json
└── tests/
```
## Public API
```python
from aipass.cli import console, header, success, error, warning, section
from aipass.cli import operation_start, operation_complete
```
## Path.home() Debt
- `json_handler.py:27-29` — `CLI_ROOT = Path.home() / "aipass_core" / "cli"` (CRITICAL) [stale: aipass_core]
- 8 files with hardcoded shebang `#!/home/aipass/.venv/bin/python3`
## Working
- Display module with Rich integration
- Templates module with operation patterns
- Handler guard system (cross-branch import protection)
- SEED pattern implementation (introspection, help, demo)
## Broken
- json_handler.py Path.home() — wrong paths in container
- No .trinity files
- No .aipass branch prompt
- Extensions/plugins empty

Some files were not shown because too many files have changed in this diff Show More