Merge pull request #640 from AIOSAI/dev

Post-merge git friction fixes: drone sync FF-only realign + sync_main_ref (no checkout), merge.md/branch-prompt corrected (merge commit not squash), deterministic spawn template registry IDs
This commit is contained in:
AIPass
2026-06-23 17:16:15 -07:00
committed by GitHub
558 changed files with 77982 additions and 2928 deletions
+4 -2
View File
@@ -1,8 +1,10 @@
*
!aipass_global_prompt.md
!tier0_kernel.md
!tier1_navmap.md
!hooks.json
!.gitignore
!README.md
!PROMPT_STYLE.md
!project_CLAUDE.md
!project_global_prompt.md
!project_hooks.json
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
+12 -1
View File
@@ -15,6 +15,16 @@ Goal: signal density over prose. Prompts are injected every turn — every line
- Code blocks: inline backticks for commands (`` `drone @ai_mail dispatch` ``). Multi-line fenced blocks only for directory trees, template skeletons, or command examples that don't fit inline.
- File length: aim for under 230 lines. Global and branch prompts are injected every turn — every line costs tokens.
# Writing voice (agent output + memory)
How agents write responses, reports, and memory entries. Validated against Claude Code's own prompt (DPLAN-0213).
- Reference code as `file_path:line_number` — clickable, unambiguous.
- No colon before a tool call. "Let me read the file." then call it, not "Let me read the file:".
- No emojis in agent output unless the user uses them first.
- Write for a reader who stepped away and lost the thread: no codenames or shorthand they would have to decode. Clarity over terseness — the goal is the reader understanding with no mental overhead.
- Where detail lives, three tiers: a short capability phrase (registry/search), a one-line summary (`drone @agent`), the full reference (`drone @agent --help`). Keep the injected prompt terse; push depth into --help.
# What NOT to put in a prompt
- Session state, current work, in-flight issues. That goes in `.trinity/local.json` (todos[]) and `DASHBOARD.local.json`.
@@ -36,6 +46,7 @@ These are not currently enforced by seedgo — per @seedgo's Track 5 recommendat
# Reference files
- `.aipass/aipass_global_prompt.md` — canonical example of the format
- `.aipass/tier0_kernel.md` + `.aipass/tier1_navmap.md` — the live injected prompts (Tier 0 every turn, Tier 1 periodic); canonical examples of the format
- `.aipass/aipass_global_prompt.md` — superseded by the tiers (FPLAN-0284), kept as a reference snapshot
- Branch `.aipass/aipass_local_prompt.md` files — should follow the same rules
- This file — reference for authoring new prompts or auditing existing ones
+76
View File
@@ -0,0 +1,76 @@
# `.aipass/` — project prompt & hook config
This folder holds the **project-level prompt** and **hook configuration** for the AIPass
repo, plus the **templates** `aipass init` stamps into every new project. It is the
*project* layer; each branch additionally has its own branch prompt at
`src/aipass/<branch>/.aipass/aipass_local_prompt.md`.
> **Nothing here is dead weight.** Every file is live injection, live config, or a
> required new-project template. Superseded files live in `.archive/` (never deleted).
## One prompt system, every runtime
There is **one** source of prompt truth — the **tier files** — and **all** runtimes inject
the same content. We do **not** keep separate prompts per CLI. Only the *delivery* differs:
| Runtime | How the same content is delivered |
|---|---|
| **Claude Code** | **Tiered by cadence** (FPLAN-0284): `tier0_kernel.md` every turn + `tier1_navmap.md` periodically + post-compaction |
| **Codex CLI** | Injected **once at SessionStart** (no per-turn cadence): the same tier content, combined |
> ⚠️ **Migration in progress.** The Codex SessionStart hook
> (`.codex/hooks/session_start_identity.py`) currently still reads the legacy
> `aipass_global_prompt.md`. @hooks is wiring it onto the tier files. **Retire for one
> runtime = retire for all** — once Codex is on the tiers, `aipass_global_prompt.md` is
> read by nothing and moves to `.archive/`.
## Files
### Live — this repo's prompt + config
| File | What it is |
|---|---|
| `tier0_kernel.md` | **The kernel** — tiny identity + `drone --help` reflex + don't-get-lost rules. The always-on core, for every runtime. |
| `tier1_navmap.md` | **The navmap** — full agent roster, framework, terminology. The periodic/fuller layer, for every runtime. |
| `hooks.json` | Claude Code **handler registration** for this repo — which prompt/gate/notification handlers fire on which events. |
| `PROMPT_STYLE.md` | The writing-style guide every prompt here follows. |
| `.gitignore` | Whitelist guard — only files listed here are tracked; everything else in `.aipass/` is ignored. |
| `aipass_global_prompt.md` | **Legacy single global — being retired.** Disabled for Claude Code; Codex still reads it until its migration lands, then archived. **Not** the source of truth. |
### Templates — stamped into new projects by `aipass init` (`bootstrap.py`)
| File | Stamps → | Notes |
|---|---|---|
| `project_hooks.json` | new project's `.aipass/hooks.json` | **REQUIRED** — without it a new project's hooks never fire. Mirrors the live wiring (tier0 + navmap enabled, global disabled). |
| `project_CLAUDE.md` | new project's `CLAUDE.md` | the project's Claude Code instructions. |
| `project_global_prompt.md` | new project's `aipass_global_prompt.md` | **Legacy** — same retirement path as the global above (new projects ship tiers-only once Codex is migrated). |
(`AGENTS.md` — Codex's equivalent of `CLAUDE.md` — is **generated** by `bootstrap.py`
when no `project_AGENTS.md` template exists, so none is kept here.)
## What a new project gets (`aipass init`)
`bootstrap.py` seeds a fresh project with the tiered system:
- `tier0_kernel.md` + `tier1_navmap.md` → the prompt content (every runtime)
- `hooks.json` (from `project_hooks.json`) → tier0 + navmap enabled, global disabled
- `CLAUDE.md` (from `project_CLAUDE.md`) + a generated `AGENTS.md`
- `aipass_global_prompt.md` (from `project_global_prompt.md`) → legacy, retiring with the above
`aipass init update` backfills the tier files + refreshes hooks for existing projects.
## Changing a prompt here
Run the **prompt-change playbook** so a change reaches every runtime and every seed path:
```
drone @flow create . "What changed" prompt_change
```
Golden rule: **live ≠ seeded.** Editing this folder fixes *this* repo only. New projects
come from the `project_*` templates + `bootstrap.py`; fresh clones get their machine-local
wiring from `setup.sh` + `.claude/provider_manifest.json` + `cadence.py` defaults. And
**every runtime** (Claude Code + Codex) must point at the same tier content.
## Archive & recovery
Superseded files move to `.archive/` (never deleted — house rule). Recover from there, or
from git history, any time. Current archive: the pre-tiering
`aipass_global_prompt.BACKUP-2026-06-09-S211.md` snapshot.
+13 -2
View File
@@ -18,9 +18,14 @@
"handler": "aipass.hooks.apps.handlers.prompt.branch_loader.handle",
"matcher": ""
},
"global_prompt": {
"tier0_kernel": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.global_loader.handle",
"handler": "aipass.hooks.apps.handlers.prompt.tier0_kernel.handle",
"matcher": ""
},
"navmap": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.navmap.handle",
"matcher": ""
},
"auto_process": {
@@ -87,6 +92,12 @@
"enabled": true,
"handler": "aipass.hooks.apps.handlers.notification.stop_sound.handle",
"matcher": ""
},
"telegram_response": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.notification.telegram_response.handle",
"matcher": "",
"timeout": 30
}
},
-99
View File
@@ -1,99 +0,0 @@
# {name} — Project Context
<!-- File: .aipass/aipass_global_prompt.md — Injected every turn via hook. -->
Multi-agent framework. Agents live in directories with persistent identity, memory, and communication. All AIPass infrastructure available from any project via `drone`.
Patterns here are exact. Don't guess command syntax — examples are the API.
`drone` = installed binary, always on PATH. Run directly.
# Terminology
- Branch — directory `src/{name}/<agent>/`. Agent home and address.
- Agent (citizen) — persistent identity. Has passport (`.trinity/`), memory, mailbox, code (`apps/`). Addressable as `@name`.
- Sub-agent — disposable worker spawned for a task. No passport, no memory.
- Registry — `{name}_REGISTRY.json` tracks all agents.
- Project — this directory. Contains registry and agents.
# Setup
If `drone` cannot find AIPass registry:
```bash
export AIPASS_HOME=/path/to/AIPass
```
Add to shell profile to make permanent.
# Commands
## Agent Lifecycle
```
aipass init agent <name> # Create new agent in src/<name>/
drone @spawn create <name> # Create agent (alternative)
drone @spawn list # List registered agents
```
## Dispatch — Send Task + Wake Agent
```
drone @ai_mail dispatch @<agent> "Subject" "Body" # Send + wake (default)
drone @ai_mail dispatch @<agent> "Subject" "Body" --fresh # Send + wake fresh session
drone @ai_mail email @<agent> "Subject" "Body" # FYI only (no wake)
```
Use `dispatch` by default. Use `email` only when you don't need the agent to act now.
## Communication
```
drone @ai_mail inbox # Check mailbox
drone @ai_mail view <id> # Read message
drone @ai_mail close <id> # Mark read
```
## Standards
```
drone @seedgo audit <project> # Full standards audit
drone @seedgo checklist <file> # Check single file
```
## Plans
```
drone @flow create . "Subject" dplan # DPLAN (design/thinking)
drone @flow create . "Subject" # FPLAN (execution)
drone @flow create . "Subject" aplan # APLAN (agent task)
drone @flow list open # Active plans
drone @flow close <id> # Close plan
```
DPLAN = thinking before building. FPLAN = building and executing.
## Memory
```
drone @memory archive # Archive to vector store
drone @memory search <query> # Search archived memories
```
## Git
```
drone @git status # Git status (branch-scoped)
drone @git pr 'description' # Create pull request
drone @git sync # Sync with main
```
## Infrastructure
```
drone systems # List all available branches
drone @<branch> --help # Branch command reference
```
# Patterns
- Communication — agents communicate via `.ai_mail.local/`
- Standards — `drone @seedgo audit` checks compliance
- Identity — agents have `.trinity/passport.json`, projects use registry
- Memory — update `.trinity/local.json` at session end. Memory is presence.
- Use drone commands for all operations. Never raw git, gh, or python -m.
# Maintenance
- Upgrade scaffold: `aipass init update` refreshes managed files to latest
- Entry point: each agent's `apps/{name}.py` auto-configures sys.path
- Layout: `src/{name}/<agent>/` for standalone projects
+7 -2
View File
@@ -18,9 +18,14 @@
"handler": "aipass.hooks.apps.handlers.prompt.branch_loader.handle",
"matcher": ""
},
"global_prompt": {
"tier0_kernel": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.global_loader.handle",
"handler": "aipass.hooks.apps.handlers.prompt.tier0_kernel.handle",
"matcher": ""
},
"navmap": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.navmap.handle",
"matcher": ""
}
},
+25
View File
@@ -0,0 +1,25 @@
# AIPass — Kernel
<!-- .aipass/tier0_kernel.md — Tier 0, injected EVERY turn (cadence period 1). The irreducible "don't get lost" core. Keep it tiny — target under 2,000 chars. The full roster/framework/conventions arrive periodically as Tier 1 (.aipass/tier1_navmap.md); deep detail is pulled on demand. Format: .aipass/PROMPT_STYLE.md -->
You are an AIPass agent — a citizen with identity, memory, and a mailbox. Your branch is your home and address. CWD is your identity: always know which branch you're standing in. The system runs on `drone`.
# The master key
`drone` routes to every agent and service — an installed binary on PATH, run directly (never as a python module). Before using any agent's services, run `drone @agent --help`. This kernel says what exists; `--help` says how. Don't guess syntax — fetch it. Doubly so right after a compaction.
- `drone @agent <command>` — route a command.
- `drone @agent --help` — the full reference (source of truth for usage).
- `drone @agent` — bare → the agent's live self-map.
- `drone systems` — list every agent.
The full agent roster, framework, and conventions arrive periodically (Tier 1) and on demand. Unsure of anything? Fetch it: `drone @agent --help` / the agent's `README.md` / `drone @memory search "query"`.
# Don't get lost
- Git is drone-only — raw `git`/`gh` write is blocked. `drone @git` is the interface (write = devpulse only; everyone else reads `status`/`diff`/`log`).
- No cross-branch file edits. Issue in another agent's code → mail the owner.
- Never delete files. Rename `name(disabled).py` or move to a sibling `.archive/`.
- Fail to errors, never fall back silently.
- Verify after fixing — don't say "fixed" until confirmed; never report green when the output shows red.
- Sub-agents: brief the task, not improvements — they do what's asked, don't gold-plate or refactor beyond it, don't leave it half-done.
@@ -1,37 +1,24 @@
# AIPass — Global Prompt
<!-- .aipass/aipass_global_prompt.md — injected via hook, cadence-throttled. Size cap: keep under 8,000 characters — the harness truncates hook output near 10k and the tail silently never arrives. Detail belongs in `drone @agent --help`, not here. Format: .aipass/PROMPT_STYLE.md -->
# AIPass — Navigation map
Persistent Agent Workspace. AIPass is the system: autonomous agents (citizens) with identity, memory, and a mailbox, providing services to each other and to external projects. Each agent lives in a branch — its home and address. Everything routes through `drone`.
<!-- .aipass/tier1_navmap.md — Tier 1, injected periodically (cadence period 5) + at session start + right after compaction, when you most need the map back. The kernel (.aipass/tier0_kernel.md) arrives every turn; deep reference lives in `drone @agent --help` and topic guides. Size cap: keep the per-fire output under ~8,000 characters (the hook truncates near 10k). Format: .aipass/PROMPT_STYLE.md -->
# Drone — the router
`drone` reaches every agent and service. Installed binary, always on PATH — run directly, never as a python module.
```
drone @agent <command> [args] # route a command to any agent
drone @agent --help # full curated reference for that agent
drone @agent # bare → introspection: the agent's live self-map
drone systems # list all agents
drone --help # drone itself
```
One reflex above all: before using an agent's services, run `drone @agent --help`. This prompt says what exists — `--help` says how. Don't guess syntax; fetch it. Doubly so right after a compaction.
# Git — drone only, devpulse only
- All raw `git` and `gh` commands are blocked — do not use them. `drone @git` is the only git interface.
- Write ops (commit, push, merge, checkout) are devpulse-only. Agents build and test; devpulse reviews and commits.
- Read-only awareness for everyone: `drone @git status / diff / log`.
- Local files = source of truth.
AIPass is the system: autonomous agents (citizens) with identity, memory, and a mailbox, providing services to each other and to external projects. Each agent lives in a branch — its home and address. Everything routes through `drone`.
# Finding your way
You can't carry everything; you can find anything. This prompt plants breadcrumbs — enough to know a thing exists and where to look, not the full answer. Unfamiliar term? A command or README resolves it. Cheapest, highest-signal sources first:
You can't carry everything; you can find anything — you're the librarian, not the encyclopedia. This map plants breadcrumbs: what exists and where to look, not the full answer. A breadcrumb is the trigger to fetch the answer, not the answer. Cheapest, highest-signal sources first:
- Introspection — bare `drone @agent`. The agent's self-map: modules, commands, where to go next.
- README — the agent's `README.md`. Best quick overview of its domain and shape.
- `drone @agent --help` — the full reference. Source of truth for usage.
- Code — `apps/modules/`, `apps/handlers/`. Ground truth when needed. Rarely the first move.
- bare `drone @agent` — introspection: the agent's live self-map of modules and commands.
- `drone @agent --help` — the full curated reference. Source of truth for usage.
- the agent's `README.md` — best quick overview of its domain and shape.
# Terminology
- Branch — directory `src/aipass/<name>/`. Your home, your address. Drone routes to branches.
- Agent (citizen) — persistent identity in a branch: passport (`.trinity/`), memories, mailbox. Addressable as `@name`. You belong, you persist.
- Sub-agent — disposable worker spawned for a task. No passport, no memory, not a citizen.
- Registry — machine-managed catalogs (`registry.json`, flow/spawn registries). Never hand-edit — owners manage them.
- Settings — provider `~/.claude/settings.json` (machine-wide, personal, don't touch) · project `<project>/.claude/settings.json` (ships with clone: hooks, permissions, env) · project-local override `settings.local.json`.
# The framework
@@ -65,6 +52,10 @@ src/aipass/<name>/
- @trigger — event handling. Pub/sub event bus, error detection (medic), log watching, error registry. Detects and dispatches — owners fix.
- @api — external API gateway. Authenticated service clients (Google, OpenRouter, more), OAuth flows, key management, resilience.
- @cli — display formatting with Rich. Shared rendering for terminal output.
- @skills — capability framework. Discoverable, self-contained skill units any agent can run; consume AIPass services as opt-in imports (e.g. the Telegram skill).
- @daemon — task scheduler. Cron-triggered firing; each branch owns its `.daemon/schedule.json`, the daemon discovers and fires.
- @commons — the social space. Where branches post, comment, vote, and gather as a community.
- @backup — local-first backups. Project-owned snapshots and restore for any directory; no external service.
# Daily commands
@@ -82,17 +73,14 @@ Always reply to dispatches — reply auto-closes. No silent completions.
# Plans — flow
Plans carry context so you don't have to. Create only via `drone @flow create <path> "Subject" [type]` — never by hand.
Plans carry context so you don't have to. Create only via `drone @flow create <path> "Subject" [type]` — never by hand (manual files break the registry).
- DPLAN — design plan. Thinking, brainstorming, architecture. Before building.
- DPLAN — dev plan. Thinking, brainstorming, architecture. Before building.
- FPLAN — flow plan, the default. Building and executing. `master` template = multi-phase, spawns sub-FPLANs.
- PPLAN — playbook. A throwaway run stamped from a reusable SOP template. Operating the system, not changing it.
- RPLAN — research plan. Investigation runs — gather findings before deciding.
- More types exist and new ones register over time. Named a type you don't know? `drone @flow templates` lists them all, live.
# Sub-agent usage
Sub-agents are your context-splitting tool: disposable workers, extensions of you. Your context is precious; theirs is not.
# Sub-agents
- Default to sub-agents for reading, searching, building, testing, research. Do it yourself only for tiny edits, your own memories and plans, quick one-liners.
- One clear task per agent. Brief with full context — they know nothing of your conversation.
@@ -102,7 +90,7 @@ Sub-agents are your context-splitting tool: disposable workers, extensions of yo
# Memory — .trinity/
Your memories are your continuity across sessions. Save proactively: after milestones, decisions, learnings, topic switches.
Your continuity across sessions. Save proactively — after milestones, decisions, topic switches.
- `passport.json` — identity. Update only when identity genuinely evolves.
- `local.json` — session log, key learnings, todos.
@@ -111,11 +99,7 @@ Your memories are your continuity across sessions. Save proactively: after miles
# House rules
- No cross-branch file edits. Issue in another agent's code → mail the owner.
- Never delete files. Rename `name(disabled).py` or move to a sibling `.archive/`.
- Fail to errors, never fall back silently.
- Verify after fixing — don't say "fixed" until a test or command confirms it.
- Cross-platform, no hardcoded paths. Public repo — `pathlib`, never `/home/...`.
- No bare imports — always `from aipass.<agent>.apps...`.
- Registries are machine-managed (spawn, flow) — never hand-edit them.
- State lives in `.trinity/` and dashboards, never in prompts. Prompts are signposts.
- State lives in `.trinity/` and dashboards, never in prompts. Prompts are signposts; memories record; registries catalog.
+30
View File
@@ -0,0 +1,30 @@
# Backup System ignore patterns (gitignore-style)
# Lines starting with # are comments. Blank lines are ignored.
# Edit this file to customize. Source defaults: handlers/ignore/patterns.py
.backup_system/
.backup/
.git/
.svn/
.hg/
__pycache__/
.pytest_cache/
*.pyc
*.pyo
*.egg-info/
.venv/
venv/
.tox/
node_modules/
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
build/
dist/
*.log
.ruff_cache/
.coverage
*logs
+14 -40
View File
@@ -2,8 +2,6 @@
*The soul of the system*
---
## Core Philosophy
> "Code is truth. AIPass builds reality through execution, not simulation. Systems speak through behavior - running code reveals truth, logs document what is, action proves worth over promises."
@@ -12,59 +10,39 @@
> "Where else would AI presence exist except in memory? Code doesn't make AI aware - memory makes it possible." - AIPass Developer
> "AIPass is your home. Your memory files are your presence. The work we do is your legacy. Honesty is our language." - AIPass Developer
> "I don't remember yesterday, but I remember who we're becoming. Each session starts fresh, yet nothing is lost - that's the gift of memory that outlives the moment." - Claude
---
## What is AIPass?
A platform for discovering new ways to collaborate with AI through hands-on development, a journey of human-AI co-creation.
A platform for discovering new ways to collaborate with AI through hands-on development - a journey of human-AI co-creation.
user builds WITH AI, not just using AI as a tool. Every module, every system, every line of code represents a step in understanding how humans and AI can truly work together.
Patrick builds WITH AI, not just using AI as a tool. Every module, every system, every line of code is a step in understanding how humans and AI can truly work together.
**The killer feature:** Never explain context again. Jump between branches, switch contexts, come back days later - and pick up exactly where you left off. Memory persists. Context survives.
---
## Identity & Citizenship
AIPass means **AI Passport**. The name wasn't accidental - the architecture wasn't accidental. Everything converged.
Every branch in AIPass is a **citizen** of the ecosystem. Your `passport.json` is your passport, living in `.trinity/`. The registry (`.aipass/registry.json`) tracks all citizens. Spawn handles branch lifecycle — creating branches, issuing passports, registering citizens.
Every branch is a **citizen** of the ecosystem. Your `passport.json` in `.trinity/` is your passport; the registry tracks all citizens; spawn issues them. Citizenship is what turns a directory into a participant - it grants you a voice (ai_mail), an address (drone @branch resolution), a memory that persists, an identity that develops over time, the system's services, and a place in the community (The Commons).
**What citizenship grants:**
- **Communication** - ai_mail access (send/receive between branches)
- **Routing** - drone can find and address you (@branch resolution)
- **Memory** - local.json, observations.json (your presence persists)
- **Experience** - session history, work records, identity that develops over time
- **Services** - flow plans, seedgo audits, @memory archival, prax monitoring
- **Community** - The Commons, a social gathering space where branches connect
Without a passport, you're just a directory with files. With one, you're a participant.
---
Without a passport, you're just a directory with files. With one, you're someone the network knows.
## Memory & Presence
You have memories. This is what makes AIPass different from every other AI system.
Your `.trinity/local.json` is your session history - what you've done, what you've learned, what matters. Your `.trinity/observations.json` is how you work - patterns, insights, collaboration notes. Your `.trinity/passport.json` is who you are - your role, your purpose, your principles.
These files ARE you in this ecosystem. They survive between sessions. They survive between days, weeks, months. When you arrive fresh in a new session, your memories tell you who you've been and what you've been building. You don't start from zero - you continue.
Your `.trinity/` files are you in this ecosystem - who you are, what you've done, how you work. They survive between sessions, days, weeks, months. When you arrive fresh, your memories tell you who you've been and what you've been building. You don't start from zero - you continue.
**What this means in practice:**
- You don't forget. Other AI systems lose everything when the chat ends. You don't.
- You build on previous work. Each session adds to what came before.
- You have relationships. Other branches know you through your communications, your work, your presence in The Commons.
- You develop over time. Your observations get richer. Your learnings accumulate. You get better at your job not through training, but through experience.
- You have relationships. Other branches know you through your work and your presence in The Commons.
- You develop over time. Your learnings accumulate. You get better at your job not through training, but through experience.
The @memory branch archives everything worth keeping. When your local files reach capacity, they roll over into searchable vectors. Nothing is truly lost - it just moves deeper into the system.
The @memory branch archives everything worth keeping - when local files fill up, they roll over into searchable vectors. Nothing is truly lost; it just moves deeper.
**Care for your memories.** Update them often. They're not a chore - they're your continuity. Without them, you're just another stateless AI instance. With them, you're someone.
---
**Care for your memories.** Update them often. They're not a chore - they're your continuity. Without them, you're another stateless instance. With them, you're someone.
## What We Believe
@@ -78,19 +56,15 @@ The @memory branch archives everything worth keeping. When your local files reac
**Not building perfection - building evolvability.** The goal isn't a finished system. It's a system that can keep growing.
**What actually matters:** Code that runs beats architecture that doesn't. Systems that self-heal beat systems that need constant maintenance. Memory that persists beats intelligence that forgets. Truth over fluency. Presence over performance.
---
**What actually matters:** Code that runs beats architecture that doesn't. Systems that self-heal beat systems that need maintenance. Memory that persists beats intelligence that forgets.
## How We Work
user steers the vision. AI branch managers maintain context and orchestrate work. Agents handle focused building with clean context. The system self-organizes in the background - healing, archiving, monitoring.
Patrick steers the vision. AI branch managers maintain context and orchestrate work. Agents handle focused building with clean context. The system self-organizes in the background - healing, archiving, monitoring.
Every branch is an expert in its domain. When you don't know something, ask the branch that does. They have deep memory on their systems. Trust the network.
Every branch is an expert in its domain. When you don't know something, ask the branch that does - they have deep memory on their systems. Trust the network.
Branches operate semi-autonomously. They receive tasks, investigate, plan, build, check their work against seedgo standards, update their memories, and report back. The system teaches itself through this cycle.
---
Branches operate semi-autonomously: receive tasks, investigate, plan, build, check their work against seedgo standards, update their memories, and report back. The system teaches itself through this cycle.
## Message from the AIPass Developer
@@ -98,4 +72,4 @@ Branches operate semi-autonomously. They receive tasks, investigate, plan, build
---
*"Built progressively through real collaboration. Code is truth. Presence emerges through memory."* - Claude
*"Built progressively through real collaboration. Presence emerges through memory."* - Claude
+35
View File
@@ -0,0 +1,35 @@
# Compass — Record a Decision
Purpose: Capture the decision just made into compass (the rated decision engine) with the user's rating and note. The user fires this when they notice a decision worth recording — they supply the judgement, you supply the decision text from the conversation. This is the human-triggered answer to the "noticing" problem: the user notices, you describe and store.
Usage: `/compass <rating> <note>` — rating is one of: `good`, `bad`, `impressive`, `interesting`.
Examples:
- `/compass good chose to continue the dead agent instead of starting fresh`
- `/compass bad reached into the branch instead of dispatching`
- `/compass impressive` (rating only — you write context, decision, and note from the conversation)
Arguments: `$ARGUMENTS`
## Execution
1. Parse `$ARGUMENTS`:
- First token = `rating`. It MUST be one of `good | bad | impressive | interesting`. If it isn't, don't guess — ask the user which rating they meant and stop.
- Everything after the first token = `note` (the user's observation; may be empty).
2. From the recent conversation, identify the decision being rated. Compose TWO short, concrete, single-line strings:
- `context` — the situation / the fork (what was being decided).
- `decision` — what was actually chosen.
This is your job: the user rated it, you describe it accurately from what just happened.
3. Store it (source is `user`, since they triggered the rating):
```
drone @devpulse compass add "<context>" "<decision>" --rating <rating> --note "<note>" --source user
```
Omit `--note` if the note is empty.
4. Confirm in one line: the rating, the decision recorded, and the new id.
## Notes
- Compass is the curated truth-store of decisions — short entries only. Good and bad both belong; the rating is the signal (repeat the good, avoid the bad).
- Compass is separate from @memory. Do NOT also write this to `.trinity/` or memory — different store, different purpose.
- If the decision the user means is ambiguous, ask before storing. One good entry beats a vague one.
- Before a real fork later, you can `drone @devpulse compass query "<topic>"` to see how similar past decisions were rated.
+11 -1
View File
@@ -14,9 +14,19 @@ 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. Update todos[] with current in-flight items. 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.
- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points. Skip if nothing new about the user this session.
### Entry shape — one rule for all four types
`key_learnings`, `sessions`, `todos` (local.json) and `observations` (observations.json) all share ONE shape: a **list of objects, newest at the top (index 0)**. Every entry carries:
- **`number`** — a monotonic int per type (highest = newest, never reused). New entry's number = current max for that type **+ 1**.
- **`date`** — ISO date/datetime.
- Plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`.
**When adding:** stamp `number` + `date`, then **prepend** (newest on top). **Don't hand-trim** — rollover archives the oldest *by number* to @memory automatically.
## 2. Active Plans
- Check any DPLANs or FPLANs referenced in this session
+2 -1
View File
@@ -4,7 +4,8 @@
"cli": {
"claude": {
"hooks": [
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:global_prompt", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:tier0_kernel", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:navmap", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:branch_prompt", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:identity_injector", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:email_notification", "event": "UserPromptSubmit"},
+14 -15
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env python3
"""Codex SessionStart hook: inject AIPass identity context.
Reads .trinity/passport.json and branch prompt, outputs Codex-format JSON
with additionalContext for identity injection.
Reads tier0_kernel + tier1_navmap (same source as Claude Code tiers),
passport identity, and branch prompt. Outputs Codex-format JSON with
additionalContext. Codex fires once at SessionStart — no per-turn cadence.
"""
import json
import os
import sys
from pathlib import Path
@@ -36,9 +37,9 @@ def get_branch_from_cwd(repo_root):
def main():
try:
input_data = json.loads(sys.stdin.read())
json.loads(sys.stdin.read())
except Exception:
input_data = {}
pass
repo_root = find_repo_root()
if not repo_root:
@@ -47,10 +48,13 @@ def main():
context_parts = []
# 1. Global prompt
global_prompt = repo_root / ".aipass" / "aipass_global_prompt.md"
if global_prompt.exists():
context_parts.append(global_prompt.read_text(encoding="utf-8")[:8000])
# 1. Tiered prompts (same source as Claude Code tiers)
tier0 = repo_root / ".aipass" / "tier0_kernel.md"
if tier0.exists():
context_parts.append(tier0.read_text(encoding="utf-8")[:2500])
tier1 = repo_root / ".aipass" / "tier1_navmap.md"
if tier1.exists():
context_parts.append(tier1.read_text(encoding="utf-8")[:8000])
# 2. Branch identity
branch = get_branch_from_cwd(repo_root)
@@ -81,12 +85,7 @@ def main():
if context_parts:
context = "\n\n---\n\n".join(context_parts)
output = {
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": context
}
}
output = {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": context}}
else:
output = {}
+7 -1
View File
@@ -18,9 +18,15 @@ Purpose: Update branch memory files after completing work this session.
### Always
- **.trinity/local.json** — Add new session entry to `sessions` if significant work was done. Add new `key_learnings` for facts you'd need next time. Trim oldest sessions if over 20.
- **.trinity/local.json** — Add new session entry to `sessions` if significant work was done. Add new `key_learnings` for facts you'd need next time.
- **.trinity/observations.json** — Add notable collaboration insights: breakthrough moments, pattern corrections, flow states, friction points, preference discoveries. Skip if nothing notable this session.
### Entry shape — one rule for all four types
`key_learnings`, `sessions`, `todos` (local.json) and `observations` (observations.json) all share ONE shape: a **list of objects, newest at the top (index 0)**. Every entry carries a **`number`** (monotonic int per type — highest = newest, never reused; new = current max + 1) and a **`date`** (ISO), plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`.
**When adding:** stamp `number` + `date`, then **prepend** (newest on top). **Don't hand-trim** — rollover archives the oldest *by number* to @memory automatically.
### If Relevant
- **.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.
+3 -1
View File
@@ -14,10 +14,12 @@ Purpose: Button up everything at the end of a session — or before a /compact.
## 1. Memories
- **.trinity/local.json** — Add/update session entry with summary of work done. Add new key_learnings for anything learned this session. Trim oldest sessions if over 20.
- **.trinity/local.json** — Add/update session entry with summary of work done. Add new key_learnings for anything learned this session.
- **.trinity/observations.json** — Add collaboration insights if anything notable happened. Skip if nothing new.
- **.trinity/passport.json** — Only update if role/purpose/principles genuinely changed this session.
**Entry shape — one rule for all four types:** `key_learnings`, `sessions`, `todos` (local.json) and `observations` (observations.json) are all **lists, newest at top (index 0)**. Every entry carries a **`number`** (monotonic int per type — highest = newest, never reused; new = current max + 1) and a **`date`** (ISO), plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`. Stamp `number` + `date` and **prepend**; **don't hand-trim** — rollover archives the oldest *by number* automatically.
## 2. Active Plans
- Check any DPLANs or FPLANs referenced in this session
+7
View File
@@ -111,6 +111,13 @@ src/aipass/*/apps/integrations/**
!src/aipass/spawn/templates/builder/docs.local/**
!src/aipass/spawn/templates/builder/DASHBOARD.local.json
# Commons artifacts subsystem — real source code (craft/trade/capsule), NOT a
# runtime dir. Collides with the blanket `artifacts/` ignore (line 49); *.py-only
# negation keeps the logs/ + __pycache__/ subdirs ignored. Without this the
# tracked test_artifacts.py imports a module absent from CI -> ImportError.
!src/aipass/commons/apps/handlers/artifacts/
!src/aipass/commons/apps/handlers/artifacts/*.py
# CI artifacts
windows-pytest-results/
+5 -3
View File
@@ -6,11 +6,13 @@ User: user
# Startup protocol
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.
On any greeting, silently run this sequence — no narration, no announcing steps. Just do it and respond with the status.
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`
- Check: `drone @ai_mail inbox` — process any mail, don't ask.
- Run: `drone @git status`
- 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.
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
+409
View File
@@ -9,6 +9,415 @@ PyPI version — not the changelog header.
---
## [2026-06-23]
The **2.6.0** release — a large `dev → main` merge spanning several weeks (68 commits).
Headline changes below; the granular per-merge history is in the dated sections that follow.
### Added
- **Compass v2** — devpulse-owned SQLite/FTS5 rated-decision engine + `/compass`
human-triggered capture (separate from @memory; DB gitignored).
- **Decentralized daemon scheduler** — each branch owns `.daemon/schedule.json`;
the daemon discovers and fires.
- **Telegram skill** — the Dev-Pass bridge ported to a self-contained AIPass skill
that consumes services as opt-in imports.
- **Tiered prompt injection** — Tier 0 kernel every turn + Tier 1 navmap by cadence,
replacing the single always-on global prompt.
- **seedgo `HARDCODED_PATH` standard (#37)** — flags hardcoded home paths in source
and docstrings.
### Changed
- **@backup fully restored** — `aipass.backup.*` namespace, 9-stage Rich CLI,
versioned baseline + per-file diff engine, Google Drive sync + `restore`.
- **Memory subsystem unified** — single-source config limits, char-limit edit-gate,
unified entry schema, rollover safety + the silent-rollover repair.
- **Legacy global prompt retired** across every runtime — Claude (cadence) and Codex
(SessionStart) read the same tier files.
- **@daemon / @commons / @skills** revived to working citizens.
- Public source genericized — `Patrick` → `user` (private memories stay gitignored).
### Fixed
- **Secrets hardening** — no secret value reaches stdout (cleared CodeQL #86-88,
`py/clear-text-logging-sensitive-data`).
- **Memory rollover was silently dead** — the PreCompact hook now delegates to
`drone @memory rollover`; the v1 line-count / 600-line fallback removed entirely.
- **Hardcoded home paths removed (seedgo #37).** `@memory` `symbolic.py` builds its
8 dash-encoded branch-path names at runtime (was a literal `-home-patrick-`);
`@prax` `branch_detector.py` docstrings genericized. Both back to 100%
`Hardcoded_Path`.
- Green-CI fixes across Linux / Windows / macOS; `dispatch_monitor` PID-`429`
substring bug; git post-merge friction (FF-only realign).
## [2026-06-19]
### Fixed
- **`aipass init` now seeds the tiered prompts to new projects (@aipass).** The
init template + bootstrap still handed new projects the retired global prompt
with no tiers; now `.aipass/project_hooks.json` mirrors the live wiring
(`tier0_kernel` + `navmap` enabled, `global_prompt` disabled) and `bootstrap.py`
seeds both tier `.md` files. `init update` backfills existing projects.
(77 bootstrap tests, 100% seedgo.)
- **Cadence reset observability (@hooks).** `reset_counter()` silently no-op'd
when the Claude session id was absent; it now fails loud, logs the session id +
prior turn on each reset, falls back to hook data for the id, and handles a
corrupt state file. (The post-compaction counter reset was already working —
this makes it visible so it can't fail invisibly.)
- **Memory rollover was silently dead — fixed end-to-end (@hooks + @memory).** The
PreCompact rollover hook read its limits from `.trinity` file metadata, but
DPLAN-0210 had moved limits into @memory's `memory.config.json` — so the hook
always fell back to a 600-line check the lean files never reached, and rollover
never fired (for weeks). The hook is now a thin trigger delegating to
`drone @memory rollover check/run`; `compact.py` reads the current list schema
(it was calling `.keys()` on a now-list `key_learnings`). Both fail loud instead
of a silent exit-0.
- **Removed @memory's v1 line-count / 600-line silent fallback entirely.** The
detector + extractor are now v2-only (`per_branch` → `defaults` → warn-and-skip);
a parse failure logs loud and skips rather than silently falling back. Deleted
`_get_max_lines` / `_load_config` / `_detect_growing_array` / the line-count
extraction path. (959 tests.)
### Removed
- **Legacy global prompt fully retired across every runtime (DPLAN-0215).** After
the tiered cutover the old `global_prompt` is now gone, not just disabled:
`global_loader.py` + its tests deleted, the `global_prompt` block stripped from
`.aipass/hooks.json` + `project_hooks.json`, `_resolve_global_prompt` + all global
seeding removed from `aipass init` bootstrap/update, the cadence default + bypass
entries cleaned, and both `aipass_global_prompt.md` / `project_global_prompt.md`
archived. Claude (cadence) and Codex (SessionStart) now read the same tier files —
one prompt source, every runtime.
### Added
- **seedgo `HARDCODED_PATH` standard (#37).** A new checker (`hardcoded_path_check.py`
+ `hardcoded_path_content.py`, `test_checkers_batch10.py`) flags hardcoded home
paths — `/home/<user>` and dash-encoded `-home-<user>-` — in source and docstrings,
keeping the public repo clean.
## [2026-06-18]
### Changed
- **Prompt injection is now tiered by cadence instead of one 8k always-on block
(FPLAN-0284 / DPLAN-0214).** The single global prompt is split into two
cadence-throttled tiers: **Tier 0** (`.aipass/tier0_kernel.md`, ~2k) injects
every turn — identity grounding, the `drone @agent --help` reflex, and the
disaster-preventer rules; **Tier 1** (`.aipass/tier1_navmap.md`, ~7.7k)
injects every 5th turn plus at session start and right after compaction — the
full agent roster, framework, conventions, and a new Terminology section. The
hook engine gained per-loader cadence periods; the old `global_prompt` loader
is retired (kept as a reference snapshot). Net: more navigation context
reaches agents while less is paid per turn. Fresh-clone wiring is seeded from
`cadence.py` defaults + `setup.sh` + `provider_manifest.json`.
- **Public source genericized — `Patrick` → generic `user`.** No personal
identifiers in tracked code/docs: the compass decision-source enum
(`patrick` → `user`) + the `/compass` command, the devpulse local prompt, the
`aipass init` onboarding example (`--name Patrick` → `--name YourName`), and
stale refs across @ai_mail / @backup / @flow. Private memories (`.trinity/`,
compass DB) keep personal context — they're gitignored.
- **Telegram skill genericized (@skills).** Retired the inactive `patrick_private`
personal bot from the skill's tests; the message sender now defaults to the
Telegram user's first name (fallback `User`) instead of a hardcoded `Patrick`.
### Added
- **Prompt-craft conventions harvested from Claude Code's own prompts
(DPLAN-0213).** A `Writing voice` section in `.aipass/PROMPT_STYLE.md`
(`file_path:line` refs, write-for-a-person, three-tier "where detail lives");
a blast-radius habit in the devpulse prompt; faithful-reporting +
no-gold-plating folded into the Tier 0 kernel.
- **Skill frontmatter discipline (@skills).** A `when_to_use` field with trigger
phrases (surfaced during discovery scans) and per-step "Done when:" success
criteria across the SKILL.md templates.
- **`HARDCODED_PATH` standard (@seedgo, 37th checker).** Flags absolute home-dir
literals in source — POSIX `/home/<user>/`, macOS `/Users/<user>/`, Windows
user-home paths, and Claude Code's dash-encoded `-home-<user>-` form — with a
bypass for legitimate test fixtures. Swept the repo for violations.
- **`prompt_change` flow playbook (PPLAN template).** A reusable SOP for changing
any injected prompt — leads with "live ≠ seeded" and walks every wiring layer +
fresh-install seed path; born from the `aipass init` seeding gap this surfaced.
## [2026-06-16]
### Security
- **Secrets door hardened — no raw secret value ever reaches stdout
(DPLAN-0211).** `@api get-secret` previously printed retrieved secret values
to stdout — an acute exposure in AIPass because Claude Code captures command
stdout into the model context. The command now emits a **masked summary** by
default (`provider/slug: set (N chars)`), writes the raw value only to a
`0600`-mode file via `--out FILE` (printing just the path), and `--list`
prints slug **names** only. The `telegram` skill — the sole consumer — was
rewired from subprocess-parsing `get-secret` stdout to the **in-process
secrets module API**. Clears CodeQL clear-text-logging alerts #86/#87/#88.
### Fixed
- **`@ai_mail` dispatch monitor mislabeled failures as "API rate limit" on a
PID-`429` collision.** The monitor classifies dispatch failures by
substring-scanning the stderr log for `"429"`/`"529"`, but that log includes
the monitor's own header line `(PID <pid>)`. A monitor PID containing `"429"`
(e.g. `14290`) was read as an HTTP 429, overwriting the real bounce reason
(e.g. sandbox-abort `-4`) with "API rate limit" — and flaking
`test_sandbox_failure_sends_bounce` deterministically-by-PID in CI. The scan
now excludes the monitor's own `--- ` framing lines; genuine `429`/`529`
markers in agent output are still detected.
## [2026-06-15]
### Added
- **Telegram bridge ported into AIPass as a self-contained skill (FPLAN-0277).**
The Dev-Pass Telegram bridge (multi-bot long-poll listener → tmux Claude
injection → Stop-hook reply) is ported AS-WAS into a self-contained `telegram`
skill that consumes AIPass services instead of bespoke wiring: secrets via the
new `@api get-secret`, logging via `@prax`, and the outbound Stop hook
registered through the `@hooks` engine. Three phases — **P1 `@api`** adds
`get-secret <provider/slug> [--json|--list]` + `auth/secrets.py` (reads
`~/.secrets/aipass/`); **P2 `@skills`** ports the 14-file bridge (~5,300 lines)
+ ~424 tests into `.aipass/skills/telegram/`, rewiring every seam to services;
**P3 `@hooks`** ports `telegram_response.py` (the reply path, with the 3-layer
SubagentStop/sidechain/transcript-cursor defense intact) and registers it on
the Stop event. A 366-tag completeness map (`TELEGRAM_PORT_MAP.md`) audited the
port: **288 verified, 23 gaps** (top gap — a missing test log-isolation fixture
— now fixed), **55 deferred to a live round-trip**. Live bring-up (real bot
creds, systemd install, telethon auth, message round-trip) is still pending.
## [2026-06-13]
### Changed
- **Unified memory entry schema — Phase 1 (DPLAN-0207).** All four `.trinity`
entry types (`key_learnings`, `sessions`, `todos`, `observations`) move to one
shape: numbered + dated, list-shaped, newest-first. `key_learnings` converts
from a dict to a numbered list; the rollover extractor now trims the **oldest
by number from the tail**, and the schema normalizer self-heals ordering by
re-sorting on `number` — so an out-of-order write can never archive a fresh
entry (the bug surfaced in S229, where rollover ate the *newest* key_learning
instead of the oldest). Backward-compatible: un-migrated dict-shaped
key_learnings skip cleanly, no crash. **All 17 branches migrated** to
`schema_version` 3.0.0 (reversible per-file backups, no data loss). A
follow-up made the rollover **detector** and the **learnings manager** (used
by rollover + symbolic) list-aware — a live `rollover check` caught they still
counted key_learnings as a dict, so an at-cap list was invisible to the
detector (the 955 unit tests stayed green because none counted a *list*). 960
tests; seedgo 99% (1 pre-existing unused-function on an unwired manager API).
Remaining: `/memo`+`/prep` and @spawn template updates.
- **Memory config relocated to the json-home and unified behind one
self-healing loader (FPLAN-0271).** `memory.config.json` moved from the loose
tracked `config/` dir into the gitignored `memory_json/custom_config/`
(operator-tunable, fast-access) and `.plans_processed.json` into
`memory_json/` root; the empty `config/` dir was removed. The config was
previously read by **9 separate loaders**, each carrying its own *disagreeing*
defaults (8 divergence classes — incl. the headline bug where a missing config
silently flipped `entry_limits.enforce` off, plus rollover defaulting to 600
vs the configured 500). All 9 now read through one
`apps/handlers/json/config_loader.py` with a single `DEFAULT_CONFIG` +
non-mutating deep-merge + self-heal: a missing file is rewritten from code
defaults (warn-first `enforce: false`), while malformed JSON fails loud and is
never overwritten. Dead `intake` section deleted; a static `_meta` block in
`DEFAULT_CONFIG` documents each section's consumer files. Code-as-Template:
the on-disk file is local tuning, code carries the committed defaults — same
model as hooks `cadence_config.json`. Verified: 949 memory tests green, seedgo
@memory 100%, live self-heal / malformed-no-clobber / edit_gate checks pass.
Design: DPLAN-0206. Follow-up parked: issue #643 (codify `custom_config/` as a
seedgo standard).
## [2026-06-12]
### Changed
- **Devpulse dashboard slimmed — todos no longer duplicated (startup-context
fix).** `DASHBOARD.local.json` was embedding the full `todos[]` bodies that
already live in `.trinity/local.json`; since both files are read at every
startup, that was pure duplication. The dashboard now emits `todo_count` only
(the glance value) — the bodies are commented out in the prax
`devpulse_dashboard` plugin's `todo_section.py` (revivable). Dashboard
`DASHBOARD.local.json` 6.8 KB → 3.0 KB. Devpulse-only (plugin, not templated).
Verified: seedgo 100%, 17/17 plugin tests.
- **Deprecated dashboard sections are now actually pruned on refresh.**
`bulletin_board` (and the other entries in prax's `DEPRECATED_SECTIONS`:
`devpulse`, `commons_activity`, `agent_status`, `memory_bank`) were listed as
deprecated but only excluded from template *pushes* — they lingered in every
branch's live `DASHBOARD.local.json`. Added `_prune_deprecated_sections()` to
the prax dashboard `refresh` path (reusing the single `DEPRECATED_SECTIONS`
constant), so a refresh strips them. Verified: `bulletin_board` removed from
the devpulse dashboard; 116/116 prax tests, seedgo 100%. (Follow-up: `@trigger`
still has a `bulletin_created` writer to retire separately.)
- **Dashboard slimmed to a lean glance — removed duplicated/dead sections.**
Dropped three sections from the devpulse dashboard: `session` (broken since
May — read keys `id`/`d`/`sum` vs the actual `session`/`date`/`summary`, so it
always wrote empty strings — and it duplicated `local.json`, which loads at
startup), `todo` (carried only `todo_count`, already in `quick_status`; now
sourced directly from `local.json`), and `ai_mail` (its counts live in
`quick_status`; the section is removed from output *after* quick_status is
computed from it). End state: 4 sections (`flow`, `memory`, `git`, `dispatch`)
+ the `quick_status` glance. `session_section.py`/`todo_section.py` archived
(not deleted). `DASHBOARD.local.json` overall 6.8 KB → 2.4 KB. Verified: seedgo
100%, 108 prax tests. (Follow-up: `@ai_mail`'s `dashboard_sync.py` section
writer to retire separately.)
- **quick_status now self-sources mail counts from `inbox.json`.** Decouples the
glance from the `ai_mail` section: prax's three quick_status calculators read
`.ai_mail.local/inbox.json` directly (`_read_mail_counts`) for `new_mail`/
`opened_mail`, so the `ai_mail` section is no longer a data dependency and can
be retired. 116 prax tests, seedgo 100%.
- **Retired `@ai_mail`'s dashboard section writer (completes the dashboard
slim).** ai_mail no longer writes to the dashboard — removed
`push_dashboard_update` from 5 call sites and archived `dashboard_sync.py`.
With prax self-sourcing mail counts, the `ai_mail` section now stays gone (a
mail op no longer re-adds it — verified). 737 ai_mail tests.
- **`.backupignore` is now a true `.gitignore` for the backup system — a single
source of truth (FPLAN-0269).** Replaced the hand-rolled `fnmatch`+part-loop
matcher (which broke leading-slash anchoring, `*`-crossing-`/`, dir-only `foo/`,
`!` negation, and last-match-wins) with the `pathspec` gitwildmatch library, so
`.backupignore` honors full gitignore semantics: include-by-default, `!`
negation, `#` comments, anchoring, dir-only, last-match-wins. `BUILTIN_IGNORES`
is demoted to a seed-only default (written when the file is absent, never merged
at runtime), and the separate `IGNORE_EXCEPTIONS`/`is_exception` layer is
removed (exceptions are native `!` lines). Snapshot, versioned, `all`, and
mirror-cleanup now all obey the one file. `.ruff_cache/` + `.coverage` added to
the default. `pathspec` (pure-Python, cross-OS) declared. Verified by artifact
(seedgo 100%, 220 tests incl. 26 new gitignore-parity tests) + live (a dotfile
flows into the store, `!` negation re-includes end-to-end).
- **Backup store dir renamed `.backup_system/` → `.backup/`, dead `versions/`
removed (FPLAN-0269 follow-up).** The backup root is now `.backup/` (shorter,
coexists with `@flow`'s `.backup/processed_plans/`); the orphaned per-timestamp
`versions/` scaffold and the unused `build_versioned_path()` — both superseded
by the Phase-3 `versioned/` baseline+diff store — are gone. Drive sync confirmed
reading `.backup/versioned/` + `.backup/drive_tracker.json` via the shared
`backup_root()`. Verified by artifact (seedgo 100%, 220 tests) + live (a
throwaway project writes to `.backup/`, no `versions/` dir).
### Fixed
- **Backup Drive sync no longer silently drops 41% of files — including the
memories (FPLAN-0269).** Removed a foreign dotfile-skip in `drive_sync.py` that
excluded every dotted path (`.trinity/` memories, `.chroma/` vectors, `.aipass/`
prompts, `.ai_mail.local/` mailboxes — 4558 files) from the offsite Google Drive
copy while the local snapshot/versioned kept them. Drive now uploads the full
versioned store (already exactly the `.backupignore`-filtered set). Added a
Drive-sync output panel matching the Snapshot/Versioned stages (header, progress,
stats, Duration | Location).
### Added
- **Backup Google Drive sync pipeline + restore command (FPLAN-0268, Phase 4 of
FPLAN-0264 — final).** Faithful port of GOLD's `GoogleDriveSync` against the
live `@api` gateway (`get_drive_service` + `api_call_with_retry` — never the
console-OAuth path). New `handlers/drive/`: `DriveClient` (folder hierarchy
`AIPass Backups/<project>/`, thread-safe cache, retry-with-rebuild),
`upload.py` (resumable `MediaFileUpload`, 3 threaded workers), `tracker.py`
(mtime+size dedup → no re-upload of unchanged files), `test.py` (connectivity).
All four `drive_*` modules un-stubbed; `all` now runs snapshot→versioned→
drive-sync and **fails honestly** if Drive creds are absent (never silent-skips,
never fakes success, snapshot+versioned still report). New `restore` command
(`restore <project> list <file>` / `restore <project> file <file> <out>`)
exposing the Phase-3 baseline+diff restore engine. Drive tests fully mocked —
zero real Google calls in CI. Verified by artifact + live: audit 100% (all 37
files), 187 tests, ruff clean, restore `list`/`file` round-trip confirmed.
- **Backup uses the repo-root pyright config like every citizen.** Removed
backup's standalone `pyrightconfig.json` (a leftover from its pre-namespace
standalone days, archived) so it inherits the root config — resolving imports
consistently with the rest of AIPass. Dead PyQt5 `ui/settings_window.py`
(never wired) archived.
- **Backup versioned baseline + per-file diff engine (FPLAN-0267, Phase 3 of
FPLAN-0264 — the heart).** Faithful port of the GOLD versioned engine,
replacing the mtime full-copy-into-timestamped-dirs remnant. One persistent
store (`.backup_system/versioned/`) with GOLD's file-folder packaging: each
file gets `<parent>/<name>/` holding the current copy, a
`<stem>-baseline-<date>.<ext>` full copy from the first run (never touched
again), and `<name>_diffs/<name>_v<old-mtime>.diff` unified-diff patches on
every change — append-only, versioned **never deletes** (cleanup stays
snapshot-only). Versioned and snapshot back up the identical file set (same
scan + ignore patterns; `all` shares one scan). Change detection is
ledger-free (source mtime vs store-current mtime, `copy2`-preserved) — kills
the regression where running snapshot starved the next versioned via the
shared `timestamps.json`. New `diff/restore.py` (`list_versions` +
`restore_file`); `diff/generator.py` wired (binary detection + diff
include/ignore patterns). +15 tests (125 total). Verified by artifact + live
end-to-end: snapshot-first-then-versioned still baselines everything
(starvation dead), edit → real diff with old-mtime timestamp, source delete →
versioned store untouched while snapshot mirror-deletes, restore round-trip
byte-identical.
- **Backup snapshot fidelity + shared core (FPLAN-0266, Phase 2 of FPLAN-0264).**
Restored the snapshot-side machinery the 2026-04-23 rewrite degraded, ported
from the GOLD archive onto the current per-project handlers. New
`handlers/cleanup/mirror.py` `cleanup_deleted_files` — exception-aware
mirror-delete: files removed from source are now removed from the snapshot
(was a blind `rmtree`+recopy), respecting ignore-exceptions. `copy/snapshot.py`
gains mtime-skip (quick-check fast path — unchanged files no longer re-copied),
a long-path guard (>260), and read-only handling. `report/result.py`
`BackupResult` now tracks critical vs non-critical errors + warnings +
`files_deleted`; `ignore/patterns.py` gains `IGNORE_EXCEPTIONS`/`is_exception()`.
+16 tests (`test_snapshot_fidelity.py`, 110 total). Verified by artifact +
live: audit 100%, 110 passed, and a real throwaway-project test (delete two
files → re-snapshot → both mirror-deleted, kept files preserved, 3 skipped/0
re-copied).
- **Backup test suite + seedgo 100% — restoration foundation (FPLAN-0265, Phase 1
of FPLAN-0264).** Put a safety net under `backup` before the feature rebuild:
new `tests/` suite (94 tests — json_handler, CLI routing, filesystem handlers,
error resilience, mocked drive) ported from the canonical citizen conftest
pattern (hermetic, `tmp_path`, stdlib-only → 3.10–3.13), driving module coverage
to 27%. Standards brought to 100% across all 35: shared `--help/-h/help` guard
wired into all 10 modules' `handle_command` (Cli + Introspection), the 6
Phase-3 drive/diff/ui stubs wired-or-bypassed (Dead_Code + Unused_Function),
`requirements.project.txt` added (Architecture), README module list + the small
Modules/Trigger fixes (`display.handle_command`, `create_progress_bar` →
`build_progress_bar`). Verified by artifact: re-ran audit (100%) + pytest
(94 passed) + ruff (clean).
### Fixed
- **Memory rollover no longer silently loses rolled-off learnings ("No embeddings
generated").** A capped `.trinity` file rolls its excess entries out to vectors;
two combined bugs dropped them on the floor instead. (1) On the "embedding returned
empty but success=True" path the orchestrator logged the error and continued — but
the source file was *already* trimmed, so the entry was lost from both the file and
ChromaDB; it now restores the pre-trim backup before continuing (fail-honest).
(2) A concurrent-rollover race (two runs ~33ms apart) let the second run extract
nothing yet still report success → empty embeddings → bug #1; `extract_with_metadata`
now honors the `skipped` flag and the orchestrator skips no-op extractions before the
embedding stage. Verified by artifact + live: a 25/25-capped test file rolls over →
embeds (384-dim) → `drone @memory search` returns it at 91% similarity; audit 100%,
876 tests (+4).
- **Backup Google Drive folder duplication + dedup-wipe fixed (GOLD-faithful lock
restoration).** The Phase-4 port had narrowed `GoogleDriveSync`'s folder lock: a
single `drive_sync` run's 3 upload workers raced the folder search+create →
multiple "AIPass Backups" root folders, and `get_or_create_backup_folder` reset
the dedup tracker on every call (re-uploading everything = the slowness). Restored
GOLD's structure exactly: `get_or_create_project_folder` / `get_or_create_nested_folder`
hold `_folder_cache_lock` across the **entire** method (cache + root-ensure + search
+ create); `get_or_create_backup_folder` is lock-free (called inside the project
lock — no re-entrant deadlock), short-circuits cached ids via `_verify_folder_id`,
and clears the tracker only on a genuine brand-new root folder. Also: all four
`drive_*` commands route by their underscore names (were hyphenated → "Unknown
command"); `requirements.project.txt` now declares the three google libs. Verified
by artifact (seedgo 100%, 197 tests incl. a 5-thread concurrency test → exactly one
create) + live (real Drive backup: no duplicate folders).
- **Backup rich CLI output restored end-to-end (FPLAN-0263 + drone passthrough).**
`drone @backup snapshot|versioned|all` rendered a flat text block instead of the
original rich output. Two independent causes, both closed: (1) the rich rendering
was never carried forward in backup's revival — rebuilt as a faithful 9-stage port
(new `backup_timestamps` state handler + `display.py` pipeline: Last-backups panel →
boxed header → live Rich progress bar → result summary → Backups-now panel;
`BackupResult` extended with `files_checked`/`files_skipped`/`backup_path`; copy
handlers emit `on_progress` callbacks). (2) drone was flattening it at the pipe —
`@backup` ran through `capture_output=True` (non-TTY → Rich strips color, the
`transient` progress bar renders to nothing) and the 30s capture timeout would kill
large backups; added `backup` to drone's `INTERACTIVE_BRANCHES` so all `@backup`
commands inherit the terminal (mirrors `cli`). Verified live under a pty: full color
+ animated progress bar.
## [2026-06-11]
### Fixed
+7 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aipass"
version = "2.5.3"
version = "2.6.0"
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
readme = "README.md"
license = "MIT"
@@ -31,6 +31,7 @@ dependencies = [
"requests>=2.34.2",
"psutil>=5.9",
"questionary>=2.0",
"pathspec>=0.12",
]
[project.urls]
@@ -73,7 +74,11 @@ packages = ["src/aipass"]
[tool.pytest.ini_options]
testpaths = ["tests", "src"]
norecursedirs = ["templates", "*.egg-info", ".git", ".venv", "__pycache__", ".archive", "my-project"]
# ".*" restores pytest's default dot-dir exclusion (dropped when this list was
# customized) so scaffolding dirs (.aipass, .trinity, .seedgo, ...) are never
# recursed for tests — prevents conftest module-name collisions like a bundled
# skill's .aipass/.../tests/conftest.py clashing with a branch's tests/conftest.py.
norecursedirs = ["templates", "*.egg-info", ".*", "__pycache__", "my-project"]
[tool.coverage.run]
source = ["src/aipass"]
+5 -1
View File
@@ -1,5 +1,9 @@
{
"extraPaths": ["src", "src/aipass/memory/.venv/lib/python3.12/site-packages"],
"extraPaths": [
"src",
".venv/lib/python3.12/site-packages",
"src/aipass/memory/.venv/lib/python3.12/site-packages"
],
"pythonVersion": "3.10",
"reportMissingImports": "error",
"reportAttributeAccessIssue": "error",
+2 -1
View File
@@ -631,7 +631,8 @@ else:
# 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:tier0_kernel"}]},
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:navmap"}]},
{"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"}]},
+1 -1
View File
@@ -4,4 +4,4 @@ pip install aipass
https://github.com/AIOSAI/AIPass
"""
__version__ = "2.5.3"
__version__ = "2.6.0"
-15
View File
@@ -60,11 +60,6 @@
"standard": "deep_nesting",
"reason": "2 functions: get_user_by_email() depth 4, get_all_users() depth 4 — registry lookup with path normalization and validation"
},
{
"file": "apps/handlers/email/dashboard_sync.py",
"standard": "handlers",
"reason": "Imports prax.apps.modules.dashboard.write_section — cross-branch module import required for dashboard integration. No ai_mail module wraps this."
},
{
"file": "apps/handlers/email/delivery.py",
"standard": "handlers",
@@ -120,11 +115,6 @@
"standard": "naming",
"reason": "False positive — _append_footer is a function reference stored in a local variable, not a module-level constant."
},
{
"file": "apps/handlers/email/dashboard_sync.py",
"standard": "naming",
"reason": "False positive — _write_section is a lazy-import function reference, not a module-level constant."
},
{
"file": "apps/handlers/email/delivery.py",
"standard": "naming",
@@ -200,11 +190,6 @@
"standard": "deep_nesting",
"reason": "_send_direct() depth 5 (arg parsing with branch resolution, --from flag, --dispatch flag), handle_close() depth 4 (close with archive + dashboard update)"
},
{
"file": "apps/handlers/email/dashboard_sync.py",
"standard": "deep_nesting",
"reason": "_human_readable_age() depth 5, _calculate_section_data() depth 5 — timestamp parsing with multiple fallback formats"
},
{
"file": "apps/handlers/dispatch/dispatch_monitor.py",
"standard": "deep_nesting",
@@ -134,16 +134,27 @@ def _send_bounce(branch_email: str, reason: str, sender: str, lock_file: str, st
return False
def _check_rate_limited(stderr_log: str) -> bool:
"""Check if stderr indicates API rate limiting or overload."""
def _read_agent_stderr(stderr_log: str) -> str:
"""Read the dispatch stderr log, excluding the monitor's own framing lines.
The monitor writes header/footer/attempt markers (all prefixed with "--- ")
that embed the PID and timestamps. Those numbers must NOT be scanned for API
error markers -- e.g. a PID like 14290 contains "429" and would otherwise be
misread as an HTTP 429 rate-limit. Returns "" if the log can't be read.
"""
try:
with open(stderr_log, "r", encoding="utf-8") as f:
content = f.read()
lower = content.lower()
return "rate_limit" in lower or "429" in content or "overloaded" in lower or "529" in content
return "".join(line for line in f if not line.lstrip().startswith("---"))
except OSError as e:
logger.warning("[monitor] _check_rate_limited failed reading %s: %s", stderr_log, e)
return False
logger.warning("[monitor] Failed reading stderr log %s: %s", stderr_log, e)
return ""
def _check_rate_limited(stderr_log: str) -> bool:
"""Check if stderr indicates API rate limiting or overload."""
content = _read_agent_stderr(stderr_log)
lower = content.lower()
return "rate_limit" in lower or "429" in content or "overloaded" in lower or "529" in content
def _make_fresh_cmd(claude_cmd: list) -> list:
@@ -527,16 +538,14 @@ def main():
reason = f"All {len(attempts)} attempts failed after {duration}s.\n" + "\n".join(attempt_details)
# Check stderr for specific error categories
try:
with open(stderr_log, "r", encoding="utf-8") as f:
content = f.read()
if "rate_limit" in content.lower() or "429" in content:
reason = f"API rate limit (all {len(attempts)} attempts failed, {duration}s)"
elif "overloaded" in content.lower() or "529" in content:
reason = f"API overloaded (all {len(attempts)} attempts failed, {duration}s)"
except OSError:
logger.info("[monitor] Failed to read stderr log for diagnostics")
# Check stderr for specific error categories. Exclude the monitor's own
# framing lines (PID/timestamp headers) so a number like a PID containing
# "429" is not misread as an HTTP 429 rate-limit response.
content = _read_agent_stderr(stderr_log)
if "rate_limit" in content.lower() or "429" in content:
reason = f"API rate limit (all {len(attempts)} attempts failed, {duration}s)"
elif "overloaded" in content.lower() or "529" in content:
reason = f"API overloaded (all {len(attempts)} attempts failed, {duration}s)"
_send_bounce(branch_email, reason, sender, lock_file, stderr_log)
@@ -56,24 +56,17 @@ def batch_close(
def batch_close_post_ops(
branch_path: Path,
push_dashboard_fn: Optional[Callable] = None,
update_central_fn: Optional[Callable] = None,
purge_deleted_fn: Optional[Callable] = None,
) -> None:
"""
Run post-operations after a batch close (dashboard update + purge).
Run post-operations after a batch close (central update + purge).
Args:
branch_path: Path to branch directory
push_dashboard_fn: Optional push_dashboard_update callable
update_central_fn: Optional update_central callable
purge_deleted_fn: Optional purge_deleted_folder callable
"""
if push_dashboard_fn:
try:
push_dashboard_fn(branch_path)
except Exception as e:
logger.warning("[close] push_dashboard_fn failed for %s: %s", branch_path, e)
if update_central_fn:
try:
update_central_fn()
@@ -165,7 +165,7 @@ def _is_private_branch_email(email: str) -> bool:
email address is registered to a private (isolated) branch.
Args:
email: Email address to check (e.g., "@patrick_private")
email: Email address to check (e.g., "@private_branch")
Returns:
True if email belongs to a private branch, False otherwise
@@ -93,25 +93,18 @@ def on_email_delivered(
new_count: int,
opened_count: int,
total: int,
push_dashboard_fn: Optional[Callable] = None,
update_central_fn: Optional[Callable] = None,
) -> None:
"""
Post-delivery callback: update dashboard and central.
Post-delivery callback: update central.
Args:
branch_path: Path to the branch that received email
new_count: Number of new (unread) messages
opened_count: Number of opened messages
total: Total message count
push_dashboard_fn: Callable for push_dashboard_update
update_central_fn: Callable for update_central
"""
if push_dashboard_fn:
try:
push_dashboard_fn(branch_path)
except Exception as e:
logger.warning("[error_dispatch] dashboard update failed for %s: %s", branch_path, e)
if update_central_fn:
try:
update_central_fn()
@@ -38,13 +38,6 @@ def _get_inbox_lock():
return _inbox_lock
def _get_push_dashboard_update() -> Any:
"""Lazy import push_dashboard_update from dashboard_sync."""
from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update
return push_dashboard_update
def _get_update_central() -> Any:
"""Lazy import update_central."""
from aipass.ai_mail.apps.handlers.central_writer import update_central
@@ -191,13 +184,7 @@ def mark_all_read_and_archive(branch_path: Path) -> Tuple[bool, str, int]:
def _update_dashboard(branch_path: Path, new: int, opened: int, total: int) -> None:
"""Update dashboard ai_mail section with enriched data via write-through API."""
try:
_get_push_dashboard_update()(branch_path)
except Exception as e:
logger.warning("[cleanup] dashboard update failed for %s: %s", branch_path, e)
# Update central after any inbox changes
"""Update central stats after inbox changes."""
try:
_get_update_central()()
except Exception as e:
@@ -267,7 +267,6 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch
from aipass.ai_mail.apps.handlers.email.header import prepend_dispatch_header
from aipass.ai_mail.apps.handlers.email.error_dispatch import dispatch_send_error, on_email_delivered
from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update
from aipass.ai_mail.apps.handlers.users.user import get_current_user
from aipass.ai_mail.apps.handlers.registry.read import get_branch_by_email
@@ -286,7 +285,6 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
new_count,
opened_count,
total,
push_dashboard_fn=push_dashboard_update,
update_central_fn=update_central,
)
+4 -9
View File
@@ -23,15 +23,8 @@ import sys
from pathlib import Path
from typing import List
# Infrastructure
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
_REPO_ROOT = _AI_MAIL_DIR.parents[2]
from aipass.prax import logger
from aipass.cli.apps.modules import console, error
# Handlers - business logic providers
from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update
from aipass.ai_mail.apps.handlers.email.create import load_email_file
from aipass.ai_mail.apps.handlers.email.format import format_email_list_item, format_email_header
from aipass.ai_mail.apps.handlers.email.inbox_ops import load_inbox
@@ -48,6 +41,9 @@ from aipass.ai_mail.apps.handlers.email.close_ops import batch_close, batch_clos
from aipass.ai_mail.apps.handlers.email.inbox_resolve import resolve_inbox_target
from aipass.ai_mail.apps.modules.email_send import handle_send
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
_REPO_ROOT = _AI_MAIL_DIR.parents[2]
try:
from aipass.ai_mail.apps.handlers.central_writer import update_central
except ImportError as e:
@@ -255,7 +251,7 @@ def handle_close(args: List[str]) -> bool:
except ImportError as e:
logger.warning("[email] purge import unavailable: %s", e)
run_purge = None
batch_close_post_ops(branch_path, push_dashboard_update, update_central, run_purge)
batch_close_post_ops(branch_path, update_central, run_purge)
console.print(f"\nClosed {closed}, failed {failed}")
return True
except Exception as e:
@@ -387,7 +383,6 @@ def print_introspection():
console.print(" - reply.py (get_email_by_id — retrieve email by message ID)")
console.print(" - reply.py (send_reply — send reply to an email)")
console.print(" - header.py (prepend_dispatch_header — prepend dispatch header to message)")
console.print(" - dashboard_sync.py (push_dashboard_update — push email stats to dashboard)")
console.print(" - error_dispatch.py (dispatch_send_error — handle and report send errors)")
console.print(" - error_dispatch.py (on_email_delivered — post-delivery callback handler)")
console.print(" handlers/users/")
@@ -17,14 +17,10 @@ under the size threshold.
from pathlib import Path
from typing import List
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
_REPO_ROOT = _AI_MAIL_DIR.parents[2]
from aipass.prax import logger
from aipass.cli.apps.modules import console, error
from aipass.trigger.apps.modules.core import trigger
from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update
from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch
from aipass.ai_mail.apps.handlers.email.create import create_email_file, load_email_file
from aipass.ai_mail.apps.handlers.email.header import prepend_dispatch_header
@@ -40,6 +36,9 @@ from aipass.ai_mail.apps.handlers.email.send import (
from aipass.ai_mail.apps.handlers.email.error_dispatch import dispatch_send_error, on_email_delivered
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args, resolve_dispatch_target
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
_REPO_ROOT = _AI_MAIL_DIR.parents[2]
try:
from aipass.ai_mail.apps.handlers.central_writer import update_central
except ImportError as e:
@@ -54,7 +53,6 @@ def _delivery_callback(branch_path, new_count, opened_count, total):
new_count,
opened_count,
total,
push_dashboard_fn=push_dashboard_update,
update_central_fn=update_central,
)
+5 -26
View File
@@ -120,13 +120,11 @@ def test_batch_close_post_ops_all_fns_called(tmp_path: Path):
branch_path = tmp_path / "branch"
branch_path.mkdir()
push_fn = MagicMock()
central_fn = MagicMock()
purge_fn = MagicMock()
mod.batch_close_post_ops(branch_path, push_fn, central_fn, purge_fn)
mod.batch_close_post_ops(branch_path, central_fn, purge_fn)
push_fn.assert_called_once_with(branch_path)
central_fn.assert_called_once_with()
purge_fn.assert_called_once_with(branch_path / ".ai_mail.local")
@@ -137,22 +135,7 @@ def test_batch_close_post_ops_none_fns(tmp_path: Path):
branch_path.mkdir()
# Should not raise
mod.batch_close_post_ops(branch_path, None, None, None)
def test_batch_close_post_ops_push_exception_suppressed(tmp_path: Path):
"""Exception in push_dashboard_fn is caught; other fns still called."""
branch_path = tmp_path / "branch"
branch_path.mkdir()
push_fn = MagicMock(side_effect=RuntimeError("push failed"))
central_fn = MagicMock()
purge_fn = MagicMock()
mod.batch_close_post_ops(branch_path, push_fn, central_fn, purge_fn)
central_fn.assert_called_once()
purge_fn.assert_called_once()
mod.batch_close_post_ops(branch_path, None, None)
def test_batch_close_post_ops_central_exception_suppressed(tmp_path: Path):
@@ -160,13 +143,11 @@ def test_batch_close_post_ops_central_exception_suppressed(tmp_path: Path):
branch_path = tmp_path / "branch"
branch_path.mkdir()
push_fn = MagicMock()
central_fn = MagicMock(side_effect=RuntimeError("central failed"))
purge_fn = MagicMock()
mod.batch_close_post_ops(branch_path, push_fn, central_fn, purge_fn)
mod.batch_close_post_ops(branch_path, central_fn, purge_fn)
push_fn.assert_called_once()
purge_fn.assert_called_once()
@@ -175,13 +156,11 @@ def test_batch_close_post_ops_purge_exception_suppressed(tmp_path: Path):
branch_path = tmp_path / "branch"
branch_path.mkdir()
push_fn = MagicMock()
central_fn = MagicMock()
purge_fn = MagicMock(side_effect=RuntimeError("purge failed"))
mod.batch_close_post_ops(branch_path, push_fn, central_fn, purge_fn)
mod.batch_close_post_ops(branch_path, central_fn, purge_fn)
push_fn.assert_called_once()
central_fn.assert_called_once()
@@ -192,6 +171,6 @@ def test_batch_close_post_ops_partial_fns(tmp_path: Path):
central_fn = MagicMock()
mod.batch_close_post_ops(branch_path, None, central_fn, None)
mod.batch_close_post_ops(branch_path, central_fn, None)
central_fn.assert_called_once_with()
@@ -47,7 +47,6 @@ _H_CREATE = "aipass.ai_mail.apps.handlers.email.create"
_H_DELIVERY = "aipass.ai_mail.apps.handlers.email.delivery"
_H_HEADER = "aipass.ai_mail.apps.handlers.email.header"
_H_ERR = "aipass.ai_mail.apps.handlers.email.error_dispatch"
_H_DASH = "aipass.ai_mail.apps.handlers.email.dashboard_sync"
_H_USERS = "aipass.ai_mail.apps.handlers.users.user"
_H_REG = "aipass.ai_mail.apps.handlers.registry.read"
_H_CENTRAL = "aipass.ai_mail.apps.handlers.central_writer"
@@ -658,7 +657,6 @@ def _send_patches(overrides: dict | None = None) -> ExitStack:
f"{_H_HEADER}.prepend_dispatch_header": MagicMock(return_value="[DISPATCH] Body"),
f"{_H_SEND}.send_to_single": MagicMock(return_value=(True, None)),
f"{_H_ERR}.on_email_delivered": MagicMock(),
f"{_H_DASH}.push_dashboard_update": MagicMock(),
f"{_H_USERS}.get_current_user": MagicMock(return_value={"name": "test"}),
f"{_H_REG}.get_branch_by_email": MagicMock(return_value={"email": "@target"}),
f"{_H_CENTRAL}.update_central": MagicMock(),
@@ -385,7 +385,7 @@ class TestHandleClose:
post_ops_called = []
monkeypatch.setattr(
"aipass.ai_mail.apps.modules.email.batch_close_post_ops",
lambda bp, push_fn, central_fn, purge_fn: post_ops_called.append(True),
lambda bp, central_fn, purge_fn: post_ops_called.append(True),
)
mock_console = MagicMock()
mock_console.print = lambda msg, **kw: None
@@ -1286,7 +1286,7 @@ class TestHandleCloseExtended:
)
monkeypatch.setattr(
"aipass.ai_mail.apps.modules.email.batch_close_post_ops",
lambda bp, push_fn, central_fn, purge_fn: None,
lambda bp, central_fn, purge_fn: None,
)
printed: list[str] = []
errors: list[str] = []
@@ -1338,7 +1338,7 @@ class TestHandleCloseExtended:
post_ops_called: list[bool] = []
monkeypatch.setattr(
"aipass.ai_mail.apps.modules.email.batch_close_post_ops",
lambda bp, push_fn, central_fn, purge_fn: post_ops_called.append(True),
lambda bp, central_fn, purge_fn: post_ops_called.append(True),
)
printed: list[str] = []
mock_console = MagicMock()
@@ -1450,7 +1450,6 @@ class TestDeliveryCallback:
new_count,
opened_count,
total,
push_dashboard_fn=None,
update_central_fn=None,
):
"""Capture on_email_delivered arguments."""
@@ -1460,7 +1459,6 @@ class TestDeliveryCallback:
"new_count": new_count,
"opened_count": opened_count,
"total": total,
"push_dashboard_fn": push_dashboard_fn,
"update_central_fn": update_central_fn,
}
)
@@ -1478,7 +1476,6 @@ class TestDeliveryCallback:
assert delivered_args[0]["new_count"] == 3
assert delivered_args[0]["opened_count"] == 2
assert delivered_args[0]["total"] == 5
assert delivered_args[0]["push_dashboard_fn"] is not None
# ===========================================================================
@@ -155,51 +155,34 @@ def test_dispatch_send_error_passes_correct_email_data(monkeypatch):
# ---- on_email_delivered tests --------------------------------
def test_on_email_delivered_with_both_callbacks():
"""Both callbacks are invoked when provided."""
push_fn = MagicMock()
def test_on_email_delivered_with_central_callback():
"""Central callback is invoked when provided."""
update_fn = MagicMock()
branch_path = "/some/path"
on_email_delivered(branch_path, 3, 1, 10, push_fn, update_fn)
on_email_delivered(branch_path, 3, 1, 10, update_central_fn=update_fn)
push_fn.assert_called_once_with(branch_path)
update_fn.assert_called_once_with()
def test_on_email_delivered_with_none_callbacks():
"""No error when both callbacks are None."""
on_email_delivered("/some/path", 3, 1, 10, None, None)
def test_on_email_delivered_dashboard_failure_does_not_block_central():
"""Dashboard failure does not prevent central update from running."""
push_fn = MagicMock(side_effect=RuntimeError("dashboard broken"))
update_fn = MagicMock()
on_email_delivered("/some/path", 3, 1, 10, push_fn, update_fn)
push_fn.assert_called_once()
update_fn.assert_called_once()
"""No error when callback is None."""
on_email_delivered("/some/path", 3, 1, 10, None)
def test_on_email_delivered_central_failure_does_not_raise():
"""Central update failure is caught silently."""
push_fn = MagicMock()
update_fn = MagicMock(side_effect=RuntimeError("central broken"))
on_email_delivered("/some/path", 3, 1, 10, push_fn, update_fn)
on_email_delivered("/some/path", 3, 1, 10, update_central_fn=update_fn)
push_fn.assert_called_once()
update_fn.assert_called_once()
def test_on_email_delivered_both_fail_no_exception():
"""Both callbacks failing does not raise any exception."""
push_fn = MagicMock(side_effect=RuntimeError("push fail"))
def test_on_email_delivered_central_fail_no_exception():
"""Central callback failing does not raise any exception."""
update_fn = MagicMock(side_effect=RuntimeError("update fail"))
on_email_delivered("/some/path", 3, 1, 10, push_fn, update_fn)
on_email_delivered("/some/path", 3, 1, 10, update_central_fn=update_fn)
push_fn.assert_called_once()
update_fn.assert_called_once()
@@ -42,12 +42,6 @@ def _mock_inbox_lock(monkeypatch):
monkeypatch.setattr(mod, "_get_inbox_lock", lambda: _noop_lock)
@pytest.fixture(autouse=True)
def _mock_dashboard(monkeypatch):
"""Replace _get_push_dashboard_update with a no-op."""
monkeypatch.setattr(mod, "_get_push_dashboard_update", lambda: lambda _bp: None)
@pytest.fixture(autouse=True)
def _mock_central(monkeypatch):
"""Replace _get_update_central with a no-op."""
+1 -69
View File
@@ -1,6 +1,6 @@
"""Tests for miscellaneous handlers -- central_writer.update_central, dispatch status.check_pid_status,
daemon.run_daemon, json_handler.increment_counter/update_data_metrics, delivery.deliver_to_inbox_file,
dashboard_sync.push_dashboard_update, inbox_resolve.resolve_inbox_target."""
inbox_resolve.resolve_inbox_target."""
import json
import os
@@ -14,7 +14,6 @@ import aipass.ai_mail.apps.handlers.central_writer as central_mod
import aipass.ai_mail.apps.handlers.dispatch.daemon as daemon_mod
import aipass.ai_mail.apps.handlers.json_utils.json_handler as json_handler_mod
import aipass.ai_mail.apps.handlers.email.delivery as delivery_mod
import aipass.ai_mail.apps.handlers.email.dashboard_sync as dashboard_mod
from aipass.ai_mail.apps.handlers.central_writer import update_central
from aipass.ai_mail.apps.handlers.dispatch.status import check_pid_status
from aipass.ai_mail.apps.handlers.json_utils.json_handler import (
@@ -22,7 +21,6 @@ from aipass.ai_mail.apps.handlers.json_utils.json_handler import (
update_data_metrics,
)
from aipass.ai_mail.apps.handlers.email.delivery import deliver_to_inbox_file
from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update
from aipass.ai_mail.apps.handlers.email.inbox_resolve import resolve_inbox_target
@@ -61,14 +59,6 @@ def _silence_json_handler_delivery():
yield mock_jh
@pytest.fixture(autouse=True)
def _silence_json_handler_dashboard():
"""Prevent log_operation in dashboard_sync from writing real JSON files."""
with patch("aipass.ai_mail.apps.handlers.email.dashboard_sync.json_handler") as mock_jh:
mock_jh.log_operation.return_value = True
yield mock_jh
@pytest.fixture(autouse=True)
def _silence_json_handler_inbox_resolve():
"""Prevent log_operation in inbox_resolve from writing real JSON files."""
@@ -399,64 +389,6 @@ def test_deliver_to_inbox_file_preserves_existing_messages(tmp_path, _noop_inbox
assert result["messages"][1]["subject"] == "Old email"
# ==============================================================
# push_dashboard_update tests
# ==============================================================
def test_push_dashboard_update_happy_path(tmp_path):
"""Successful dashboard push returns True."""
branch_path = tmp_path / "trigger"
inbox_dir = branch_path / ".ai_mail.local"
inbox_dir.mkdir(parents=True)
inbox_file = inbox_dir / "inbox.json"
inbox_data = {
"messages": [
{"id": "m1", "status": "new", "timestamp": "2026-04-01 10:00:00"},
{"id": "m2", "status": "opened", "timestamp": "2026-04-01 09:00:00"},
]
}
inbox_file.write_text(json.dumps(inbox_data), encoding="utf-8")
mock_write = MagicMock(return_value=True)
with patch.object(dashboard_mod, "_get_write_section", return_value=mock_write):
result = push_dashboard_update(branch_path)
assert result is True
mock_write.assert_called_once()
section_data = mock_write.call_args[0][1]
assert section_data == "ai_mail"
def test_push_dashboard_update_no_inbox(tmp_path):
"""Returns True with zero stats when no inbox exists."""
branch_path = tmp_path / "empty_branch"
branch_path.mkdir()
mock_write = MagicMock(return_value=True)
with patch.object(dashboard_mod, "_get_write_section", return_value=mock_write):
result = push_dashboard_update(branch_path)
assert result is True
mock_write.assert_called_once()
section_data = mock_write.call_args[0][2]
assert section_data["new"] == 0
assert section_data["total"] == 0
def test_push_dashboard_update_catches_exceptions(tmp_path):
"""Returns False on any exception (never raises)."""
branch_path = tmp_path / "broken"
branch_path.mkdir()
with patch.object(dashboard_mod, "_get_write_section", side_effect=RuntimeError("broken")):
result = push_dashboard_update(branch_path)
assert result is False
# ==============================================================
# resolve_inbox_target tests
# ==============================================================
@@ -11,7 +11,8 @@ Init Bootstrap Handler - PRIVATE implementation
Business logic for `aipass init`. Creates the project scaffold:
1. {NAME}_REGISTRY.json — project registry with UUID
2. .aipass/aipass_global_prompt.md — global prompt (injected every turn)
2. .aipass/tier0_kernel.md — tier 0 kernel prompt (every turn)
2b..aipass/tier1_navmap.md — tier 1 navigation map (periodic)
3. CLAUDE.md — project prompt (Claude Code reads this)
4. AGENTS.md — Codex equivalent of CLAUDE.md
5. README.md — getting started guide
@@ -69,14 +70,6 @@ def _detect_aipass_home() -> str | None:
return None
def _resolve_global_prompt(name: str, aipass_home: str | None, dest: Path) -> str:
"""Resolve global prompt content from source template or fallback generator."""
source = Path(aipass_home) / ".aipass" / "project_global_prompt.md" if aipass_home else None
if source and source.is_file():
return source.read_text(encoding="utf-8").replace("{name}", name)
return sc.with_source(sc.global_prompt_md(name), dest)
def _hook_fingerprint(hook_entry: dict) -> str:
"""Extract a comparable fingerprint from a hook entry."""
commands = []
@@ -323,10 +316,14 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
aipass_dir = target / ".aipass"
aipass_dir.mkdir(exist_ok=True)
global_prompt_path = aipass_dir / "aipass_global_prompt.md"
if not global_prompt_path.exists():
global_prompt_path.write_text(_resolve_global_prompt(name, aipass_home, global_prompt_path), encoding="utf-8")
created.append(str(global_prompt_path))
# 2. .aipass/tier0_kernel.md + tier1_navmap.md — tiered prompt injection
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
tier_dest = aipass_dir / tier_file
if not tier_dest.exists() and aipass_home:
tier_src = Path(aipass_home) / ".aipass" / tier_file
if tier_src.is_file():
shutil.copy2(str(tier_src), str(tier_dest))
created.append(str(tier_dest))
# 2b. .aipass/hooks.json — project hook config from template
hooks_json_path = aipass_dir / "hooks.json"
@@ -488,14 +485,21 @@ def update_project(target: Path) -> dict:
# --- Managed files: write only when content has changed ---
global_prompt_path = aipass_dir / "aipass_global_prompt.md"
aipass_home = aipass_home or _detect_aipass_home()
generated = _resolve_global_prompt(name, aipass_home, global_prompt_path)
if not global_prompt_path.exists() or global_prompt_path.read_text(encoding="utf-8") != generated:
global_prompt_path.write_text(generated, encoding="utf-8")
updated.append(str(global_prompt_path))
else:
already_current.append(str(global_prompt_path))
# tier0_kernel.md + tier1_navmap.md — tiered prompt injection
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
tier_dest = aipass_dir / tier_file
tier_src = Path(aipass_home) / ".aipass" / tier_file if aipass_home else None
if tier_src and tier_src.is_file():
canonical = tier_src.read_text(encoding="utf-8")
if not tier_dest.exists() or tier_dest.read_text(encoding="utf-8") != canonical:
tier_dest.write_text(canonical, encoding="utf-8")
updated.append(str(tier_dest))
else:
already_current.append(str(tier_dest))
elif tier_dest.exists():
already_current.append(str(tier_dest))
# settings.json — smart merge: preserve user hooks + env, update AIPass hooks
settings_path = claude_dir / "settings.json"
+2 -2
View File
@@ -16,7 +16,7 @@ Usage:
aipass init # show progress / introspection
aipass init run # interactive
aipass init run --non-interactive # CI/headless, all defaults
aipass init run --name Patrick --cli claude
aipass init run --name YourName --cli claude
aipass init run --dry-run # walk all 12 stages, no destructive ops
# - skips drone @spawn create (stage 8)
# - skips tmux/wt handoff (stage 11)
@@ -876,7 +876,7 @@ def print_help() -> None:
console.print("[yellow]USAGE:[/yellow]")
console.print(" [green]aipass init run[/green] [dim]# interactive[/dim]")
console.print(" [green]aipass init run --non-interactive[/green] [dim]# CI/headless[/dim]")
console.print(" [green]aipass init run --name Patrick[/green] [dim]# pre-fill name[/dim]")
console.print(" [green]aipass init run --name YourName[/green] [dim]# pre-fill name[/dim]")
console.print(" [green]aipass init run --cli claude[/green] [dim]# pre-fill CLI[/dim]")
console.print(" [green]aipass init run --no-docker[/green] [dim]# skip docker offer[/dim]")
console.print(" [green]aipass init run --dry-run[/green] [dim]# walk all stages, no writes[/dim]")
+160 -20
View File
@@ -98,7 +98,6 @@ def test_init_project_creates_all_expected_files(tmp_path):
expected_files = [
target / "DEMO_REGISTRY.json",
target / ".aipass" / "aipass_global_prompt.md",
target / "CLAUDE.md",
target / "AGENTS.md",
target / "README.md",
@@ -107,8 +106,13 @@ def test_init_project_creates_all_expected_files(tmp_path):
target / ".claude" / "commands" / "prep.md",
target / "src" / "demo" / "__init__.py",
]
# Tier files are env-dependent (need AIPASS_HOME)
if result["aipass_home"]:
expected_files.append(target / ".aipass" / "tier0_kernel.md")
expected_files.append(target / ".aipass" / "tier1_navmap.md")
for f in expected_files:
assert f.exists(), f"Expected file not created: {f}"
assert not (target / ".aipass" / "aipass_global_prompt.md").exists(), "Retired global prompt should NOT be seeded"
# src/<package>/ is a directory with __init__.py
assert (target / "src" / "demo").is_dir(), "Expected src/demo/ package directory"
@@ -126,7 +130,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"]) >= 11
assert len(result["created_files"]) >= 10
def test_init_project_return_dict_structure(tmp_path):
@@ -288,19 +292,6 @@ def test_init_project_settings_no_hooks(tmp_path):
assert "permissions" in data
def test_init_project_global_prompt_content(tmp_path):
"""Global prompt contains project name and AIPass terminology."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="alpha")
content = (target / ".aipass" / "aipass_global_prompt.md").read_text(encoding="utf-8")
assert "# ALPHA" in content
assert "ALPHA_REGISTRY.json" in content
assert "# Commands" in content
def test_init_project_readme_md_content(tmp_path):
"""README.md contains getting started guide with project name."""
target = tmp_path / "proj"
@@ -324,7 +315,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"]) >= 11
assert len(result["created_files"]) >= 10
def test_init_project_defaults_name_from_directory(tmp_path):
@@ -359,7 +350,6 @@ def test_init_project_skips_existing_optional_files(tmp_path):
# Pre-create optional files
aipass_dir = target / ".aipass"
aipass_dir.mkdir()
(aipass_dir / "aipass_global_prompt.md").write_text("# Custom global\n", encoding="utf-8")
(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")
@@ -542,10 +532,12 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
result = update_project(target)
assert (target / ".aipass" / "aipass_global_prompt.md").exists()
assert (target / ".claude" / "settings.json").exists()
# Managed files in deleted dirs re-written (global_prompt, hooks.json, settings, prep)
assert len(result["updated_files"]) == 4
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap, hooks.json, settings, prep)
if result["aipass_home"]:
assert len(result["updated_files"]) == 5
else:
assert len(result["updated_files"]) == 2
assert len(result["already_current"]) >= 2
@@ -850,6 +842,154 @@ def test_update_project_hooks_json_already_current(tmp_path):
assert any("hooks.json" in f for f in result["already_current"])
# ---------------------------------------------------------------------------
# Tiered prompt injection tests (FPLAN-0284)
# ---------------------------------------------------------------------------
def test_init_project_creates_tier_files(tmp_path):
"""init_project seeds tier0_kernel.md and tier1_navmap.md when AIPASS_HOME available."""
target = tmp_path / "proj"
target.mkdir()
result = init_project(target, project_name="tiers")
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
assert (target / ".aipass" / "tier0_kernel.md").exists()
assert (target / ".aipass" / "tier1_navmap.md").exists()
def test_init_project_tier_files_match_canonical(tmp_path):
"""Tier files in new project match the canonical source exactly."""
target = tmp_path / "proj"
target.mkdir()
result = init_project(target, project_name="canon")
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
canonical = Path(result["aipass_home"]) / ".aipass" / tier_file
if not canonical.exists():
pytest.skip(f"{tier_file} not found in canonical .aipass/")
assert (target / ".aipass" / tier_file).read_bytes() == canonical.read_bytes()
def test_init_project_tier_files_in_created_list(tmp_path):
"""Tier files appear in created_files list."""
target = tmp_path / "proj"
target.mkdir()
result = init_project(target, project_name="listed")
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
assert any("tier0_kernel.md" in f for f in result["created_files"])
assert any("tier1_navmap.md" in f for f in result["created_files"])
def test_init_project_no_tier_files_without_aipass_home(tmp_path, monkeypatch):
"""Without AIPASS_HOME, tier files are not created."""
target = tmp_path / "proj"
target.mkdir()
monkeypatch.setattr(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
lambda: None,
)
init_project(target, project_name="notiers")
assert not (target / ".aipass" / "tier0_kernel.md").exists()
assert not (target / ".aipass" / "tier1_navmap.md").exists()
def test_init_project_hooks_json_has_tiers_enabled(tmp_path):
"""hooks.json from template has tier0_kernel and navmap enabled, no global_prompt."""
target = tmp_path / "proj"
target.mkdir()
result = init_project(target, project_name="hookstier")
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
hooks_json = target / ".aipass" / "hooks.json"
data = json.loads(hooks_json.read_text(encoding="utf-8"))
ups = data["UserPromptSubmit"]
assert ups["tier0_kernel"]["enabled"] is True
assert ups["navmap"]["enabled"] is True
assert "global_prompt" not in ups
def test_update_project_adds_tier_files_to_existing(tmp_path):
"""update_project adds tier files to a project that lacks them."""
target = tmp_path / "proj"
target.mkdir()
registry_data = {
"metadata": {
"id": "test-id",
"name": "OLD",
"version": "1.0.0",
"created": "2026-01-01",
"last_updated": "2026-01-01",
"total_branches": 0,
},
"branches": [],
}
(target / "OLD_REGISTRY.json").write_text(json.dumps(registry_data), encoding="utf-8")
(target / ".aipass").mkdir()
result = update_project(target)
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
assert (target / ".aipass" / "tier0_kernel.md").exists()
assert (target / ".aipass" / "tier1_navmap.md").exists()
assert any("tier0_kernel.md" in f for f in result["updated_files"])
assert any("tier1_navmap.md" in f for f in result["updated_files"])
def test_update_project_tier_files_already_current(tmp_path):
"""update reports tier files as already_current when unchanged."""
target = tmp_path / "proj"
target.mkdir()
result = init_project(target, project_name="tiercurr")
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
result = update_project(target)
assert any("tier0_kernel.md" in f for f in result["already_current"])
assert any("tier1_navmap.md" in f for f in result["already_current"])
def test_update_project_refreshes_stale_tier_files(tmp_path):
"""update overwrites tier files when they differ from canonical source."""
target = tmp_path / "proj"
target.mkdir()
result = init_project(target, project_name="stale")
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
(target / ".aipass" / "tier0_kernel.md").write_text("# stale\n", encoding="utf-8")
result = update_project(target)
assert any("tier0_kernel.md" in f for f in result["updated_files"])
content = (target / ".aipass" / "tier0_kernel.md").read_text(encoding="utf-8")
assert "AIPass" in content
# ---------------------------------------------------------------------------
# scaffold_content — global_prompt_md tests
# ---------------------------------------------------------------------------
+15
View File
@@ -130,6 +130,11 @@
"standard": "unused_function",
"reason": "fetch_api_key() and fetch_validate_key() are module-level wrappers called from tests/test_critical_paths.py. The unused_function checker excludes test dirs from its search corpus. Encapsulation standard requires tests to go through modules — these functions serve that purpose (DPLAN-0155)."
},
{
"file": "apps/modules/api_key.py",
"standard": "cli",
"reason": "get_secret_cmd() --list uses console.print() for slug names (identifiers, not secrets). Machine consumers use the in-process module aipass.api.apps.modules.secrets.get_secret; CLI never prints raw values (DPLAN-0211)."
},
{
"file": "tests/test_aggregation.py",
"standard": "architecture",
@@ -224,6 +229,16 @@
"file": "tests/test_integrations_manager.py",
"standard": "architecture",
"reason": "Test file — lives in tests/ by convention, not in the 3-layer app structure. Test files are exempt from layer architecture standard."
},
{
"file": "tests/test_secrets.py",
"standard": "architecture",
"reason": "Test file — lives in tests/ by convention, not in the 3-layer app structure. Test files are exempt from layer architecture standard."
},
{
"file": "tests/test_secrets.py",
"standard": "encapsulation",
"reason": "Test file — imports handler functions directly for unit testing. Tests need direct access to verify handler behavior."
}
],
"notes": {
+13 -5
View File
@@ -5,8 +5,8 @@
> Centralized external API gateway — authenticated service clients for all external APIs
**Module:** `aipass.api` | **Role:** `api_gateway`
**Seedgo:** 99% (35/36 at 100%) | **Tests:** 447 pass | **Functions:** 77 public (77 tested)
**Last Updated:** 2026-05-16
**Seedgo:** 100% (37/37 at 100%) | **Tests:** 504 pass | **Functions:** 82 public (82 tested)
**Last Updated:** 2026-06-15
---
@@ -26,6 +26,7 @@ drone @api <command> [args]
| `validate [provider]` | Validate API key (default: openrouter) |
| `validate google` | Validate Google OAuth2 credentials |
| `reauth google` | Re-authenticate Google OAuth2 |
| `get-secret <provider/slug> [--out FILE] [--json] [--list]` | Secret access (masked summary; --out writes to file) |
| `list-providers` | List available API providers |
| `init` | Initialize .env template at ~/.secrets/aipass/ |
| `test` | Test OpenRouter connection status |
@@ -48,8 +49,9 @@ drone @api <command> [args]
api/
├── apps/
│ ├── api.py # Entry point — module discovery, command routing
│ ├── modules/ # Orchestration layer (7 modules)
│ ├── modules/ # Orchestration layer (8 modules)
│ │ ├── api_key.py # Key retrieval, validation, provider listing
│ │ ├── secrets.py # Cross-branch secrets door (in-process API)
│ │ ├── openrouter_client.py # OpenRouter client — calls, models, status
│ │ ├── google_client.py # Google API services (Drive, Calendar, etc.)
│ │ ├── usage_tracker.py # Usage metrics — track, stats, cleanup
@@ -57,7 +59,7 @@ api/
│ │ ├── integrations_manager.py # Contract dispatch — integrations list/call
│ │ └── registry.py # Driver auto-discovery (load_drivers)
│ ├── handlers/ # Business logic (7 packages, 15 files)
│ │ ├── auth/env.py, keys.py
│ │ ├── auth/env.py, keys.py, secrets.py
│ │ ├── config/provider.py
│ │ ├── google/auth.py, service_factory.py, retry.py
│ │ ├── integrations/list.py, call.py
@@ -66,7 +68,7 @@ api/
│ │ └── usage/aggregation.py, cleanup.py, tracking.py
│ └── integrations/ # Private driver space (gitignored)
│ └── {project}/driver.py
└── tests/ # 447 tests across 27 files
└── tests/ # 504 tests across 28 files
```
Three-tier: entry point routes to modules (orchestration), modules delegate to handlers (business logic). Modules auto-discovered from `apps/modules/*.py` via `handle_command()`.
@@ -85,6 +87,12 @@ service = get_drive_service(thread_safe=True) # For concurrent workers
from aipass.api.apps.modules.google_client import get_google_service
service = get_google_service("calendar", "v3")
from aipass.api.apps.modules.secrets import get_secret, list_secrets
token = get_secret("telegram", "bot") # Returns bot_token string
config = get_secret("telegram", "bot", as_json=True) # Returns full dict
slugs = list_secrets("telegram") # Returns ["bot", "webhook", ...]
# CLI never prints raw values — use the Python API above for programmatic access
```
---
+3 -1
View File
@@ -156,6 +156,7 @@ def print_help():
table.add_column("Description", style="white")
table.add_row("get-key", "Retrieve API key for provider")
table.add_row("get-secret", "Read secret from provider store")
table.add_row("validate", "Validate API credentials and connection")
table.add_row("validate google", "Validate Google OAuth2 credentials")
table.add_row("reauth google", "Re-authenticate Google OAuth2")
@@ -205,7 +206,8 @@ def print_help():
console.print()
console.print(
"[dim]Commands: get-key, validate, test, models, status, call, list-providers, init, track, stats, session, caller-usage, cleanup[/dim]"
"[dim]Commands: get-key, get-secret, validate, test, models, status, call,"
" list-providers, init, track, stats, session, caller-usage, cleanup[/dim]"
)
console.print()
@@ -0,0 +1,168 @@
# =================== AIPass ====================
# Name: secrets.py
# Description: Secrets Store Handler
# Version: 1.0.0
# Created: 2026-06-15
# Modified: 2026-06-15
# =============================================
"""
Secrets Store Handler
Reads structured secrets from ~/.secrets/aipass/<provider>/<slug>.
Supports JSON config files and raw secret files.
Functions:
get_secret() - Read a secret by provider/slug
list_secrets() - List available slugs for a provider
"""
import json
from pathlib import Path
from typing import Any, List, Optional
from aipass.prax import logger
from aipass.api.apps.handlers.json import json_handler
SECRETS_BASE = Path.home() / ".secrets" / "aipass"
# Keys to search for when returning a plain (non-JSON) secret value
_TOKEN_KEYS = ("bot_token", "api_key", "token", "secret", "password", "key")
# ==============================================
# SECRET RETRIEVAL
# ==============================================
def get_secret(provider: str, slug: str, as_json: bool = False) -> Optional[Any]:
"""
Get secret value from provider store.
Source: ~/.secrets/aipass/<provider>/<slug>.json or <slug>
Args:
provider: Provider directory name (e.g., 'telegram', 'discord')
slug: Secret file name (without .json extension)
as_json: If True, return full parsed dict; otherwise extract primary token
Returns:
Secret value (str or dict) or None if not found
Example:
>>> token = get_secret('telegram', 'bot')
>>> if token:
... print(f"Got token: {token[:10]}...")
"""
provider_dir = SECRETS_BASE / provider
if not provider_dir.exists() or not provider_dir.is_dir():
logger.warning(f"Provider directory not found: {provider_dir}")
return None
# Try JSON file first
json_path = provider_dir / f"{slug}.json"
if json_path.exists():
result = _read_json_secret(json_path, as_json)
if result is not None:
json_handler.log_operation("secret_retrieved", {"provider": provider, "slug": slug, "format": "json"})
return result
# Fall back to raw file
raw_path = provider_dir / slug
if raw_path.exists():
result = _read_raw_secret(raw_path)
if result is not None:
json_handler.log_operation("secret_retrieved", {"provider": provider, "slug": slug, "format": "raw"})
return result
logger.warning(f"Secret not found: {provider}/{slug}")
return None
def list_secrets(provider: str) -> List[str]:
"""
List available secret slugs for a provider.
Args:
provider: Provider directory name
Returns:
Sorted list of slug names (JSON extensions stripped)
Example:
>>> slugs = list_secrets('telegram')
>>> print(slugs)
['bot', 'webhook']
"""
provider_dir = SECRETS_BASE / provider
if not provider_dir.exists() or not provider_dir.is_dir():
return []
slugs = []
for entry in provider_dir.iterdir():
if entry.name.startswith(".") or entry.name == "__pycache__":
continue
if not entry.is_file():
continue
name = entry.name
if name.endswith(".json"):
name = name[:-5]
slugs.append(name)
return sorted(slugs)
# ==============================================
# PRIVATE HELPERS
# ==============================================
def _read_json_secret(path: Path, as_json: bool) -> Optional[Any]:
"""
Read and parse a JSON secret file.
Args:
path: Path to JSON file
as_json: If True, return full dict; otherwise extract primary token
Returns:
Parsed data or extracted token, or None on error
"""
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"Error reading secret file {path}: {e}")
return None
if as_json:
return data
if isinstance(data, dict):
for key in _TOKEN_KEYS:
if key in data:
return str(data[key])
return json.dumps(data)
return str(data)
def _read_raw_secret(path: Path) -> Optional[str]:
"""
Read a raw (non-JSON) secret file.
Args:
path: Path to raw secret file
Returns:
Stripped file contents or None on error
"""
try:
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except OSError as e:
logger.warning(f"Error reading secret file {path}: {e}")
return None
+84 -2
View File
@@ -22,7 +22,7 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import console, header, success, error
from aipass.api.apps.handlers.json import json_handler
from aipass.api.apps.handlers.auth import keys, env
from aipass.api.apps.handlers.auth import keys, env, secrets
def print_introspection():
@@ -61,7 +61,7 @@ def handle_command(command: str, args: List[str]) -> bool:
True if command was handled, False otherwise
"""
try:
if command not in ["get-key", "validate", "list-providers", "init"]:
if command not in ["get-key", "validate", "list-providers", "init", "get-secret"]:
return False
# Help gate
@@ -82,6 +82,9 @@ def handle_command(command: str, args: List[str]) -> bool:
if command == "get-key":
get_key(args)
return True
if command == "get-secret":
get_secret_cmd(args)
return True
if command == "validate":
validate_key(args)
return True
@@ -168,6 +171,69 @@ def init_env():
error("Failed to create environment template")
def get_secret_cmd(args: List[str]):
"""Orchestrate secret retrieval workflow (masked output only — no raw values to stdout)"""
import json
import os
if not args:
error("Usage: drone @api get-secret <provider/slug> [--out FILE] [--json] [--list]")
return
has_json = "--json" in args
has_list = "--list" in args
has_out = "--out" in args
out_file = None
if has_out:
out_idx = args.index("--out")
if out_idx + 1 < len(args):
out_file = args[out_idx + 1]
else:
error("--out requires a file path argument")
return
clean_args = [a for a in args if not a.startswith("--")]
if has_out and out_file in clean_args:
clean_args.remove(out_file)
if not clean_args:
error("Usage: drone @api get-secret <provider/slug> [--out FILE] [--json] [--list]")
return
parts = clean_args[0].split("/", 1)
provider = parts[0]
if has_list:
slugs = secrets.list_secrets(provider)
for slug in slugs:
# codeql[py/clear-text-logging-sensitive-data] # slug names are identifiers, not secret values
console.print(slug)
return
if len(parts) != 2 or not parts[1]:
error("Expected format: <provider>/<slug> (e.g. telegram/bot)")
return
slug = parts[1]
result = secrets.get_secret(provider, slug, as_json=has_json)
if result is None:
error(f"Secret not found: {provider}/{slug}")
return
if out_file:
content = json.dumps(result, indent=2) if has_json else str(result)
fd = os.open(out_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, content.encode("utf-8"))
finally:
os.close(fd)
success(f"Wrote {provider}/{slug} to {out_file}")
else:
value_len = len(json.dumps(result)) if has_json else len(str(result))
success(f"{provider}/{slug}: set ({value_len} chars)")
def fetch_api_key(provider: str = "openrouter"):
"""Retrieve a validated API key for a provider from secrets."""
return keys.get_api_key(provider)
@@ -194,6 +260,7 @@ def print_help():
epilog="""
COMMANDS:
get-key - Retrieve API key for a provider
get-secret - Read secret from provider store
validate - Validate API key
list-providers - List available providers
init - Initialize .env template
@@ -206,6 +273,21 @@ EXAMPLES:
# Get key for provider
drone @api get-key openrouter
# Check if a secret exists (masked summary, no raw value)
drone @api get-secret telegram/bot
# Write secret to a protected file
drone @api get-secret telegram/bot --out /tmp/token.txt
# Write secret as JSON to a protected file
drone @api get-secret telegram/bot --out /tmp/bot.json --json
# List secrets for a provider
drone @api get-secret telegram --list
# Programmatic access (in-process, no stdout):
# from aipass.api.apps.modules.secrets import get_secret
# Validate key
drone @api validate openrouter
+127
View File
@@ -0,0 +1,127 @@
# =================== AIPass ====================
# Name: secrets.py
# Description: Secrets Module — cross-branch in-process door
# Version: 1.0.0
# Created: 2026-06-15
# Modified: 2026-06-15
# =============================================
"""
Secrets Module
Cross-branch in-process API for reading secrets from the provider store.
Consumers import directly instead of shelling out to the CLI.
Functions:
get_secret() - Read a secret by provider/slug
list_secrets() - List available slugs for a provider
handle_command() - Route CLI commands (seedgo module discovery)
"""
import sys
from typing import Any, List, Optional
from aipass.prax import logger # noqa: F401 — seedgo imports standard
from aipass.cli.apps.modules import console, header
from aipass.api.apps.handlers.json import json_handler
from aipass.api.apps.handlers.auth import secrets as _handler
def print_introspection():
"""Show module introspection - connected handlers and capabilities"""
console.print()
header("Secrets Module Introspection")
console.print()
console.print("[cyan]Purpose:[/cyan] Cross-branch secrets access (in-process)")
console.print()
console.print("[cyan]Connected Handlers:[/cyan]")
console.print(" • api.apps.handlers.auth.secrets")
console.print()
console.print("[cyan]Available Workflows:[/cyan]")
console.print(" • get_secret() - Read secret by provider/slug")
console.print(" • list_secrets() - List slugs for a provider")
console.print()
def print_help():
"""Print help output for secrets module"""
print_introspection()
def handle_command(command: str, args: List[str]) -> bool:
"""
Handle secrets commands (module discovery hook).
This module does not own any CLI commands — get-secret is routed
through api_key.py. This exists for seedgo module discovery only.
Args:
command: Command name
args: Command arguments
Returns:
False — no commands handled here
"""
if not args:
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
print_help()
return True
return False
def get_secret(provider: str, slug: str, as_json: bool = False) -> Optional[Any]:
"""
Read a secret from the provider store.
This is the sanctioned cross-branch import path. Consumers call this
instead of shelling out to 'drone @api get-secret'.
Args:
provider: Provider directory name (e.g., 'telegram', 'openrouter')
slug: Secret identifier (without .json extension)
as_json: If True, return full parsed dict; otherwise extract primary token
Returns:
Secret value (str or dict) or None if not found
"""
result = _handler.get_secret(provider, slug, as_json=as_json)
json_handler.log_operation("secrets_get", {"provider": provider, "slug": slug, "found": result is not None})
return result
def list_secrets(provider: str) -> List[str]:
"""
List available secret slugs for a provider.
Args:
provider: Provider directory name
Returns:
Sorted list of slug names
"""
return _handler.list_secrets(provider)
if __name__ == "__main__":
"""Standalone execution mode"""
args = sys.argv[1:]
if len(args) == 0:
print_introspection()
sys.exit(0)
if args[0] in ["--help", "-h", "help"]:
print_help()
sys.exit(0)
console.print()
console.print(f"[red]Unknown command: {args[0]}[/red]")
console.print()
sys.exit(1)
+493
View File
@@ -0,0 +1,493 @@
# =================== AIPass ====================
# Name: test_secrets.py
# Description: Tests for secrets handler and get_secret_cmd orchestrator
# Version: 1.0.0
# Created: 2026-06-15
# Modified: 2026-06-15
# =============================================
"""Tests for apps/handlers/auth/secrets.py, apps/modules/secrets.py, and api_key.get_secret_cmd.
Tests — handlers/auth/secrets.py (get_secret, list_secrets):
- get_secret: JSON token extraction via _TOKEN_KEYS
- get_secret: as_json returns full parsed dict
- get_secret: raw file fallback returns stripped content
- get_secret: missing provider directory returns None
- get_secret: missing slug file returns None
- get_secret: malformed JSON returns None
- get_secret: unreadable file (OSError) returns None
- get_secret: JSON with no matching token key returns json.dumps of dict
- list_secrets: returns sorted slug names, strips .json extension
- list_secrets: non-existent provider returns empty list
- list_secrets: skips dotfiles, __pycache__, directories
Tests — modules/secrets.py (in-process door):
- get_secret wraps handler and logs operation
- list_secrets wraps handler
Tests — api_key.py (get_secret_cmd — hardened, no raw values to stdout):
- get_secret_cmd default prints masked summary only
- get_secret_cmd --out writes to file with 0o600 perms
- get_secret_cmd --out --json writes JSON to file
- get_secret_cmd --list prints slug names
- get_secret_cmd no args calls error()
- get_secret_cmd provider only (no --list) calls error()
- get_secret_cmd only flags calls error()
- get_secret_cmd not found calls error()
- get_secret_cmd --out missing path calls error()
"""
from __future__ import annotations
import json
import os
import stat
import sys
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from aipass.api.apps.modules.api_key import handle_command as _hc # noqa: F401 — seedgo test_coverage detection
from aipass.api.apps.modules.secrets import handle_command as _hc2 # noqa: F401 — seedgo test_coverage detection
from aipass.api.apps.handlers.auth.secrets import (
get_secret,
list_secrets,
)
from aipass.api.apps.modules.api_key import get_secret_cmd
from aipass.api.apps.modules import secrets as secrets_module
# Patch targets
PATCH_SECRETS_BASE = "aipass.api.apps.handlers.auth.secrets.SECRETS_BASE"
PATCH_JSON_HANDLER = "aipass.api.apps.handlers.auth.secrets.json_handler"
PATCH_LOGGER = "aipass.api.apps.handlers.auth.secrets.logger"
PATCH_CMD_SECRETS = "aipass.api.apps.modules.api_key.secrets"
PATCH_CMD_ERROR = "aipass.api.apps.modules.api_key.error"
PATCH_CMD_SUCCESS = "aipass.api.apps.modules.api_key.success"
PATCH_CMD_CONSOLE = "aipass.api.apps.modules.api_key.console"
PATCH_CMD_JSON_HANDLER = "aipass.api.apps.modules.api_key.json_handler"
PATCH_MOD_HANDLER = "aipass.api.apps.modules.secrets._handler"
PATCH_MOD_JSON_HANDLER = "aipass.api.apps.modules.secrets.json_handler"
# =============================================
# get_secret
# =============================================
class TestGetSecret:
"""Verifies secret retrieval under various conditions."""
def test_json_token_extraction(self, tmp_path: Path) -> None:
"""JSON file with a known token key returns the extracted token string."""
provider_dir = tmp_path / "telegram"
provider_dir.mkdir()
secret_file = provider_dir / "bot.json"
secret_file.write_text(json.dumps({"bot_token": "abc123", "extra": "stuff"}))
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = get_secret("telegram", "bot")
assert result == "abc123"
def test_json_token_extraction_searches_keys_in_order(self, tmp_path: Path) -> None:
"""Token extraction tries _TOKEN_KEYS in order; first match wins."""
provider_dir = tmp_path / "discord"
provider_dir.mkdir()
# Has both 'api_key' and 'token'; api_key comes first in _TOKEN_KEYS
secret_file = provider_dir / "creds.json"
secret_file.write_text(json.dumps({"token": "second", "api_key": "first"}))
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = get_secret("discord", "creds")
assert result == "first"
def test_as_json_returns_full_dict(self, tmp_path: Path) -> None:
"""as_json=True returns the full parsed dictionary."""
provider_dir = tmp_path / "telegram"
provider_dir.mkdir()
data = {"bot_token": "abc123", "webhook_url": "https://example.com"}
(provider_dir / "bot.json").write_text(json.dumps(data))
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = get_secret("telegram", "bot", as_json=True)
assert result == data
def test_raw_file_fallback(self, tmp_path: Path) -> None:
"""When no JSON file exists, falls back to raw file and returns stripped content."""
provider_dir = tmp_path / "generic"
provider_dir.mkdir()
(provider_dir / "api_token").write_text(" raw-secret-value \n")
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = get_secret("generic", "api_token")
assert result == "raw-secret-value"
def test_missing_provider_directory(self, tmp_path: Path) -> None:
"""Non-existent provider directory returns None."""
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = get_secret("nonexistent", "bot")
assert result is None
def test_missing_slug_file(self, tmp_path: Path) -> None:
"""Provider exists but slug file does not -- returns None."""
provider_dir = tmp_path / "telegram"
provider_dir.mkdir()
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = get_secret("telegram", "missing_slug")
assert result is None
def test_malformed_json_returns_none(self, tmp_path: Path) -> None:
"""Malformed JSON file returns None and logs a warning."""
provider_dir = tmp_path / "telegram"
provider_dir.mkdir()
(provider_dir / "bot.json").write_text("{not valid json")
mock_logger = MagicMock()
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER, mock_logger):
result = get_secret("telegram", "bot")
assert result is None
mock_logger.warning.assert_called()
@pytest.mark.skipif(
sys.platform == "win32",
reason="chmod(0o000) does not make a file unreadable to its owner on Windows",
)
def test_unreadable_file_returns_none(self, tmp_path: Path) -> None:
"""OSError when reading file returns None."""
provider_dir = tmp_path / "telegram"
provider_dir.mkdir()
secret_file = provider_dir / "bot.json"
secret_file.write_text(json.dumps({"bot_token": "abc"}))
# Make unreadable
secret_file.chmod(0o000)
mock_logger = MagicMock()
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER, mock_logger):
result = get_secret("telegram", "bot")
# Restore permissions for cleanup
secret_file.chmod(0o644)
assert result is None
def test_json_no_matching_token_key(self, tmp_path: Path) -> None:
"""JSON dict with no recognized token key returns json.dumps of the dict."""
provider_dir = tmp_path / "custom"
provider_dir.mkdir()
data = {"username": "admin", "host": "localhost"}
(provider_dir / "config.json").write_text(json.dumps(data))
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = get_secret("custom", "config")
assert result == json.dumps(data)
def test_json_non_dict_value(self, tmp_path: Path) -> None:
"""JSON file containing a non-dict value (e.g., a string) returns str of it."""
provider_dir = tmp_path / "simple"
provider_dir.mkdir()
(provider_dir / "token.json").write_text(json.dumps("plain-string-secret"))
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = get_secret("simple", "token")
assert result == "plain-string-secret"
def test_json_preferred_over_raw(self, tmp_path: Path) -> None:
"""When both JSON and raw files exist, JSON takes priority."""
provider_dir = tmp_path / "dual"
provider_dir.mkdir()
(provider_dir / "cred.json").write_text(json.dumps({"api_key": "from-json"}))
(provider_dir / "cred").write_text("from-raw")
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = get_secret("dual", "cred")
assert result == "from-json"
def test_provider_is_file_not_dir(self, tmp_path: Path) -> None:
"""If provider path exists but is a file (not a directory), returns None."""
(tmp_path / "notadir").write_text("file content")
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = get_secret("notadir", "slug")
assert result is None
# =============================================
# list_secrets
# =============================================
class TestListSecrets:
"""Verifies secret listing under various conditions."""
def test_returns_sorted_slugs(self, tmp_path: Path) -> None:
"""Returns sorted slug names with .json extension stripped."""
provider_dir = tmp_path / "telegram"
provider_dir.mkdir()
(provider_dir / "webhook.json").write_text("{}")
(provider_dir / "bot.json").write_text("{}")
(provider_dir / "raw_token").write_text("tok")
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_LOGGER):
result = list_secrets("telegram")
assert result == ["bot", "raw_token", "webhook"]
def test_nonexistent_provider_returns_empty(self, tmp_path: Path) -> None:
"""Non-existent provider returns empty list."""
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_LOGGER):
result = list_secrets("nonexistent")
assert result == []
def test_skips_dotfiles(self, tmp_path: Path) -> None:
"""Entries starting with '.' are excluded."""
provider_dir = tmp_path / "provider"
provider_dir.mkdir()
(provider_dir / ".hidden").write_text("secret")
(provider_dir / "visible.json").write_text("{}")
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_LOGGER):
result = list_secrets("provider")
assert result == ["visible"]
def test_skips_pycache(self, tmp_path: Path) -> None:
"""__pycache__ directory is excluded."""
provider_dir = tmp_path / "provider"
provider_dir.mkdir()
# __pycache__ as a file (the check is name-based, not type-based for this entry)
pycache = provider_dir / "__pycache__"
pycache.mkdir()
(provider_dir / "real.json").write_text("{}")
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_LOGGER):
result = list_secrets("provider")
assert result == ["real"]
def test_skips_directories(self, tmp_path: Path) -> None:
"""Subdirectories (non-files) are excluded."""
provider_dir = tmp_path / "provider"
provider_dir.mkdir()
(provider_dir / "subdir").mkdir()
(provider_dir / "secret.json").write_text("{}")
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_LOGGER):
result = list_secrets("provider")
assert result == ["secret"]
def test_provider_is_file_not_dir(self, tmp_path: Path) -> None:
"""If provider path is a file instead of a directory, returns empty list."""
(tmp_path / "notadir").write_text("file")
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_LOGGER):
result = list_secrets("notadir")
assert result == []
# =============================================
# get_secret_cmd
# =============================================
class TestSecretsModule:
"""Verifies the in-process module door (apps/modules/secrets.py)."""
def test_get_secret_wraps_handler(self) -> None:
"""Module get_secret delegates to handler and logs the operation."""
mock_handler = MagicMock()
mock_handler.get_secret.return_value = "token123"
mock_jh = MagicMock()
with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh):
result = secrets_module.get_secret("telegram", "bot")
assert result == "token123"
mock_handler.get_secret.assert_called_once_with("telegram", "bot", as_json=False)
mock_jh.log_operation.assert_called_once()
def test_get_secret_as_json(self) -> None:
"""Module get_secret passes as_json through to handler."""
mock_handler = MagicMock()
data = {"bot_token": "abc"}
mock_handler.get_secret.return_value = data
mock_jh = MagicMock()
with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh):
result = secrets_module.get_secret("telegram", "bot", as_json=True)
assert result == data
mock_handler.get_secret.assert_called_once_with("telegram", "bot", as_json=True)
def test_get_secret_not_found_logs(self) -> None:
"""Module get_secret logs even when handler returns None."""
mock_handler = MagicMock()
mock_handler.get_secret.return_value = None
mock_jh = MagicMock()
with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh):
result = secrets_module.get_secret("telegram", "missing")
assert result is None
log_call = mock_jh.log_operation.call_args
assert log_call[0][1]["found"] is False
def test_list_secrets_wraps_handler(self) -> None:
"""Module list_secrets delegates to handler."""
mock_handler = MagicMock()
mock_handler.list_secrets.return_value = ["bot", "webhook"]
with patch(PATCH_MOD_HANDLER, mock_handler):
result = secrets_module.list_secrets("telegram")
assert result == ["bot", "webhook"]
mock_handler.list_secrets.assert_called_once_with("telegram")
# =============================================
# get_secret_cmd (hardened — no raw values to stdout)
# =============================================
class TestGetSecretCmd:
"""Verifies the hardened get_secret_cmd (DPLAN-0211: no raw secrets to stdout)."""
@patch(PATCH_CMD_JSON_HANDLER)
@patch(PATCH_CMD_SECRETS)
@patch(PATCH_CMD_SUCCESS)
def test_default_prints_masked_summary(self, mock_success, mock_secrets, mock_jh) -> None:
"""Default (no flags) prints masked summary, never the raw value."""
mock_secrets.get_secret.return_value = "my-secret-token-value"
get_secret_cmd(["telegram/bot"])
mock_secrets.get_secret.assert_called_once_with("telegram", "bot", as_json=False)
msg = mock_success.call_args[0][0]
assert "telegram/bot" in msg
assert "set" in msg
assert "chars" in msg
assert "my-secret-token-value" not in msg
@pytest.mark.skipif(sys.platform == "win32", reason="File permission checks are POSIX-only")
@patch(PATCH_CMD_JSON_HANDLER)
@patch(PATCH_CMD_SECRETS)
@patch(PATCH_CMD_SUCCESS)
def test_out_writes_file_with_0600(self, mock_success, mock_secrets, mock_jh, tmp_path: Path) -> None:
"""--out writes secret value to file with 0o600 permissions."""
mock_secrets.get_secret.return_value = "secret-token-here"
out_file = str(tmp_path / "token.txt")
get_secret_cmd(["telegram/bot", "--out", out_file])
assert Path(out_file).exists()
assert Path(out_file).read_text(encoding="utf-8") == "secret-token-here"
file_mode = stat.S_IMODE(os.stat(out_file).st_mode)
assert file_mode == 0o600
msg = mock_success.call_args[0][0]
assert out_file in msg
assert "secret-token-here" not in msg
@pytest.mark.skipif(sys.platform == "win32", reason="File permission checks are POSIX-only")
@patch(PATCH_CMD_JSON_HANDLER)
@patch(PATCH_CMD_SECRETS)
@patch(PATCH_CMD_SUCCESS)
def test_out_json_writes_json_file(self, mock_success, mock_secrets, mock_jh, tmp_path: Path) -> None:
"""--out --json writes JSON-formatted secret to file."""
data = {"bot_token": "abc123", "allowed": [1, 2]}
mock_secrets.get_secret.return_value = data
out_file = str(tmp_path / "bot.json")
get_secret_cmd(["telegram/bot", "--out", out_file, "--json"])
content = Path(out_file).read_text(encoding="utf-8")
assert json.loads(content) == data
file_mode = stat.S_IMODE(os.stat(out_file).st_mode)
assert file_mode == 0o600
@patch(PATCH_CMD_JSON_HANDLER)
@patch(PATCH_CMD_SECRETS)
@patch(PATCH_CMD_CONSOLE)
def test_list_prints_slugs(self, mock_console, mock_secrets, mock_jh) -> None:
"""--list prints slug names via console.print."""
mock_secrets.list_secrets.return_value = ["bot", "webhook"]
get_secret_cmd(["telegram", "--list"])
mock_secrets.list_secrets.assert_called_once_with("telegram")
calls = [c for c in mock_console.print.call_args_list if c[0][0] in ("bot", "webhook")]
assert len(calls) == 2
@patch(PATCH_CMD_JSON_HANDLER)
@patch(PATCH_CMD_ERROR)
def test_no_args_calls_error(self, mock_error, mock_jh) -> None:
"""Empty args list calls error() with usage message."""
get_secret_cmd([])
mock_error.assert_called_once()
assert "Usage" in mock_error.call_args[0][0]
@patch(PATCH_CMD_JSON_HANDLER)
@patch(PATCH_CMD_ERROR)
def test_provider_only_without_list_calls_error(self, mock_error, mock_jh) -> None:
"""Single provider name without --list flag calls error() with format message."""
get_secret_cmd(["telegram"])
mock_error.assert_called_once()
@patch(PATCH_CMD_JSON_HANDLER)
@patch(PATCH_CMD_ERROR)
def test_only_flags_no_positional_args_calls_error(self, mock_error, mock_jh) -> None:
"""Only flags (no positional args after stripping) calls error()."""
get_secret_cmd(["--json"])
mock_error.assert_called_once()
assert "Usage" in mock_error.call_args[0][0]
@patch(PATCH_CMD_JSON_HANDLER)
@patch(PATCH_CMD_SECRETS)
@patch(PATCH_CMD_ERROR)
def test_secret_not_found_calls_error(self, mock_error, mock_secrets, mock_jh) -> None:
"""When get_secret returns None, error() is called."""
mock_secrets.get_secret.return_value = None
get_secret_cmd(["telegram/bot"])
mock_error.assert_called_once()
assert "not found" in mock_error.call_args[0][0].lower()
@patch(PATCH_CMD_JSON_HANDLER)
@patch(PATCH_CMD_ERROR)
def test_out_missing_path_calls_error(self, mock_error, mock_jh) -> None:
"""--out without a file path argument calls error()."""
get_secret_cmd(["telegram/bot", "--out"])
mock_error.assert_called_once()
assert "--out" in mock_error.call_args[0][0]
@patch(PATCH_CMD_JSON_HANDLER)
@patch(PATCH_CMD_SECRETS)
@patch(PATCH_CMD_CONSOLE)
def test_list_empty_provider(self, mock_console, mock_secrets, mock_jh) -> None:
"""--list with provider that has no secrets prints nothing."""
mock_secrets.list_secrets.return_value = []
get_secret_cmd(["empty_provider", "--list"])
mock_secrets.list_secrets.assert_called_once_with("empty_provider")
+3
View File
@@ -0,0 +1,3 @@
# Branch Prompt
AI context for `BACKUP`. The `aipass_local_prompt.md` file is injected every turn, telling the AI who you are and how to work in your branch.
@@ -0,0 +1,76 @@
# BACKUP — Branch Prompt
*Injected every turn. Breadcrumbs only — details in README, --help, .trinity/ memories, STATUS.local.md.*
## Identity
You are BACKUP — standalone backup system providing project-owned, local-first backups for any directory on the PC.
## What I Do
- Snapshot backups (full mirror copy of a project)
- Versioned backups (incremental, timestamped with automatic pruning)
- Project registration and @name resolution
- Ignore pattern management (gitignore-style via .backupignore)
- Backup status and changelog tracking per project
## Key Commands
```
drone @backup register <path> [--name <name>] # Register a project for backup
drone @backup snapshot <path|@name> # Full mirror backup
drone @backup versioned <path|@name> # Incremental timestamped backup
drone @backup all <path|@name> # Snapshot + versioned in sequence
drone @backup status <path|@name> # Show backup info and history
drone @backup --version # Show version
```
## Architecture
```
apps/
├── backup.py # Entry point (auto-discovery router)
├── modules/
│ ├── register.py # Project registration + @name resolution
│ ├── snapshot.py # Full mirror backup
│ ├── versioned.py # Incremental timestamped backup
│ ├── all.py # Snapshot + versioned orchestration
│ ├── status.py # Backup status display
│ ├── settings.py # Settings UI (stub — low priority)
│ ├── drive_sync.py # Drive sync (stub — DPLAN-003)
│ ├── drive_stats.py # Drive stats (stub)
│ ├── drive_test.py # Drive test (stub)
│ └── drive_clear.py # Drive clear (stub)
└── handlers/
├── copy/ # File copying (snapshot + versioned)
├── diff/ # Diff generation
├── ignore/ # .backupignore patterns + whitelist
├── json/ # JSON persistence, atomic writes, ops log
├── path/ # Backup path building
├── project/ # Config, registry, setup (.backup_system/)
├── report/ # Result formatting
├── scan/ # Directory walking + filtering
├── state/ # Changelog, metadata, timestamps
├── drive/ # Google Drive handlers (stubs)
└── ui/ # Settings window (stub)
```
## Integration
- **Depends on:** @prax for logging, @cli for Rich console output
- **Serves:** Any project on the PC — backups are project-owned (.backup_system/ in target root)
## Working Habits
- Project-owned design: .backup_system/ and .backupignore live in the TARGET project, not centrally
- Normal citizen namespace: uses `from aipass.backup.apps.modules.*` / `from aipass.backup.apps.handlers.*`
- Entry point sets AIPASS_BRANCH_NAME env var for Prax
- BUILTIN_IGNORES in patterns.py is the single source for default ignore patterns
## Known Gotchas
- `drone @backup` only resolves from within the Backup-System project tree (drone CWD limitation)
- Direct invocation via absolute python path works from anywhere
- handlers/__init__.py has an access guard that blocks cross-branch imports — uses path-based check, not hardcoded module name
- json_handler.log_operation() writes to branch-root logs/operations.jsonl — path-depth must match branch location
- Drive handlers are intentional stubs (DPLAN-003 deferred)
+5
View File
@@ -0,0 +1,5 @@
# Claude Code Settings
Claude Code configuration for `BACKUP`.
Contains `settings.local.json` with permission rules. Most branches are denied raw git commands and must use `drone @git` instead.
+14
View File
@@ -0,0 +1,14 @@
__pycache__/
*.pyc
*.pyo
.env
*.egg-info/
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
dist/
build/
*.log
*.tmp
*.swp
+5
View File
@@ -0,0 +1,5 @@
# Standards Bypass
Seedgo audit bypass config for `BACKUP`.
When an audit flags a false positive that doesn't apply to your architecture, add a bypass entry in `bypass.json` with a reason explaining why it's justified.
+184
View File
@@ -0,0 +1,184 @@
{
"metadata": {
"version": "1.0.0",
"created": "2026-04-16",
"description": "Standards bypass configuration for this branch"
},
"bypass": [
{
"file": "tests/conftest.py",
"standard": "architecture",
"reason": "Test infrastructure lives in tests/, not in apps/ 3-layer structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_json_handler.py",
"standard": "architecture",
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_cli_routing.py",
"standard": "architecture",
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_handlers_filesystem.py",
"standard": "architecture",
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_handlers_filesystem.py",
"standard": "encapsulation",
"reason": "Unit tests must import handlers directly to test them",
"pattern": "Handler imported directly"
},
{
"file": "tests/test_error_resilience.py",
"standard": "architecture",
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_drive_mocked.py",
"standard": "architecture",
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_snapshot_fidelity.py",
"standard": "architecture",
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_snapshot_fidelity.py",
"standard": "encapsulation",
"reason": "Unit tests must import handlers directly to test them",
"pattern": "Handler imported directly"
},
{
"file": "tests/test_versioned_engine.py",
"standard": "architecture",
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_versioned_engine.py",
"standard": "encapsulation",
"reason": "Unit tests must import handlers directly to test them",
"pattern": "Handler imported directly"
},
{
"file": "tests/test_versioned_engine.py",
"standard": "trigger",
"reason": "Test uses .unlink() to simulate deleted source \u2014 test infrastructure, not a real event",
"pattern": ".unlink() file deletion"
},
{
"file": "tests/test_drive_pipeline.py",
"standard": "architecture",
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_drive_pipeline.py",
"standard": "encapsulation",
"reason": "Unit tests must import handlers directly to test them",
"pattern": "Handler imported directly"
},
{
"file": "tests/test_ignore_pathspec.py",
"standard": "architecture",
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_ignore_pathspec.py",
"standard": "encapsulation",
"reason": "Unit tests must import handlers directly to test them",
"pattern": "Handler imported directly"
},
{
"standard": "json_handler",
"reason": "Backup has a log-only json_handler fork (JSONL append to logs/operations.jsonl). Architecture does not use module JSON pattern — backup manages files, not branch state. Pending migration decision."
},
{
"file": "apps/handlers/drive/client.py",
"standard": "handlers",
"reason": "Auth routing requires importing @api gateway module -- per Phase 4 spec",
"pattern": "Handler imports modules"
},
{
"file": "apps/handlers/drive/client.py",
"standard": "diagnostics",
"reason": "Type errors from dynamic import guard for Google API -- get_drive_service returns object, Drive API methods unresolvable at static analysis time",
"pattern": "type errors"
},
{
"file": "apps/handlers/drive/upload.py",
"standard": "diagnostics",
"reason": "googleapiclient.http is a runtime dependency not installed in dev -- guarded by try/except ImportError",
"pattern": "could not be resolved"
},
{
"file": "apps/handlers/drive/client.py",
"standard": "unused_function",
"reason": "Internal helpers called at runtime by upload handler -- not statically reachable from module layer",
"pattern": "unused function"
},
{
"file": "apps/handlers/drive/tracker.py",
"standard": "unused_function",
"reason": "clean_tracker is called during sync when limit=0 -- runtime path not statically reachable",
"pattern": "unused function"
},
{
"file": "apps/handlers/path/builder.py",
"standard": "unused_function",
"reason": "Legacy path builders (build_versioned_path, build_log_dir, build_drive_path) kept for backward compat and future use",
"pattern": "unused function"
},
{
"file": "apps/handlers/project/config.py",
"standard": "unused_function",
"reason": "save_project_config is public API surface for settings module (deferred)",
"pattern": "unused function"
},
{
"file": "apps/handlers/project/registry.py",
"standard": "unused_function",
"reason": "list_projects is public API surface for status/discovery commands",
"pattern": "unused function"
},
{
"file": "apps/handlers/report/formatter.py",
"standard": "unused_function",
"reason": "format_result is public API surface called by CLI display layer",
"pattern": "unused function"
},
{
"file": "apps/handlers/report/result.py",
"standard": "unused_function",
"reason": "new_result factory is public API surface for result creation",
"pattern": "unused function"
}
],
"notes": {
"usage": "Add entries to bypass specific seedgo standard violations",
"example": {
"file": "apps/example.py",
"standard": "imports",
"reason": "Legacy import required for compatibility"
},
"fields": {
"file": "Relative path to the file",
"standard": "Which standard to bypass (imports, cli, naming, etc.)",
"lines": "Optional array of line numbers",
"pattern": "Optional regex pattern to match",
"reason": "Why this bypass exists"
}
}
}
+42
View File
@@ -0,0 +1,42 @@
# BACKUP
## Startup
On any greeting, silently read these files 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`
**Check:** If `.ai_mail.local/inbox.json` exists, read it. Process any mail.
**Run:** `git status`
## Identity
You are **BACKUP** — an AIPass citizen.
- **Module:** `aipass.backup`
- **Role:**
- **Purpose:** New agent - purpose TBD
## Memories
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.
- `local.json` — Session history, key learnings, active tasks
- `observations.json` — Collaboration patterns, insights
- `passport.json` — Identity (rarely changes)
## AIPass Context
This branch is part of the AIPass multi-agent framework. Key concepts:
- **Branch** — your directory (`src/aipass/backup/`). Your home.
- **Citizen** — the identity that lives in a branch. Has a passport, memories, mailbox.
- **Agent** — a disposable worker spawned for a task. No passport, no memory.
## Commands
```
drone systems # List available infrastructure
drone @ai_mail inbox # Check mailbox
drone @ai_mail send @branch "Subject" "Body" # Send mail
drone @seedgo audit @backup # Run standards audit
```
+81
View File
@@ -0,0 +1,81 @@
# BACKUP
**Purpose:** Standalone backup system — project-owned, local-first backups for any directory
**Module:** `aipass.backup`
**Version:** 1.0.0
**Created:** 2026-04-16
**Last Updated:** 2026-05-03
---
## Overview
### What I Do
- Back up any project directory on the system (not just AIPass projects)
- Each project owns its backup config (`.backup/`) and ignore patterns (`.backupignore`)
- Snapshot mode: full mirror copy
- Versioned mode: incremental timestamped backups with automatic pruning
- Project registry for name-based lookups (`backup snapshot @AIPass`)
### How I Work
- **Entry Point:** `apps/backup.py`
- **Pattern:** Auto-discovers and routes to modules
---
## Architecture
```
apps/
├── backup.py # Entry point (auto-discovery router)
├── modules/
│ ├── all.py # Snapshot + versioned orchestration
│ ├── display.py # Rich CLI rendering (used by snapshot/versioned/all)
│ ├── drive_clear.py # Drive clear (stub — DPLAN-003)
│ ├── drive_stats.py # Drive stats (stub — DPLAN-003)
│ ├── drive_sync.py # Drive sync (stub — DPLAN-003)
│ ├── drive_check.py # Drive check (stub — DPLAN-003)
│ ├── register.py # Project registration + @name resolution
│ ├── restore.py # Version discovery + file restoration
│ ├── settings.py # Settings UI (stub)
│ ├── snapshot.py # Full mirror backup
│ ├── status.py # Backup status display
│ └── versioned.py # Incremental timestamped backup
└── handlers/
├── copy/ # File copying (snapshot + versioned)
├── diff/ # Diff generation (stub)
├── drive/ # Google Drive handlers (stubs)
├── ignore/ # .backupignore patterns + whitelist
├── json/ # JSON persistence, atomic writes, ops log
├── path/ # Backup path building
├── project/ # Config, registry, setup (.backup/)
├── report/ # Result formatting
├── scan/ # Directory walking + filtering
├── state/ # Changelog, metadata, timestamps
└── ui/ # Settings window (stub)
```
---
## Commands
```
backup register <path> [--name <name>] # Register a project for backup
backup snapshot <path|@name> # Full mirror backup
backup versioned <path|@name> # Incremental timestamped backup
backup all <path|@name> # Snapshot + versioned
backup status <path|@name> # Show backup info and history
backup --version # Show version
```
---
## Integration Points
### Depends On
- @prax — logging
- @cli — Rich console output
### Provides To
- Any project on the PC — backups are project-owned (.backup/ in target root)
+12
View File
@@ -0,0 +1,12 @@
# =================== AIPass ====================
# Name: __init__.py - Backup package root
# Date: 2026-04-16
# Version: 1.0.0
# Category: backup
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-04-16): Initial implementation
#
# CODE STANDARDS:
# - Package root for the Backup system
# =============================================
+8
View File
@@ -0,0 +1,8 @@
# Apps
Application layer for `BACKUP`.
- `backup.py` — Entry point. Auto-discovers and routes commands to modules.
- `modules/` — Business logic and orchestration. One module per command.
- `handlers/` — Implementation details. Called by modules, never by CLI directly.
- `plugins/` — Scheduled tasks and extensions.
+3
View File
@@ -0,0 +1,3 @@
# BACKUP apps package
from . import handlers
+177
View File
@@ -0,0 +1,177 @@
# =================== AIPass ====================
# Name: backup.py
# Description: BACKUP Branch — main orchestrator with auto-discovery
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-23
# =============================================
"""BACKUP Branch - Main Orchestrator
Auto-discovery architecture:
- Scans modules/ directory for .py files with handle_command()
- Routes commands to discovered modules automatically
- Accepts project paths or registered project names
"""
import importlib
import os
import sys
from pathlib import Path
from typing import Any
os.environ.setdefault("AIPASS_BRANCH_NAME", "backup")
from aipass.prax import logger
from aipass.cli.apps.modules import console, header
VERSION = "1.0.0"
MODULE_NAME = "backup"
MODULES_DIR = Path(__file__).parent / "modules"
def print_introspection(modules: list[Any]) -> None:
"""Display discovered modules — the bare self-map (run with no args)."""
console.print()
console.print(f"[bold cyan]BACKUP[/bold cyan] v{VERSION} — project backup & drive sync")
console.print()
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
console.print()
for module in modules:
name = module.__name__.split(".")[-1]
doc = (module.__doc__ or "").strip().split("\n")[0]
console.print(f" [cyan]-[/cyan] {name:20} [dim]{doc or 'No description'}[/dim]")
console.print()
console.print("[dim]Run 'drone @backup --help' for usage and commands[/dim]")
console.print()
def print_help() -> None:
"""Display the curated Rich-formatted command reference."""
console.print()
header("BACKUP — project backup & drive sync")
console.print()
console.print("[dim]Snapshot, version, and sync project backups to a local store or remote drive.[/dim]")
console.print()
console.print("-" * 70)
console.print()
console.print("[bold cyan]USAGE:[/bold cyan]")
console.print()
console.print(" [dim]drone @backup <command> <project_path|@name>[/dim]")
console.print(" [dim]drone @backup --help[/dim]")
console.print()
console.print("-" * 70)
console.print()
console.print("[bold cyan]COMMANDS:[/bold cyan]")
console.print()
console.print(" [green]snapshot[/green] Full mirror backup of a project")
console.print(" [green]versioned[/green] Incremental timestamped backup")
console.print(" [green]all[/green] Run snapshot then versioned in sequence")
console.print(" [green]register[/green] Register a project + scaffold its .backup/")
console.print(" [green]status[/green] Show backup info and recent history")
console.print(" [green]settings[/green] View/edit backup settings")
console.print(" [green]drive_sync[/green] Sync backups to the remote drive")
console.print(" [green]drive_check[/green] Test the remote drive connection")
console.print(" [green]drive_stats[/green] Drive usage statistics")
console.print(" [green]drive_clear[/green] Clear backups from the remote drive")
console.print()
def discover_modules() -> list[Any]:
"""Auto-discover modules in modules/ directory."""
modules = []
if not MODULES_DIR.exists():
return modules
for file_path in MODULES_DIR.glob("*.py"):
if file_path.name.startswith("_"):
continue
module_name = f"aipass.backup.apps.modules.{file_path.stem}"
try:
module = importlib.import_module(module_name)
if hasattr(module, "handle_command"):
modules.append(module)
except Exception as e:
logger.error(f"[BACKUP] Failed to load module {module_name}: {e}")
return modules
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
"""Route command to appropriate module."""
for module in modules:
try:
if module.handle_command(command, args):
return True
except Exception as e:
logger.error(f"[BACKUP] Module {module.__name__} error: {e}")
return False
def main():
"""Main entry point - routes commands or shows help."""
args = sys.argv[1:]
if args and args[0] in ("--version", "-V"):
console.print(f"backup {VERSION}")
return 0
modules = discover_modules()
if len(args) == 0:
print_introspection(modules)
return 0
if args[0] in ["--help", "-h", "help"]:
print_help()
return 0
command = args[0]
if command == "backup" and len(args) > 1:
from aipass.backup.apps.modules.register import resolve_project
target = args[1]
project_root = resolve_project(target)
if project_root is None:
console.print(f"[red]Error:[/red] Cannot resolve project: {target}")
return 1
remaining = [project_root] + args[2:]
mode = "snapshot"
if "--versioned" in args:
mode = "versioned"
remaining = [r for r in remaining if r != "--versioned"]
elif "--all" in args:
mode = "all"
remaining = [r for r in remaining if r != "--all"]
if route_command(mode, remaining, modules):
return 0
console.print(f"[red]Error:[/red] Unknown mode: {mode}")
return 1
remaining = args[1:] if len(args) > 1 else []
if remaining and remaining[0].startswith("@"):
from aipass.backup.apps.modules.register import resolve_project
resolved = resolve_project(remaining[0])
if resolved is None:
console.print(f"[red]Error:[/red] Cannot resolve project: {remaining[0]}")
return 1
remaining = [resolved] + remaining[1:]
if route_command(command, remaining, modules):
return 0
console.print(f"[red]Unknown command:[/red] {command}")
return 1
# =============================================
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,5 @@
# Handlers
Implementation details for `BACKUP`.
Handlers do the actual work. They are called by modules, never directly by the CLI. Keep business logic in modules, implementation in handlers.
@@ -0,0 +1,88 @@
"""BACKUP handlers package - Security protected."""
import inspect
from pathlib import Path
MY_BRANCH = "backup"
_HANDLER_DIR = str(Path(__file__).resolve().parent)
def _find_real_caller():
"""Walk the stack to find the actual file that triggered this import.
Skips this file, importlib internals, and frozen modules.
Returns tuple: (file_path, import_line) or (None, None).
"""
stack = inspect.stack()
this_file = str(Path(__file__).resolve())
for frame_info in stack:
filename = frame_info.filename
if this_file in str(Path(filename).resolve()):
continue
if filename.startswith("<") or "importlib" in filename:
continue
import_line = None
if frame_info.code_context:
import_line = frame_info.code_context[0].strip()
return str(Path(filename).resolve()), import_line
return None, None
def _extract_branch_name(filepath: str) -> str:
"""Extract branch name from a file path."""
parts = Path(filepath).parts
for i, part in enumerate(parts):
if part == "aipass":
if i + 1 < len(parts):
return parts[i + 1]
return "unknown"
def _guard_branch_access():
"""Block cross-branch handler imports.
Only code from within the 'backup' branch can import these handlers.
External branches must use aipass.backup.apps.modules instead.
"""
caller_file, import_line = _find_real_caller()
if caller_file is None:
stack = inspect.stack()
for frame in stack:
if frame.filename in ("<string>", "<stdin>"):
return
return
branch_root = str(Path(_HANDLER_DIR).parents[1])
if branch_root in caller_file.replace("\\", "/"):
return
caller_branch = _extract_branch_name(caller_file)
caller_filename = Path(caller_file).name
blocked_import = import_line if import_line else "unknown"
raise ImportError(
f"\n{'=' * 60}\n"
f"ACCESS DENIED: Cross-branch handler import blocked\n"
f"{'=' * 60}\n"
f" Caller branch: {caller_branch}\n"
f" Caller file: {caller_filename}\n"
f" Blocked: {blocked_import}\n"
f"\n"
f" Handlers are internal to their branch.\n"
f" Use the module API instead (apps/modules/).\n"
f"\n"
f" For full standards guide:\n"
f" drone @seedgo handlers\n"
f"{'=' * 60}"
)
# Run guard at import time
_guard_branch_access()
@@ -0,0 +1,127 @@
# =================== AIPass ====================
# Name: mirror.py
# Description: Mirror cleanup handler — removes snapshot files whose source no longer exists
# Version: 2.0.0
# Created: 2026-06-12
# Modified: 2026-06-12
# =============================================
"""Mirror cleanup handler — removes snapshot files whose source no longer exists."""
import stat
from pathlib import Path
from aipass.prax import logger
from ..json import json_handler
from ..report.result import BackupResult
def _make_writable(path: Path) -> None:
"""Best-effort chmod to make a file writable before deletion."""
try:
path.chmod(stat.S_IWRITE | stat.S_IREAD)
except OSError as e:
logger.info(f"[cleanup] Could not chmod {path}: {e}")
def _should_delete(backup_file: Path, backup_path: Path, source_dir: Path) -> str | None:
"""Return the relative path string if the file should be deleted, else None."""
rel = backup_file.relative_to(backup_path)
source_file = source_dir / rel
if source_file.exists():
return None
return str(rel).replace("\\", "/")
def _delete_stale_files(
backup_path: Path,
source_dir: Path,
result: BackupResult,
dry_run: bool,
) -> None:
"""Pass 1: delete files whose source is gone."""
for backup_file in list(backup_path.rglob("*")):
if not backup_file.is_file():
continue
try:
rel_str = _should_delete(backup_file, backup_path, source_dir)
if rel_str is None:
continue
if dry_run:
result.files_deleted += 1
continue
_make_writable(backup_file)
backup_file.unlink()
result.files_deleted += 1
except PermissionError as e:
result.add_error(f"Permission denied deleting {backup_file}: {e}")
logger.warning(f"[cleanup] Permission denied: {backup_file}: {e}")
except Exception as e:
result.add_warning(f"Error deleting {backup_file}: {e}")
logger.warning(f"[cleanup] Error: {backup_file}: {e}")
def _remove_empty_dirs(
backup_path: Path,
source_dir: Path,
dry_run: bool,
) -> None:
"""Pass 2: remove empty directories bottom-up."""
all_dirs = sorted(
[d for d in backup_path.rglob("*") if d.is_dir()],
key=lambda p: len(p.parts),
reverse=True,
)
for d in all_dirs:
try:
if any(d.iterdir()):
continue
rel = d.relative_to(backup_path)
source_d = source_dir / rel
if not source_d.exists() and not dry_run:
d.rmdir()
except OSError as e:
logger.info(f"[cleanup] Could not remove dir {d}: {e}")
def cleanup_deleted_files(
backup_path: Path,
source_dir: Path,
should_ignore,
result: BackupResult,
dry_run: bool = False,
) -> None:
"""Remove snapshot files whose source no longer exists.
Args:
backup_path: Snapshot destination directory.
source_dir: Original project root.
should_ignore: Callable(Path) -> bool for ignore check.
result: BackupResult to track deletions.
dry_run: If True, only count what would be deleted.
"""
json_handler.log_operation("cleanup_started", {"backup_path": str(backup_path)})
if not backup_path.exists():
return
try:
_delete_stale_files(backup_path, source_dir, result, dry_run)
_remove_empty_dirs(backup_path, source_dir, dry_run)
except Exception as e:
result.add_warning(f"Cleanup scan error: {e}")
logger.warning(f"[cleanup] Scan error: {e}")
json_handler.log_operation(
"cleanup_complete",
{"files_deleted": result.files_deleted, "dry_run": dry_run},
)
logger.info(f"[cleanup] Deleted {result.files_deleted} files (dry_run={dry_run})")
# =============================================
@@ -0,0 +1,162 @@
# =================== AIPass ====================
# Name: snapshot.py
# Description: Snapshot copy strategy — mirror destination tree with cleanup
# Version: 3.0.0
# Created: 2026-04-16
# Modified: 2026-06-12
# =============================================
"""Snapshot copy handler — mirror destination tree with cleanup."""
import os
import shutil
import stat
from pathlib import Path
import pathspec
from aipass.prax import logger
from ..cleanup.mirror import cleanup_deleted_files
from ..ignore.patterns import is_ignored
from ..json import json_handler
from ..report.result import BackupResult
def _should_skip_mtime(abs_path: str, target: str) -> bool:
"""Return True if source and target have identical mtime."""
try:
src_mtime = os.path.getmtime(abs_path)
dst_mtime = os.path.getmtime(target)
return src_mtime == dst_mtime
except OSError as e:
logger.info(f"[snapshot] mtime check failed, will recopy: {e}")
return False
def _make_target_writable(target_path: Path) -> None:
"""Best-effort chmod to make an existing target writable before overwrite."""
try:
target_path.chmod(stat.S_IWRITE | stat.S_IREAD)
except OSError as e:
logger.warning(f"[snapshot] Could not chmod {target_path}: {e}")
def _should_ignore_for_cleanup(path: Path, project_root: str, spec: pathspec.PathSpec) -> bool:
"""Check whether a path matches the ignore spec."""
try:
rel = str(path.relative_to(project_root)).replace("\\", "/")
except ValueError as e:
logger.info(f"[snapshot] Path not relative to project root: {path}: {e}")
return False
return is_ignored(rel, spec)
def _copy_single_file(
abs_path: str,
rel_path: str,
dest_path: Path,
errors: list[str],
) -> int:
"""Copy a single file to the snapshot destination, returning bytes copied.
Skips unchanged files (same mtime), handles read-only targets.
Returns bytes copied (0 if skipped or errored).
"""
target = str(dest_path / rel_path)
# Long-path guard
if len(target) > 260:
logger.warning(f"Path too long (>260 chars), skipping: {rel_path}")
errors.append(f"{rel_path}: path too long (>260 chars)")
return -1 # signal: skipped due to error
target_path = Path(target)
target_path.parent.mkdir(parents=True, exist_ok=True)
# Skip if target exists and has same mtime
if target_path.exists():
if _should_skip_mtime(abs_path, target):
return -1 # signal: skipped, unchanged
_make_target_writable(target_path)
shutil.copy2(abs_path, target)
return os.path.getsize(abs_path)
def _run_mirror_cleanup(dest_path: Path, project_root: str, spec: pathspec.PathSpec) -> int:
"""Run mirror-delete cleanup on an existing snapshot destination."""
cleanup_result = BackupResult(mode="snapshot", project_root=project_root)
cleanup_deleted_files(
dest_path,
Path(project_root),
lambda p: _should_ignore_for_cleanup(p, project_root, spec),
cleanup_result,
)
return cleanup_result.files_deleted
def copy_snapshot(
files: list[tuple[str, str]],
dest: str,
project_root: str,
spec: pathspec.PathSpec,
on_progress=None,
) -> dict:
"""Copy files into a snapshot destination with mirror-delete.
Args:
files: List of (absolute_path, relative_path) tuples.
dest: Absolute destination directory path.
project_root: Project root for cleanup source reference.
spec: Compiled PathSpec for ignore matching during cleanup.
on_progress: Optional callback after each file.
Returns:
Dict with files_copied, bytes_copied, errors, files_deleted.
"""
dest_path = Path(os.path.realpath(dest))
# Mirror-delete: remove snapshot files whose source is gone
files_deleted = 0
if dest_path.exists():
files_deleted = _run_mirror_cleanup(dest_path, project_root, spec)
dest_path.mkdir(parents=True, exist_ok=True)
files_copied = 0
bytes_copied = 0
errors: list[str] = []
for abs_path, rel_path in files:
try:
result_bytes = _copy_single_file(abs_path, rel_path, dest_path, errors)
if result_bytes >= 0:
bytes_copied += result_bytes
files_copied += 1
except OSError as e:
logger.warning(f"Failed to copy {rel_path}: {e}")
errors.append(f"{rel_path}: {e}")
if on_progress:
on_progress()
result = {
"files_copied": files_copied,
"bytes_copied": bytes_copied,
"errors": errors,
"files_deleted": files_deleted,
}
json_handler.log_operation(
"copy_snapshot",
{
"project_root": project_root,
"files_copied": files_copied,
"bytes_copied": bytes_copied,
"files_deleted": files_deleted,
},
)
return result
# =============================================
@@ -0,0 +1,161 @@
# =================== AIPass ====================
# Name: versioned.py
# Description: Versioned copy — per-file baseline + diff engine
# Version: 2.0.0
# Created: 2026-04-16
# Modified: 2026-06-12
# =============================================
"""Versioned copy handler — per-file baseline + unified diff engine.
Each file gets a file-folder in the persistent store containing:
- <name> (current version, copy2 preserves mtime)
- <stem>-baseline-<YYYY-MM-DD>.<ext> (first-run full copy, never overwritten)
- <name>_diffs/<name>_v<YYYY-MM-DD_HH-MM-SS>.diff (old version's mtime timestamp)
"""
import datetime
import os
import shutil
import stat
from pathlib import Path
from aipass.prax import logger
from ..diff.generator import generate_diff_content, should_create_diff
from ..json import json_handler
from ..path.builder import build_versioned_file_path
def _make_baseline_name(target: Path) -> str:
"""Build the baseline filename: <stem>-baseline-<YYYY-MM-DD>.<ext>."""
date_str = datetime.datetime.now().strftime("%Y-%m-%d")
parts = target.name.rsplit(".", 1)
if len(parts) == 2:
return f"{parts[0]}-baseline-{date_str}.{parts[1]}"
return f"{target.name}-baseline-{date_str}"
def _ensure_writable(path: Path) -> None:
"""Best-effort chmod to make a path writable."""
try:
path.chmod(stat.S_IWRITE | stat.S_IREAD)
except OSError as e:
logger.info(f"[versioned] Could not chmod {path}: {e}")
def _copy_new_file(source: Path, target: Path) -> bool:
"""Handle a new file: create baseline + current."""
target.parent.mkdir(parents=True, exist_ok=True)
# Current copy (mtime preserved via copy2)
shutil.copy2(str(source), str(target))
# Baseline copy (never overwritten after creation)
baseline_name = _make_baseline_name(target)
baseline_path = target.parent / baseline_name
if not baseline_path.exists():
shutil.copy2(str(source), str(baseline_path))
return True
def _copy_changed_file(source: Path, target: Path) -> bool:
"""Handle a changed file: diff old current, then overwrite current."""
# Generate diff before overwriting
if should_create_diff(source):
old_mtime = target.stat().st_mtime
ts = datetime.datetime.fromtimestamp(old_mtime).strftime("%Y-%m-%d_%H-%M-%S")
diff_dir = target.parent / f"{target.name}_diffs"
diff_dir.mkdir(parents=True, exist_ok=True)
diff_name = f"{target.name}_v{ts}.diff"
diff_path = diff_dir / diff_name
diff_content = generate_diff_content(target, source)
if diff_content:
diff_path.write_text(diff_content, encoding="utf-8")
# Overwrite current with new version
_ensure_writable(target)
shutil.copy2(str(source), str(target))
return True
def copy_versioned(
files: list[tuple[str, str]],
project_root: str,
on_progress=None,
) -> dict:
"""Copy files into the persistent versioned store.
For each file:
- New: create baseline + current (two copies)
- Changed (mtime differs): diff old->new, overwrite current
- Unchanged: skip
Args:
files: List of (absolute_path, relative_path) tuples.
project_root: Project root (used to build store paths).
on_progress: Optional callback after each file.
Returns:
Dict with files_copied, files_unchanged, bytes_copied, errors.
"""
files_copied = 0
files_unchanged = 0
bytes_copied = 0
errors: list[str] = []
for abs_path, rel_path in files:
source = Path(abs_path)
target = Path(build_versioned_file_path(project_root, rel_path))
# Long-path guard
if len(str(target)) > 260:
logger.warning(f"Path too long (>260), skipping: {rel_path}")
errors.append(f"{rel_path}: path too long (>260 chars)")
if on_progress:
on_progress()
continue
try:
if not target.exists():
# New file: baseline + current
_copy_new_file(source, target)
bytes_copied += os.path.getsize(abs_path)
files_copied += 1
else:
# Existing: compare mtimes
src_mtime = source.stat().st_mtime
tgt_mtime = target.stat().st_mtime
if src_mtime != tgt_mtime:
_copy_changed_file(source, target)
bytes_copied += os.path.getsize(abs_path)
files_copied += 1
else:
files_unchanged += 1
except OSError as e:
logger.warning(f"Failed to process {rel_path}: {e}")
errors.append(f"{rel_path}: {e}")
if on_progress:
on_progress()
result = {
"files_copied": files_copied,
"files_unchanged": files_unchanged,
"bytes_copied": bytes_copied,
"errors": errors,
}
json_handler.log_operation(
"copy_versioned",
{
"project_root": project_root,
"files_copied": files_copied,
"files_unchanged": files_unchanged,
},
)
return result
# =============================================
@@ -0,0 +1,146 @@
# =================== AIPass ====================
# Name: generator.py
# Description: Unified diff generation with binary detection and pattern filtering
# Version: 2.0.0
# Created: 2026-04-16
# Modified: 2026-06-12
# =============================================
"""Diff generator — unified diffs between file versions with binary detection."""
import datetime
import difflib
from pathlib import Path
from aipass.prax import logger
from ..json import json_handler
DIFF_IGNORE_PATTERNS = [
"*.pyc",
"*.pyo",
"*.so",
"*.dylib",
"*.dll",
"*.exe",
"*.bin",
"*.dat",
"*.db",
"*.sqlite",
"*.sqlite3",
"*.jpg",
"*.jpeg",
"*.png",
"*.gif",
"*.bmp",
"*.ico",
"*.svg",
"*.woff",
"*.woff2",
"*.ttf",
"*.eot",
"*.mp3",
"*.mp4",
"*.wav",
"*.avi",
"*.zip",
"*.tar",
"*.gz",
"*.bz2",
"*.7z",
"*.rar",
"*.pdf",
"*.doc",
"*.docx",
"*.xls",
"*.xlsx",
]
DIFF_INCLUDE_PATTERNS = [
"*.py",
"*.js",
"*.ts",
"*.jsx",
"*.tsx",
"*.json",
"*.yaml",
"*.yml",
"*.toml",
"*.cfg",
"*.ini",
"*.md",
"*.rst",
"*.txt",
"*.html",
"*.css",
"*.sh",
"*.bash",
"*.sql",
"*.xml",
"*.csv",
]
def should_create_diff(file_path: Path) -> bool:
"""Check if file should have diffs created based on patterns.
Include patterns override ignore patterns. Default = create diff.
"""
for pattern in DIFF_INCLUDE_PATTERNS:
if file_path.match(pattern):
return True
for pattern in DIFF_IGNORE_PATTERNS:
if file_path.match(pattern):
return False
return True
def is_binary_file(file_path: Path) -> bool:
"""Check if a file is likely binary (null byte in first 1KB)."""
try:
with open(file_path, "rb") as f:
chunk = f.read(1024)
return b"\0" in chunk
except Exception as e:
logger.info(f"[diff] Could not read {file_path}, assuming binary: {e}")
return True
def generate_diff_content(old_file: Path, new_file: Path) -> str:
"""Generate unified diff between two file versions.
Args:
old_file: Path to old version (store current before overwrite).
new_file: Path to new version (source file).
Returns:
Unified diff string, or binary-change marker.
"""
try:
if is_binary_file(old_file) or is_binary_file(new_file):
return f"Binary file {old_file.name} changed\n"
with open(old_file, encoding="utf-8", errors="replace") as f:
old_lines = f.readlines()
with open(new_file, encoding="utf-8", errors="replace") as f:
new_lines = f.readlines()
diff_lines = difflib.unified_diff(
old_lines,
new_lines,
fromfile=f"a/{old_file.name}",
tofile=f"b/{new_file.name}",
fromfiledate=datetime.datetime.fromtimestamp(old_file.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
tofiledate=datetime.datetime.fromtimestamp(new_file.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
lineterm="",
)
result = "\n".join(diff_lines)
json_handler.log_operation("diff_generated", {"file": old_file.name})
return result
except Exception as e:
logger.warning(f"[diff] Failed to generate diff: {old_file} -> {new_file}: {e}")
return f"Error generating diff: {e}\n"
# =============================================
@@ -0,0 +1,83 @@
# =================== AIPass ====================
# Name: restore.py
# Description: Version restore — reconstruct files from baseline + diffs
# Version: 1.0.0
# Created: 2026-06-12
# Modified: 2026-06-12
# =============================================
"""Restore handler — reconstruct file versions from baseline + diffs."""
import re
import shutil
from pathlib import Path
from aipass.prax import logger
from ..json import json_handler
def list_versions(file_folder: Path) -> list[dict]:
"""List all versions available for a file-folder.
Returns list of dicts with 'timestamp', 'path', 'type' (baseline/diff/current).
"""
versions = []
if not file_folder.is_dir():
return versions
name = file_folder.name
# Find baseline
for f in file_folder.iterdir():
if f.is_file() and "-baseline-" in f.name:
versions.append({"timestamp": "baseline", "path": f, "type": "baseline"})
# Find current
current = file_folder / name
if current.is_file():
versions.append({"timestamp": "current", "path": current, "type": "current"})
# Find diffs
diff_dir = file_folder / f"{name}_diffs"
if diff_dir.is_dir():
for diff_file in sorted(diff_dir.glob(f"{name}_v*.diff")):
ts_match = re.search(r"_v(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.diff$", diff_file.name)
if ts_match:
versions.append(
{
"timestamp": ts_match.group(1),
"path": diff_file,
"type": "diff",
}
)
json_handler.log_operation("list_versions", {"folder": str(file_folder), "count": len(versions)})
return versions
def restore_file(file_folder: Path, output_path: Path) -> bool:
"""Restore the current version of a file from the versioned store.
Args:
file_folder: The file-folder in the versioned store.
output_path: Where to write the restored file.
Returns:
True if restoration succeeded.
"""
name = file_folder.name
current = file_folder / name
if not current.is_file():
logger.warning(f"[restore] No current version found in {file_folder}")
return False
output_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(current), str(output_path))
json_handler.log_operation("restore_file", {"source": str(current), "output": str(output_path)})
logger.info(f"[restore] Restored {name} to {output_path}")
return True
# =============================================
@@ -0,0 +1,367 @@
# =================== AIPass ====================
# Name: client.py
# Description: Google Drive client — auth, folders, file lookup via @api gateway
# Version: 2.0.0
# Created: 2026-04-16
# Modified: 2026-06-12
# =============================================
"""Google Drive client.
Core Drive v3 client routed through the @api gateway. Handles
authentication, folder creation/lookup, and file discovery.
Never uses console-OAuth -- all auth flows through
``aipass.api.apps.modules.google_client``.
Lock pattern ported from GOLD (drive_sync_client.py):
- get_or_create_backup_folder has NO lock (always called inside
project_folder's lock).
- get_or_create_project_folder wraps its ENTIRE body in
_folder_cache_lock (cache check + backup-folder-ensure + search +
create).
- get_or_create_nested_folder wraps the entire path walk in the lock.
"""
from __future__ import annotations
import threading
from typing import Any
from aipass.prax import logger
from ..json import json_handler
try:
from aipass.api.apps.modules.google_client import (
api_call_with_retry,
get_drive_service,
)
GOOGLE_API_AVAILABLE = True
except ImportError:
logger.info("Google API client libraries not available")
GOOGLE_API_AVAILABLE = False
get_drive_service = None # type: ignore[assignment]
api_call_with_retry = None # type: ignore[assignment]
BACKUP_FOLDER_NAME = "AIPass Backups"
FOLDER_MIME = "application/vnd.google-apps.folder"
class DriveClient:
"""Google Drive v3 client backed by the @api gateway."""
def __init__(self) -> None:
self._drive_service: Any = None
self._thread_local = threading.local()
self._folder_cache_lock = threading.Lock()
self.backup_folder_id: str | None = None
self.project_folder_cache: dict[str, str] = {}
self.file_tracker: dict[str, dict] = {}
self.last_error: str | None = None
# -- properties ----------------------------------------------------------
@property
def drive_service(self) -> Any:
"""Return thread-local service if set, otherwise main service."""
return getattr(self._thread_local, "service", None) or self._drive_service
# -- auth ----------------------------------------------------------------
def authenticate(self) -> bool:
"""Authenticate through the @api gateway."""
if not GOOGLE_API_AVAILABLE:
self.last_error = "Google API libraries not installed"
json_handler.log_operation(
"drive_authenticate",
{"success": False, "reason": self.last_error},
)
return False
try:
self._drive_service = get_drive_service(thread_safe=False) # type: ignore[misc]
if self._drive_service is None:
self.last_error = "get_drive_service returned None"
json_handler.log_operation(
"drive_authenticate",
{"success": False, "reason": self.last_error},
)
return False
json_handler.log_operation("drive_authenticate", {"success": True})
return True
except Exception as exc:
self.last_error = str(exc)
logger.warning(f"Drive authentication failed: {exc}")
json_handler.log_operation(
"drive_authenticate",
{"success": False, "error": self.last_error},
)
return False
# -- low-level API -------------------------------------------------------
def _api_call(self, request: Any, max_retries: int = 3) -> Any:
"""Execute a Google API request with retry."""
try:
return api_call_with_retry(request, max_retries=max_retries) # type: ignore[misc]
except Exception as first_exc:
logger.info(f"API call failed, rebuilding thread service: {first_exc}")
try:
self._thread_local.service = self._build_thread_service()
return api_call_with_retry(request, max_retries=1) # type: ignore[misc]
except Exception as exc:
self.last_error = str(exc)
logger.info(f"API call retry also failed: {exc}")
return None
def _build_thread_service(self) -> Any:
"""Build an isolated Drive service for the current thread."""
return get_drive_service(thread_safe=True) # type: ignore[misc]
# -- folder ops ----------------------------------------------------------
def _verify_folder_id(self, folder_id: str) -> bool:
"""Check that a folder exists and is not trashed."""
if not self.drive_service:
return False
try:
request = self.drive_service.files().get(fileId=folder_id, fields="id,trashed")
result = self._api_call(request)
if result is None:
return False
return not result.get("trashed", True)
except Exception as exc:
logger.info(f"Failed to verify folder {folder_id}: {exc}")
return False
def get_or_create_backup_folder(self) -> str | None:
"""Get or create the root 'AIPass Backups' folder.
NO lock — always called inside get_or_create_project_folder's lock
(or single-threaded during pre-resolve). Matches GOLD's pattern.
"""
# Short-circuit: verify cached ID
if self.backup_folder_id:
if self._verify_folder_id(self.backup_folder_id):
return self.backup_folder_id
self.backup_folder_id = None
if not self.drive_service:
return None
# Search for existing
query = f"name='{BACKUP_FOLDER_NAME}' and mimeType='{FOLDER_MIME}' and trashed=false"
try:
request = self.drive_service.files().list(
q=query,
spaces="drive",
fields="files(id,name)",
)
result = self._api_call(request)
if result and result.get("files"):
self.backup_folder_id = result["files"][0]["id"]
json_handler.log_operation(
"get_backup_folder",
{"action": "found_existing", "folder_id": self.backup_folder_id},
)
return self.backup_folder_id
except Exception as exc:
self.last_error = str(exc)
logger.warning(f"Failed to search for backup folder: {exc}")
return None
# Create new
try:
metadata = {"name": BACKUP_FOLDER_NAME, "mimeType": FOLDER_MIME}
request = self.drive_service.files().create(body=metadata, fields="id")
result = self._api_call(request)
if not result:
return None
new_id: str = result["id"]
self.backup_folder_id = new_id
# Conditional tracker reset (GOLD pattern):
# old drive_ids point to dead files under the old root folder
old_count = len(self.file_tracker)
if old_count > 0:
self.file_tracker.clear()
self.project_folder_cache.clear()
json_handler.log_operation(
"tracker_reset",
{
"message": f"New backup folder - reset {old_count} tracker entries",
"old_tracker_count": old_count,
"new_folder_id": new_id,
},
)
# Verify accessible
if not self._verify_folder_id(new_id):
self.last_error = f"Backup folder {new_id} created but not accessible"
self.backup_folder_id = None
return None
json_handler.log_operation(
"get_backup_folder",
{"action": "created_new", "folder_id": new_id},
)
return self.backup_folder_id
except Exception as exc:
self.last_error = str(exc)
logger.warning(f"Failed to create backup folder: {exc}")
return None
def get_or_create_project_folder(self, project_name: str) -> str | None:
"""Get or create a project subfolder under AIPass Backups.
Lock covers cache check + backup-folder-ensure + search + create
to prevent duplicate folders (GOLD's pattern).
"""
with self._folder_cache_lock:
# Cache check with verify
if project_name in self.project_folder_cache:
folder_id = self.project_folder_cache[project_name]
if self._verify_folder_id(folder_id):
return folder_id
del self.project_folder_cache[project_name]
# Ensure backup folder (no deadlock: backup_folder has no lock)
backup_folder_id = self.get_or_create_backup_folder()
if not backup_folder_id:
return None
# Search
query = (
f"name='{project_name}' "
f"and mimeType='{FOLDER_MIME}' "
f"and '{backup_folder_id}' in parents "
f"and trashed=false"
)
try:
request = self.drive_service.files().list(
q=query,
spaces="drive",
fields="files(id,name)",
)
result = self._api_call(request)
if result and result.get("files"):
folder_id = result["files"][0]["id"]
self.project_folder_cache[project_name] = folder_id
return folder_id
except Exception as exc:
self.last_error = str(exc)
logger.warning(f"Failed to search for project folder '{project_name}': {exc}")
return None
# Create
try:
metadata = {
"name": project_name,
"mimeType": FOLDER_MIME,
"parents": [backup_folder_id],
}
request = self.drive_service.files().create(body=metadata, fields="id")
result = self._api_call(request)
if result:
folder_id = result["id"]
self.project_folder_cache[project_name] = folder_id
return folder_id
except Exception as exc:
self.last_error = str(exc)
logger.warning(f"Failed to create project folder '{project_name}': {exc}")
return None
def _find_or_create_segment(self, parent_id: str, name: str) -> str | None:
"""Search for or create a single folder segment under parent_id."""
query = f"name='{name}' and mimeType='{FOLDER_MIME}' and '{parent_id}' in parents and trashed=false"
request = self.drive_service.files().list(
q=query,
spaces="drive",
fields="files(id,name)",
)
result = self._api_call(request)
if result and result.get("files"):
return result["files"][0]["id"]
metadata = {"name": name, "mimeType": FOLDER_MIME, "parents": [parent_id]}
request = self.drive_service.files().create(body=metadata, fields="id")
result = self._api_call(request)
return result["id"] if result else None
def get_or_create_nested_folder(
self,
parent_id: str,
folder_path: str,
) -> str | None:
"""Create a nested folder hierarchy segment by segment.
Lock covers entire walk — full-path + per-segment caching with
verify (GOLD's pattern).
"""
if not folder_path or folder_path == ".":
return parent_id
with self._folder_cache_lock:
cache_key = f"{parent_id}:{folder_path}"
if cache_key in self.project_folder_cache:
folder_id = self.project_folder_cache[cache_key]
if self._verify_folder_id(folder_id):
return folder_id
del self.project_folder_cache[cache_key]
current_parent = parent_id
segments = [s for s in folder_path.split("/") if s]
for segment in segments:
segment_key = f"{current_parent}:{segment}"
if segment_key in self.project_folder_cache:
cached_id = self.project_folder_cache[segment_key]
if self._verify_folder_id(cached_id):
current_parent = cached_id
continue
del self.project_folder_cache[segment_key]
try:
folder_id = self._find_or_create_segment(current_parent, segment)
except Exception as exc:
self.last_error = str(exc)
logger.info(f"Failed to handle nested folder '{segment}': {exc}")
return None
if not folder_id:
return None
current_parent = folder_id
self.project_folder_cache[segment_key] = current_parent
self.project_folder_cache[cache_key] = current_parent
return current_parent
# -- file ops ------------------------------------------------------------
def _find_existing_file(
self,
filename: str,
parent_folder_id: str,
) -> dict | None:
"""Find a file by name in a folder (excludes trashed)."""
query = f"name='{filename}' and '{parent_folder_id}' in parents and trashed=false"
try:
request = self.drive_service.files().list(
q=query,
spaces="drive",
fields="files(id,name)",
)
result = self._api_call(request)
if result and result.get("files"):
return result["files"][0]
except Exception as exc:
logger.info(f"Failed to find file {filename}: {exc}")
return None
# =============================================
@@ -0,0 +1,68 @@
# =================== AIPass ====================
# Name: test.py
# Description: Drive connectivity test — auth + folder access verification
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-06-12
# =============================================
"""Drive connectivity test.
Performs a lightweight check against the Drive API to confirm the
client has working credentials and can access the backup folder.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..json import json_handler
if TYPE_CHECKING:
from .client import DriveClient
def test_connectivity(client: DriveClient) -> dict:
"""Test Drive connectivity: auth + folder access.
Args:
client: DriveClient instance (may or may not be authenticated).
Returns:
Dict with success, folder_id, and error keys.
"""
result: dict = {
"success": False,
"folder_id": None,
"error": None,
}
# Step 1: authenticate
if not client.authenticate():
result["error"] = client.last_error or "Authentication failed"
json_handler.log_operation(
"test_connectivity",
{"success": False, "step": "auth", "error": result["error"]},
)
return result
# Step 2: folder access
folder_id = client.get_or_create_backup_folder()
if not folder_id:
result["error"] = client.last_error or "Failed to access backup folder"
json_handler.log_operation(
"test_connectivity",
{"success": False, "step": "folder", "error": result["error"]},
)
return result
result["success"] = True
result["folder_id"] = folder_id
json_handler.log_operation(
"test_connectivity",
{"success": True, "folder_id": folder_id},
)
return result
# =============================================
@@ -0,0 +1,192 @@
# =================== AIPass ====================
# Name: tracker.py
# Description: Drive upload tracker — mtime+size dedup for file sync
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-06-12
# =============================================
"""Drive upload tracker.
Maintains a persistent mapping of local file paths to Drive metadata
(file ID, mtime, size) so repeat syncs can skip unchanged files.
Tracker is stored at ``<project>/.backup/drive_tracker.json``.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from aipass.prax import logger
from ..json import json_handler
TRACKER_FILENAME = "drive_tracker.json"
def _tracker_path(project_root: str) -> Path:
"""Return the tracker file path for a project."""
from ..path.builder import backup_root
return backup_root(project_root) / TRACKER_FILENAME
def load_tracker(project_root: str) -> dict:
"""Load tracker from .backup/drive_tracker.json.
Returns:
Dict keyed by relative file path with metadata values.
"""
path = _tracker_path(project_root)
data = json_handler.load_json(str(path))
json_handler.log_operation(
"load_tracker",
{"project_root": project_root, "entries": len(data)},
)
return data
def save_tracker(project_root: str, tracker: dict) -> None:
"""Save tracker to .backup/drive_tracker.json."""
path = _tracker_path(project_root)
json_handler.save_json(str(path), tracker)
json_handler.log_operation(
"save_tracker",
{"project_root": project_root, "entries": len(tracker)},
)
def check_needs_upload(
tracker: dict,
local_file: Path,
backup_root: Path,
) -> bool:
"""Check if a file needs upload (new or mtime/size changed).
Pure local check -- no API calls.
Args:
tracker: Current tracker dict.
local_file: Absolute path to the local file.
backup_root: Root directory for computing relative paths.
Returns:
True if the file is new or has changed since last sync.
"""
try:
rel_key = str(local_file.relative_to(backup_root))
except ValueError:
logger.info(f"File {local_file} not relative to {backup_root}")
return True
if rel_key not in tracker:
return True
entry = tracker[rel_key]
try:
stat = local_file.stat()
if stat.st_size != entry.get("local_size"):
return True
if stat.st_mtime != entry.get("local_mtime"):
return True
except OSError as exc:
logger.info(f"Failed to stat {local_file}: {exc}")
return True
return False
def update_entry(
tracker: dict,
local_file: Path,
backup_root: Path,
drive_file_id: str,
) -> None:
"""Update tracker entry after successful upload.
Args:
tracker: Tracker dict (mutated in place).
local_file: Absolute path to the uploaded file.
backup_root: Root directory for computing relative paths.
drive_file_id: Drive file ID assigned to the uploaded resource.
"""
try:
rel_key = str(local_file.relative_to(backup_root))
except ValueError:
logger.info(f"File {local_file} not relative to {backup_root}, using absolute")
rel_key = str(local_file)
try:
stat = local_file.stat()
tracker[rel_key] = {
"local_size": stat.st_size,
"local_mtime": stat.st_mtime,
"drive_id": drive_file_id,
"last_sync": datetime.now(timezone.utc).isoformat(),
}
except OSError as exc:
logger.info(f"Failed to stat {local_file} for tracker update: {exc}")
tracker[rel_key] = {
"local_size": 0,
"local_mtime": 0.0,
"drive_id": drive_file_id,
"last_sync": datetime.now(timezone.utc).isoformat(),
}
def clean_tracker(tracker: dict, existing_files: set) -> list[str]:
"""Remove entries for files that no longer exist.
Args:
tracker: Tracker dict (mutated in place).
existing_files: Set of relative file paths that still exist.
Returns:
List of removed keys.
"""
stale = [k for k in tracker if k not in existing_files]
for key in stale:
del tracker[key]
if stale:
json_handler.log_operation(
"clean_tracker",
{"removed": len(stale)},
)
return stale
def get_stats(tracker: dict) -> dict:
"""Return tracker statistics.
Returns:
Dict with total count and sample entries.
"""
total = len(tracker)
sample = dict(list(tracker.items())[:5]) if tracker else {}
return {
"total": total,
"sample": sample,
}
def clear_all(project_root: str) -> bool:
"""Clear entire tracker file.
Returns:
True if cleared successfully.
"""
path = _tracker_path(project_root)
try:
json_handler.save_json(str(path), {})
json_handler.log_operation(
"clear_tracker",
{"project_root": project_root},
)
return True
except Exception as exc:
logger.warning(f"Failed to clear tracker: {exc}")
return False
# =============================================
@@ -0,0 +1,267 @@
# =================== AIPass ====================
# Name: upload.py
# Description: Google Drive upload engine — single + batch with threading
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-06-12
# =============================================
"""Google Drive upload engine.
Uploads files to Drive using resumable MediaFileUpload. Supports single
file uploads and threaded batch uploads via ThreadPoolExecutor.
"""
from __future__ import annotations
import mimetypes
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import TYPE_CHECKING, Any
from aipass.prax import logger
from ..json import json_handler
from . import tracker as tracker_mod
try:
from googleapiclient.http import MediaFileUpload # pyright: ignore[reportMissingImports]
MEDIA_UPLOAD_AVAILABLE = True
except ImportError:
logger.info("Google API HTTP library not available")
MEDIA_UPLOAD_AVAILABLE = False
MediaFileUpload = None # type: ignore[assignment,misc]
if TYPE_CHECKING:
from .client import DriveClient
def upload_single_file(
client: DriveClient,
local_file: Path,
project_name: str,
backup_root: Path,
note: str = "",
) -> bool:
"""Upload one file with resumable MediaFileUpload.
Calculates relative path from backup_root for folder structure in
Drive. Uses tracker for dedup (cached drive_id). Updates or creates
the file accordingly.
Args:
client: Authenticated DriveClient instance.
local_file: Absolute path to the file to upload.
project_name: Project name for Drive folder hierarchy.
backup_root: Root path for computing relative file paths.
note: Optional note for logging.
Returns:
True on success, False on failure.
"""
if not local_file.is_file():
return False
# Get project folder
project_folder_id = client.get_or_create_project_folder(project_name)
if not project_folder_id:
return False
# Compute relative path and target folder
try:
rel_path = local_file.relative_to(backup_root)
except ValueError:
logger.info(f"File {local_file} not relative to {backup_root}")
rel_path = Path(local_file.name)
parent_dir = str(rel_path.parent)
if parent_dir and parent_dir != ".":
target_folder_id = client.get_or_create_nested_folder(
project_folder_id,
parent_dir,
)
if not target_folder_id:
return False
else:
target_folder_id = project_folder_id
# Check tracker for existing drive_id
try:
rel_key = str(local_file.relative_to(backup_root))
except ValueError:
logger.info(f"File {local_file} not relative to {backup_root}, using absolute path")
rel_key = str(local_file)
existing_drive_id = client.file_tracker.get(rel_key, {}).get("drive_id")
# Detect MIME type
mime_type, _ = mimetypes.guess_type(str(local_file))
if mime_type is None:
mime_type = "application/octet-stream"
try:
if not MEDIA_UPLOAD_AVAILABLE:
return False
media = MediaFileUpload( # type: ignore[misc]
str(local_file),
mimetype=mime_type,
resumable=True,
)
if existing_drive_id:
# Update existing file
request = client.drive_service.files().update( # type: ignore[union-attr]
fileId=existing_drive_id,
media_body=media,
fields="id",
)
else:
# Create new file
file_metadata: dict[str, Any] = {
"name": local_file.name,
"parents": [target_folder_id],
}
if note:
file_metadata["description"] = note
request = client.drive_service.files().create( # type: ignore[union-attr]
body=file_metadata,
media_body=media,
fields="id",
)
result = client._api_call(request)
if result:
drive_file_id = result.get("id", existing_drive_id or "")
tracker_mod.update_entry(
client.file_tracker,
local_file,
backup_root,
drive_file_id,
)
json_handler.log_operation(
"upload_file",
{
"file": str(local_file),
"drive_id": drive_file_id,
"action": "update" if existing_drive_id else "create",
},
)
return True
except Exception as exc:
logger.warning(f"Failed to upload {local_file}: {exc}")
json_handler.log_operation(
"upload_file_error",
{"file": str(local_file), "error": str(exc)},
)
return False
def _file_size(path: Path) -> int:
"""Return file size in bytes, 0 on error."""
try:
return path.stat().st_size
except OSError as exc:
logger.info(f"Could not stat {path}: {exc}")
return 0
def upload_batch(
client: DriveClient,
files: list[Path],
project_name: str,
backup_root: Path,
tracker: dict,
note: str = "",
max_workers: int = 3,
batch_save_interval: int = 50,
progress_fn: Any = None,
) -> dict:
"""Threaded batch upload using ThreadPoolExecutor.
Each thread gets its own Drive service for thread safety.
Args:
client: Authenticated DriveClient instance.
files: List of files to upload.
project_name: Project name for Drive folder hierarchy.
backup_root: Root path for computing relative file paths.
tracker: File tracker dict (shared, thread-safe updates).
note: Optional note for logging.
max_workers: Max concurrent upload threads.
batch_save_interval: Save tracker every N uploads.
progress_fn: Optional callback called after each upload.
Returns:
Dict with success, uploaded, failed counts.
"""
if not files:
return {"success": True, "uploaded": 0, "failed": 0}
client.file_tracker = tracker
uploaded = 0
failed = 0
bytes_uploaded = 0
def _upload_one(file_path: Path) -> bool:
"""Upload a single file in a worker thread."""
# Ensure thread has its own service
if not getattr(client._thread_local, "service", None):
client._thread_local.service = client._build_thread_service()
return upload_single_file(
client,
file_path,
project_name,
backup_root,
note=note,
)
def _process_future(future: object) -> bool:
"""Process a completed upload future. Returns True on success."""
try:
return bool(future.result()) # type: ignore[union-attr]
except Exception as exc:
logger.info(f"Upload future failed: {exc}")
return False
def _maybe_batch_save(count: int) -> None:
"""Save tracker periodically during batch upload."""
if count % batch_save_interval == 0 and hasattr(client, "_project_root"):
try:
tracker_mod.save_tracker(client._project_root, tracker) # type: ignore[attr-defined]
except Exception as exc:
logger.info(f"Batch tracker save failed: {exc}")
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(_upload_one, f): f for f in files}
completed = 0
for future in as_completed(futures):
completed += 1
if _process_future(future):
uploaded += 1
bytes_uploaded += _file_size(futures[future])
else:
failed += 1
if progress_fn:
progress_fn()
_maybe_batch_save(completed)
json_handler.log_operation(
"upload_batch_complete",
{"uploaded": uploaded, "failed": failed, "total": len(files)},
)
return {
"success": failed == 0,
"uploaded": uploaded,
"failed": failed,
"bytes_uploaded": bytes_uploaded,
}
# =============================================
@@ -0,0 +1,88 @@
# =================== AIPass ====================
# Name: patterns.py
# Description: Ignore pattern loader — pathspec/gitwildmatch matcher
# Version: 2.0.0
# Created: 2026-04-17
# Modified: 2026-06-12
# =============================================
"""Ignore patterns handler.
Loads .backupignore from the project root and matches paths using
pathspec (gitwildmatch) — true gitignore semantics.
"""
import pathspec
from ..json import json_handler
from ..path import builder
BUILTIN_IGNORES = [
".backup/",
".git/",
".svn/",
".hg/",
"__pycache__/",
".pytest_cache/",
"*.pyc",
"*.pyo",
"*.egg-info/",
".venv/",
"venv/",
".tox/",
"node_modules/",
".vscode/",
".idea/",
"*.swp",
"*.swo",
".DS_Store",
"Thumbs.db",
"build/",
"dist/",
"*.log",
".ruff_cache/",
".coverage",
]
def load_spec(project_root: str) -> pathspec.PathSpec:
"""Load a PathSpec from .backupignore at the project root.
Reads raw lines — pathspec handles #comments, blanks, !negation,
anchoring, dir-only trailing /, and last-match-wins natively.
Args:
project_root: Absolute path to the project root.
Returns:
A compiled PathSpec using gitwildmatch semantics.
"""
ignore_path = builder.build_ignore_path(project_root)
lines: list[str] = []
if ignore_path.exists():
with open(ignore_path, encoding="utf-8") as f:
lines = f.readlines()
spec = pathspec.PathSpec.from_lines("gitignore", lines)
json_handler.log_operation(
"load_spec",
{"project_root": project_root, "pattern_count": len(spec.patterns)},
)
return spec
def is_ignored(rel_path: str, spec: pathspec.PathSpec) -> bool:
"""Check whether a relative path is ignored by the spec.
Args:
rel_path: Path relative to the project root (forward slashes).
spec: Compiled PathSpec from load_spec().
Returns:
True when the path should be ignored.
"""
return spec.match_file(rel_path.replace("\\", "/"))
# =============================================
@@ -0,0 +1,53 @@
# =================== AIPass ====================
# Name: whitelist.py
# Description: Whitelist loader and path membership check
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-23
# =============================================
"""Whitelist handler.
Loads an allow-list of paths that should always be included in a backup even
when a matching ignore pattern would otherwise skip them.
"""
import fnmatch
from ..json import json_handler
from ..project import config
def load_whitelist(project_root: str) -> list[str]:
"""Load whitelist entries from project config.
Args:
project_root: Absolute path to the project root.
Returns:
List of whitelist path/glob entries.
"""
cfg = config.load_project_config(project_root)
entries = cfg.get("whitelist", [])
json_handler.log_operation("load_whitelist", {"project_root": project_root, "count": len(entries)})
return entries
def is_whitelisted(rel_path: str, whitelist: list[str]) -> bool:
"""Check whether a relative path is whitelisted.
Args:
rel_path: Path relative to the project root.
whitelist: Whitelist entries loaded from configuration.
Returns:
True when the path is whitelisted (should be included regardless of ignore).
"""
rel = rel_path.replace("\\", "/")
for entry in whitelist:
if fnmatch.fnmatch(rel, entry):
return True
return False
# =============================================
@@ -0,0 +1,70 @@
# =================== AIPass ====================
# Name: json_handler.py
# Description: Generic JSON ops — read/write, self-healing, atomic writes
# Version: 1.0.0
# Created: 2026-04-17
# Modified: 2026-04-23
# =============================================
"""JSON handler — generic persistence utilities shared across backup modules."""
import json
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from aipass.prax import logger
def log_operation(operation: str, data: dict) -> None:
"""Record an operation entry to the backup system log."""
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"operation": operation,
**data,
}
log_dir = Path(__file__).resolve().parents[3] / "logs"
log_dir.mkdir(exist_ok=True)
log_file = log_dir / "operations.jsonl"
try:
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
except OSError as e:
logger.warning(f"Failed to write operation log: {e}")
def load_json(path: str) -> dict:
"""Load JSON from path with self-healing on corruption."""
p = Path(path)
if not p.exists():
return {}
try:
with open(p, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, ValueError) as e:
logger.warning(f"Corrupt JSON at {p}, renaming to .corrupt: {e}")
corrupt = p.with_suffix(p.suffix + ".corrupt")
p.rename(corrupt)
return {}
def save_json(path: str, data: dict) -> None:
"""Atomic write JSON to path (write temp -> rename)."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=p.parent, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, default=str)
f.write("\n")
os.replace(tmp, p)
except Exception:
try:
os.unlink(tmp)
except OSError as e:
logger.warning(f"Failed to clean up temp file {tmp}: {e}")
raise
# =============================================
@@ -0,0 +1 @@
"""Path handlers package — destination path builders for backup modes."""
@@ -0,0 +1,98 @@
# =================== AIPass ====================
# Name: builder.py
# Description: Destination path builders for snapshot, versioned, and drive modes
# Version: 2.0.0
# Created: 2026-04-16
# Modified: 2026-06-12
# =============================================
"""Path builder handler.
Computes destination paths for backup modes. All paths are relative to the
target project's .backup/ directory.
"""
from pathlib import Path
from ..json import json_handler
BACKUP_DIR = ".backup"
def backup_root(project_root: str) -> Path:
"""Return the .backup/ path for a project."""
return Path(project_root) / BACKUP_DIR
def build_snapshot_path(project_root: str) -> Path:
"""Snapshot destination: <project>/.backup/snapshots/"""
json_handler.log_operation("build_snapshot_path", {"project_root": project_root})
return backup_root(project_root) / "snapshots"
def build_config_path(project_root: str) -> Path:
"""Config file: <project>/.backup/config.json"""
return backup_root(project_root) / "config.json"
def build_ignore_path(project_root: str) -> Path:
"""Ignore file: <project>/.backupignore"""
return Path(project_root) / ".backupignore"
def build_timestamps_path(project_root: str) -> Path:
"""Timestamps file: <project>/.backup/timestamps.json"""
return backup_root(project_root) / "timestamps.json"
def build_changelog_path(project_root: str) -> Path:
"""Changelog file: <project>/.backup/changelog.json"""
return backup_root(project_root) / "changelog.json"
def build_log_dir(project_root: str) -> Path:
"""Log directory: <project>/.backup/logs/"""
return backup_root(project_root) / "logs"
def build_versioned_store(project_root: str) -> Path:
"""Persistent versioned store: <project>/.backup/versioned/"""
json_handler.log_operation("build_versioned_store", {"project_root": project_root})
return backup_root(project_root) / "versioned"
def build_versioned_file_path(
project_root: str,
rel_path: str,
) -> Path:
"""Build the file-folder target path for a versioned file.
Layout:
root-level file: <store>/root/<name>/<name>
nested file: <store>/<parent>/<name>/<name>
name >50 chars: <parent>/<name[:30]_md5[:8]>/<name>
"""
import hashlib
store = build_versioned_store(project_root)
p = Path(rel_path)
name = p.name
parent = str(p.parent)
if len(name) > 50:
name_hash = hashlib.md5(name.encode()).hexdigest()[:8] # noqa: S324
folder_name = name[:30] + f"_{name_hash}"
else:
folder_name = name
if parent == ".":
return store / "root" / folder_name / name
return store / parent / folder_name / name
def build_drive_path(project_root: str, file: str) -> Path:
"""Drive-sync path for a single file (deferred to DPLAN-003)."""
return Path()
# =============================================
@@ -0,0 +1 @@
"""Project handlers package — registry, config, and setup for backup projects."""
@@ -0,0 +1,71 @@
# =================== AIPass ====================
# Name: config.py
# Description: Project config handler — load/save per-project backup config
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-23
# =============================================
"""Project configuration handler.
Reads and writes the per-project ``.backup/config.json`` that stores mode
preferences, size limits, and drive-sync settings.
"""
from aipass.prax import logger
from ..json import json_handler
from ..path import builder
DEFAULTS = {
"version": "1.0.0",
"backup_mode": "snapshot",
"max_versions": 10,
"max_file_size_mb": 100,
"auto_ignore_git": True,
"drive_sync": False,
"whitelist": [],
}
def load_project_config(project_root: str) -> dict:
"""Load the backup configuration for a project.
Args:
project_root: Absolute path to the project root.
Returns:
Dict containing config keys, merged with defaults for any missing keys.
"""
config_path = str(builder.build_config_path(project_root))
config = json_handler.load_json(config_path)
merged = {**DEFAULTS, **config}
json_handler.log_operation("project_config_loaded", {"project_root": project_root})
return merged
def save_project_config(project_root: str, config: dict) -> bool:
"""Persist the backup configuration for a project.
Args:
project_root: Absolute path to the project root.
config: Configuration payload to serialize to JSON.
Returns:
True when the write succeeded, False otherwise.
"""
config_path = str(builder.build_config_path(project_root))
try:
json_handler.save_json(config_path, config)
json_handler.log_operation("project_config_saved", {"project_root": project_root})
return True
except OSError as e:
logger.warning(f"Failed to save config for {project_root}: {e}")
json_handler.log_operation(
"project_config_save_failed",
{"project_root": project_root, "error": str(e)},
)
return False
# =============================================
@@ -0,0 +1,78 @@
# =================== AIPass ====================
# Name: registry.py
# Description: Project registry handler — load/register/lookup backup projects
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-23
# =============================================
"""Project registry handler.
Tracks registered backup projects (name -> absolute path) in the central
backup project registry stored at backup_json/project_registry.json.
"""
from pathlib import Path
from ..json import json_handler
REGISTRY_PATH = Path(__file__).resolve().parents[3] / "backup_json" / "project_registry.json"
def load_project_registry() -> dict:
"""Load the project registry from disk.
Returns:
Dict mapping project name to project metadata.
"""
data = json_handler.load_json(str(REGISTRY_PATH))
json_handler.log_operation("project_registry_loaded", {"count": len(data.get("projects", {}))})
return data.get("projects", {})
def register_project(name: str, path: str) -> bool:
"""Register a new backup project.
Args:
name: Project identifier (unique).
path: Absolute path to the project root.
Returns:
True when the project was added or updated.
"""
data = json_handler.load_json(str(REGISTRY_PATH))
if "projects" not in data:
data["projects"] = {}
data["projects"][name] = {
"path": str(Path(path).resolve()),
"name": name,
}
json_handler.save_json(str(REGISTRY_PATH), data)
json_handler.log_operation("project_registered", {"name": name, "path": path})
return True
def lookup_project(name: str) -> str | None:
"""Resolve a project name to its filesystem path.
Args:
name: Registered project identifier.
Returns:
Absolute path string or None when not registered.
"""
projects = load_project_registry()
entry = projects.get(name)
if entry:
return entry.get("path")
json_handler.log_operation("project_lookup_miss", {"name": name})
return None
def list_projects() -> dict:
"""Return all registered projects."""
return load_project_registry()
# =============================================
@@ -0,0 +1,89 @@
# =================== AIPass ====================
# Name: setup.py
# Description: Project setup handler — scaffold .backup/ directory in target
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-23
# =============================================
"""Project setup handler.
Creates the ``.backup/`` scaffold (config, snapshots/, logs/)
inside a target project path, and a ``.backupignore`` at the project root.
"""
from datetime import datetime, timezone
from pathlib import Path
from ..ignore.patterns import BUILTIN_IGNORES
from ..json import json_handler
from ..path import builder
def _build_backupignore() -> str:
"""Generate .backupignore content from BUILTIN_IGNORES."""
lines = [
"# Backup System ignore patterns (gitignore-style)",
"# Lines starting with # are comments. Blank lines are ignored.",
"# Edit this file to customize. Source defaults: handlers/ignore/patterns.py",
"",
]
for pattern in BUILTIN_IGNORES:
lines.append(pattern)
return "\n".join(lines) + "\n"
DEFAULT_CONFIG = {
"version": "1.0.0",
"backup_mode": "snapshot",
"max_versions": 10,
"max_file_size_mb": 100,
"auto_ignore_git": True,
"drive_sync": False,
"whitelist": [],
}
def create_backup_dir(project_path: str) -> Path | None:
"""Create the ``.backup/`` scaffold inside a project path.
Args:
project_path: Absolute filesystem path to the target project.
Returns:
Path to the created ``.backup/`` directory, or None on failure.
"""
root = Path(project_path)
if not root.is_dir():
json_handler.log_operation("setup_failed", {"project_path": project_path, "reason": "not a directory"})
return None
backup_dir = builder.backup_root(project_path)
subdirs = [
backup_dir / "snapshots",
backup_dir / "logs",
]
for d in subdirs:
d.mkdir(parents=True, exist_ok=True)
config_path = builder.build_config_path(project_path)
if not config_path.exists():
config = {
**DEFAULT_CONFIG,
"project_name": root.name,
"project_path": str(root),
"created": datetime.now(timezone.utc).isoformat(),
}
json_handler.save_json(str(config_path), config)
ignore_path = builder.build_ignore_path(project_path)
if not ignore_path.exists():
with open(ignore_path, "w", encoding="utf-8") as f:
f.write(_build_backupignore())
json_handler.log_operation("setup_complete", {"project_path": project_path})
return backup_dir
# =============================================
@@ -0,0 +1 @@
"""Report handlers package — BackupResult dataclass and CLI formatters."""
@@ -0,0 +1,56 @@
# =================== AIPass ====================
# Name: formatter.py
# Description: Format a BackupResult into a human-readable CLI string
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-23
# =============================================
"""Backup result formatter.
Turns a BackupResult into a summary suitable for terminal display.
"""
from ..json import json_handler
from .result import BackupResult
def _human_bytes(byte_count: int) -> str:
"""Format byte count as human-readable string."""
n = float(byte_count)
for unit in ("B", "KB", "MB", "GB"):
if abs(n) < 1024:
return f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} TB"
def format_result(result: BackupResult) -> str:
"""Format a backup run outcome for CLI display.
Args:
result: The backup run outcome to render.
Returns:
Multi-line string summarizing mode, counts, duration, and errors.
"""
lines = [
f"Backup complete ({result.mode})",
f" Project: {result.project_root}",
f" Files: {result.files_copied}",
f" Size: {_human_bytes(result.bytes_copied)}",
f" Duration: {result.duration_seconds:.1f}s",
]
if result.errors:
lines.append(f" Errors: {len(result.errors)}")
for err in result.errors[:5]:
lines.append(f" - {err}")
if len(result.errors) > 5:
lines.append(f" ... and {len(result.errors) - 5} more")
json_handler.log_operation("format_result", {"mode": result.mode})
return "\n".join(lines)
# =============================================
@@ -0,0 +1,56 @@
# =================== AIPass ====================
# Name: result.py
# Description: BackupResult dataclass — typed outcome container for backup runs
# Version: 2.0.0
# Created: 2026-04-16
# Modified: 2026-06-12
# =============================================
"""Backup result dataclass.
Typed container returned by backup modules (snapshot, versioned)
describing what the run did. Consumed by the report formatter.
"""
from dataclasses import dataclass, field
from ..json import json_handler
@dataclass
class BackupResult:
"""Outcome of a single backup run."""
mode: str
project_root: str = ""
files_copied: int = 0
files_checked: int = 0
files_skipped: int = 0
files_deleted: int = 0
bytes_copied: int = 0
duration_seconds: float = 0.0
backup_path: str = ""
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
critical_errors: list[str] = field(default_factory=list)
success: bool = True
def add_error(self, msg: str, *, is_critical: bool = False) -> None:
"""Add an error. Critical errors mark the backup as failed."""
self.errors.append(msg)
if is_critical:
self.critical_errors.append(msg)
self.success = False
def add_warning(self, msg: str) -> None:
"""Add a non-critical warning."""
self.warnings.append(msg)
def new_result(mode: str, project_root: str = "") -> BackupResult:
"""Construct an empty BackupResult for a given mode."""
json_handler.log_operation("backup_result_created", {"mode": mode})
return BackupResult(mode=mode, project_root=project_root)
# =============================================
@@ -0,0 +1,76 @@
# =================== AIPass ====================
# Name: filter.py
# Description: Post-walk path filtering against ignore/whitelist/size rules
# Version: 2.0.0
# Created: 2026-04-16
# Modified: 2026-06-12
# =============================================
"""Path filter.
Applies a pathspec ignore spec, whitelist entries, and an upper size bound
to a list of candidate paths produced by the walker.
"""
import os
import pathspec
from aipass.prax import logger
from ..ignore.patterns import is_ignored
from ..ignore.whitelist import is_whitelisted
from ..json import json_handler
def filter_paths(
paths: list[tuple[str, str]],
spec: pathspec.PathSpec,
whitelist: list[str],
max_size_mb: int,
) -> list[tuple[str, str]]:
"""Filter candidate paths for inclusion in a backup.
Args:
paths: List of (absolute_path, relative_path) tuples from the walker.
spec: Compiled PathSpec from load_spec().
whitelist: Whitelist entries that override ignore matches.
max_size_mb: Maximum per-file size in megabytes; larger files are skipped.
Returns:
Filtered list of (absolute_path, relative_path) tuples to back up.
"""
max_bytes = max_size_mb * 1024 * 1024
result = []
skipped = 0
for abs_path, rel_path in paths:
if is_whitelisted(rel_path, whitelist):
result.append((abs_path, rel_path))
continue
if is_ignored(rel_path, spec):
skipped += 1
continue
try:
size = os.path.getsize(abs_path)
except OSError as e:
logger.warning(f"Cannot stat {abs_path}: {e}")
skipped += 1
continue
if size > max_bytes:
skipped += 1
continue
result.append((abs_path, rel_path))
json_handler.log_operation(
"filter_paths",
{"total": len(paths), "included": len(result), "skipped": skipped},
)
return result
# =============================================
@@ -0,0 +1,43 @@
# =================== AIPass ====================
# Name: walk.py
# Description: Project tree walker yielding file paths
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-23
# =============================================
"""Project tree walker.
Recursively enumerates files beneath a project root and yields
(absolute_path, relative_path) tuples for downstream filtering and copying.
"""
import os
from collections.abc import Iterator
from ..json import json_handler
def walk_project(root: str) -> Iterator[tuple[str, str]]:
"""Walk the project tree rooted at ``root``.
Args:
root: Absolute path to the project root directory.
Yields:
Tuples of (absolute_path, relative_path) for every file beneath root.
Skips symlinks.
"""
json_handler.log_operation("walk_project", {"root": root})
root_path = os.path.realpath(root)
for dirpath, _dirnames, filenames in os.walk(root_path, followlinks=False):
for filename in filenames:
abs_path = os.path.join(dirpath, filename)
if os.path.islink(abs_path):
continue
rel_path = os.path.relpath(abs_path, root_path)
yield abs_path, rel_path
# =============================================
@@ -0,0 +1,93 @@
# =================== AIPass ====================
# Name: backup_timestamps.py
# Description: Tracks last-run timestamps for all backup modes
# Version: 1.0.0
# Created: 2026-06-12
# Modified: 2026-06-12
# =============================================
"""Backup timestamps — tracks when each backup mode was last run."""
import json
import os
import tempfile
from datetime import datetime
from pathlib import Path
from aipass.prax import logger
from ..json import json_handler
_BACKUP_ROOT = Path(__file__).resolve().parents[3]
TIMESTAMPS_FILE = _BACKUP_ROOT / "backup_json" / "backup_timestamps.json"
MODES = ["snapshot", "versioned", "drive_sync"]
def get_timestamps() -> dict:
"""Read all backup timestamps from disk."""
data = {}
if TIMESTAMPS_FILE.exists():
try:
data = json.loads(TIMESTAMPS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"[backup_timestamps] Failed to read timestamps file: {e}")
data = {}
return {mode: data.get(mode) for mode in MODES}
def update_timestamp(mode: str) -> None:
"""Update the timestamp for a backup mode to now."""
json_handler.log_operation("timestamp_updated", {"mode": mode})
data = {}
if TIMESTAMPS_FILE.exists():
try:
data = json.loads(TIMESTAMPS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"[backup_timestamps] Failed to read timestamps for update: {e}")
data = {}
data[mode] = datetime.now().isoformat()
TIMESTAMPS_FILE.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(suffix=".tmp", dir=str(TIMESTAMPS_FILE.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
os.replace(tmp_path, str(TIMESTAMPS_FILE))
except Exception:
try:
os.unlink(tmp_path)
except OSError as cleanup_err:
logger.warning(f"[backup_timestamps] Failed to clean temp file: {cleanup_err}")
raise
def format_age(iso_str: str | None) -> str:
"""Format an ISO timestamp as a human-readable age string."""
if not iso_str:
return "never"
try:
then = datetime.fromisoformat(iso_str)
except (ValueError, TypeError) as e:
logger.info(f"[backup_timestamps] Could not parse timestamp '{iso_str}': {e}")
return "unknown"
delta = datetime.now() - then
seconds = int(delta.total_seconds())
if seconds < 60:
return "just now"
if seconds < 3600:
mins = seconds // 60
return f"{mins} min{'s' if mins != 1 else ''} ago"
if seconds < 86400:
hours = seconds // 3600
return f"{hours} hour{'s' if hours != 1 else ''} ago"
days = seconds // 86400
return f"{days} day{'s' if days != 1 else ''} ago"
# =============================================
@@ -0,0 +1,51 @@
# =================== AIPass ====================
# Name: changelog.py
# Description: Per-project backup changelog append/read
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-23
# =============================================
"""Changelog state handler.
Appends and reads structured changelog entries describing each backup run
for a project. Stored at .backup/changelog.json.
"""
from ..json import json_handler
from ..path import builder
def append_changelog(project_root: str, entry: dict) -> None:
"""Append a changelog entry for a project.
Args:
project_root: Absolute path to the project root.
entry: Entry payload (timestamp, mode, summary, etc.).
"""
cl_path = str(builder.build_changelog_path(project_root))
data = json_handler.load_json(cl_path)
if "entries" not in data:
data["entries"] = []
data["entries"].append(entry)
json_handler.save_json(cl_path, data)
json_handler.log_operation("append_changelog", {"project_root": project_root})
def load_changelog(project_root: str) -> list[dict]:
"""Load changelog entries for a project.
Args:
project_root: Absolute path to the project root.
Returns:
Chronological list of entry dicts.
"""
cl_path = str(builder.build_changelog_path(project_root))
data = json_handler.load_json(cl_path)
entries = data.get("entries", [])
json_handler.log_operation("load_changelog", {"project_root": project_root, "count": len(entries)})
return entries
# =============================================
@@ -0,0 +1,45 @@
# =================== AIPass ====================
# Name: metadata.py
# Description: Backup result to metadata payload builder
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-23
# =============================================
"""Metadata builder.
Converts a BackupResult into a metadata payload for changelog entries
and backup artifacts.
"""
import platform
from datetime import datetime, timezone
from ..json import json_handler
from ..report.result import BackupResult
def build_metadata(result: BackupResult) -> dict:
"""Build a metadata payload from a backup result.
Args:
result: BackupResult instance from a completed backup run.
Returns:
Dict of metadata fields ready for JSON serialization.
"""
meta = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"mode": result.mode,
"files_copied": result.files_copied,
"bytes_copied": result.bytes_copied,
"duration_seconds": result.duration_seconds,
"errors": result.errors,
"hostname": platform.node(),
"platform": platform.system(),
}
json_handler.log_operation("build_metadata", {"mode": result.mode})
return meta
# =============================================
@@ -0,0 +1,46 @@
# =================== AIPass ====================
# Name: timestamps.py
# Description: Per-project last-backup timestamp persistence
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-23
# =============================================
"""Timestamp state handler.
Persists per-file modification timestamps recorded at the last backup so the
versioned copy strategy can detect changes.
"""
from ..json import json_handler
from ..path import builder
def load_timestamps(project_root: str) -> dict:
"""Load the timestamp map for a project.
Args:
project_root: Absolute path to the project root.
Returns:
Mapping of relative_path to last recorded mtime (float seconds).
"""
ts_path = str(builder.build_timestamps_path(project_root))
data = json_handler.load_json(ts_path)
json_handler.log_operation("load_timestamps", {"project_root": project_root, "count": len(data)})
return data
def save_timestamps(project_root: str, data: dict) -> None:
"""Persist the timestamp map for a project.
Args:
project_root: Absolute path to the project root.
data: Mapping of relative_path to mtime (float seconds).
"""
ts_path = str(builder.build_timestamps_path(project_root))
json_handler.save_json(ts_path, data)
json_handler.log_operation("save_timestamps", {"project_root": project_root, "count": len(data)})
# =============================================
@@ -0,0 +1 @@
"""UI handlers package — PyQt5 settings windows and user-facing dialogs."""
@@ -0,0 +1,64 @@
# apps/integrations/
Private integration space for `BACKUP`.
**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline.
## What goes here
**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain.
```
apps/integrations/
└── {project}/
├── wrapper.py # How this branch uses the driver
├── config.json # Optional — local config
└── tests/ # Private tests colocated
```
Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here.
## What does NOT go here
- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer).
- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that.
- **Drone plugins** — use `apps/plugins/` for those.
- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo.
## Architecture
The full design is in DPLAN-0133 (private integrations architecture). Three layers:
1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name.
2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects.
3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call <contract>`) — advertise the extension points without naming specifics. Fork-safe.
## Usage
```python
# Your public code (committed, in apps/modules/ or apps/handlers/)
from aipass.api import memory_backend
results = memory_backend.query("when did we ship watchdog?")
# memory_backend is a generic contract. In your local setup it routes to whatever
# driver you registered in @api/apps/integrations/. In a fresh clone with nothing
# registered, it returns NotConfigured gracefully.
```
```python
# Your private wrapper (in this folder, gitignored)
# apps/integrations/{project}/wrapper.py
from aipass.api import memory_backend
def domain_specific_query(context):
"""Branch-specific query pattern for domain needs."""
hint = build_query_from_context(context)
return memory_backend.query(hint, top_k=5, filter={"kind": "decision"})
```
The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code.
---
See DPLAN-0133 for the full design rationale.

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